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

192 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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:255286), 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 <wg_subnet> -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 <name>`** — 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 <name>`** — 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:323326 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://<server-tunnel-ip>`; public `http://<public-ip>` 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).