#!/usr/bin/env bash # Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg ` (the host # wrapper execs this from the deployed repo tree). Puts the panel behind a WG tunnel: the VM is the # WG server, operator devices peer in, and TCP 80/443 is filtered to the WG subnet by a dedicated # iptables chain hung off DOCKER-USER. SSH (22) and the WG UDP port are NEVER matched — you can # always SSH in and run `clusev wg down`. The gate is OFF until `clusev wg up`. set -euo pipefail WG_IF="wg0" WG_DIR="/etc/wireguard" WG_CONF="${WG_DIR}/${WG_IF}.conf" GATE_CHAIN="CLUSEV-WG-GATE" STATE_DIR="/etc/clusev" WG_ENV="${STATE_DIR}/wg.env" GATE_MARKER="${STATE_DIR}/wg-gate.enabled" PANEL_PORTS="80,443" # see spec §3 post-DNAT note: matches the published container ports (Caddy 80/443) # Project root (for the run/ bridge dir). The collect timer sets CLUSEV_DIR; otherwise derive it # from this script's location (docker/wg/clusev-wg.sh -> ../..). SELF_DIR="$(cd "$(dirname "$0")" && pwd)" CLUSEV_DIR="${CLUSEV_DIR:-$(cd "${SELF_DIR}/../.." && pwd)}" RUN_DIR="${CLUSEV_DIR}/run" bold() { printf '\033[1m%s\033[0m\n' "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf '\033[33m ! %s\033[0m\n' "$*" >&2; } die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } need_root() { [ "$(id -u)" = 0 ] || die "Bitte mit sudo ausfuehren: sudo clusev wg ${1}"; } # Peer names land in a wg0.conf comment + are matched by awk/grep; restrict to a safe charset so a # name can never inject extra config lines (defence-in-depth — the caller is already root). valid_name() { case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac; } # ── subnet helpers (SP1 assumes the default /24; documented in the runbook) ─────────────────── subnet_first_ip() { local b="${1%%/*}"; printf '%s.%s\n' "${b%.*}" "$(( ${b##*.} + 1 ))"; } # Heuristic collision check: warn+re-prompt if the chosen subnet's /24 prefix already appears in the # host's routes or addresses. Not a formal CIDR-overlap proof — it is the practical guard the spec # asks for (catch a subnet that clashes with an existing interface/route), so a default cannot slip # past a LAN collision. subnet_collides() { local net="${1%%/*}" prefix prefix="${net%.*}" # first three octets, e.g. 10.99.0 ip -o addr 2>/dev/null | awk '{print $4}' | grep -q "^${prefix}\." && return 0 ip -o route 2>/dev/null | awk '{print $1}' | grep -q "^${prefix}\." && return 0 return 1 } detect_endpoint_ip() { # Same lookup as install.sh: public IP via ipify, fall back to the first local address. curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null \ || hostname -I 2>/dev/null | awk '{print $1}' } require_setup() { [ -f "$WG_CONF" ] && [ -f "$WG_ENV" ] || die "Keine WG-Konfiguration — erst 'clusev wg setup' ausfuehren."; } # ── the gate ────────────────────────────────────────────────────────────────────────────────── gate_apply() { # Called by `up` and by the systemd unit on boot/Docker-restart. No-op (success) unless the # marker says the gate is enabled, so the boot unit never gates an operator who ran `down`. [ -f "$GATE_MARKER" ] || { info "Gate-Marker fehlt — Gate nicht angewendet."; return 0; } have iptables || die "iptables nicht gefunden." # shellcheck disable=SC1090 [ -f "$WG_ENV" ] && . "$WG_ENV" [ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET unbekannt (${WG_ENV} fehlt) — Gate nicht angewendet." iptables -nL DOCKER-USER >/dev/null 2>&1 || die "DOCKER-USER-Chain fehlt (laeuft Docker?) — Gate nicht angewendet." iptables -N "$GATE_CHAIN" 2>/dev/null || iptables -F "$GATE_CHAIN" iptables -A "$GATE_CHAIN" -i lo -j RETURN iptables -A "$GATE_CHAIN" -s "$WG_SUBNET" -p tcp -m multiport --dport "$PANEL_PORTS" -j RETURN iptables -A "$GATE_CHAIN" -p tcp -m multiport --dport "$PANEL_PORTS" -j DROP # exactly one jump at the top of DOCKER-USER (idempotent) iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true iptables -I DOCKER-USER 1 -j "$GATE_CHAIN" } gate_remove() { have iptables || return 0 iptables -D DOCKER-USER -j "$GATE_CHAIN" 2>/dev/null || true iptables -F "$GATE_CHAIN" 2>/dev/null || true iptables -X "$GATE_CHAIN" 2>/dev/null || true } # ── 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'." _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" } cmd_down() { need_root down rm -f "$GATE_MARKER" gate_remove bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)." } next_free_ip() { # shellcheck disable=SC1090 . "$WG_ENV" [ -n "${WG_SUBNET:-}" ] || die "WG_SUBNET fehlt in ${WG_ENV} — Setup erneut ausfuehren." local prefix="${WG_SUBNET%%/*}"; prefix="${prefix%.*}" # first three octets (SP1: /24) local used; used="$(awk -F= '/AllowedIPs/{gsub(/[ \t]/,"",$2);split($2,a,"/");print a[1]}' "$WG_CONF" 2>/dev/null || true)" used="$(printf '%s\n%s\n' "$used" "${WG_SERVER_IP:-}")" local i cand for i in $(seq 2 254); do cand="${prefix}.${i}" printf '%s\n' "$used" | grep -qx "$cand" || { printf '%s\n' "$cand"; return 0; } done return 1 } cmd_status() { need_root status bold "WireGuard (${WG_IF})" if [ -e "/sys/class/net/${WG_IF}" ]; then wg show "$WG_IF" 2>/dev/null || true; else info "wg0 nicht aktiv (Setup/Tunnel noch nicht gestartet)."; fi echo bold "Gate" if [ -f "$GATE_MARKER" ]; then info "Marker: aktiv (${GATE_MARKER})"; else info "Marker: aus"; fi if iptables -nL "$GATE_CHAIN" >/dev/null 2>&1; then info "Chain ${GATE_CHAIN}: vorhanden"; else info "Chain ${GATE_CHAIN}: nicht vorhanden"; fi echo bold "Dienst wg-quick@${WG_IF}" systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || true } # Non-interactive setup for the dashboard write-bridge. Args are re-validated here (defence in # depth). Refuses to clobber an existing wg0.conf. Does NOT enable the gate. On success prints the # first peer's client config to stdout (for the show-once modal). Errors to stderr, non-zero on fail. _setup_noninteractive() { local subnet="$1" server_ip="$2" port="$3" endpoint="$4" peer1="$5" have wg || { echo "wireguard-tools fehlt" >&2; return 1; } [ -f "$WG_CONF" ] && { echo "WireGuard ist bereits eingerichtet" >&2; return 1; } printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "Ungueltiges Subnetz" >&2; return 1; } case "$server_ip" in ''|*[!0-9.]*) echo "Ungueltige Server-IP" >&2; return 1 ;; esac case "$port" in ''|*[!0-9]*) echo "Ungueltiger Port" >&2; return 1 ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "Port ausserhalb 1-65535" >&2; return 1; } case "$endpoint" in ''|*[!A-Za-z0-9.:_-]*) echo "Ungueltiger Endpoint" >&2; return 1 ;; esac valid_name "$peer1" || { echo "Ungueltiger Peer-Name" >&2; return 1; } subnet_collides "$subnet" && { echo "Subnetz kollidiert mit einer bestehenden Route/Adresse" >&2; return 1; } umask 077; mkdir -p "$WG_DIR" "$STATE_DIR" local srv_priv srv_pub prefix srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)" prefix="${subnet##*/}" cat > "$WG_CONF" < "$WG_ENV" <&2; return 1; } _peer_add "$peer1" } cmd_setup() { need_root setup have wg || die "wireguard-tools nicht installiert (erwartet via install.sh)." if [ -f "$WG_CONF" ]; then warn "${WG_CONF} existiert bereits — bestehende Peers gingen bei Neukonfiguration verloren." read -rp " Trotzdem neu konfigurieren? [reconfigure/abort] (abort): " ans || ans=abort [ "${ans:-abort}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt." fi local subnet default_subnet="10.99.0.0/24" while :; do if ! read -rp " WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet; then subnet="$default_subnet" # closed stdin → take the default once fi subnet="${subnet:-$default_subnet}" if subnet_collides "$subnet"; then warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen." [ -t 0 ] || die "Subnetz-Kollision und keine interaktive Eingabe moeglich (stdin geschlossen)." continue fi break done local server_ip default_ip port endpoint peer1 default_ip="$(subnet_first_ip "$subnet")" read -rp " Server-Tunnel-IP [${default_ip}]: " server_ip || true; server_ip="${server_ip:-$default_ip}" read -rp " Listen-Port (UDP) [51820]: " port || true; port="${port:-51820}" local ep; ep="$(detect_endpoint_ip)" if [ -n "$ep" ]; then local default_ep="${ep}:${port}" info "Hinweis: hinter NAT/Cloud-LB ist die erkannte IP evtl. NICHT die Waehl-Adresse der Clients — vor 'clusev wg up' pruefen." read -rp " Oeffentlicher Endpoint (IP oder DNS:Port) [${default_ep}]: " endpoint || true endpoint="${endpoint:-$default_ep}" else warn "Oeffentliche IP konnte nicht erkannt werden — bitte Endpoint manuell angeben." read -rp " Oeffentlicher Endpoint (IP oder DNS:Port, erforderlich): " endpoint || true fi [ -n "$endpoint" ] || die "Endpoint erforderlich." read -rp " Name des ersten Peers [client-1]: " peer1 || true; peer1="${peer1:-client-1}" umask 077; mkdir -p "$WG_DIR" "$STATE_DIR" local srv_priv srv_pub prefix srv_priv="$(wg genkey)"; srv_pub="$(printf '%s' "$srv_priv" | wg pubkey)" prefix="${subnet##*/}" cat > "$WG_CONF" < "$WG_ENV" <.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 umask 077 # result + QR sidecar carry a private key — born 0600, never world-readable in run/ (0775) 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 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/"$//')" 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 ;; 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)" ep="$endpoint"; [ -n "$ep" ] || ep="$(detect_endpoint_ip):${port}" pn="$name"; [ -n "$pn" ] || pn='client-1' if [ -n "$subnet" ] && [ -n "$port" ]; then local tmperr; tmperr="$(mktemp 2>/dev/null || echo /tmp/wgsetup.err)" if config="$(_setup_noninteractive "$subnet" "$sip" "$port" "$ep" "$pn" 2>"$tmperr")"; then ok=true else ok=false; config=''; msg="$(tr '\n' ' ' < "$tmperr" 2>/dev/null | head -c 120)"; [ -n "$msg" ] || msg='setup-failed' fi rm -f "$tmperr" else msg='setup-bad-args' fi ;; *) msg='unknown-action' ;; esac _write_result "$id" "$ok" "$msg" "$config" if [ "$ok" = true ] && { [ "$action" = add-peer ] || [ "$action" = setup ]; } && [ -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. # ── settings mutators (cores: no prints; errors to stderr; non-zero on fail) ────────────────── _set_endpoint() { local ep="$1"; require_setup case "$ep" in *[!A-Za-z0-9.:_-]*|'') echo "bad endpoint" >&2; return 1 ;; esac sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV" } _set_port() { local port="$1"; require_setup case "$port" in ''|*[!0-9]*) echo "bad port" >&2; return 1 ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "port out of range" >&2; return 1; } sed -i "s#^ListenPort = .*#ListenPort = ${port}#" "$WG_CONF" sed -i "s#^WG_PORT=.*#WG_PORT=${port}#" "$WG_ENV" systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; } } _set_subnet() { local subnet="$1"; require_setup case "$subnet" in *[!0-9./]*|'') echo "bad subnet" >&2; return 1 ;; esac printf '%s' "$subnet" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' || { echo "bad subnet" >&2; return 1; } subnet_collides "$subnet" && { echo "subnet collides with an existing route/address" >&2; return 1; } local prefix server_ip prefix="${subnet##*/}" server_ip="$(subnet_first_ip "$subnet")" sed -i "s#^Address = .*#Address = ${server_ip}/${prefix}#" "$WG_CONF" sed -i "s#^WG_SUBNET=.*#WG_SUBNET=${subnet}#" "$WG_ENV" sed -i "s#^WG_SERVER_IP=.*#WG_SERVER_IP=${server_ip}#" "$WG_ENV" systemctl restart "wg-quick@${WG_IF}" || { echo "wg-quick restart failed" >&2; return 1; } } cmd_set_endpoint() { need_root set-endpoint; local v="${1:-}"; [ -n "$v" ] || die "Endpoint fehlt."; _set_endpoint "$v" || die "Endpoint setzen fehlgeschlagen."; bold "Endpoint gesetzt: ${v}"; } cmd_set_port() { need_root set-port; local v="${1:-}"; [ -n "$v" ] || die "Port fehlt."; _set_port "$v" || die "Port setzen fehlgeschlagen."; bold "Port gesetzt: ${v} (Clients muessen den Port aktualisieren)."; } cmd_set_subnet() { need_root set-subnet; local v="${1:-}"; [ -n "$v" ] || die "Subnetz fehlt."; _set_subnet "$v" || die "Subnetz setzen fehlgeschlagen."; bold "Subnetz gesetzt: ${v} (bestehende Peers muessen neu angelegt werden)."; } 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" tmp="$(mktemp "${RUN_DIR}/.wg-status.XXXXXX" 2>/dev/null)" || tmp="${RUN_DIR}/.wg-status.tmp" if [ ! -f "$WG_CONF" ]; then printf '{"at":%s,"configured":false}\n' "$now" > "$tmp" mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; } chmod 644 "$dest" 2>/dev/null || true return 0 fi # shellcheck disable=SC1090 [ -f "$WG_ENV" ] && . "$WG_ENV" local gate_marker=false gate_chain=false wgq [ -f "$GATE_MARKER" ] && gate_marker=true iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true wgq="$(systemctl is-active "wg-quick@${WG_IF}" 2>/dev/null || echo inactive)" # Peers from `wg show wg0 dump`, named via the wg0.conf `# clusev-peer:` comments. Peer names are # valid_name-restricted and pubkeys/endpoints are from safe charsets, so the values are JSON-safe. local peers_json peers_json="$({ wg show "$WG_IF" dump 2>/dev/null || true; } | awk -v conf="$WG_CONF" ' BEGIN { name="" while ((getline line < conf) > 0) { if (line ~ /^# clusev-peer:/) { sub(/^# clusev-peer:[ \t]*/,"",line); name=line } else if (name != "" && line ~ /^PublicKey[ \t]*=/) { p=line; sub(/^PublicKey[ \t]*=[ \t]*/,"",p); names[p]=name; name="" } } close(conf); out=""; first=1 } NR==1 { next } # interface line { pk=$1; endp=$3; hs=$5+0; rx=$6+0; tx=$7+0 if (endp=="(none)") endp="" nm=(pk in names)?names[pk]:"" rec="{\"name\":\"" nm "\",\"pubkey\":\"" pk "\",\"endpoint\":\"" endp "\",\"latest_handshake\":" hs ",\"rx\":" rx ",\"tx\":" tx "}" if (first) { out=rec; first=0 } else { out=out "," rec } } END { printf "[%s]", out } ')" [ -n "$peers_json" ] || peers_json="[]" printf '{"at":%s,"configured":true,"gate":{"marker":%s,"chain":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","pubkey":"%s"},"peers":%s}\n' \ "$now" "$gate_marker" "$gate_chain" "$wgq" \ "${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_SERVER_PUBKEY:-}" \ "$peers_json" > "$tmp" mv -f "$tmp" "$dest" 2>/dev/null || { rm -f "$tmp"; return 0; } chmod 644 "$dest" 2>/dev/null || true } usage() { cat <<'EOF' clusev wg — WireGuard-Zugang (Host) clusev wg setup WG einrichten (interaktiv): Subnetz, Keys, erster Peer clusev wg up Gate aktivieren — Panel (80/443) nur ueber den Tunnel clusev wg down Gate entfernen — Panel wieder oeffentlich (Notausgang!) clusev wg status Tunnel, Peers, Gate-Status anzeigen clusev wg add-peer neuen Client anlegen (+ QR-Code) clusev wg remove-peer Client entfernen clusev wg set-endpoint oeffentlichen Endpoint aendern clusev wg set-port Listen-Port aendern (Clients neu) clusev wg set-subnet Subnetz aendern (Peers neu anlegen) SSH (22) und der WG-Port bleiben IMMER offen. 'clusev wg down' per SSH ist der Notausgang. EOF } cmd="${1:-help}"; shift 2>/dev/null || true case "$cmd" in setup) cmd_setup "$@" ;; up) cmd_up "$@" ;; down) cmd_down "$@" ;; status) cmd_status "$@" ;; add-peer) cmd_add_peer "$@" ;; remove-peer) cmd_remove_peer "$@" ;; set-endpoint) cmd_set_endpoint "$@" ;; set-port) cmd_set_port "$@" ;; set-subnet) cmd_set_subnet "$@" ;; 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