diff --git a/CHANGELOG.md b/CHANGELOG.md index e272cea..9ca5376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/clusev.php b/config/clusev.php index aa906f9..37f3686 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -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 diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh index b2b2cce..6437d02 100755 --- a/docker/wg/clusev-wg.sh +++ b/docker/wg/clusev-wg.sh @@ -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)" diff --git a/docker/wg/serve-request.test.sh b/docker/wg/serve-request.test.sh new file mode 100755 index 0000000..8e7f4c5 --- /dev/null +++ b/docker/wg/serve-request.test.sh @@ -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-.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