13 KiB
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 downis the escape hatch. It removes the gate → the panel is publicly reachable on 80/443 again (subject to any upstream cloud/host firewall). Printed bywg upand prominent in help.- The gate is isolated in its own iptables chain (
CLUSEV-WG-GATE) —downremoves only that chain, never the operator's otherDOCKER-USERrules (§3). - Reboot with a failed
wg0does NOT brick access. The persistence unit only re-applies the gate whenwg0is actually up (ConditionPathExists=/sys/class/net/wg0). Ifwg-quick@wg0fails on boot, the gate is not applied → the panel stays publicly reachable rather than dropped-with-no- tunnel. (And SSH +clusev wg downis 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 runsclusev wg up. - No Caddy / compose change. The gate is a host-firewall rule, so Caddy keeps listening on
0.0.0.0:80/443and 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 smallapt-get installin 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.serviceto/etc/systemd/systeminside the existinginstall_host_watchers()(install.sh:255–286), reusing itssystemctl- presence guard + singledaemon-reload. (enableit so it runs on boot, but it self-skips unless the marker +wg0are 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, fallbackhostname -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 beforewg up. - First peer name — prompt (default e.g.
client-1); setup then callsadd-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-GATEiptables chain hung offDOCKER-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-USERis the chain Docker evaluates before its own forward rules.clusev wg up:- creates the chain
CLUSEV-WG-GATE(idempotent: flush if it exists), - 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. - inserts a single jump at the top of
DOCKER-USER:-j CLUSEV-WG-GATE.
- Post-DNAT note:
DOCKER-USERruns after DNAT, so--dport 80,443matches the container listening port — which is 80/443 here because Caddy is published80:80/443:443. If that mapping ever changed, the dport here would need to follow.
- creates the chain
clusev wg down: delete the jump fromDOCKER-USER, then flush + deleteCLUSEV-WG-GATE. Any otherDOCKER-USERrules the operator added are untouched.- Persistence:
upwrites the marker/etc/clusev/wg-gate.enabled;downremoves 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 andwg0is up (the condition guarantees the latter — see §0). upguards: refuses (non-zero exit) ifwg0is not up; prints the escape reminder ("verify the tunnel reaches the panel first — escape:clusev wg downover 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?) + thewg-quick@wg0service state.add-peer <name>— generate a client keypair, assign the next free address in the WG subnet, append the[Peer]towg0.conf+wg set(live, no restart), and print the client config + aqrencodeQR (ifqrencodefails, print the text config and continue — never abort). The client'sAllowedIPsdefaults 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.confis the source of truth for peers (no DB in SP1).remove-peer <name>— remove the peer fromwg0.conf+wg set ... remove.- Exit codes:
0success; 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)):
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 theTOPICSconst (before'recovery') and a__('help.topic_wireguard')entry to the$labelsmap.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 existingsecuritytopic partial). Content: what the gate does + that it sits on top of 2FA/Anmeldeschutz, theclusev wg setupwalkthrough (collision hint, endpoint/NAT caveat), importing a client via QR, testing the tunnel,clusev wg upto gate, split-vs-full-tunnel, and the escape hatch prominently (clusev wg downover SSH; reboot-with-wg0-failure leaves the panel open). DE+EN, terse, no emoji (R9/R16), only@themetoken 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):
shellcheckclean ondocker/wg/clusev-wg.shand the touchedinstall.shsections.- 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 clobberwg0.conf.- Import the client; the tunnel reaches
http://<server-tunnel-ip>; publichttp://<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 statusshows the peer + gate on; a manually-addedDOCKER-USERrule survivesup+down.- Reboot:
wg0+ gate persist; and a forcedwg0failure on boot leaves the gate unapplied (panel publicly reachable, not bricked) — SSH +clusev wg downrecover. clusev wg down: public open again;add-peer/remove-peerwork; 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:reviewover 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
./runbind-mount status bridge (a hostwg showcollector writing a status file the app reads) + request sentinels + host watchers +wg_peer/wg_trafficmodels. 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 viaclusev wg setup(no first-install lockout risk).