#!/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. The WG UDP port is NEVER matched. SSH (22) is only filtered if # the operator opts into the SEPARATE ssh-lock (default off); the server console + `clusev wg down` # are always a way back in. The panel 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) # OPTIONAL SSH gate (separate, dangerous): SSH (22) is a HOST service — not Docker-published — so it # can't be filtered via DOCKER-USER; it needs a dedicated chain on the host INPUT chain. ESTABLISHED # sessions + the wg0 interface + the WG subnet are always allowed, so enabling it never cuts the # current SSH session, only NEW non-tunnel SSH. Off unless the operator explicitly enables it. SSH_MARKER="${STATE_DIR}/wg-ssh-gate.enabled" SSH_CHAIN="CLUSEV-WG-SSH" # Shape-check for a /N CIDR (single source for the subnet validators below). Single-quoted = literal. readonly CIDR_RE='^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$' # 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 } # ── the SSH gate (optional, host INPUT chain) ─────────────────────────────────────────────────── ssh_gate_apply() { # No-op unless the operator enabled it. Applied alongside the panel gate by the boot unit + `up`. # Every guard below is FAIL-OPEN (return 0 = no DROP installed = SSH stays reachable): a missing # marker, no iptables, a tunnel that is down, or a corrupt subnet must NEVER wall off SSH. [ -f "$SSH_MARKER" ] || return 0 have iptables || return 0 # Self-guard (defence-in-depth; the boot unit also has ConditionPathExists=wg0): never install the # DROP while the tunnel interface is down — that is exactly when it would lock the operator out. [ -e "/sys/class/net/${WG_IF}" ] || return 0 # shellcheck disable=SC1090 [ -f "$WG_ENV" ] && . "$WG_ENV" [ -n "${WG_SUBNET:-}" ] || return 0 # Re-validate the subnet shape before it reaches `iptables -s`; a malformed value fails open # (no DROP) rather than aborting mid-chain and leaving SSH half-filtered. printf '%s' "$WG_SUBNET" | grep -qE "$CIDR_RE" || return 0 iptables -N "$SSH_CHAIN" 2>/dev/null || iptables -F "$SSH_CHAIN" iptables -A "$SSH_CHAIN" -i lo -j RETURN iptables -A "$SSH_CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN # never cut a live session iptables -A "$SSH_CHAIN" -i "$WG_IF" -j RETURN # SSH over the tunnel iptables -A "$SSH_CHAIN" -s "$WG_SUBNET" -j RETURN # belt + suspenders iptables -A "$SSH_CHAIN" -p tcp --dport 22 -j DROP # everyone else iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true iptables -I INPUT 1 -p tcp --dport 22 -j "$SSH_CHAIN" } ssh_gate_remove() { have iptables || return 0 iptables -D INPUT -p tcp --dport 22 -j "$SSH_CHAIN" 2>/dev/null || true iptables -F "$SSH_CHAIN" 2>/dev/null || true iptables -X "$SSH_CHAIN" 2>/dev/null || true } # Cores: enable/disable the SSH gate (marker + apply/remove). Refuse to enable if wg0 is down (so a # broken tunnel can't be the moment you also wall off SSH). Errors to stderr. _ssh_gate_on() { require_setup [ -e "/sys/class/net/${WG_IF}" ] || { echo "wg0 nicht aktiv" >&2; return 1; } mkdir -p "$STATE_DIR" printf 'enabled\n' > "$SSH_MARKER" ssh_gate_apply } _ssh_gate_off() { rm -f "$SSH_MARKER" ssh_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'." _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 # Full escape: removes BOTH gates (panel + the optional SSH gate) + their markers. rm -f "$GATE_MARKER" "$SSH_MARKER" gate_remove ssh_gate_remove bold "WireGuard-Gate entfernt — Panel (80/443) wieder oeffentlich erreichbar (vorbehaltlich Cloud-/Host-Firewall)." info "SSH-Sperre (falls aktiv) ebenfalls entfernt." } cmd_ssh_lock() { need_root ssh-lock warn "SSH (22) wird gesperrt — danach erreichst du den Host nur noch UEBER DEN TUNNEL oder die Server-Konsole." warn "Verlierst du deinen WG-Zugang, kommst du per SSH NICHT mehr rein. Lege dir vorher einen zweiten WG-Peer als Backup an." if _ssh_gate_on; then bold "SSH-Sperre aktiv — Port 22 nur noch ueber wg0/${WG_IF} bzw. das WG-Subnetz erreichbar." info "Aufheben: 'clusev wg ssh-unlock' (oder 'clusev wg down' fuer alle Gates) — von der Server-Konsole aus immer moeglich." else die "SSH-Sperre fehlgeschlagen (wg0 aktiv? Setup vorhanden?)." fi } cmd_ssh_unlock() { need_root ssh-unlock _ssh_gate_off bold "SSH-Sperre entfernt — Port 22 wieder normal 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 "Panel-Marker: aktiv (${GATE_MARKER})"; else info "Panel-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 if [ -f "$SSH_MARKER" ]; then info "SSH-Sperre: AKTIV (${SSH_MARKER}) — Notausgang nur ueber den Tunnel/Konsole"; else info "SSH-Sperre: aus"; 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 "$CIDR_RE" || { 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; } _write_server_conf "$subnet" "$server_ip" "$port" "$endpoint" systemctl enable --now "wg-quick@${WG_IF}" || { echo "wg-quick@${WG_IF} konnte nicht gestartet werden" >&2; return 1; } _peer_add "$peer1" } # Generate the server keypair and write wg0.conf + wg.env (both 0600). Shared by both setup paths # (dashboard _setup_noninteractive + interactive cmd_setup); the caller does `systemctl enable --now` # and the first-peer add, since those differ (error style return-vs-die, _peer_add vs cmd_add_peer). _write_server_conf() { local subnet="$1" server_ip="$2" port="$3" endpoint="$4" # WireGuard requires the endpoint as host:port. If the operator gave a bare host (no colon), # append the listen port — otherwise every client config has an invalid `Endpoint =` line. case "$endpoint" in *:*) ;; *) endpoint="${endpoint}:${port}" ;; esac 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" # 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/"$//' || 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 dns 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)" dns="$(printf '%s' "$json" | grep -oE '"dns":"[0-9., ]{1,128}"' | head -n1 | sed -E 's/.*:"//; s/"$//' || true)" 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 ;; # 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 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 ( 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 ;; set-dns) if [ -n "$dns" ] && ( _set_dns "$dns" ) >/dev/null 2>&1; then ok=true; else msg='set-dns-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 # Re-validated here (defence-in-depth): the bare CLI path cmd_set_endpoint passes $1 unchecked. case "$ep" in *[!A-Za-z0-9.:_-]*|'') echo "bad endpoint" >&2; return 1 ;; esac # WireGuard requires host:port — append the listen port if the operator gave a bare host (no colon). if [ "${ep#*:}" = "$ep" ]; then local _p # shellcheck disable=SC1090 _p="$( . "$WG_ENV" 2>/dev/null; printf '%s' "${WG_PORT:-51820}" )" ep="${ep}:${_p}" fi sed -i "s#^WG_ENDPOINT=.*#WG_ENDPOINT=${ep}#" "$WG_ENV" } _set_dns() { local dns="$1"; require_setup # One or more IPv4 addresses, comma/space separated on INPUT (e.g. "10.0.0.1" or "1.1.1.1, 1.0.0.1"). # Only affects NEW peer configs (the DNS line is baked into each client config at creation). case "$dns" in *[!0-9.,\ ]*|'') echo "bad dns" >&2; return 1 ;; esac printf '%s' "$dns" | grep -qE '^[0-9]{1,3}(\.[0-9]{1,3}){3}([,[:space:]]+[0-9]{1,3}(\.[0-9]{1,3}){3})*$' || { echo "bad dns" >&2; return 1; } # Normalise every comma/space run to a single comma before STORING: WireGuard's peer `DNS =` # line wants a comma list, and — critically — an unquoted space in wg.env would split the # WG_DNS assignment so the next `. "$WG_ENV"` runs the second address as a command. Store as a # single comma-separated token and QUOTE the assignment (defence-in-depth) so sourcing is safe. dns="$(printf '%s' "$dns" | tr -s ',[:space:]' ',' | sed -e 's/^,//' -e 's/,$//')" if grep -q '^WG_DNS=' "$WG_ENV" 2>/dev/null; then sed -i "s#^WG_DNS=.*#WG_DNS=\"${dns}\"#" "$WG_ENV" else printf 'WG_DNS="%s"\n' "$dns" >> "$WG_ENV" # older wg.env (pre-DNS) has no line yet fi } _set_port() { local port="$1"; require_setup # Re-validated here: cmd_set_port passes $1 unchecked; also adds the 1-65535 range the bridge regex omits. 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 # Re-validated here (defence-in-depth): the bare CLI path cmd_set_subnet passes $1 unchecked. case "$subnet" in *[!0-9./]*|'') echo "bad subnet" >&2; return 1 ;; esac printf '%s' "$subnet" | grep -qE "$CIDR_RE" || { 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_set_dns() { need_root set-dns; local v="${1:-}"; [ -n "$v" ] || die "DNS fehlt."; _set_dns "$v" || die "DNS ungueltig — nur IPv4, kommagetrennt."; bold "DNS gesetzt: ${v} (gilt fuer kuenftige Peer-Configs)."; } 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 ssh_marker=false wgq [ -f "$GATE_MARKER" ] && gate_marker=true iptables -nL "$GATE_CHAIN" >/dev/null 2>&1 && gate_chain=true [ -f "$SSH_MARKER" ] && ssh_marker=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,"ssh":%s},"wg_quick":"%s","server":{"subnet":"%s","server_ip":"%s","port":%s,"endpoint":"%s","dns":"%s","pubkey":"%s"},"peers":%s}\n' \ "$now" "$gate_marker" "$gate_chain" "$ssh_marker" "$wgq" \ "${WG_SUBNET:-}" "${WG_SERVER_IP:-}" "${WG_PORT:-0}" "${WG_ENDPOINT:-}" "${WG_DNS:-}" "${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) clusev wg set-dns DNS fuer kuenftige Peer-Configs setzen clusev wg ssh-lock SSH (22) sperren — nur noch ueber den Tunnel (Vorsicht: Aussperr-Gefahr!) clusev wg ssh-unlock SSH-Sperre wieder aufheben Der WG-Port bleibt IMMER offen. 'clusev wg down' (Server-Konsole) ist der Notausgang fuer ALLE Gates. ssh-lock kann dich aussperren — vorher einen zweiten WG-Peer als Backup-Zugang anlegen. 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 "$@" ;; set-dns) cmd_set_dns "$@" ;; ssh-lock) cmd_ssh_lock "$@" ;; ssh-unlock) cmd_ssh_unlock "$@" ;; gate-apply) need_root gate-apply; gate_apply; ssh_gate_apply ;; # called by clusev-wg-gate.service (both gates re-applied on boot) 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