feat(wg): clusev-wg.sh — WireGuard gate + peer CLI (host)
parent
f154ee8aaf
commit
dd964a2e8b
|
|
@ -0,0 +1,267 @@
|
|||
#!/usr/bin/env bash
|
||||
# Clusev WireGuard gate + peer CLI (HOST-side, root). Invoked via `clusev wg <cmd>` (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 <name>"
|
||||
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" <<EOF
|
||||
|
||||
# clusev-peer: ${name}
|
||||
[Peer]
|
||||
PublicKey = ${cpub}
|
||||
AllowedIPs = ${ip}/32
|
||||
EOF
|
||||
wg set "$WG_IF" peer "$cpub" allowed-ips "${ip}/32" # live, no restart
|
||||
|
||||
local client_conf
|
||||
client_conf="$(cat <<EOF
|
||||
[Interface]
|
||||
PrivateKey = ${cpriv}
|
||||
Address = ${ip}/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${WG_SERVER_PUBKEY}
|
||||
Endpoint = ${WG_ENDPOINT}
|
||||
AllowedIPs = ${WG_SUBNET}
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
)"
|
||||
bold "Peer '${name}' angelegt (${ip})."
|
||||
printf '%s\n' "$client_conf"
|
||||
if have qrencode; then
|
||||
printf '%s\n' "$client_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."
|
||||
}
|
||||
|
||||
cmd_remove_peer() {
|
||||
need_root remove-peer
|
||||
local name="${1:-}"; [ -n "$name" ] || die "Name fehlt: clusev wg remove-peer <name>"
|
||||
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" <<EOF
|
||||
[Interface]
|
||||
Address = ${server_ip}/${prefix}
|
||||
ListenPort = ${port}
|
||||
PrivateKey = ${srv_priv}
|
||||
EOF
|
||||
chmod 600 "$WG_CONF"
|
||||
|
||||
cat > "$WG_ENV" <<EOF
|
||||
WG_SUBNET=${subnet}
|
||||
WG_SERVER_IP=${server_ip}
|
||||
WG_PORT=${port}
|
||||
WG_ENDPOINT=${endpoint}
|
||||
WG_SERVER_PUBKEY=${srv_pub}
|
||||
EOF
|
||||
chmod 600 "$WG_ENV"
|
||||
|
||||
systemctl enable --now "wg-quick@${WG_IF}" || die "wg-quick@${WG_IF} konnte nicht gestartet werden — 'journalctl -u wg-quick@${WG_IF}' pruefen."
|
||||
cmd_add_peer "$peer1"
|
||||
echo
|
||||
bold "Setup fertig. Tunnel ist OBEN, aber das Gate ist AUS (Panel weiter oeffentlich)."
|
||||
info "1) Client importieren (QR oben), 2) http://${server_ip} ueber den Tunnel oeffnen, 3) dann: clusev wg up"
|
||||
}
|
||||
|
||||
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 <name> neuen Client anlegen (+ QR-Code)
|
||||
clusev wg remove-peer <name> 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
|
||||
Loading…
Reference in New Issue