docs: spec — WireGuard panel gate + clusev wg CLI (SP1)
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 <noreply@anthropic.com>feat/v1-foundation
parent
77d50d44e5
commit
f0210c302b
|
|
@ -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 <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: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://<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).
|
||||
Loading…
Reference in New Issue