clusev/docs/superpowers/specs/2026-06-20-wireguard-dashbo...

14 KiB
Raw Blame History

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 ./runstorage/app/restart-signal (bind mount) + host watchers. SP2 adds two bridges over it.

Bridge 1 — status collector (READ, periodic)

  • New clusev-wg.sh collect subcommand writes run/wg-status.json (atomic: temp file + mv). Content (whitelisted, no secrets — never a private key):
    {
      "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}
      ]
    }
    
    Source: wg show wg0 dump (counters + handshakes + endpoints), the # clusev-peer: <name> comments in wg0.conf (pubkey→name map), and the gate marker/chain + wg-quick@wg0 state. If WG is not configured (wg0.conf absent), 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 show is instant, and a no-WG host exits immediately. Rendered + enabled in install_host_watchers() (like the gate unit). The .timer is the only new "periodic" host pattern (SP1 used only .path event watchers).
  • New route GET /wg-status.json reads storage_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 stale at (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):

  1. The app writes run/wg-request.json = {"id":"<random-token>","action":"<whitelisted>","args":{…},"at":…} via a new DeploymentService-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 an AuditEvent (like an update request).
  2. 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 .processing temp, so a re-arm can't double-run),
    • validates action against the host-side whitelist (unknown → error result), maps it to a fixed cmd_*,
    • 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 + new cmd_set_endpoint/cmd_set_port/cmd_set_subnet/non-interactive setup),
    • writes run/wg-result-<id>.json = {"id":…,"ok":true|false,"message":"…", …}. For add-peer only, it additionally carries "client_config":"<wg conf text>" and "qr":"<ascii/SVG>" — the only secret-bearing result.
  3. 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. For add-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 (the run/ owner that install.sh chowns), so a private key is never world-readable; the collector prunes wg-result-*.json older 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 to x-icon; lock/shield already exist.)
  • Live data via wire:poll.5s calling 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 (SSH clusev wg down is 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@wg0 state.
  • 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.json read-bridge pattern + the existing x-panel/x-badge/x-status-dot kit (R10).

3. Phase P2 — traffic history + graph

  • wg_traffic_sample table + 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.json and 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 @theme accent/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 an add-peer request 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-peer request → 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/down switch → gate-up/gate-down requests. 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-endpoint request.
  • Port edit (destructive — restarts wg-quick, all clients must update): form → R5 confirm + warningset-port.
  • Subnet edit (highly destructive — re-IPs everything, breaks all peers): form → R5 confirm + a typed-confirmation warningset-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 setup request; the host runs the same collision check + key/conf generation as clusev wg setup and returns the first peer's config in a show-once modal. (The interactive clusev wg setup on the host stays the alternative.)
  • New host subcommands: cmd_set_endpoint, cmd_set_port, cmd_set_subnet, and a non-interactive setup path driven by the request args (collision check still enforced; never silently clobbers an existing wg0.conf unless the request explicitly says reconfigure).

6. Security & safety (cross-cutting)

  • Container input is data, never code. serve-request whitelists action and re-sanitises every arg host-side. No request value is ever interpolated into a shell command unquoted; peer names pass valid_name; ports must be 165535; 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 down over SSH remains the authoritative escape and is documented in the UI. A gate toggled on from the UI persists via SP1's clusev-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 requestUpdate is.
  • Result-id binding: GET /wg-result.json only returns a result for an id the app issued (held in cache/session), so the route can't be used to read arbitrary run/ files.

7. Data flow summary

  • Read: host .timerclusev-wg.sh collectrun/wg-status.jsonGET /wg-status.json → page (wire:poll). History: app scheduler → reads status → wg_traffic_sample → chart.
  • Write: page action → DeploymentService::requestWg($action,$args) writes run/wg-request.json + audits → clusev-wg-request.pathclusev-wg.sh serve-request (validate + run + result) → run/wg-result-<id>.jsonGET /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: shellcheck clean; 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.json route parsing/validation, the request writer + audit + throttle + result-id binding, the WgTrafficSample model + 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.