14 KiB
WireGuard dashboard (SP2) — full in-UI management — Design
Date: 2026-06-20 · Branch: feat/v1-foundation · Status: approved · Follows: SP1 (2026-06-20-wireguard-gate-cli-design.md, shipped v0.9.33)
Bring all WireGuard administration into the dashboard: see who is connected (live peer status + traffic), view traffic history, create/remove client peers, and edit every server setting + toggle the gate — without leaving the UI. Built on SP1's host CLI (clusev wg …), bridged through the ./run bind-mount (the container still has no host network/kernel/firewall access — same hard constraint as SP1).
Decided scope (user): full management. Client config (private key + QR) is shown once in the UI then discarded (never persisted) — the 2FA-backup-code pattern. All server settings are editable from the UI, with hard confirmation for destructive ones.
Built in phases (each independently shippable): P1 live status → P2 traffic history + graph → P3 peer management → P4 server settings + gate. This spec defines the shared architecture + all four phases; each phase gets its own implementation plan.
0. The two bridges (container ↔ host)
The container cannot run wg/iptables. SP1 already established ./run ↔ storage/app/restart-signal (bind mount) + host watchers. SP2 adds two bridges over it.
Bridge 1 — status collector (READ, periodic)
- New
clusev-wg.sh collectsubcommand writesrun/wg-status.json(atomic: temp file +mv). Content (whitelisted, no secrets — never a private key):
Source:{ "at": 1750000000, "configured": true, "gate": {"marker": true, "chain": true}, "wg_quick": "active", "server": {"subnet":"10.99.0.0/24","server_ip":"10.99.0.1","port":51820,"endpoint":"1.2.3.4:51820","pubkey":"abc…"}, "peers": [ {"name":"laptop","pubkey":"def…","endpoint":"5.6.7.8:41820","latest_handshake":1750000000,"rx":12345,"tx":67890} ] }wg show wg0 dump(counters + handshakes + endpoints), the# clusev-peer: <name>comments inwg0.conf(pubkey→name map), and the gate marker/chain +wg-quick@wg0state. If WG is not configured (wg0.confabsent), it emits{"configured": false, "at": …}— never errors. - New systemd units
clusev-wg-collect.timer(OnBootSec=10s,OnUnitActiveSec=5s,AccuracySec=1s) →clusev-wg-collect.service(oneshot, root,ExecStart=clusev-wg.sh collect). Cheap:wg showis instant, and a no-WG host exits immediately. Rendered + enabled ininstall_host_watchers()(like the gate unit). The.timeris the only new "periodic" host pattern (SP1 used only.pathevent watchers). - New route
GET /wg-status.jsonreadsstorage_path('app/restart-signal/wg-status.json'), validates/whitelists the shape, returns it (or{"configured": false}if the file is absent/stale). Public, no secrets (mirrors/update-status.json). The page treats a staleat(older than ~20s) as "collector offline".
Bridge 2 — write requests (WRITE, parameterized) — the central design choice
SP1's sentinels are parameterless triggers. UI-driven mutation needs a parameterized, whitelisted request. Chosen: variant A (extends the proven sentinel/.path pattern; no long-running daemon; container input is never eval'd):
- The app writes
run/wg-request.json={"id":"<random-token>","action":"<whitelisted>","args":{…},"at":…}via a newDeploymentService-style writer.action∈ a fixed whitelist:add-peer | remove-peer | gate-up | gate-down | set-endpoint | set-port | set-subnet | setup. The app also records the request (id, action) in anAuditEvent(like an update request). - A new host watcher
clusev-wg-request.path(PathExists=run/wg-request.json) →clusev-wg-request.service(oneshot, root) →clusev-wg.sh serve-request:- reads + consumes the request (move to a
.processingtemp, so a re-arm can't double-run), - validates
actionagainst the host-side whitelist (unknown → error result), maps it to a fixedcmd_*, - re-sanitises every arg host-side (peer name via the existing
valid_name; endpoint/port/subnet via format checks; subnet via the existing collision check) — the container's strings are data, never code, - runs the operation (non-interactive variants of
cmd_add_peer/cmd_remove_peer/cmd_up/cmd_down+ newcmd_set_endpoint/cmd_set_port/cmd_set_subnet/non-interactivesetup), - writes
run/wg-result-<id>.json={"id":…,"ok":true|false,"message":"…", …}. Foradd-peeronly, it additionally carries"client_config":"<wg conf text>"and"qr":"<ascii/SVG>"— the only secret-bearing result.
- reads + consumes the request (move to a
- Result handling. The app polls
GET /wg-result.json?id=<id>(validates the id is a token it just issued, server-side, against the cached pending id — no arbitrary file read) → shows the outcome. Foradd-peer, the config + QR render once in a modal and are never written to the DB. Result files: mode 0600, owned by the app-read uid (therun/owner thatinstall.shchowns), so a private key is never world-readable; the collector pruneswg-result-*.jsonolder than 60 s.
This is the riskiest surface in SP2; §5 details the safety rules.
1. The page
App\Livewire\Wireguard\Index(full-page class-based component, R1/R2) at route/wireguard(Route::get('/wireguard', Wireguard\Index::class)->name('wireguard'), in the onboarded panel group). English route, German+English labels (R13/R9/R16).- Sidebar nav entry in the Fleet group (after Audit),
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>(shell.nav_wireguard= "WireGuard" both locales). (If a more fitting Lucide glyph is wanted, add one tox-icon;lock/shieldalready exist.) - Live data via
wire:poll.5scalling a component method that reads/wg-status.json(or reads the file directly server-side). Token-only styling (R3), no inline style except a possible bar width (R4), responsive 375/768/1280 (R7). - Empty/edge states: WG not configured → a "Set up WireGuard" panel (P4's setup form / link to
clusev wg setup); collector stale → a muted "status unavailable" note; gate-off-from-UI caveat shown near the gate toggle (SSHclusev wg downis the authoritative escape).
2. Phase P1 — live status (read-only)
Foundation. Deliverables: clusev-wg.sh collect + the .timer/.service + install.sh wiring + GET /wg-status.json + the page rendering:
- Gate state badge (an/aus) + the SSH-escape caveat.
- Server card: subnet, tunnel IP, listen port, endpoint, server pubkey (short),
wg-quick@wg0state. - Peer list (cards or a responsive table): name, online dot (handshake within ~3 min), endpoint, "last handshake vor X", rx/tx (mono, human bytes). Empty state if no peers.
- Stale-collector indicator. No writes, no chart, no DB. Reuses the
/update-status.jsonread-bridge pattern + the existingx-panel/x-badge/x-status-dotkit (R10).
3. Phase P2 — traffic history + graph
wg_traffic_sampletable + model:id, peer_pubkey (string, idx), peer_name (string, null), rx (bigint), tx (bigint), sampled_at (timestamp, idx). Cumulative counters; the chart computes deltas (throughput).- Sampling: a scheduled app job (Laravel scheduler, ~every 60 s) reads
wg-status.jsonand inserts one row per peer. Independent of whether the page is open. Requires the prod scheduler to run (schedule:work/ a cron in the stack — a deploy note if not present). Retention: prune samples older than 7 days in the same job. - Chart: first chart in the project. Add ApexCharts (npm) wired as an Alpine island in a Blade partial (CLAUDE.md §2), respecting the prod-JS-env rules (Vite-baked; top-level JS must not throw → guard init; data passed via a
wire:payload /window.__clusev-style hand-off, not a baked secret). Per-peer throughput over a selectable window (1h/24h/7d). Tokens only for colours (R3) — feed ApexCharts the@themeaccent/cyan values via CSS vars read in JS. - Degrades cleanly if there is no data yet (empty chart + hint).
4. Phase P3 — peer management (create / remove)
- Add peer: a name input (client-side + server-side charset validation mirroring
valid_name) → the app writes anadd-peerrequest via Bridge 2 → polls the result → a show-once modal renders the client config + QR (never persisted). wire-elements/modal. - Remove peer: a per-peer remove control → R5 confirm modal + ConfirmToken → a
remove-peerrequest → status toast + the peer drops from the live list on the next poll. - Both audited. The host re-validates the name; the app never constructs a shell command.
5. Phase P4 — server settings + gate
- Gate toggle: an
up/downswitch →gate-up/gate-downrequests. Turning on is safe; turning off from the UI works only while the UI is reachable — show the caveat + the SSH escape prominently. Gate-off → R5 confirm (it exposes the panel publicly). - Endpoint edit (safe — affects only new peers): a form →
set-endpointrequest. - Port edit (destructive — restarts wg-quick, all clients must update): form → R5 confirm + warning →
set-port. - Subnet edit (highly destructive — re-IPs everything, breaks all peers): form → R5 confirm + a typed-confirmation warning →
set-subnet. The host re-runs the collision check. - First-time setup from the UI: when unconfigured, a guided form (subnet/server-IP/port/endpoint/first-peer name) → a non-interactive
setuprequest; the host runs the same collision check + key/conf generation asclusev wg setupand returns the first peer's config in a show-once modal. (The interactiveclusev wg setupon the host stays the alternative.) - New host subcommands:
cmd_set_endpoint,cmd_set_port,cmd_set_subnet, and a non-interactivesetuppath driven by the request args (collision check still enforced; never silently clobbers an existingwg0.confunless the request explicitly says reconfigure).
6. Security & safety (cross-cutting)
- Container input is data, never code.
serve-requestwhitelistsactionand re-sanitises every arg host-side. No request value is ever interpolated into a shell command unquoted; peer names passvalid_name; ports must be1–65535; subnets must match a CIDR pattern + the collision check. - Private keys (add-peer / setup result) are show-once, mode 0600, owned by the app-read uid, auto-pruned after 60 s, and never stored in the DB or logged. The status feed never carries a private key.
- Destructive actions (remove-peer, gate-off, set-port, set-subnet) use wire-elements/modal + ConfirmToken (R5). set-subnet also requires typed confirmation.
- Never lock out: the gate still only filters 80/443; SSH + the WG port are untouched;
clusev wg downover SSH remains the authoritative escape and is documented in the UI. A gate toggled on from the UI persists via SP1'sclusev-wg-gate.service. - Audit: every write request is an
AuditEvent(actor, action, args summary, ip) — like the deploy/update requests. - Throttle: write requests are per-user rate-limited (auto-expiring, never a lockout), as
requestUpdateis. - Result-id binding:
GET /wg-result.jsononly returns a result for anidthe app issued (held in cache/session), so the route can't be used to read arbitraryrun/files.
7. Data flow summary
- Read: host
.timer→clusev-wg.sh collect→run/wg-status.json→GET /wg-status.json→ page (wire:poll). History: app scheduler → reads status →wg_traffic_sample→ chart. - Write: page action →
DeploymentService::requestWg($action,$args)writesrun/wg-request.json+ audits →clusev-wg-request.path→clusev-wg.sh serve-request(validate + run + result) →run/wg-result-<id>.json→GET /wg-result.json?id→ page (toast / show-once modal).
8. Files
New (host): clusev-wg.sh gains collect, serve-request, cmd_set_endpoint, cmd_set_port, cmd_set_subnet, non-interactive setup; docker/wg/clusev-wg-collect.{timer,service}, docker/wg/clusev-wg-request.{path,service}.
New (app): app/Livewire/Wireguard/Index.php + resources/views/livewire/wireguard/index.blade.php; a peer/result modal component; app/Models/WgTrafficSample.php + a migration; a sampling+prune job/command; lang/{de,en}/wireguard.php; an ApexCharts Alpine-island partial; lang/{de,en}/shell.php (nav_wireguard).
Modified: routes/web.php (/wireguard, /wg-status.json, /wg-result.json), app/Services/DeploymentService.php (the request writer + result reader), resources/views/components/sidebar.blade.php (nav), install.sh (render+enable the collect timer + request watcher), package.json/resources/js (ApexCharts island), the host runbook (extend for P3/P4).
Not touched: Caddy/compose (host-firewall gate), SP1's gate logic (reused), the remote-fleet Firewall/Fail2ban services.
9. Testing
- Host scripts:
shellcheckclean; the manual VM runbook extended for collect/serve-request + the P3/P4 operations (real host needed — not PHPUnit-testable). - App: PHPUnit for the page render, the
/wg-status.json+/wg-result.jsonroute parsing/validation, the request writer + audit + throttle + result-id binding, theWgTrafficSamplemodel + sampling/prune job, the show-once modal grant. R12 browser-verify the page (DE+EN, 200, no console errors, 3 breakpoints, no leaked tokens — incl. the chart island not throwing). R15 Codex over the app diff + the shell additions; Pint clean.
10. Out of scope
- Clusev as a WG client joining an existing/corporate WG (SP1 was server-only; unchanged).
- Multi-server WG (per fleet server) — SP2 is the local control-plane host only.
- Live push (Reverb) for status — polling is sufficient and matches the existing pattern; Reverb can come later if the cadence proves too coarse.