diff --git a/docker/wg/clusev-wg.sh b/docker/wg/clusev-wg.sh new file mode 100755 index 0000000..93d3b83 --- /dev/null +++ b/docker/wg/clusev-wg.sh @@ -0,0 +1,267 @@ +#!/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) + +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}"; } + +# ── 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" + 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 " + require_setup + # shellcheck disable=SC1090 + . "$WG_ENV" + 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" <" + require_setup + local pub + pub="$(awk -v n="# clusev-peer: ${name}" '$0==n{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)" + awk -v n="# clusev-peer: ${name}" ' + $0==n {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}" = "reconfigure" ] || die "Abgebrochen — bestehende Konfiguration unberuehrt." + fi + + local subnet default_subnet="10.99.0.0/24" + while :; do + read -rp " WG-Subnetz (privates /24, darf NICHT mit LAN/VPN kollidieren) [${default_subnet}]: " subnet + subnet="${subnet:-$default_subnet}" + if subnet_collides "$subnet"; then warn "Subnetz ${subnet} ueberschneidet eine bestehende Route/Adresse — bitte ein anderes waehlen."; continue; fi + break + done + + local server_ip default_ip port endpoint default_ep peer1 + default_ip="$(subnet_first_ip "$subnet")" + read -rp " Server-Tunnel-IP [${default_ip}]: " server_ip; server_ip="${server_ip:-$default_ip}" + read -rp " Listen-Port (UDP) [51820]: " port; port="${port:-51820}" + default_ep="$(detect_endpoint_ip):${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; endpoint="${endpoint:-$default_ep}" + read -rp " Name des ersten Peers [client-1]: " peer1; 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" < 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 + help|-h|--help) usage ;; + *) printf 'Unbekannter Befehl: %s\n\n' "$cmd" >&2; usage; exit 64 ;; +esac