#!/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 } 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 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" 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 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 "$@" ;; 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