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

13 KiB
Raw Blame History

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 updateupdate.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

  • statuswg 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)):

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