#!/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 ────────────────────────────────────────────────────────────────────────────────── 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 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_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})" 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" </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) help|-h|--help) usage ;; *) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;; esac