fix(wg): write-bridge handler no longer dies before replying (v0.9.41)

The host handler `clusev-wg.sh serve-request` aborted under `set -euo pipefail`
BEFORE writing run/wg-result-<id>.json, so the dashboard waited ~30s and showed
"Keine Antwort vom Host" even though the systemd watcher was running correctly.
The write-bridge had therefore never actually worked on a real deployment.

Two root causes, both fixed:

1. Field extraction: each `id/action/name/endpoint/port/subnet` value is pulled
   with `grep -oE | head | sed`. A field that is empty or absent (an "auto"
   endpoint at setup; gate/add-peer/remove-peer payloads that carry no
   endpoint/port/subnet) makes grep fail → with pipefail the whole pipeline
   fails → `set -e` aborts the handler before _write_result. Appended `|| true`
   to every extraction so a non-match yields "" instead of killing the handler.

2. Action dispatch: gate-up/gate-down/gate-ssh-on/off and set-endpoint/port/
   subnet (and remove-peer) called helpers that run require_setup, which `die`s
   (exit) when the tunnel isn't configured yet — aborting serve-request before
   the reply. Each handler now runs in a subshell `( ... )` so an internal exit
   becomes a caught non-zero and still yields a clean failure result.
   (add-peer/setup were already isolated via their $(...) command substitution.)

Verified end-to-end: all ten actions now write a result for minimal/empty-field
payloads instead of timing out. New smoke test docker/wg/serve-request.test.sh
(root-bypassed, no WireGuard needed) guards both regressions. shellcheck clean,
348 PHP tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.41
boban 2026-06-21 21:53:25 +02:00
parent 20aec565ed
commit a1b70ccfc7
4 changed files with 87 additions and 15 deletions

View File

@ -13,6 +13,21 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.41] - 2026-06-21
### Behoben
- **WireGuard-Aktionen aus dem Dashboard liefen auf dem Host ins Leere („Keine Antwort vom Host").**
Der Host-Dienst, der die Dashboard-Anfragen verarbeitet (`clusev-wg.sh serve-request`), brach unter
`set -euo pipefail` ab, **bevor** er eine Antwort schrieb — sobald ein Feld leer oder gar nicht
vorhanden war: ein leeres Endpoint-Feld bei der Einrichtung („auto"), oder Aktionen ohne
bestimmte Felder (Gate ein/aus, Peer anlegen/entfernen). Dadurch wartete die Oberfläche ~30 s und
meldete „Keine Antwort vom Host", obwohl der Watcher korrekt lief. Zwei Ursachen behoben:
(1) die Feld-Extraktion per `grep` bricht bei fehlendem Treffer nicht mehr den ganzen Handler ab;
(2) jede Aktion läuft jetzt in einer Subshell, sodass ein interner Abbruch (`die` bei noch nicht
eingerichtetem Tunnel) immer eine saubere Fehlerantwort liefert statt eines Timeouts. Damit
funktionieren Einrichtung, Peer-Verwaltung, Gate und SSH-Sperre aus dem Dashboard auf einem echten
Host. Neuer Smoke-Test (`docker/wg/serve-request.test.sh`) sichert das ab.
## [0.9.40] - 2026-06-21
### Hinzugefügt

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.40',
'version' => '0.9.41',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -421,14 +421,18 @@ cmd_serve_request() {
local json id action name
json="$(cat "$work" 2>/dev/null || true)"; rm -f "$work"
id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
# Each field is OPTIONAL per action (e.g. gate-up carries no name/endpoint/port/subnet, setup may
# carry an empty endpoint). The trailing `|| true` is LOAD-BEARING: under `set -euo pipefail` a
# non-matching grep makes the pipeline fail, which would abort the whole handler BEFORE it writes a
# result — the app then never gets a reply and times out ("Keine Antwort vom Host"). Never remove it.
id="$(printf '%s' "$json" | grep -oE '"id":"[A-Za-z0-9]{16,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
[ -n "$id" ] || return 0
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/"$//')"
action="$(printf '%s' "$json" | grep -oE '"action":"[a-z-]{1,24}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
name="$(printf '%s' "$json" | grep -oE '"name":"[A-Za-z0-9._-]{1,64}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
local endpoint port subnet
endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
port="$(printf '%s' "$json" | grep -oE '"port":"[0-9]{1,5}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
subnet="$(printf '%s' "$json" | grep -oE '"subnet":"[0-9./]{1,18}"' | head -n1 | sed -E 's/.*:"//; s/"$//')"
endpoint="$(printf '%s' "$json" | grep -oE '"endpoint":"[A-Za-z0-9.:_-]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
port="$(printf '%s' "$json" | grep -oE '"port":"[0-9]{1,5}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
subnet="$(printf '%s' "$json" | grep -oE '"subnet":"[0-9./]{1,18}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)"
local ok=false msg='ok' config=''
case "$action" in
@ -436,17 +440,21 @@ cmd_serve_request() {
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 ;;
# Every handler runs in a SUBSHELL ( ... ): these helpers call require_setup, which `die`s (exit)
# on a not-yet-configured tunnel. Without the subshell that exit would abort serve-request BEFORE
# _write_result, so the app would time out instead of getting a clean failure reply. add-peer/setup
# are already isolated because they run inside a $(...) command substitution (its own subshell).
remove-peer)
if valid_name "$name"; then
if _peer_remove "$name" >/dev/null 2>&1; then ok=true; else msg='remove-failed'; fi
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) { rm -f "$GATE_MARKER"; gate_remove; } >/dev/null 2>&1 && ok=true || msg='gate-down-failed' ;; # panel only; SSH gate is its own toggle
gate-ssh-on) if _ssh_gate_on >/dev/null 2>&1; then ok=true; else msg='ssh-gate-on-failed'; fi ;;
gate-ssh-off) if _ssh_gate_off >/dev/null 2>&1; then ok=true; else msg='ssh-gate-off-failed'; fi ;;
set-endpoint) if [ -n "$endpoint" ] && _set_endpoint "$endpoint" >/dev/null 2>&1; then ok=true; else msg='set-endpoint-failed'; fi ;;
set-port) if [ -n "$port" ] && _set_port "$port" >/dev/null 2>&1; then ok=true; else msg='set-port-failed'; fi ;;
set-subnet) if [ -n "$subnet" ] && _set_subnet "$subnet" >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;;
gate-up) if ( _gate_up_now ) >/dev/null 2>&1; then ok=true; else msg='gate-up-failed'; fi ;;
gate-down) if ( rm -f "$GATE_MARKER"; gate_remove ) >/dev/null 2>&1; then ok=true; else msg='gate-down-failed'; fi ;; # panel only; SSH gate is its own toggle
gate-ssh-on) if ( _ssh_gate_on ) >/dev/null 2>&1; then ok=true; else msg='ssh-gate-on-failed'; fi ;;
gate-ssh-off) if ( _ssh_gate_off ) >/dev/null 2>&1; then ok=true; else msg='ssh-gate-off-failed'; fi ;;
set-endpoint) if [ -n "$endpoint" ] && ( _set_endpoint "$endpoint" ) >/dev/null 2>&1; then ok=true; else msg='set-endpoint-failed'; fi ;;
set-port) if [ -n "$port" ] && ( _set_port "$port" ) >/dev/null 2>&1; then ok=true; else msg='set-port-failed'; fi ;;
set-subnet) if [ -n "$subnet" ] && ( _set_subnet "$subnet" ) >/dev/null 2>&1; then ok=true; else msg='set-subnet-failed'; fi ;;
setup)
local sip ep pn
sip="$(subnet_first_ip "$subnet" 2>/dev/null || true)"

49
docker/wg/serve-request.test.sh Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Smoke test for the WireGuard write-bridge handler (clusev-wg.sh serve-request).
#
# Regression guard for two timeout bugs: under `set -euo pipefail` the handler MUST always reach
# _write_result and write run/wg-result-<id>.json — otherwise the dashboard waits ~30s and shows
# "Keine Antwort vom Host". Two ways it used to die before writing a result:
# 1. a non-matching field-extraction grep (e.g. an empty/absent endpoint) failing the pipeline;
# 2. an action helper calling require_setup -> die (exit) on a not-yet-configured tunnel.
# This test drives every action with minimal/empty-field payloads and asserts a result file appears.
# It does NOT need root or a real WireGuard install — failures are fine, a missing REPLY is not.
#
# Run: bash docker/wg/serve-request.test.sh
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/run"
# Root-bypassed copy: neutralise need_root so the handler runs unprivileged in a temp CLUSEV_DIR.
sed 's/^need_root()/_test_need_root()/; s/^ need_root serve-request/ :/' "$here/clusev-wg.sh" > "$work/clusev-wg.sh"
reqs=(
'{"id":"SETUPEMPTYENDP0001","action":"setup","at":1,"subnet":"10.99.0.0/24","port":"51820","endpoint":"","name":"client-1"}'
'{"id":"ADDPEERMINIMAL0002","action":"add-peer","at":1,"name":"laptop"}'
'{"id":"REMOVEPEERMINI0003","action":"remove-peer","at":1,"name":"laptop"}'
'{"id":"GATEUPNOFIELDS0004","action":"gate-up","at":1}'
'{"id":"GATEDOWNNOFLDS0005","action":"gate-down","at":1}'
'{"id":"GATESSHONNOFLD0006","action":"gate-ssh-on","at":1}'
'{"id":"GATESSHOFFNOFL0007","action":"gate-ssh-off","at":1}'
'{"id":"SETENDPOINTONL0008","action":"set-endpoint","at":1,"endpoint":"1.2.3.4:51820"}'
'{"id":"SETPORTONLYFLD0009","action":"set-port","at":1,"port":"51821"}'
'{"id":"SETSUBNETONLYF0010","action":"set-subnet","at":1,"subnet":"10.98.0.0/24"}'
)
fail=0
for req in "${reqs[@]}"; do
rm -f "$work/run/"wg-result-*.json "$work/run/wg-request.json"
printf '%s\n' "$req" > "$work/run/wg-request.json"
CLUSEV_DIR="$work" bash "$work/clusev-wg.sh" serve-request >/dev/null 2>&1 || true
act="$(printf '%s' "$req" | grep -oE '"action":"[a-z-]+"' | sed 's/.*://; s/"//g')"
if ls "$work/run/"wg-result-*.json >/dev/null 2>&1; then
printf ' ok %-13s wrote a result\n' "$act"
else
printf ' FAIL %-13s NO result (would time out)\n' "$act"; fail=1
fi
done
if [ "$fail" = 0 ]; then echo "PASS — every action writes a result"; else echo "FAILED"; exit 1; fi