From 695b33cf377b31d63af8d4b1f7195bae5a102b77 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 00:28:34 +0200 Subject: [PATCH] feat(wg): serve-request write-bridge + peer/gate cores (host-side validation) Co-Authored-By: Claude Opus 4.8 --- docker/wg/clusev-wg.sh | 225 ++++++++++++++++++++++++++++------------- 1 file changed, 155 insertions(+), 70 deletions(-) diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh index d68d16e..3c5842b 100755 --- a/docker/wg/clusev-wg.sh +++ b/docker/wg/clusev-wg.sh @@ -82,14 +82,100 @@ gate_remove() { } # ── commands ────────────────────────────────────────────────────────────────────────────────── + +# 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" <" + 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." +} + +# 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 " + valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -" + _peer_remove "$name" || die "Entfernen fehlgeschlagen." + bold "Peer '${name}' entfernt." +} + +# 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'." - mkdir -p "$STATE_DIR" - printf 'enabled\n' > "$GATE_MARKER" - gate_apply + _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" } @@ -116,73 +202,6 @@ next_free_ip() { return 1 } -cmd_add_peer() { - need_root add-peer - local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg add-peer " - valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -" - require_setup - # shellcheck disable=SC1090 - . "$WG_ENV" - [ -n "${WG_SUBNET:-}" ] && [ -n "${WG_SERVER_PUBKEY:-}" ] && [ -n "${WG_ENDPOINT:-}" ] || die "wg.env unvollstaendig — Setup erneut ausfuehren." - grep -qF "# clusev-peer: ${name}" "$WG_CONF" 2>/dev/null && die "Peer '${name}' existiert bereits." - local ip; ip="$(next_free_ip)" || die "Kein freier Adressraum im Subnetz ${WG_SUBNET}." - local cpriv cpub - cpriv="$(wg genkey)"; cpub="$(printf '%s' "$cpriv" | wg pubkey)" - - cat >> "$WG_CONF" <" - valid_name "$name" || die "Ungueltiger Peer-Name — erlaubt: A-Z a-z 0-9 . _ -" - 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" ] || die "Peer '${name}' nicht gefunden." - wg set "$WG_IF" peer "$pub" remove 2>/dev/null || true - # drop the block from the marker comment up to (and including) the trailing blank line - 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" - bold "Peer '${name}' entfernt." -} - cmd_status() { need_root status bold "WireGuard (${WG_IF})" @@ -265,10 +284,75 @@ EOF info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up" } +# Minimal-but-correct JSON string escaper (backslash, quote, CR, tab, newline). +_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-.json (0600, owned by the run/ owner = the app uid). +_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. Whitelists the action + re-validates every arg HOST-SIDE. +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 + 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/"$//')" + [ -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/"$//')" + + 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 +} + # Emit run/wg-status.json (atomic temp+mv). Public, NO secrets. If WG isn't configured, emits # {"configured":false}. Run every few seconds by clusev-wg-collect.timer; the dashboard reads it. cmd_collect() { mkdir -p "$RUN_DIR" 2>/dev/null || true + # 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 local now tmp dest now="$(date +%s 2>/dev/null || echo 0)" dest="${RUN_DIR}/wg-status.json" @@ -345,6 +429,7 @@ case "$cmd" in remove-peer) cmd_remove_peer "$@" ;; gate-apply) need_root gate-apply; gate_apply ;; # called by clusev-wg-gate.service collect) cmd_collect ;; # called by clusev-wg-collect.timer (read-only; no root needed) + serve-request) cmd_serve_request ;; # called by clusev-wg-request.service help|-h|--help) usage ;; *) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;; esac