docs(wg): SP2 Phase 3 plan — peer management + write-bridge
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
037d285148
commit
f91d8ed834
|
|
@ -0,0 +1,860 @@
|
|||
# WireGuard dashboard — Phase 3 (peer management) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** Create + remove WireGuard client peers from the `/wireguard` dashboard, with the new client config (private key) + QR shown ONCE in a modal and never persisted.
|
||||
|
||||
**Architecture:** The security-critical **write-bridge** (spec §0 Bridge 2, variant A): the app writes `run/wg-request.json` `{id,action,args}`; a new host `.path` watcher runs `clusev-wg.sh serve-request`, which **whitelists the action + re-sanitises every arg host-side**, dispatches to a fixed `cmd_*`, and writes `run/wg-result-<id>.json` (0600, owned by the app uid) + for add-peer a `run/wg-qr-<id>.svg` sidecar. The container's strings are DATA, never code. The Livewire component polls its own pending id for the result (no public route → automatic id-binding) and shows the config+QR once.
|
||||
|
||||
**Tech Stack:** bash (host bridge), systemd `.path`/`.service`, Laravel 13 + Livewire 3 + wire-elements/modal, Tailwind tokens, Pint, shellcheck. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §0/§4/§6.
|
||||
|
||||
**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc '… php artisan test --filter=<X>'`; Pint `… vendor/bin/pint <files>`; shellcheck `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable <script>`. Commit on `feat/v1-foundation`; push only at release.
|
||||
|
||||
**Security invariants (must hold):** (1) container input never reaches a shell command unquoted; action is whitelisted; each arg passes a strict host-side regex/`valid_name`. (2) The result file (private key) is 0600 + owned by the app uid + pruned after 60 s, never DB-persisted/logged. (3) An invalid/oversized id → no result written (un-claimable), drop silently. (4) Destructive (remove-peer) goes through an R5 confirm modal in the UI.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **`docker/wg/clusev-wg.sh`** (modify) — refactor `cmd_add_peer`/`cmd_remove_peer`/`cmd_up` to share `_peer_add`/`_peer_remove`/`_gate_up_now` cores; add `serve-request` + `_write_result` + `_json_str`; prune stale result/QR files in `cmd_collect`.
|
||||
- **`docker/wg/clusev-wg-request.service`** + **`docker/wg/clusev-wg-request.path`** (new) — the request watcher.
|
||||
- **`install.sh`** (modify) — render + enable the request watcher in `install_host_watchers()`.
|
||||
- **`app/Services/WgBridge.php`** (new) — `request(action,args): id` (writes `run/wg-request.json`), `result(id): ?array` (reads `run/wg-result-<id>.json` + the QR sidecar). One responsibility: the app side of the write-bridge.
|
||||
- **`app/Livewire/Wireguard/Index.php`** (modify) — add-peer form, remove-peer (confirm), pending-id polling, show-once result, audit + throttle.
|
||||
- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — add-peer input, per-peer remove button, the show-once modal.
|
||||
- **`lang/{de,en}/wireguard.php`** (modify) — peer-mgmt strings.
|
||||
- **Tests:** `tests/Feature/WgBridgeTest.php`, extend `WireguardPageTest`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: the host write-bridge (`serve-request`) + refactor
|
||||
|
||||
**Files:** Modify `docker/wg/clusev-wg.sh`
|
||||
|
||||
- [ ] **Step 1: Extract `_peer_add` core** (returns ONLY the client config text on stdout; no prints). Add it just above the existing `cmd_add_peer`:
|
||||
|
||||
```bash
|
||||
# Core: create a peer + print ONLY the client config to stdout (errors to stderr, non-zero on fail).
|
||||
# Shared by the interactive cmd_add_peer and the write-bridge serve-request.
|
||||
_peer_add() {
|
||||
local name="$1"
|
||||
require_setup
|
||||
# shellcheck disable=SC1090
|
||||
. "$WG_ENV"
|
||||
[ -n "${WG_SUBNET:-}" ] && [ -n "${WG_SERVER_PUBKEY:-}" ] && [ -n "${WG_ENDPOINT:-}" ] || { echo "wg.env unvollstaendig" >&2; return 1; }
|
||||
grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && { echo "Peer existiert bereits" >&2; return 1; }
|
||||
local ip cpriv cpub
|
||||
ip="$(next_free_ip)" || { echo "kein freier Adressraum" >&2; return 1; }
|
||||
cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)"
|
||||
cat >> "$WG_CONF" <<EOF
|
||||
|
||||
# clusev-peer: ${name}
|
||||
[Peer]
|
||||
PublicKey = ${cpub}
|
||||
AllowedIPs = ${ip}/32
|
||||
EOF
|
||||
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32"
|
||||
cat <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${cpriv}
|
||||
Address = ${ip}/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${WG_SERVER_PUBKEY}
|
||||
Endpoint = ${WG_ENDPOINT}
|
||||
AllowedIPs = ${WG_SUBNET}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite `cmd_add_peer`** to wrap the core (preserving its prior output: heading, config, QR, hint):
|
||||
|
||||
```bash
|
||||
cmd_add_peer() {
|
||||
need_root add-peer
|
||||
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer <name>"
|
||||
valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
|
||||
local conf; conf="$(_peer_add "$name")" || die "Anlegen fehlgeschlagen: ${conf}"
|
||||
# shellcheck disable=SC1090
|
||||
[ -f "$WG_ENV" ] && . "$WG_ENV"
|
||||
local ip; ip="$(printf '%s\n' "$conf" | awk '/^Address/{print $3; exit}')"
|
||||
bold "Peer '${name}' angelegt (${ip%%/*})."
|
||||
printf '%s\n' "$conf"
|
||||
if have qrencode; then
|
||||
printf '%s\n' "$conf" | qrencode -t ansiutf8 || warn "QR-Erzeugung fehlgeschlagen — nutze die Textkonfiguration oben."
|
||||
else
|
||||
warn "qrencode nicht installiert — nutze die Textkonfiguration oben."
|
||||
fi
|
||||
info "AllowedIPs=${WG_SUBNET:-} = Split-Tunnel (nur Panel ueber WG). Voll-Tunnel (0.0.0.0/0) moeglich, aber NICHT empfohlen — leitet allen Client-Verkehr ueber Clusev."
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extract `_peer_remove` core** (no prints; non-zero on fail) and slim `cmd_remove_peer` to wrap it. Replace the body of `cmd_remove_peer` (everything after the `valid_name` line) with a call to the core, and add the core above it:
|
||||
|
||||
```bash
|
||||
# Core: remove the named peer from the running interface + wg0.conf. Errors to stderr.
|
||||
_peer_remove() {
|
||||
local name="$1"
|
||||
require_setup
|
||||
local pub
|
||||
pub="$(WANT="# clusev-peer: ${name}" awk '$0==ENVIRON["WANT"]{f=1;next} f&&/PublicKey/{gsub(/[ \t]/,"");sub(/PublicKey=/,"");print;exit}' "$WG_CONF")"
|
||||
[ -n "$pub" ] || { echo "Peer '${name}' nicht gefunden" >&2; return 1; }
|
||||
wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true
|
||||
local tmp; tmp="$(mktemp)"
|
||||
WANT="# clusev-peer: ${name}" awk '
|
||||
$0==ENVIRON["WANT"] {drop=1; next}
|
||||
drop && /^[[:space:]]*$/ {drop=0; next}
|
||||
drop {next}
|
||||
{print}
|
||||
' "$WG_CONF" > "$tmp"
|
||||
install -m 0600 "$tmp" "$WG_CONF"; rm -f "$tmp"
|
||||
}
|
||||
|
||||
cmd_remove_peer() {
|
||||
need_root remove-peer
|
||||
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
|
||||
valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -"
|
||||
_peer_remove "$name" || die "Entfernen fehlgeschlagen."
|
||||
bold "Peer '${name}' entfernt."
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Extract `_gate_up_now` core** (marker + apply, no interactive warnings) and have `cmd_up` wrap it. Add the core above `cmd_up`, and replace `cmd_up`'s marker+gate_apply lines with a call:
|
||||
|
||||
```bash
|
||||
# Core: enable the gate (write marker + apply). Refuses if wg0 is down. Errors to stderr.
|
||||
_gate_up_now() {
|
||||
require_setup
|
||||
[ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; }
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf 'enabled\n' > "$GATE_MARKER"
|
||||
gate_apply
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
need_root up
|
||||
require_setup
|
||||
[ -e "/sys/class/net/${WG_IF}" ] || die "${WG_IF} ist nicht aktiv. Erst Tunnel testen (siehe 'clusev wg status'), dann 'clusev wg up'."
|
||||
warn "Vor dem Gate sicherstellen, dass der Tunnel das Panel erreicht. Notausgang ueber SSH: 'clusev wg down'."
|
||||
_gate_up_now
|
||||
bold "WireGuard-Gate aktiv — Panel (80/443) nur noch ueber den Tunnel erreichbar."
|
||||
info "Notausgang (per SSH, falls der Tunnel nicht erreicht): clusev wg down"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the JSON-string escaper + result writer + serve-request.** Place these just before `cmd_collect` (or near the other helpers):
|
||||
|
||||
```bash
|
||||
# Minimal-but-correct JSON string escaper (quotes, backslashes, newlines, tabs; strip CR + other
|
||||
# control). Our values are config text / short messages — this covers them safely.
|
||||
_json_str() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '"%s"' "$s"
|
||||
}
|
||||
|
||||
# Write run/wg-result-<id>.json (0600, owned by the run/ owner = the app uid). config is the client
|
||||
# config text (add-peer only) or empty.
|
||||
_write_result() {
|
||||
local id="$1" ok="$2" msg="$3" config="$4"
|
||||
local f="${RUN_DIR}/wg-result-${id}.json" cfg='null'
|
||||
[ -n "$config" ] && cfg="$(_json_str "$config")"
|
||||
mkdir -p "$RUN_DIR" 2>/dev/null || true
|
||||
printf '{"id":"%s","ok":%s,"message":%s,"config":%s,"at":%s}\n' \
|
||||
"$id" "$ok" "$(_json_str "$msg")" "$cfg" "$(date +%s 2>/dev/null || echo 0)" > "${f}.tmp"
|
||||
mv -f "${f}.tmp" "$f" 2>/dev/null || { rm -f "${f}.tmp"; return 0; }
|
||||
chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$f" 2>/dev/null || true
|
||||
chmod 600 "$f" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Process one UI write-request (run/wg-request.json). Whitelists the action + re-sanitises every arg
|
||||
# HOST-SIDE (the container's strings are data, never code), runs the fixed cmd, writes the result.
|
||||
cmd_serve_request() {
|
||||
need_root serve-request
|
||||
local req="${RUN_DIR}/wg-request.json" work="${RUN_DIR}/wg-request.processing"
|
||||
[ -f "$req" ] || return 0
|
||||
mv -f "$req" "$work" 2>/dev/null || return 0 # consume single-shot (re-arm can't double-run)
|
||||
local json id action name
|
||||
json="$(cat "$work" 2>/dev/null || true)"; rm -f "$work"
|
||||
|
||||
# Extract with STRICT regexes — a value with any other char simply won't match (→ empty → rejected).
|
||||
id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
|
||||
[ -n "$id" ] || return 0 # no valid id → result un-claimable → drop silently
|
||||
action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,24}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
|
||||
name="$(printf '%s' "$json" | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
|
||||
|
||||
local ok=false msg='ok' config=''
|
||||
case "$action" in
|
||||
add-peer)
|
||||
if valid_name "$name"; then
|
||||
if config="$(_peer_add "$name" 2>/dev/null)"; then ok=true; else ok=false; msg='add-failed'; config=''; fi
|
||||
else msg='invalid-name'; fi ;;
|
||||
remove-peer)
|
||||
if valid_name "$name"; then
|
||||
if _peer_remove "$name" >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi
|
||||
else msg='invalid-name'; fi ;;
|
||||
gate-up) if _gate_up_now >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;;
|
||||
gate-down) if cmd_down >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;;
|
||||
*) msg='unknown-action' ;;
|
||||
esac
|
||||
|
||||
_write_result "$id" "$ok" "$msg" "$config"
|
||||
if [ "$ok" = true ] && [ "$action" = add-peer ] && [ -n "$config" ] && have qrencode; then
|
||||
local qr="${RUN_DIR}/wg-qr-${id}.svg"
|
||||
if printf '%s\n' "$config" | qrencode -t SVG -o "$qr" 2>/dev/null; then
|
||||
chown "$(stat -c %u:%g "$RUN_DIR" 2>/dev/null || echo 0:0)" "$qr" 2>/dev/null || true
|
||||
chmod 600 "$qr" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Prune stale result/QR files in `cmd_collect`.** At the very top of `cmd_collect` (after `mkdir -p "$RUN_DIR"`), add:
|
||||
|
||||
```bash
|
||||
# Drop result + QR sidecars older than 60 s (they carry a private key; the app reads them once).
|
||||
find "$RUN_DIR" -maxdepth 1 -type f \( -name 'wg-result-*.json' -o -name 'wg-qr-*.svg' \) -mmin +1 -delete 2>/dev/null || true
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add dispatch arms.** In the `case "$cmd"` switch, after the `collect)` arm add:
|
||||
|
||||
```bash
|
||||
serve-request) cmd_serve_request ;; # called by clusev-wg-request.service
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Verify.** `bash -n docker/wg/clusev-wg.sh` → clean. shellcheck → clean (the SC1090 disables stay). Smoke the safe paths:
|
||||
- `bash docker/wg/clusev-wg.sh help` → usage, exit 0.
|
||||
- `CLUSEV_DIR="$PWD" bash docker/wg/clusev-wg.sh collect; cat run/wg-status.json; rm -f run/wg-status.json` → `configured:false`, exit 0 (collect still works after the prune line).
|
||||
- Test `_json_str` in isolation: `bash -c 'source <(sed -n "/^_json_str/,/^}/p" docker/wg/clusev-wg.sh); _json_str "$(printf "a\"b\nc\\\\d")"'` → prints `"a\"b\nc\\d"` (quotes/newline/backslash escaped).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/wg/clusev-wg.sh
|
||||
git commit -m "feat(wg): serve-request write-bridge + peer/gate cores (host-side validation)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: the request watcher units
|
||||
|
||||
**Files:** Create `docker/wg/clusev-wg-request.service`, `docker/wg/clusev-wg-request.path`
|
||||
|
||||
- [ ] **Step 1: `docker/wg/clusev-wg-request.service`:**
|
||||
|
||||
```ini
|
||||
# Clusev WireGuard write-request handler — oneshot (HOST-side). Triggered by clusev-wg-request.path
|
||||
# when the dashboard writes run/wg-request.json. Runs `clusev-wg.sh serve-request`, which consumes
|
||||
# the request, whitelists the action + re-validates args, runs the fixed command, and writes a
|
||||
# result file the app reads once. install.sh rewrites the /home/nexxo/clusev paths.
|
||||
[Unit]
|
||||
Description=Clusev WireGuard write-request handler
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Environment=CLUSEV_DIR=/home/nexxo/clusev
|
||||
WorkingDirectory=/home/nexxo/clusev
|
||||
ExecStart=/home/nexxo/clusev/docker/wg/clusev-wg.sh serve-request
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `docker/wg/clusev-wg-request.path`:**
|
||||
|
||||
```ini
|
||||
# Watches for a dashboard WireGuard write-request and fires the handler the moment it appears. The
|
||||
# handler consumes the request, so a re-arm cannot double-run it. install.sh rewrites the path.
|
||||
[Unit]
|
||||
Description=Clusev WireGuard write-request — watch for a dashboard request
|
||||
After=docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Path]
|
||||
PathExists=/home/nexxo/clusev/run/wg-request.json
|
||||
Unit=clusev-wg-request.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify** `systemd-analyze verify docker/wg/clusev-wg-request.path docker/wg/clusev-wg-request.service 2>&1` (path-not-found is fine). Confirm the `.path` `Unit=` matches the `.service` filename + `PathExists=` matches `run/wg-request.json`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docker/wg/clusev-wg-request.service docker/wg/clusev-wg-request.path
|
||||
git commit -m "feat(wg): clusev-wg-request watcher (path+service) for the UI write-bridge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: install.sh — render + enable the request watcher
|
||||
|
||||
**Files:** Modify `install.sh` (`install_host_watchers()`)
|
||||
|
||||
- [ ] **Step 1:** Mirror the collector wiring for the request watcher (a `.path` + `.service`, enabled with `--now` so it arms immediately). After the collector temp vars line (`local tmp_csvc tmp_ctimer; …`) add:
|
||||
|
||||
```bash
|
||||
local tmp_rsvc tmp_rpath; tmp_rsvc="$(mktemp)"; tmp_rpath="$(mktemp)"
|
||||
```
|
||||
|
||||
After the collector sed lines add:
|
||||
|
||||
```bash
|
||||
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.service" > "$tmp_rsvc"
|
||||
sed "s#/home/nexxo/clusev#${proj}#g" "docker/wg/clusev-wg-request.path" > "$tmp_rpath"
|
||||
```
|
||||
|
||||
In the `&&` chain, after the collector install lines add:
|
||||
|
||||
```bash
|
||||
&& install -m 0644 "$tmp_rsvc" "${dst}/clusev-wg-request.service" \
|
||||
&& install -m 0644 "$tmp_rpath" "${dst}/clusev-wg-request.path" \
|
||||
```
|
||||
|
||||
and after the `systemctl enable --now clusev-wg-collect.timer` line add:
|
||||
|
||||
```bash
|
||||
&& systemctl enable --now clusev-wg-request.path \
|
||||
```
|
||||
|
||||
Append the two temps to the cleanup `rm -f` line.
|
||||
|
||||
- [ ] **Step 2: Verify.** `bash -n install.sh` → clean. shellcheck → no new findings vs `git show HEAD:install.sh`. Eyeball: `daemon-reload` still after all installs; the `.path` enabled among the enables.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add install.sh
|
||||
git commit -m "feat(wg): install + enable the WireGuard write-request watcher"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `WgBridge` app service
|
||||
|
||||
**Files:** Create `app/Services/WgBridge.php`; Test `tests/Feature/WgBridgeTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (`tests/Feature/WgBridgeTest.php`):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\WgBridge;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WgBridgeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->dir = storage_path('app/restart-signal');
|
||||
@mkdir($this->dir, 0775, true);
|
||||
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_request_writes_a_well_formed_request_and_returns_an_id(): void
|
||||
{
|
||||
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
|
||||
|
||||
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
|
||||
$req = json_decode((string) file_get_contents($this->dir.'/wg-request.json'), true);
|
||||
$this->assertSame($id, $req['id']);
|
||||
$this->assertSame('add-peer', $req['action']);
|
||||
$this->assertSame('laptop', $req['name']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_an_unknown_action(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(WgBridge::class)->request('rm -rf', ['name' => 'x']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_an_invalid_name(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(WgBridge::class)->request('add-peer', ['name' => 'bad name; rm']);
|
||||
}
|
||||
|
||||
public function test_result_only_returns_for_the_matching_id(): void
|
||||
{
|
||||
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
|
||||
file_put_contents($this->dir."/wg-result-{$id}.json", json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "cfg", 'at' => time()]));
|
||||
|
||||
$this->assertNull(app(WgBridge::class)->result('different-id-totally'));
|
||||
$r = app(WgBridge::class)->result($id);
|
||||
$this->assertTrue($r['ok']);
|
||||
$this->assertSame('cfg', $r['config']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → fails.**
|
||||
|
||||
- [ ] **Step 3: Write `app/Services/WgBridge.php`:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The app side of the WireGuard write-bridge. `request()` writes a strictly-validated request to
|
||||
* run/wg-request.json (a host watcher runs it); `result()` reads the host-written result for an id
|
||||
* the app issued. Validation here is the FIRST gate — the host re-validates every value too.
|
||||
*/
|
||||
class WgBridge
|
||||
{
|
||||
/** Actions the UI may request; the host whitelists the same set. */
|
||||
public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet'];
|
||||
|
||||
private function dir(): string
|
||||
{
|
||||
return storage_path('app/restart-signal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a write-request; returns the request id. Throws InvalidArgumentException on a bad
|
||||
* action or arg (defence-in-depth; the host re-validates).
|
||||
*
|
||||
* @param array<string,string> $args
|
||||
*/
|
||||
public function request(string $action, array $args = []): string
|
||||
{
|
||||
if (! in_array($action, self::ACTIONS, true)) {
|
||||
throw new \InvalidArgumentException('unknown action');
|
||||
}
|
||||
$clean = $this->validateArgs($action, $args);
|
||||
|
||||
$id = Str::random(32);
|
||||
$payload = array_merge(['id' => $id, 'action' => $action, 'at' => time()], $clean);
|
||||
|
||||
@mkdir($this->dir(), 0775, true);
|
||||
file_put_contents($this->dir().'/wg-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host result for an id THIS app issued. Returns null until the result exists.
|
||||
* Includes the QR sidecar SVG when present (add-peer). Never throws.
|
||||
*
|
||||
* @return array{ok:bool, message:string, config:?string, qr:?string}|null
|
||||
*/
|
||||
public function result(string $id): ?array
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$file = $this->dir()."/wg-result-{$id}.json";
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string) @file_get_contents($file), true);
|
||||
if (! is_array($data) || ($data['id'] ?? null) !== $id) {
|
||||
return null;
|
||||
}
|
||||
$qrFile = $this->dir()."/wg-qr-{$id}.svg";
|
||||
$qr = is_file($qrFile) ? (string) @file_get_contents($qrFile) : null;
|
||||
|
||||
return [
|
||||
'ok' => (bool) ($data['ok'] ?? false),
|
||||
'message' => (string) ($data['message'] ?? ''),
|
||||
'config' => isset($data['config']) && is_string($data['config']) ? $data['config'] : null,
|
||||
'qr' => $qr,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $args
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function validateArgs(string $action, array $args): array
|
||||
{
|
||||
$name = (string) ($args['name'] ?? '');
|
||||
switch ($action) {
|
||||
case 'add-peer':
|
||||
case 'remove-peer':
|
||||
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid name');
|
||||
}
|
||||
|
||||
return ['name' => $name];
|
||||
case 'gate-up':
|
||||
case 'gate-down':
|
||||
return [];
|
||||
case 'set-endpoint': // P4
|
||||
$ep = (string) ($args['endpoint'] ?? '');
|
||||
if (preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $ep) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid endpoint');
|
||||
}
|
||||
|
||||
return ['endpoint' => $ep];
|
||||
case 'set-port': // P4
|
||||
$port = (string) ($args['port'] ?? '');
|
||||
if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
|
||||
throw new \InvalidArgumentException('invalid port');
|
||||
}
|
||||
|
||||
return ['port' => $port];
|
||||
case 'set-subnet': // P4
|
||||
$subnet = (string) ($args['subnet'] ?? '');
|
||||
if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid subnet');
|
||||
}
|
||||
|
||||
return ['subnet' => $subnet];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(`set-*` are validated now so P4 only adds the host `cmd_*` + UI — the app gate is ready.)
|
||||
|
||||
- [ ] **Step 4: Run → passes.** Pint the service + test.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Services/WgBridge.php tests/Feature/WgBridgeTest.php
|
||||
git commit -m "feat(wg): WgBridge — app side of the write-bridge (validate + request + result)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: peer management UI
|
||||
|
||||
**Files:** Modify `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/{de,en}/wireguard.php`; Test extend `tests/Feature/WireguardPageTest.php`
|
||||
|
||||
- [ ] **Step 1: Extend the page test** — add to `WireguardPageTest`:
|
||||
|
||||
```php
|
||||
public function test_add_peer_writes_a_request_and_audits(): void
|
||||
{
|
||||
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
|
||||
->set('newPeer', 'laptop')
|
||||
->call('addPeer')
|
||||
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||
|
||||
$this->assertTrue(\App\Models\AuditEvent::where('action', 'wg.add-peer')->exists());
|
||||
$this->assertFileExists(storage_path('app/restart-signal/wg-request.json'));
|
||||
@unlink(storage_path('app/restart-signal/wg-request.json'));
|
||||
}
|
||||
|
||||
public function test_add_peer_rejects_an_invalid_name(): void
|
||||
{
|
||||
\Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)
|
||||
->set('newPeer', 'bad name')
|
||||
->call('addPeer')
|
||||
->assertHasErrors('newPeer');
|
||||
$this->assertSame(0, \App\Models\AuditEvent::where('action', 'wg.add-peer')->count());
|
||||
}
|
||||
|
||||
public function test_poll_result_surfaces_the_config_once(): void
|
||||
{
|
||||
$c = \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class)->set('newPeer', 'laptop')->call('addPeer');
|
||||
$id = $c->get('pendingId');
|
||||
file_put_contents(storage_path("app/restart-signal/wg-result-{$id}.json"), json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => "[Interface]\nPrivateKey = x", 'at' => time()]));
|
||||
|
||||
$c->call('pollResult')
|
||||
->assertSet('pendingId', null)
|
||||
->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey'));
|
||||
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → fails.**
|
||||
|
||||
- [ ] **Step 3: Update the component** — add to `app/Livewire/Wireguard/Index.php` (keep the existing `$window`/`setWindow`/`render`; add the peer-mgmt state + methods + inject `WgBridge`). The full updated class:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Wireguard;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\WgBridge;
|
||||
use App\Services\WgStatus;
|
||||
use App\Services\WgTraffic;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* WireGuard dashboard — live status (P1) + traffic (P2) + peer management (P3). Reads the
|
||||
* host-collected status; mutations go through the host write-bridge (WgBridge), never a shell.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public int $window = 3600;
|
||||
|
||||
public const WINDOWS = [3600, 86400, 604800];
|
||||
|
||||
// ── peer management ──
|
||||
public string $newPeer = '';
|
||||
|
||||
/** id of an in-flight write-request we are polling for. */
|
||||
public ?string $pendingId = null;
|
||||
|
||||
/** What we are waiting on ('add-peer'|'remove-peer'|…) — drives the toast/modal. */
|
||||
public ?string $pendingAction = null;
|
||||
|
||||
/** Show-once add-peer result (config text + QR svg). Never persisted. */
|
||||
public ?string $resultConfig = null;
|
||||
|
||||
public ?string $resultQr = null;
|
||||
|
||||
public function setWindow(int $seconds): void
|
||||
{
|
||||
$this->window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600;
|
||||
}
|
||||
|
||||
public function addPeer(WgBridge $bridge): void
|
||||
{
|
||||
$this->validate(['newPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/']], [
|
||||
'newPeer.regex' => __('wireguard.peer_name_invalid'),
|
||||
]);
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$name = $this->newPeer;
|
||||
$this->pendingId = $bridge->request('add-peer', ['name' => $name]);
|
||||
$this->pendingAction = 'add-peer';
|
||||
$this->newPeer = '';
|
||||
$this->audit('wg.add-peer', $name);
|
||||
}
|
||||
|
||||
public function removePeer(WgBridge $bridge, string $name): void
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1 || ! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('remove-peer', ['name' => $name]);
|
||||
$this->pendingAction = 'remove-peer';
|
||||
$this->audit('wg.remove-peer', $name);
|
||||
}
|
||||
|
||||
/** Polled (wire:poll) while a request is in flight; surfaces the host result once. */
|
||||
public function pollResult(WgBridge $bridge): void
|
||||
{
|
||||
if ($this->pendingId === null) {
|
||||
return;
|
||||
}
|
||||
$res = $bridge->result($this->pendingId);
|
||||
if ($res === null) {
|
||||
return; // not ready yet
|
||||
}
|
||||
$id = $this->pendingId;
|
||||
$this->pendingId = null;
|
||||
if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
|
||||
$this->resultConfig = $res['config']; // show once
|
||||
$this->resultQr = $res['qr'];
|
||||
} elseif (! $res['ok']) {
|
||||
$this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
|
||||
} else {
|
||||
$this->dispatch('notify', message: __('wireguard.action_done'));
|
||||
}
|
||||
$this->pendingAction = null;
|
||||
}
|
||||
|
||||
public function dismissResult(): void
|
||||
{
|
||||
$this->resultConfig = null;
|
||||
$this->resultQr = null;
|
||||
}
|
||||
|
||||
private function throttle(): bool
|
||||
{
|
||||
$key = 'wg-request:'.Auth::id();
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$this->dispatch('notify', message: __('wireguard.throttled'), level: 'error');
|
||||
|
||||
return false;
|
||||
}
|
||||
RateLimiter::hit($key, 60);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function audit(string $action, string $target): void
|
||||
{
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $action,
|
||||
'target' => $target,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(WgStatus $wg, WgTraffic $traffic)
|
||||
{
|
||||
$window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600;
|
||||
|
||||
return view('livewire.wireguard.index', [
|
||||
'status' => $wg->read(),
|
||||
'traffic' => $traffic->series($window),
|
||||
'windows' => self::WINDOWS,
|
||||
])->title(__('wireguard.title'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add lang strings** (BOTH files, identical keys). DE:
|
||||
|
||||
```php
|
||||
'add_peer' => 'Peer hinzufügen',
|
||||
'peer_name_placeholder' => 'Name (z. B. laptop)',
|
||||
'peer_name_invalid' => 'Nur A–Z a–z 0–9 . _ - (max. 64).',
|
||||
'remove' => 'Entfernen',
|
||||
'remove_confirm_title' => 'Peer entfernen?',
|
||||
'remove_confirm_body' => 'Der Client verliert sofort den Zugang. Das lässt sich nicht rückgängig machen.',
|
||||
'pending' => 'Wird angewendet …',
|
||||
'action_done' => 'Aktion angewendet.',
|
||||
'action_failed' => 'Aktion fehlgeschlagen — auf dem Host prüfen.',
|
||||
'throttled' => 'Zu viele Aktionen — kurz warten.',
|
||||
'new_peer_title' => 'Neuer Peer — Konfiguration',
|
||||
'new_peer_once' => 'Diese Konfiguration wird NUR EINMAL angezeigt und nicht gespeichert. Jetzt importieren (QR scannen oder Text kopieren).',
|
||||
'close' => 'Schließen',
|
||||
```
|
||||
|
||||
EN:
|
||||
|
||||
```php
|
||||
'add_peer' => 'Add peer',
|
||||
'peer_name_placeholder' => 'Name (e.g. laptop)',
|
||||
'peer_name_invalid' => 'Only A–Z a–z 0–9 . _ - (max 64).',
|
||||
'remove' => 'Remove',
|
||||
'remove_confirm_title' => 'Remove peer?',
|
||||
'remove_confirm_body' => 'The client loses access immediately. This cannot be undone.',
|
||||
'pending' => 'Applying …',
|
||||
'action_done' => 'Action applied.',
|
||||
'action_failed' => 'Action failed — check on the host.',
|
||||
'throttled' => 'Too many actions — wait a moment.',
|
||||
'new_peer_title' => 'New peer — configuration',
|
||||
'new_peer_once' => 'This configuration is shown ONCE and never stored. Import it now (scan the QR or copy the text).',
|
||||
'close' => 'Close',
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update the view.** In `resources/views/livewire/wireguard/index.blade.php`:
|
||||
- Add `wire:poll.2s="pollResult"` is NOT global — instead, add a small polling element shown only while pending. Put this near the top of the configured branch (inside `@else`), above the Traffic panel:
|
||||
|
||||
```blade
|
||||
@if ($pendingId)
|
||||
<div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3" wire:poll.2s="pollResult">
|
||||
<svg class="h-4 w-4 shrink-0 animate-spin text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
<span class="font-mono text-[11px] text-ink-2">{{ __('wireguard.pending') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
```
|
||||
|
||||
- In the Peers panel header (the `<x-panel :title="__('wireguard.peers_title')" …>`), add an add-peer form. Change the panel to include a header action — put this inside the peers panel, just after its opening, as a sub-header row:
|
||||
|
||||
```blade
|
||||
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
|
||||
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
|
||||
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
|
||||
</x-btn>
|
||||
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
||||
</form>
|
||||
```
|
||||
|
||||
- In each peer row, add a remove control (a destructive action → R5 wire-elements/modal confirm via the project's confirm pattern). Add to the peer row, in the `ml-auto` area:
|
||||
|
||||
```blade
|
||||
<button type="button"
|
||||
wire:click="$dispatch('open-modal', { component: 'modals.confirm-delete', arguments: { title: @js(__('wireguard.remove_confirm_title')), body: @js(__('wireguard.remove_confirm_body')), confirm: @js(__('wireguard.remove')), method: 'removePeer', params: @js([$peer['name']]) }})"
|
||||
class="rounded-md border border-line bg-raised px-2 py-1 font-mono text-[10px] text-ink-3 transition-colors hover:border-offline/40 hover:text-offline">{{ __('wireguard.remove') }}</button>
|
||||
```
|
||||
|
||||
NOTE: the implementer MUST first read how destructive confirms are actually wired in this project — find the existing `Modals\ConfirmDelete` (or equivalent) component and an existing call site (e.g. server delete or `clusev unban`), and replicate THAT exact pattern + arguments (component name, the dispatch event, how the confirmed method + params are invoked, the ConfirmToken if used per R5). Replace the snippet above with the project's real confirm-modal invocation calling `removePeer($peer['name'])`. Do not invent a modal API.
|
||||
|
||||
- Add the show-once result modal at the END of the root `<div>` (before its closing tag):
|
||||
|
||||
```blade
|
||||
@if ($resultConfig)
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-void/80 p-4" wire:key="wg-result-modal">
|
||||
<div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
|
||||
<h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
|
||||
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
|
||||
@if ($resultQr)
|
||||
<div class="mx-auto mt-4 h-44 w-44 [&>svg]:h-full [&>svg]:w-full">{!! $resultQr !!}</div>
|
||||
@endif
|
||||
<pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
```
|
||||
|
||||
(The QR is host-generated SVG; `{!! !!}` is safe here — it is produced by `qrencode` on the host from our own config, not user HTML. The implementer should still confirm the SVG has no `<script>` — `qrencode -t SVG` emits only `<svg><rect/>…`.)
|
||||
|
||||
- [ ] **Step 6: Run the page tests → pass.** Pint. (If the confirm-modal pattern needs adjusting, fix until the remove path works in a Livewire test.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WireguardPageTest.php
|
||||
git commit -m "feat(wg): add/remove peers from the dashboard (show-once config + QR)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: review + R12 + release
|
||||
|
||||
- [ ] **Step 1: Adversarial security review** of the write-bridge (Task 1 + 4 + 5) — focus: can container input reach a shell command? is every arg whitelisted host-side AND app-side? is the result file un-leakable (0600, id-bound, pruned)? id-collision/claim-other-user's-result? the QR SVG injection surface? throttle + audit present? Fix every confirmed finding.
|
||||
- [ ] **Step 2: shellcheck + Pint + full suite** all green.
|
||||
- [ ] **Step 3: R12** — browser-verify `/wireguard` add-peer flow: stub a configured status, type a name → addPeer → (simulate the host by writing a `wg-result-<id>.json` with a config + a small `wg-qr-<id>.svg`) → pollResult surfaces the show-once modal with the config + QR; the remove confirm modal opens. 1280 + 375, DE+EN, no console errors, no leaked tokens. Restore the gkonrad password + clean stubs.
|
||||
- [ ] **Step 4: Release** v0.9.36 — CHANGELOG (Hinzugefügt: Peer-Verwaltung aus dem Dashboard + Schreib-Brücke), bump, commit, tag, push.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (P3 = spec §4 + the §0 Bridge 2 + §6 security):**
|
||||
- Write-bridge (request file → host watcher → serve-request whitelist+re-validate → result file) → Tasks 1–4. ✓
|
||||
- add-peer (show-once config + QR, never persisted) → Tasks 1, 5. ✓
|
||||
- remove-peer (R5 confirm) → Tasks 1, 5. ✓
|
||||
- Host-side re-validation + app-side validation (defence in depth) → Tasks 1, 4. ✓
|
||||
- Result 0600 + app-uid-owned + 60s prune + id-bound read → Tasks 1, 4. ✓
|
||||
- Audit + throttle → Task 5. ✓
|
||||
- gate-up/gate-down actions are wired in the bridge now (UI for them is P4). ✓
|
||||
|
||||
**Placeholder scan:** none, except Task 5 explicitly defers the confirm-modal markup to the project's real pattern (the implementer must read + replicate it) — flagged, not a silent gap.
|
||||
|
||||
**Type/name consistency:** `WgBridge::ACTIONS`/`request()`/`result()` shapes match the host serve-request whitelist (`add-peer/remove-peer/gate-up/gate-down/set-*`) and the component's calls. The request JSON keys (`id/action/name/at`) written by `WgBridge::request` are exactly what `cmd_serve_request` greps. The result keys (`id/ok/message/config`) written by `_write_result` match `WgBridge::result()`’s parse + the component's use. `pendingId/pendingAction/resultConfig/resultQr/newPeer` consistent across component + view + tests.
|
||||
|
||||
**Risk note:** the confirm-modal wiring (Task 5) is the one place the plan can't fully prescribe without the project's modal API — the implementer reads the existing pattern. Everything else is fully specified. The host bridge is exercised by shellcheck + the runbook (P3 manual section) + the app-side tests with stubbed result files; the true end-to-end (container→host→container) only runs on a deployed host.
|
||||
Loading…
Reference in New Issue