docs(wg): SP1 implementation plan — WireGuard gate + clusev wg CLI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
084c21a869
commit
f154ee8aaf
|
|
@ -0,0 +1,832 @@
|
|||
# WireGuard panel gate + `clusev wg` CLI (SP1) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let an operator put the Clusev panel behind a WireGuard tunnel — the VM becomes a WG server, operator devices peer in, and TCP 80/443 is reachable only from the WG subnet — driven entirely from a host CLI `clusev wg setup|up|down|status|add-peer|remove-peer`, off by default and impossible to lock yourself out of.
|
||||
|
||||
**Architecture:** WireGuard (`wg0`, kernel) and the firewall live on the **host**; the Laravel app runs in a container with no host network access. So the work is **host shell scripts** invoked through the existing `clusev` wrapper (exactly like `update` → `update.sh`) — NOT an artisan command. The gate is a dedicated `CLUSEV-WG-GATE` iptables chain hung off `DOCKER-USER` (ufw can't filter Docker-published ports). The only application-side change is a Help topic. Spec: `docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md` (approved).
|
||||
|
||||
**Tech Stack:** bash (host), `wireguard-tools` (`wg`, `wg-quick`), `qrencode`, `iptables` (nft backend, Debian 13), systemd (`.service` oneshot), Laravel 13 + Livewire 3 (Help topic only), Pint, shellcheck.
|
||||
|
||||
**Why no PHPUnit for the scripts:** the test runner is the app container, which has no host WireGuard/firewall/kernel access. Host scripts are gated by **shellcheck** (automated) + a **manual runbook** on a throwaway Debian-13 VM (Task 8). Only the Help topic (Task 6) is PHPUnit-testable.
|
||||
|
||||
**Run shellcheck portably (it is not installed on host or in the app image):**
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script-path>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files**
|
||||
- `docker/wg/clusev-wg.sh` — the whole CLI: setup (interactive, collision-checked), up/down (the gate), status, add-peer/remove-peer, and the `gate-apply` subcommand the systemd unit calls. One file, one responsibility (host WG control).
|
||||
- `docker/wg/clusev-wg-gate.service` — boot/Docker-restart persistence oneshot; `ExecStart` calls `clusev-wg.sh gate-apply`.
|
||||
- `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php` — Help topic content (mirrors the `security` partial).
|
||||
- `docs/superpowers/runbooks/wireguard-gate-runbook.md` — manual verification steps (the real test for the host scripts).
|
||||
|
||||
**Modified files**
|
||||
- `install.sh` — `apt-get install -y wireguard-tools qrencode` in the package phase; render + `enable` the gate unit inside `install_host_watchers()`.
|
||||
- `docker/clusev/clusev` (template) — a `wg)` case + a German usage line.
|
||||
- `app/Livewire/Help/Index.php` — add `'wireguard'` to `TOPICS` + the `$labels` map.
|
||||
- `lang/de/help.php`, `lang/en/help.php` — `topic_wireguard` key.
|
||||
|
||||
**Not touched:** `docker/caddy/Caddyfile`, `docker-compose.prod.yml` (host-firewall gate — Caddy keeps listening on `0.0.0.0:80/443`), `FirewallService`/`Fail2banService` (remote fleet, unrelated), `trustProxies`/domain/TLS.
|
||||
|
||||
**State files created at runtime (not in git):** `/etc/wireguard/wg0.conf` (source of truth for peers, `0600`), `/etc/clusev/wg.env` (subnet/port/endpoint/server-pubkey metadata, `0600`), `/etc/clusev/wg-gate.enabled` (gate marker).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `clusev-wg.sh` — the CLI
|
||||
|
||||
The whole host CLI in one file. Builds the gate logic, up/down, setup, add/remove-peer, status, and the `gate-apply` subcommand.
|
||||
|
||||
**Files:**
|
||||
- Create: `docker/wg/clusev-wg.sh`
|
||||
|
||||
- [ ] **Step 1: Write the script**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg <cmd>` (the host
|
||||
# wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the
|
||||
# WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated
|
||||
# iptables chain hung off DOCKER-USER. SSH (22) and the WG UDP port are NEVER matched — you can
|
||||
# always SSH in and run `clusev wg down`. The gate is OFF until `clusev wg up`.
|
||||
set -euo pipefail
|
||||
|
||||
WG_IF="wg0"
|
||||
WG_DIR="/etc/wireguard"
|
||||
WG_CONF="${WG_DIR}/${WG_IF}.conf"
|
||||
GATE_CHAIN="CLUSEV-WG-GATE"
|
||||
STATE_DIR="/etc/clusev"
|
||||
WG_ENV="${STATE_DIR}/wg.env"
|
||||
GATE_MARKER="${STATE_DIR}/wg-gate.enabled"
|
||||
PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443)
|
||||
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
warn() { printf '\033[33m ! %s\033[0m\n' "$*" >&2; }
|
||||
die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
need_root() { [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo clusev wg ${1}"; }
|
||||
|
||||
# ── subnet helpers (SP1 assumes the default /24; documented in the runbook) ───────────────────
|
||||
subnet_first_ip() { local b="${1%%/*}"; printf '%s.%s\n' "${b%.*}" "$(( ${b##*.} + 1 ))"; }
|
||||
|
||||
# Heuristic collision check: warn+re-prompt if the chosen subnet's /24 prefix already appears in the
|
||||
# host's routes or addresses. Not a formal CIDR-overlap proof — it is the practical guard the spec
|
||||
# asks for (catch a subnet that clashes with an existing interface/route), so a default cannot slip
|
||||
# past a LAN collision.
|
||||
subnet_collides() {
|
||||
local net="${1%%/*}" prefix
|
||||
prefix="${net%.*}" # first three octets, e.g. 10.99.0
|
||||
ip -o addr 2>/dev/null | awk '{print $4}' | grep -q "^${prefix}\." && return 0
|
||||
ip -o route 2>/dev/null | awk '{print $1}' | grep -q "^${prefix}\." && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_endpoint_ip() {
|
||||
# Same lookup as install.sh: public IP via ipify, fall back to the first local address.
|
||||
curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null \
|
||||
|| hostname -I 2>/dev/null | awk '{print $1}'
|
||||
}
|
||||
|
||||
require_setup() { [ -f "$WG_CONF" ] && [ -f "$WG_ENV" ] || die "Keine WG-Konfiguration — erst 'clusev wg setup' ausfuehren."; }
|
||||
|
||||
# ── the gate ──────────────────────────────────────────────────────────────────────────────────
|
||||
gate_apply() {
|
||||
# Called by `up` and by the systemd unit on boot/Docker-restart. No-op (success) unless the
|
||||
# marker says the gate is enabled, so the boot unit never gates an operator who ran `down`.
|
||||
[ -f "$GATE_MARKER" ] || { info "Gate-Marker fehlt — Gate nicht angewendet."; return 0; }
|
||||
have iptables || die "iptables nicht gefunden."
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$WG_ENV" ] && . "$WG_ENV"
|
||||
[ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET unbekannt (${WG_ENV} fehlt) — Gate nicht angewendet."
|
||||
iptables -nL DOCKER-USER >/dev/null 2>&1 || die "DOCKER-USER-Chain fehlt (laeuft Docker?) — Gate nicht angewendet."
|
||||
|
||||
iptables -N "$GATE_CHAIN" 2>/dev/null || iptables -F "$GATE_CHAIN"
|
||||
iptables -A "$GATE_CHAIN" -i lo -j RETURN
|
||||
iptables -A "$GATE_CHAIN" -s "$WG_SUBNET" -p tcp -m multiport --dport "$PANEL_PORTS" -j RETURN
|
||||
iptables -A "$GATE_CHAIN" -p tcp -m multiport --dport "$PANEL_PORTS" -j DROP
|
||||
# exactly one jump at the top of DOCKER-USER (idempotent)
|
||||
iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
|
||||
iptables -I DOCKER-USER 1 -j "$GATE_CHAIN"
|
||||
}
|
||||
|
||||
gate_remove() {
|
||||
have iptables || return 0
|
||||
iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true
|
||||
iptables -F "$GATE_CHAIN" 2>/dev/null || true
|
||||
iptables -X "$GATE_CHAIN" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── commands ──────────────────────────────────────────────────────────────────────────────────
|
||||
cmd_up() {
|
||||
need_root up
|
||||
require_setup
|
||||
[ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
|
||||
warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf 'enabled\n' > "$GATE_MARKER"
|
||||
gate_apply
|
||||
bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
|
||||
info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
need_root down
|
||||
rm -f "$GATE_MARKER"
|
||||
gate_remove
|
||||
bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)."
|
||||
}
|
||||
|
||||
next_free_ip() {
|
||||
# shellcheck disable=SC1090
|
||||
. "$WG_ENV"
|
||||
local prefix="${WG_SUBNET%%/*}"; prefix="${prefix%.*}" # first three octets (SP1: /24)
|
||||
local used; used="$(awk -F= '/AllowedIPs/{gsub(/[ \t]/,"",$2);split($2,a,"/");print a[1]}' "$WG_CONF" 2>/dev/null || true)"
|
||||
used="$(printf '%s\n%s\n' "$used" "${WG_SERVER_IP:-}")"
|
||||
local i cand
|
||||
for i in $(seq 2 254); do
|
||||
cand="${prefix}.${i}"
|
||||
printf '%s\n' "$used" | grep -qx "$cand" || { printf '%s\n' "$cand"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_add_peer() {
|
||||
need_root add-peer
|
||||
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
|
||||
require_setup
|
||||
# shellcheck disable=SC1090
|
||||
. "$WG_ENV"
|
||||
grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && die "Peer '${name}' existiert bereits."
|
||||
local ip; ip="$(next_free_ip)" || die "Kein freier Adressraum im Subnetz ${WG_SUBNET}."
|
||||
local cpriv cpub
|
||||
cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
|
||||
|
||||
cat >> "$WG_CONF" <<EOF
|
||||
|
||||
# clusev-peer: ${name}
|
||||
[Peer]
|
||||
PublicKey = ${cpub}
|
||||
AllowedIPs = ${ip}/32
|
||||
EOF
|
||||
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32" # live, no restart
|
||||
|
||||
local client_conf
|
||||
client_conf="$(cat <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${cpriv}
|
||||
Address = ${ip}/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${WG_SERVER_PUBKEY}
|
||||
Endpoint = ${WG_ENDPOINT}
|
||||
AllowedIPs = ${WG_SUBNET}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
)"
|
||||
bold "Peer '${name}' angelegt (${ip})."
|
||||
printf '%s\n' "$client_conf"
|
||||
if have qrencode; then
|
||||
printf '%s\n' "$client_conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
|
||||
else
|
||||
warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
|
||||
fi
|
||||
info "AllowedIPs=${WG_SUBNET} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
|
||||
}
|
||||
|
||||
cmd_remove_peer() {
|
||||
need_root remove-peer
|
||||
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
|
||||
require_setup
|
||||
local pub
|
||||
pub="$(awk -v n="# clusev-peer: ${name}" '$0==n{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
|
||||
[ -n "$pub" ] || die "Peer '${name}' nicht gefunden."
|
||||
wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
|
||||
# drop the block from the marker comment up to (and including) the trailing blank line
|
||||
local tmp; tmp="$(mktemp)"
|
||||
awk -v n="# clusev-peer: ${name}" '
|
||||
$0==n {drop=1; next}
|
||||
drop && /^[[:space:]]*$/ {drop=0; next}
|
||||
drop {next}
|
||||
{print}
|
||||
' "$WG_CONF" > "$tmp"
|
||||
install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
|
||||
bold "Peer '${name}' entfernt."
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
need_root status
|
||||
bold "WireGuard (${WG_IF})"
|
||||
if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi
|
||||
echo
|
||||
bold "Gate"
|
||||
if [ -f "$GATE_MARKER" ]; then info "Marker: aktiv (${GATE_MARKER})"; else info "Marker: aus"; fi
|
||||
if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi
|
||||
echo
|
||||
bold "Dienst wg-quick@${WG_IF}"
|
||||
systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_setup() {
|
||||
need_root setup
|
||||
have wg || die "wireguard-tools nicht installiert (erwartet via install.sh)."
|
||||
if [ -f "$WG_CONF" ]; then
|
||||
warn "${WG_CONF} existiert bereits — bestehende Peers gingen bei Neukonfiguration verloren."
|
||||
read -rp " Trotzdem neu konfigurieren? [reconfigure/abort] (abort): " ans
|
||||
[ "${ans:-abort}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt."
|
||||
fi
|
||||
|
||||
local subnet default_subnet="10.99.0.0/24"
|
||||
while :; do
|
||||
read -rp " WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet
|
||||
subnet="${subnet:-$default_subnet}"
|
||||
if subnet_collides "$subnet"; then warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen."; continue; fi
|
||||
break
|
||||
done
|
||||
|
||||
local server_ip default_ip port endpoint default_ep peer1
|
||||
default_ip="$(subnet_first_ip "$subnet")"
|
||||
read -rp " Server-Tunnel-IP [${default_ip}]: " server_ip; server_ip="${server_ip:-$default_ip}"
|
||||
read -rp " Listen-Port (UDP) [51820]: " port; port="${port:-51820}"
|
||||
default_ep="$(detect_endpoint_ip):${port}"
|
||||
info "Hinweis: hinter NAT/Cloud-LB ist die erkannte IP evtl. NICHT die Wähl-Adresse der Clients — vor 'clusev wg up' pruefen."
|
||||
read -rp " Oeffentlicher Endpoint (IP oder DNS:Port) [${default_ep}]: " endpoint; endpoint="${endpoint:-$default_ep}"
|
||||
read -rp " Name des ersten Peers [client-1]: " peer1; peer1="${peer1:-client-1}"
|
||||
|
||||
umask 077; mkdir -p "$WG_DIR" "$STATE_DIR"
|
||||
local srv_priv srv_pub prefix
|
||||
srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)"
|
||||
prefix="${subnet##*/}"
|
||||
|
||||
cat > "$WG_CONF" <<EOF
|
||||
[Interface]
|
||||
Address = ${server_ip}/${prefix}
|
||||
ListenPort = ${port}
|
||||
PrivateKey = ${srv_priv}
|
||||
EOF
|
||||
chmod 600 "$WG_CONF"
|
||||
|
||||
cat > "$WG_ENV" <<EOF
|
||||
WG_SUBNET=${subnet}
|
||||
WG_SERVER_IP=${server_ip}
|
||||
WG_PORT=${port}
|
||||
WG_ENDPOINT=${endpoint}
|
||||
WG_SERVER_PUBKEY=${srv_pub}
|
||||
EOF
|
||||
chmod 600 "$WG_ENV"
|
||||
|
||||
systemctl enable --now "wg-quick@${WG_IF}" || die "wg-quick@${WG_IF} konnte nicht gestartet werden — 'journalctl -u wg-quick@${WG_IF}' pruefen."
|
||||
cmd_add_peer "$peer1"
|
||||
echo
|
||||
bold "Setup fertig. Tunnel ist OBEN, aber das Gate ist AUS (Panel weiter oeffentlich)."
|
||||
info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
clusev wg — WireGuard-Zugang (Host)
|
||||
|
||||
clusev wg setup WG einrichten (interaktiv): Subnetz, Keys, erster Peer
|
||||
clusev wg up Gate aktivieren — Panel (80/443) nur ueber den Tunnel
|
||||
clusev wg down Gate entfernen — Panel wieder oeffentlich (Notausgang!)
|
||||
clusev wg status Tunnel, Peers, Gate-Status anzeigen
|
||||
clusev wg add-peer <name> neuen Client anlegen (+ QR-Code)
|
||||
clusev wg remove-peer <name> Client entfernen
|
||||
|
||||
SSH (22) und der WG-Port bleiben IMMER offen. 'clusev wg down' per SSH ist der Notausgang.
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-help}"; shift 2>/dev/null || true
|
||||
case "$cmd" in
|
||||
setup) cmd_setup "$@" ;;
|
||||
up) cmd_up "$@" ;;
|
||||
down) cmd_down "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
add-peer) cmd_add_peer "$@" ;;
|
||||
remove-peer) cmd_remove_peer "$@" ;;
|
||||
gate-apply) need_root gate-apply; gate_apply ;; # called by clusev-wg-gate.service
|
||||
help|-h|--help) usage ;;
|
||||
*) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make it executable**
|
||||
|
||||
```bash
|
||||
chmod +x docker/wg/clusev-wg.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Syntax check**
|
||||
|
||||
Run: `bash -n docker/wg/clusev-wg.sh`
|
||||
Expected: no output, exit 0.
|
||||
|
||||
- [ ] **Step 4: shellcheck clean**
|
||||
|
||||
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/wg/clusev-wg.sh`
|
||||
Expected: no errors. (The intentional `# shellcheck disable=SC1090` lines cover the dynamic `.` sources.)
|
||||
|
||||
- [ ] **Step 5: Smoke the no-root / usage paths (safe on the dev host — they never touch iptables/wg)**
|
||||
|
||||
Run: `bash docker/wg/clusev-wg.sh help`
|
||||
Expected: the usage block prints, exit 0.
|
||||
|
||||
Run: `bash docker/wg/clusev-wg.sh bogus; echo "exit=$?"`
|
||||
Expected: "Unbekannter Befehl: bogus" + usage, `exit=64`.
|
||||
|
||||
Run (as non-root): `bash docker/wg/clusev-wg.sh status; echo "exit=$?"`
|
||||
Expected: "Bitte mit sudo ausfuehren: sudo clusev wg status", non-zero exit. (Everything real is gated behind `need_root` + a Debian VM — see the runbook in Task 7.)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/wg/clusev-wg.sh
|
||||
git commit -m "feat(wg): clusev-wg.sh — WireGuard gate + peer CLI (host)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: The gate persistence systemd unit
|
||||
|
||||
A oneshot that re-applies the gate on boot/Docker-restart — but only when `wg0` is up (`ConditionPathExists`) and the marker is present (checked inside `gate-apply`). Modelled on `docker/restart-sentinel/clusev-update.service`.
|
||||
|
||||
**Files:**
|
||||
- Create: `docker/wg/clusev-wg-gate.service`
|
||||
|
||||
- [ ] **Step 1: Write the unit**
|
||||
|
||||
```ini
|
||||
# Clusev WireGuard gate — persistence oneshot (HOST-side).
|
||||
#
|
||||
# Re-applies the CLUSEV-WG-GATE iptables chain after boot and after a Docker daemon restart (Docker
|
||||
# flushes DOCKER-USER). It is a no-op unless BOTH are true:
|
||||
# - wg0 is up (ConditionPathExists below — a failed wg0 on boot leaves the panel OPEN, not bricked)
|
||||
# - the gate marker (/etc/clusev/wg-gate.enabled, checked inside `clusev-wg.sh gate-apply`)
|
||||
# So `clusev wg down` (removes the marker) survives a reboot, and a tunnel that fails to come up never
|
||||
# drops the panel with no way in. install.sh rewrites the /home/nexxo/clusev paths to the real tree.
|
||||
[Unit]
|
||||
Description=Clusev WireGuard gate — re-apply the panel firewall when wg0 is up
|
||||
After=docker.service wg-quick@wg0.service
|
||||
Wants=docker.service
|
||||
ConditionPathExists=/sys/class/net/wg0
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Environment=CLUSEV_DIR=/home/nexxo/clusev
|
||||
WorkingDirectory=/home/nexxo/clusev
|
||||
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh gate-apply
|
||||
|
||||
[Install]
|
||||
# Pulled in when docker.service starts (boot AND `systemctl restart docker`), so a Docker restart
|
||||
# that flushes DOCKER-USER re-applies the gate.
|
||||
WantedBy=docker.service
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check the unit syntax**
|
||||
|
||||
Run: `systemd-analyze verify docker/wg/clusev-wg-gate.service 2>&1 | grep -v 'Failed to prepare' || true`
|
||||
Expected: no syntax errors reported about the `[Unit]`/`[Service]`/`[Install]` keys. (Path-not-found warnings for the not-yet-installed ExecStart are fine — the file is rendered at install time. If `systemd-analyze` is unavailable, just eyeball that the three sections + the directives match `clusev-update.service`.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/wg/clusev-wg-gate.service
|
||||
git commit -m "feat(wg): clusev-wg-gate.service — boot/docker-restart gate persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `install.sh` — packages + install the gate unit
|
||||
|
||||
Two idempotent additions. Packages near the Docker/preflight block; the unit inside `install_host_watchers()` (reuses its `systemctl` guard + single `daemon-reload`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `install.sh` (package phase ~line 104; `install_host_watchers()` ~lines 269–299)
|
||||
|
||||
- [ ] **Step 1: Add the WireGuard packages after the Docker/preflight block**
|
||||
|
||||
Find the end of the preflight section — the line that confirms openssl/Docker, just before `# ── [2/9] user setup`. Add a standalone apt install (guarded by `IS_APT`, idempotent — apt is a no-op if present), right before the `phase 2/9` line (currently `install.sh:94`):
|
||||
|
||||
```bash
|
||||
# WireGuard gate (clusev wg ...) — present but inert until the operator runs `clusev wg setup`.
|
||||
if [ "$IS_APT" = 1 ]; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools qrencode >/dev/null 2>&1 \
|
||||
&& info "wireguard-tools + qrencode vorhanden" \
|
||||
|| warn "wireguard-tools/qrencode nicht installiert — 'clusev wg' erst nach 'apt-get install wireguard-tools qrencode' nutzbar."
|
||||
fi
|
||||
```
|
||||
|
||||
(`set -euo pipefail` is active — the `&& … || …` keeps a failed apt from aborting the install.)
|
||||
|
||||
- [ ] **Step 2: Render + enable the gate unit inside `install_host_watchers()`**
|
||||
|
||||
In `install_host_watchers()`, add the gate unit alongside the existing four. After the `tmp_usvc="$(mktemp)"` line, add a temp var:
|
||||
|
||||
```bash
|
||||
local tmp_gate; tmp_gate="$(mktemp)"
|
||||
```
|
||||
|
||||
After the existing `sed ... clusev-update.service > "$tmp_usvc"` line, add:
|
||||
|
||||
```bash
|
||||
# Gate unit keeps User=root; only the path is rewritten.
|
||||
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-gate.service" > "$tmp_gate"
|
||||
```
|
||||
|
||||
In the `if install -m 0644 ... && systemctl ...; then` block, add the install + an `enable` (NOT `--now`: the gate must not be applied at install time — it self-skips via the marker + `ConditionPathExists`, but `enable` registers it for boot/docker-restart). Add these two lines into the `&&` chain, before the final `; then`:
|
||||
|
||||
```bash
|
||||
&& install -m 0644 "$tmp_gate" "${dst}/clusev-wg-gate.service" \
|
||||
&& systemctl enable clusev-wg-gate.service \
|
||||
```
|
||||
|
||||
And add `$tmp_gate` to the closing `rm -f` cleanup line:
|
||||
|
||||
```bash
|
||||
rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" "$tmp_gate"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Syntax + shellcheck**
|
||||
|
||||
Run: `bash -n install.sh`
|
||||
Expected: exit 0.
|
||||
|
||||
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable install.sh`
|
||||
Expected: no NEW errors versus the pre-change baseline (run it once before editing to capture the baseline; the additions must not introduce new findings).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add install.sh
|
||||
git commit -m "feat(wg): install wireguard-tools/qrencode + enable the gate unit (inert by default)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Host CLI wiring — `clusev wg`
|
||||
|
||||
Add a `wg)` case + a usage line to the `clusev` wrapper template. The rendered `CLUSEV_DIR` makes the script path resolve on the host.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker/clusev/clusev` (case switch ~line 47; usage ~line 27)
|
||||
|
||||
- [ ] **Step 1: Add the `wg)` case after `artisan)` and before `version)`**
|
||||
|
||||
Find the `artisan)` arm:
|
||||
```bash
|
||||
artisan) compose exec app php artisan "$@" ;;
|
||||
```
|
||||
Add directly below it:
|
||||
```bash
|
||||
wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the usage line after the `clusev artisan` line**
|
||||
|
||||
Find in `usage()`:
|
||||
```bash
|
||||
clusev artisan <...> beliebiges artisan-Kommando im app-Container
|
||||
```
|
||||
Add directly below it:
|
||||
```bash
|
||||
clusev wg <...> WireGuard-Zugang (setup|up|down|status|add-peer|remove-peer)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Syntax + shellcheck the template**
|
||||
|
||||
Run: `bash -n docker/clusev/clusev`
|
||||
Expected: exit 0. (The `__CLUSEV_DIR__` token is a literal here; bash parses the unquoted assignment fine.)
|
||||
|
||||
Run: `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable docker/clusev/clusev`
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/clusev/clusev
|
||||
git commit -m "feat(wg): wire 'clusev wg' into the host CLI wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Help topic registration (PHPUnit-testable)
|
||||
|
||||
Register the `wireguard` topic + its label. This is the only part with real unit tests.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/Livewire/Help/Index.php` (TOPICS ~line 20; `$labels` ~line 53)
|
||||
- Modify: `lang/de/help.php`, `lang/en/help.php`
|
||||
- Test: `tests/Feature/HelpTest.php` (extend if it exists, else create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create or extend `tests/Feature/HelpTest.php`:
|
||||
|
||||
```php
|
||||
<?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 & 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`:
|
||||
|
||||
```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 & 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:
|
||||
|
||||
```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 1–8. ✓
|
||||
|
||||
**Deviations from the spec, justified:**
|
||||
- Spec §3 derives the subnet implicitly; this plan persists it in `/etc/clusev/wg.env` at setup so `gate_apply` (and the boot unit) and `add-peer` have a single, reliable source without re-deriving a network from an IP/prefix in bash. `wg0.conf` remains the source of truth for **peers** as the spec requires.
|
||||
- Spec §6 suggested the topic before `'recovery'`; implemented exactly there (after `'audit'`).
|
||||
- Help label uses the spec's wording (`WireGuard-Zugang` / `WireGuard access`), not the scout's `WireGuard & VPN`.
|
||||
|
||||
**Placeholder scan:** none — every step has concrete code/commands.
|
||||
|
||||
**Type/name consistency:** `gate_apply`/`gate_remove`/`cmd_up`/`cmd_down`/`cmd_setup`/`cmd_add_peer`/`cmd_remove_peer`/`cmd_status`/`next_free_ip`/`subnet_collides`/`subnet_first_ip`/`detect_endpoint_ip`/`require_setup` are referenced consistently in the dispatch and the systemd `gate-apply` subcommand. `WG_SUBNET`/`WG_SERVER_IP`/`WG_PORT`/`WG_ENDPOINT`/`WG_SERVER_PUBKEY` are written by `cmd_setup` and read by `gate_apply`/`cmd_add_peer`/`next_free_ip`. ✓
|
||||
|
||||
**Known SP1 limitation (documented, not a gap):** the subnet helpers (`next_free_ip`, `subnet_collides`, `subnet_first_ip`) assume the default /24. Non-/24 subnets work for the tunnel but peer-IP allocation + the collision heuristic are /24-shaped; noted in the runbook. SP2 territory.
|
||||
Loading…
Reference in New Issue