From f0210c302bab56774c63d69c1afcca3d17ceadaf Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 20 Jun 2026 19:48:47 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20=E2=80=94=20WireGuard=20panel=20?= =?UTF-8?q?gate=20+=20clusev=20wg=20CLI=20(SP1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional host-level WireGuard gate for the Clusev panel: install.sh installs wireguard-tools (off), a 'clusev wg setup/up/down/status/add-peer/remove-peer' host CLI sets up wg0 + gates TCP 80/443 to the WG subnet via a dedicated CLUSEV-WG-GATE iptables chain off DOCKER-USER, with 'clusev wg down' as the escape hatch and SSH/WG-UDP never gated. Hardened after an adversarial review: exact ordered firewall rules, persistence skips if wg0 is down (no reboot brick), down isolates Clusev's chain, setup idempotency, precise install wiring (CLUSEV_DIR exec + install_host_watchers). SP1 of 2; dashboard status/traffic is SP2. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-20-wireguard-gate-cli-design.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md diff --git a/docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md b/docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md new file mode 100644 index 0000000..93472a6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-wireguard-gate-cli-design.md @@ -0,0 +1,191 @@ +# 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).