#!/usr/bin/env bash # tunnelctl — MacLustr Tunnel : gestion des pairs WireGuard et des routes Caddy sur la passerelle publique (R9128). # Usage : # tunnelctl peer add ajoute/remplace un Mac (IP fixe tirée de /etc/maclustr-tunnel/ipmap) # tunnelctl peer rm # tunnelctl peer ls pairs + dernier handshake # tunnelctl add : [:…] [--no-tls] [--websocket] # route publique https:// → upstream(s) via WireGuard (LB + health si plusieurs) # tunnelctl redirect redirection 308 (ex. apex → www) # tunnelctl rm # tunnelctl ls routes publiques # tunnelctl status wg + caddy + routes # tunnelctl json même chose en JSON (pour maclustr-agentd) set -euo pipefail ETC=/etc/maclustr-tunnel IPMAP=$ETC/ipmap PEERS=/etc/wireguard/peers.d SITES=/etc/caddy/sites WG_IF=wg0 PUBLIC_IP=${PUBLIC_IP:-$(cat /etc/maclustr-tunnel/public_ip 2>/dev/null || echo 51.255.75.61)} [ "$(id -u)" = 0 ] || exec sudo -E "$0" "$@" mkdir -p "$ETC" "$PEERS" "$SITES" die(){ echo "tunnelctl: $*" >&2; exit 1; } ip_of(){ awk -v a="$1" '$1==a{print $2}' "$IPMAP"; } alias_of(){ awk -v ip="$1" '$2==ip{print $1}' "$IPMAP"; } resolve_upstream(){ # alias:port | ip:port -> ip:port local hp=$1 h=${1%%:*} p=${1##*:} [ "$h" != "$p" ] || die "upstream « $hp » : format :" if [[ "$h" =~ ^[0-9.]+$ ]]; then echo "$h:$p"; else local ip; ip=$(ip_of "$h"); [ -n "$ip" ] || die "alias inconnu « $h » (voir $IPMAP)"; echo "$ip:$p"; fi } wg_sync(){ # reconstruit wg0.conf = [Interface] + peers.d/*.conf puis applique sans couper les tunnels local conf=/etc/wireguard/$WG_IF.conf tmp; tmp=$(mktemp) awk '/^\[Peer\]/{exit} {print}' "$conf" > "$tmp" for f in "$PEERS"/*.conf; do [ -f "$f" ] && { echo; cat "$f"; } >> "$tmp"; done install -m 600 "$tmp" "$conf"; rm -f "$tmp" wg syncconf "$WG_IF" <(wg-quick strip "$WG_IF") } caddy_reload(){ caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null && systemctl reload caddy; } cmd=${1:-status}; shift || true case "$cmd" in peer) sub=${1:-ls}; shift || true case "$sub" in add) a=${1:?alias}; pk=${2:?pubkey}; ip=$(ip_of "$a"); [ -n "$ip" ] || die "alias « $a » absent de $IPMAP" printf "[Peer]\n# %s\nPublicKey = %s\nAllowedIPs = %s/32\n" "$a" "$pk" "$ip" > "$PEERS/$a.conf" wg_sync; echo "pair $a → $ip ajouté" ;; rm) a=${1:?alias}; rm -f "$PEERS/$a.conf"; wg_sync; echo "pair $a retiré" ;; ls) wg show "$WG_IF" dump | tail -n +2 | while IFS=$'\t' read -r pk psk ep allowed hs rx tx ka; do ip=${allowed%/32}; a=$(alias_of "$ip"); age="jamais"; [ "$hs" != 0 ] && age="$(( $(date +%s) - hs )) s" printf "%-8s %-12s %-22s handshake %-10s rx %6.1f Mo tx %6.1f Mo\n" "${a:-?}" "$ip" "${ep:-—}" "$age" "$(echo "$rx/1048576" | bc -l)" "$(echo "$tx/1048576" | bc -l)" done ;; *) die "peer add|rm|ls" ;; esac ;; add) d=${1:?domaine}; shift; ups=(); tls=1; ws=0 for x in "$@"; do case "$x" in --no-tls) tls=0;; --websocket) ws=1;; *) ups+=("$(resolve_upstream "$x")");; esac; done [ ${#ups[@]} -gt 0 ] || die "au moins un upstream" { [ $tls = 1 ] && echo "$d {" || echo "http://$d {" echo " import maclustr_errors" # page MacLustr « service indisponible » (502/503/504) avec contact@spboucher.ai echo " encode zstd gzip" echo " log" # journal d accès → journald (journalctl -u caddy), filtrable par request.host echo " reverse_proxy ${ups[*]} {" if [ ${#ups[@]} -gt 1 ]; then echo " lb_policy first"; echo " lb_try_duration 5s" echo " health_uri /"; echo " health_interval 10s"; echo " health_timeout 4s"; echo " health_status 2xx 3xx 4xx" fi echo " header_up X-Forwarded-Proto {scheme}"; echo " header_up X-Real-IP {remote_host}" echo " transport http {"; echo " dial_timeout 5s"; echo " response_header_timeout 300s"; echo " }" echo " }" echo "}" } > "$SITES/$d.caddy" if caddy_reload; then echo "route https://$d → ${ups[*]} active (TLS Let's Encrypt automatique si le DNS de $d pointe vers $PUBLIC_IP)"; else rm -f "$SITES/$d.caddy"; caddy_reload || true; die "Caddyfile invalide, route annulée"; fi ;; redirect) # tunnelctl redirect (ex. apex → www), 308 permanent, TLS auto s=${1:?source}; t=${2:?cible} printf "%s {\n\tredir https://%s{uri} permanent\n}\n" "$s" "$t" > "$SITES/$s.caddy" if caddy_reload; then echo "redirection https://$s → https://$t active"; else rm -f "$SITES/$s.caddy"; caddy_reload || true; die "Caddyfile invalide, redirection annulée"; fi ;; rm) d=${1:?domaine}; rm -f "$SITES/$d.caddy"; caddy_reload; echo "route $d retirée" ;; ls) for f in "$SITES"/*.caddy; do [ -f "$f" ] || continue; d=$(basename "$f" .caddy); up=$(awk '/reverse_proxy/{ $1=""; sub(/ *\{ *$/,""); print }' "$f" | sed 's/^ *//'); printf "%-36s → %s\n" "$d" "$up"; done ;; json) # état machine-lisible (consommé par maclustr-agentd → apps MacLustr) GW_NAME=${GW_NAME:-$(hostname -s | tr a-z A-Z)} wg show "$WG_IF" dump 2>/dev/null | tail -n +2 > /tmp/.wgdump.$$ || true python3 - "$GW_NAME" "$PUBLIC_IP" "$IPMAP" "$SITES" "/tmp/.wgdump.$$" "$(wg show $WG_IF listen-port 2>/dev/null)" "$(systemctl is-active caddy 2>/dev/null)" <<'PY' import sys, json, glob, os, re, time name, pub_ip, ipmap, sites, dump, port, caddy = sys.argv[1:8] alias_of = {} for line in open(ipmap): p = line.split() if len(p) >= 2 and not line.startswith('#'): alias_of[p[1]] = p[0] peers = [] now = int(time.time()) for line in open(dump): f = line.rstrip('\n').split('\t') if len(f) < 8: continue ip = f[3].replace('/32', '') hs = int(f[4] or 0) peers.append({"alias": alias_of.get(ip, "?"), "ip": ip, "endpoint": f[2] if f[2] != '(none)' else None, "handshakeS": (now - hs) if hs else None, "rxBytes": int(f[5] or 0), "txBytes": int(f[6] or 0)}) routes = [] for fn in sorted(glob.glob(os.path.join(sites, '*.caddy'))): dom = os.path.basename(fn)[:-6]; txt = open(fn).read() m = re.search(r'redir\s+(\S+)', txt) if m: routes.append({"domain": dom, "kind": "redirect", "target": m.group(1).replace('{uri}', ''), "upstreams": []}) continue m = re.search(r'reverse_proxy\s+([^{\n]+)', txt) ups = m.group(1).split() if m else [] routes.append({"domain": dom, "kind": "proxy", "upstreams": [{"addr": u, "alias": alias_of.get(u.split(':')[0], None)} for u in ups]}) print(json.dumps({"gateway": name, "ip": pub_ip, "ts": now, "wg": {"listenPort": int(port or 0), "peers": peers}, "caddy": {"active": caddy == "active", "routes": routes}}, ensure_ascii=False)) PY rm -f /tmp/.wgdump.$$ ;; status) echo "== WireGuard $WG_IF ($(wg show $WG_IF listen-port 2>/dev/null) udp) — $(ls "$PEERS"/*.conf 2>/dev/null | wc -l) pairs"; "$0" peer ls echo; echo "== Caddy : $(systemctl is-active caddy) — $(ls "$SITES"/*.caddy 2>/dev/null | wc -l) routes"; "$0" ls ;; *) sed -n '2,12p' "$0"; exit 1 ;; esac