# WireGuard panel gate + `clusev wg` CLI (SP1) — Design **Date:** 2026-06-20 · **Branch:** `feat/v1-foundation` · **Status:** approved Lets an operator put the Clusev panel **behind a WireGuard tunnel**: the Clusev VM becomes a WG server, operator devices peer in, and the panel's HTTP(S) ports are reachable **only** from the WG subnet. A network-layer gate **on top of** the existing app-layer protection — 2FA + **Anmeldeschutz** (the v0.9.27 brute-force IP ban: `App\Services\BruteforceGuard` + the login-protection settings tab). Those guard the *login*; this hides the *whole panel* from the public internet. Driven entirely from a host CLI: `clusev wg setup | up | down | status | add-peer | remove-peer`. **SP1 of 2.** SP2 (a dashboard WireGuard page: live "who is connected", traffic history, peer management + server settings from the UI, bridged via the `./run` bind-mount + sentinels) is a separate later spec; see *Out of scope*. **The constraint that shapes everything:** WireGuard (`wg0`, kernel) and the firewall live on the **host**; the Laravel app runs in a container with no host network/kernel access. So this is **host shell scripts**, invoked through the existing `clusev` host wrapper (like `update` → `update.sh`) — **not** an artisan command. The only application-side change is a help topic. ## 0. Safety model — never get locked out - **The gate only ever filters TCP 80/443.** SSH (22) and the WireGuard UDP listen port are never matched by any gate rule — they are always reachable (an operator can always SSH to the host). - **`clusev wg down` is the escape hatch.** It removes the gate → the panel is publicly reachable on 80/443 again (subject to any upstream cloud/host firewall). Printed by `wg up` and prominent in help. - **The gate is isolated in its own iptables chain (`CLUSEV-WG-GATE`)** — `down` removes only that chain, never the operator's other `DOCKER-USER` rules (§3). - **Reboot with a failed `wg0` does NOT brick access.** The persistence unit only re-applies the gate when `wg0` is actually up (`ConditionPathExists=/sys/class/net/wg0`). If `wg-quick@wg0` fails on boot, the gate is **not** applied → the panel stays publicly reachable rather than dropped-with-no- tunnel. (And SSH + `clusev wg down` is always the fallback — documented in help, tested in the runbook.) - **Setup never gates automatically; the gate is off by default.** Nothing about WG is enabled until the operator runs `clusev wg setup`, verifies the tunnel reaches the panel, then explicitly runs `clusev wg up`. - **No Caddy / compose change.** The gate is a host-firewall rule, so Caddy keeps listening on `0.0.0.0:80/443` and no image rebuild is needed to toggle it. ## 1. Installation — present but inert `install.sh` (the host bootstrap) gains two idempotent additions, configuring **nothing** (no `wg0`, no keys, no gate — WG is available but off): - **Packages:** install `wireguard-tools` + `qrencode` (a small `apt-get install` in the package phase near the Docker install around install.sh:85; idempotent — apt is a no-op if already present). - **The gate systemd unit:** install `docker/wg/clusev-wg-gate.service` to `/etc/systemd/system` **inside the existing `install_host_watchers()`** (install.sh:255–286), reusing its `systemctl`- presence guard + single `daemon-reload`. (`enable` it so it runs on boot, but it self-skips unless the marker + `wg0` are present.) **The `clusev-wg.sh` script is NOT copied to disk.** It runs from its repo path via the wrapper's `CLUSEV_DIR` (`exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh"`, exactly like `update) exec "${CLUSEV_DIR}/update.sh"`). So it is always the version in the deployed tree, and `clusev update` (git pull + idempotent install.sh) ships changes to it for free — no separate refresh step. ## 2. `clusev wg setup` — interactive, pre-filled, collision-checked, idempotent Runs as root on the host. Each value is **pre-filled with a sensible default + an explaining hint**, editable, and confirmed — nothing silently chosen: - **WG subnet** — default `10.99.0.0/24` (hint: a private /24 that must not clash with your LAN/VPN). **Collision check:** compare against the host's routes/addresses (`ip -o route`, `ip -o addr` — host namespace, since this runs on the host); on overlap, warn and **re-prompt** (a default can't slip past a LAN collision — the check is the real protection, not the absence of a default). - **Server tunnel IP** — default the subnet's first address (`10.99.0.1`). - **Listen port (UDP)** — default `51820` (the WG de-facto standard). - **Public endpoint** — default the auto-detected outbound IP `:port`, using the same lookup as install.sh:131 (`curl -fsS --max-time 5 https://api.ipify.org`, fallback `hostname -I`/manual). Hint: editable to a DNS name; **if the host is behind NAT / a cloud LB this may not be the address clients should dial — verify it before `wg up`.** - **First peer name** — prompt (default e.g. `client-1`); setup then calls `add-peer` (§4) for it. **Idempotency / partial-failure safety:** if `/etc/wireguard/wg0.conf` already exists, setup does **not** silently overwrite it (that would drop existing peers) — it warns and asks to reconfigure or abort. Setup writes keys + `wg0.conf` (`0600`), enables + starts `wg-quick@wg0` (systemd, survives reboot), then creates the first peer + prints its config + QR. Setup **does not** write the gate marker and **does not** gate. Closing message: test the tunnel, then `clusev wg up`. ## 3. The gate — `clusev wg up` / `clusev wg down` Goal: TCP 80/443 reachable only from the WG subnet (+ loopback); everything else dropped — **without** touching SSH, the WG UDP port, or the operator's own firewall rules. - **Mechanism: a dedicated `CLUSEV-WG-GATE` iptables chain hung off `DOCKER-USER`** (iptables-nft on Debian 13). Docker publishes 80/443 via DNAT and **ufw cannot reliably filter Docker-published ports** (a known trap), so ufw/firewalld are deliberately avoided; `DOCKER-USER` is the chain Docker evaluates before its own forward rules. `clusev wg up`: 1. creates the chain `CLUSEV-WG-GATE` (idempotent: flush if it exists), 2. fills it, **in order**: `-i lo -j RETURN` (loopback) · `-s -p tcp -m multiport --dport 80,443 -j RETURN` (allow the tunnel) · `-p tcp -m multiport --dport 80,443 -j DROP` (drop everyone else). **No rule matches tcp 22 or any UDP** — SSH and the WG handshake pass untouched by definition. 3. inserts a single jump at the top of `DOCKER-USER`: `-j CLUSEV-WG-GATE`. - **Post-DNAT note:** `DOCKER-USER` runs after DNAT, so `--dport 80,443` matches the *container* listening port — which is 80/443 here because Caddy is published `80:80` / `443:443`. If that mapping ever changed, the dport here would need to follow. - **`clusev wg down`:** delete the jump from `DOCKER-USER`, then flush + delete `CLUSEV-WG-GATE`. Any other `DOCKER-USER` rules the operator added are untouched. - **Persistence:** `up` writes the marker `/etc/clusev/wg-gate.enabled`; `down` removes it. `clusev-wg-gate.service` (oneshot, `After=docker.service wg-quick@wg0.service`, `ConditionPathExists=/sys/class/net/wg0`) re-applies the chain on boot/Docker-restart **only** when the marker is present *and* `wg0` is up (the condition guarantees the latter — see §0). - **`up` guards:** refuses (non-zero exit) if `wg0` is not up; prints the escape reminder ("verify the tunnel reaches the panel first — escape: `clusev wg down` over SSH") before applying. ## 4. `status` / `add-peer` / `remove-peer` - **`status`** — `wg show wg0` (peers, latest handshake, endpoints, transfer) + the gate state (marker present? chain present?) + the `wg-quick@wg0` service state. - **`add-peer `** — generate a client keypair, assign the next free address in the WG subnet, append the `[Peer]` to `wg0.conf` + `wg set` (live, no restart), and print the client config + a `qrencode` QR (**if `qrencode` fails, print the text config and continue — never abort**). The client's `AllowedIPs` defaults to the **WG subnet only** (split tunnel — only panel traffic goes through WG, the operator's normal internet does not). A hint notes full tunnel (`0.0.0.0/0`) is possible but **not recommended** (it routes all the client's traffic through Clusev). `wg0.conf` is the source of truth for peers (no DB in SP1). - **`remove-peer `** — remove the peer from `wg0.conf` + `wg set ... remove`. - **Exit codes:** `0` success; non-zero on usage error / validation failure / system error, so the CLI is scriptable. ## 5. Host CLI wiring In the `docker/clusev/clusev` **template** (the file with `CLUSEV_DIR=__CLUSEV_DIR__`, which install.sh:323–326 renders into `/usr/local/bin/clusev`), add a `wg)` case to the `case "$cmd"` switch (after `artisan)`, before `version)`): ```bash wg) exec "${CLUSEV_DIR}/docker/wg/clusev-wg.sh" "$@" ;; ``` and a German usage line (`clusev wg setup|up|down|status|add-peer|remove-peer WireGuard-Zugang (host)`). The rendered `CLUSEV_DIR` makes the script path resolve on the host. ## 6. Help topic "WireGuard-Zugang" - `app/Livewire/Help/Index.php`: add `'wireguard'` to the `TOPICS` const (before `'recovery'`) and a `__('help.topic_wireguard')` entry to the `$labels` map. - `lang/de/help.php`: `'topic_wireguard' => 'WireGuard-Zugang'`; `lang/en/help.php`: `'topic_wireguard' => 'WireGuard access'` (identical keys, R9/R16). - `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php` (mirrors the existing `security` topic partial). Content: what the gate does + that it sits on top of 2FA/Anmeldeschutz, the `clusev wg setup` walkthrough (collision hint, endpoint/NAT caveat), importing a client via QR, testing the tunnel, `clusev wg up` to gate, split-vs-full-tunnel, and the **escape hatch prominently** (`clusev wg down` over SSH; reboot-with-wg0-failure leaves the panel open). DE+EN, terse, no emoji (R9/R16), only `@theme` token utilities, no inline styles (R3/R4), no leaked tokens (R17). URL is `/help?topic=wireguard` (English path; German label). ## Files touched New: `docker/wg/clusev-wg.sh` (the CLI + collision check + ipify endpoint detect + key/config/QR generation + the `CLUSEV-WG-GATE` apply/remove logic), `docker/wg/clusev-wg-gate.service` (the persistence oneshot; may call a tiny `clusev-wg.sh gate-apply` subcommand so the rule logic lives in one place), `resources/views/livewire/help/content/de/wireguard.blade.php` + `.../en/wireguard.blade.php`, `docs/superpowers/runbooks/wireguard-gate-runbook.md`. Modify: `install.sh` (apt `wireguard-tools qrencode`; install the gate unit inside `install_host_watchers()`), `docker/clusev/clusev` (template: the `wg)` case + usage), `app/Livewire/Help/Index.php` (topic + label), `lang/de/help.php`, `lang/en/help.php`. **Not touched:** `docker/caddy/Caddyfile`, `docker-compose.prod.yml` (host-firewall gate, no Caddy/compose change), the Laravel app beyond the help topic, `FirewallService`/`Fail2banService` (remote fleet, unrelated), `trustProxies`/domain/TLS. ## Testing Host shell scripts cannot be exercised by the PHPUnit suite (the runner is the container, with no host WireGuard/firewall): - **`shellcheck`** clean on `docker/wg/clusev-wg.sh` and the touched `install.sh` sections. - **Manual runbook** (`docs/superpowers/runbooks/wireguard-gate-runbook.md`) on a throwaway Debian-13 VM with the prod stack, recording each expected result: - `clusev wg setup`: collision check **rejects** a colliding subnet, **accepts** a clean one; the first client config + QR print; re-running setup does not silently clobber `wg0.conf`. - Import the client; the tunnel reaches `http://`; public `http://` is still open (gate off). - `clusev wg up`: public 80/443 now **refused**, the tunnel still serves the panel, **SSH still works**; `clusev wg status` shows the peer + gate on; a manually-added `DOCKER-USER` rule survives `up`+`down`. - **Reboot:** `wg0` + gate persist; and a forced `wg0` failure on boot leaves the gate **unapplied** (panel publicly reachable, not bricked) — SSH + `clusev wg down` recover. - `clusev wg down`: public open again; `add-peer`/`remove-peer` work; full-tunnel client warning shown. - **Help topic:** R12 browser-verify `/help?topic=wireguard` (DE+EN, HTTP 200, no console errors, 3 breakpoints, no leaked tokens). R15 `/codex:review` over the app-side diff + the shell scripts; Pint clean for the PHP/blade. ## Out of scope (= SP2) - A dashboard WireGuard page: live connected-peer status, **traffic history/graph**, add/remove peers and edit server settings **from the UI** — needs the `./run` bind-mount status bridge (a host `wg show` collector writing a status file the app reads) + request sentinels + host watchers + `wg_peer`/`wg_traffic` models. Separate spec. - Clusev **joining an existing** (corporate) WireGuard as a client. SP1 is Clusev-as-server only. - An `install.sh` "gate now?" prompt — setup is post-install via `clusev wg setup` (no first-install lockout risk).