# 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 collect` subcommand** writes `run/wg-status.json` (atomic: temp file + `mv`). Content (whitelisted, no secrets — never a private key):
  ```json
  {
    "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 + 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 `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 `1–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 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 `.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)` writes `run/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:** `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.
