# 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).
