spb/maclustr-dispatch
Public
Python 95.8%
Shell 4.2%
1"""MacLustr Tunnel — le « ngrok maison » du cluster, vu depuis mld.23Une passerelle OVH (BHS64 par défaut) porte un hub WireGuard et Caddy (TLS Let's Encrypt). Chaque Mac y est raccordé4par une interface WireGuard (LaunchDaemon io.maclustr.wireguard-wg1) et chaque site public est une route Caddy5`https://<domaine> → <ip-wg du nœud>:<port>`. Tout se pilote sur la passerelle avec `tunnelctl` :67 tunnelctl json état machine-lisible (pairs + routes)8 tunnelctl add <dom> <alias>:<port> route (idempotent : réécrit sites/<dom>.caddy + reload)9 tunnelctl redirect <dom> <cible> 308 (apex → www)10 tunnelctl rm <dom>11 tunnelctl peer add <alias> <pubkey> pair WireGuard (IP fixe tirée de /etc/maclustr-tunnel/ipmap)1213Ici : `ensure_peer(alias)` raccorde un nœud qui ne l'est pas (script assets/wg-node-setup.sh, même recette que `mlt peer`),14`ensure_route(m, alias)` fait suivre la route publique d'une app au nœud où mld vient de la démarrer. Le manifeste ne porte15que `tunnel.domain` (+ `gateway`, `redirects`, `websocket`) : l'upstream est TOUJOURS le nœud courant du registre.16"""17import json18import os19import shlex20import subprocess21import time22from . import config, ssh2324ASSETS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets")25HANDSHAKE_OK_S = 200 # keepalive 25 s : au-delà de ~3 min sans handshake, le pair est considéré tombé262728class TunnelError(Exception):29 pass303132def gateway(name=None):33 name = name or config.TUNNEL_DEFAULT34 if name not in config.TUNNEL_GATEWAYS:35 raise TunnelError("passerelle inconnue « %s » (connues : %s)" % (name, ", ".join(config.TUNNEL_GATEWAYS)))36 return name, config.TUNNEL_GATEWAYS[name]373839def gw_run(name, cmd, timeout=60):40 """Commande sur la passerelle (ssh direct ubuntu@ip avec la clé de M1M32)."""41 name, g = gateway(name)42 try:43 p = subprocess.run(["ssh"] + ssh.SSH_OPTS + ["%s@%s" % (g["user"], g["host"]), cmd], capture_output=True, text=True, timeout=timeout)44 except subprocess.TimeoutExpired:45 return 124, "", "timeout après %ss" % timeout46 return p.returncode, p.stdout, p.stderr474849def state(name=None):50 """Sortie de `tunnelctl json` : {"gateway", "ip", "wg": {"peers": [...]}, "caddy": {"active", "routes": [...]}}."""51 name, g = gateway(name)52 rc, out, err = gw_run(name, "sudo -n tunnelctl json", timeout=40)53 if rc != 0 or not out.strip():54 raise TunnelError("passerelle %s (%s) injoignable ou tunnelctl KO : %s" % (name, g["host"], (err or out).strip()[-160:]))55 return json.loads(out.strip().splitlines()[-1])565758def routes(name=None, st=None):59 st = st or state(name)60 return {r["domain"]: r for r in st["caddy"]["routes"]}616263def peers(name=None, st=None):64 st = st or state(name)65 return {p["alias"]: p for p in st["wg"]["peers"]}666768# ------------------------------------------------------------------ routes ---6970def spec(m, alias):71 """Route attendue pour l'app `m` tournant sur `alias` → dict ou None si l'app n'est pas publique."""72 t = m.get("tunnel") or {}73 domain = t.get("domain") or m.get("domain")74 if not domain or not m.get("port"):75 return None76 return {77 "gateway": t.get("gateway") or config.TUNNEL_DEFAULT,78 "domain": domain,79 "upstreams": ["%s:%s" % (alias, m["port"])],80 "redirects": list(t.get("redirects") or []),81 "websocket": bool(t.get("websocket")),82 }838485def _route_matches(route, want):86 """La route Caddy actuelle pointe-t-elle déjà vers les upstreams voulus (comparaison par alias:port) ?"""87 if not route or route.get("kind") != "proxy":88 return False89 cur = sorted("%s:%s" % (u.get("alias") or u["addr"].split(":")[0], u["addr"].split(":")[-1]) for u in route.get("upstreams", []))90 return cur == sorted(want)919293def ensure_route(m, alias, log=print, st=None):94 """Fait pointer https://<domaine> vers <alias>:<port> (et pose les redirections). Retourne le spec, ou None si l'app n'est pas publique."""95 s = spec(m, alias)96 if not s:97 return None98 name, g = gateway(s["gateway"])99 cur = routes(name, st)100 r = cur.get(s["domain"])101 if _route_matches(r, s["upstreams"]):102 log(" tunnel %s : route https://%s → %s déjà en place" % (name, s["domain"], ", ".join(s["upstreams"])))103 else:104 args = " ".join(shlex.quote(x) for x in s["upstreams"]) + (" --websocket" if s["websocket"] else "")105 rc, out, err = gw_run(name, "sudo -n tunnelctl add %s %s" % (shlex.quote(s["domain"]), args), timeout=90)106 if rc != 0:107 raise TunnelError("route %s → %s refusée par %s : %s" % (s["domain"], args, name, (err or out).strip()[-200:]))108 log(" tunnel %s : route https://%s → %s%s" % (name, s["domain"], ", ".join(s["upstreams"]), " (remplace %s)" % ", ".join(u["addr"] for u in r.get("upstreams", [])) if r else ""))109 for red in s["redirects"]:110 rr = cur.get(red)111 if rr and rr.get("kind") == "redirect" and rr.get("target", "").rstrip("/").endswith(s["domain"]):112 continue113 rc, out, err = gw_run(name, "sudo -n tunnelctl redirect %s %s" % (shlex.quote(red), shlex.quote(s["domain"])), timeout=90)114 log(" tunnel %s : redirection %s → %s %s" % (name, red, s["domain"], "ok" if rc == 0 else "ÉCHEC " + (err or out).strip()[-120:]))115 return s116117118def remove_route(m, log=print):119 """Retire la route (et ses redirections) d'une app retirée du cluster : le domaine tombe sur la page 404 MacLustr."""120 t = m.get("tunnel") or {}121 domain = t.get("domain") or m.get("domain")122 if not domain:123 return False124 name, g = gateway(t.get("gateway"))125 for d in [domain] + list(t.get("redirects") or []):126 rc, out, err = gw_run(name, "sudo -n tunnelctl rm %s" % shlex.quote(d), timeout=60)127 log(" tunnel %s : route %s %s" % (name, d, "retirée" if rc == 0 else "ÉCHEC " + (err or out).strip()[-120:]))128 return True129130131# ------------------------------------------------------------------- pairs ---132133def peer_state(alias, name=None, st=None):134 """(présent, handshake_s) — handshake None = jamais."""135 p = peers(name, st).get(alias)136 if not p:137 return False, None138 return True, p.get("handshakeS")139140141def peer(alias, name=None, log=print):142 """Raccorde un Mac au hub : installe WireGuard (Homebrew) + LaunchDaemon, enregistre la clé sur la passerelle (= `mlt peer`)."""143 from . import nodes144 name, g = gateway(name)145 ip = nodes.ip_of(alias)146 if not ip:147 raise TunnelError("nœud %s injoignable" % alias)148 rc, out, err = gw_run(name, "awk -v a=%s '$1==a{print $2}' /etc/maclustr-tunnel/ipmap; sudo -n wg show wg0 public-key" % shlex.quote(alias))149 parts = out.split()150 if rc != 0 or len(parts) < 2:151 raise TunnelError("alias %s absent de /etc/maclustr-tunnel/ipmap sur %s (l'ajouter : « %s %s.<n> »)" % (alias, name, alias, g["subnet"]))152 wg_ip, hub_pub = parts[0], parts[1]153 pwf = config.sudo_pw_file(alias)154 pw = open(pwf).read().strip() if os.path.exists(pwf) else None155 if not pw:156 raise TunnelError("mot de passe sudo de %s absent (%s)" % (alias, pwf))157 script = open(os.path.join(ASSETS, "wg-node-setup.sh")).read()158 ssh.write_remote_file(ip, "/tmp/mld-wg-node-setup.sh", script, mode="755")159 log(" tunnel %s : raccordement de %s (%s, %s) …" % (name, alias, wg_ip, g["iface"]))160 rc, out, err = ssh.run(ip, "bash /tmp/mld-wg-node-setup.sh %s %s %s %s %s; rm -f /tmp/mld-wg-node-setup.sh" % (wg_ip, hub_pub, shlex.quote(pw), g["iface"], g["endpoint"]), timeout=1200)161 pub = [l.split()[-1] for l in out.splitlines() if "PUBKEY" in l]162 if not pub:163 raise TunnelError("wg-node-setup.sh sur %s n'a pas renvoyé de clé publique : %s" % (alias, (err or out).strip()[-300:]))164 rc, out, err = gw_run(name, "sudo -n tunnelctl peer add %s %s" % (shlex.quote(alias), shlex.quote(pub[-1])), timeout=60)165 if rc != 0:166 raise TunnelError("tunnelctl peer add %s : %s" % (alias, (err or out).strip()[-200:]))167 for _ in range(12):168 time.sleep(4)169 present, hs = peer_state(alias, name)170 if present and hs is not None and hs < 60:171 log(" tunnel %s : %s raccordé (%s, handshake %ss)" % (name, alias, wg_ip, hs))172 return True173 log(" tunnel %s : %s enregistré (%s), handshake pas encore vu — keepalive 25 s" % (name, alias, wg_ip))174 return True175176177def kick_node_iface(alias, ip, name=None, log=print):178 """Relance le LaunchDaemon WireGuard du nœud (interface tombée après un réveil réseau, par ex.)."""179 name, g = gateway(name)180 pwf = config.sudo_pw_file(alias)181 pw = open(pwf).read().strip() if os.path.exists(pwf) else None182 if not pw:183 return False184 lbl = "io.maclustr.wireguard-%s" % g["iface"]185 rc, out, err = ssh.run(ip, "test -f /Library/LaunchDaemons/%s.plist || exit 9; echo %s | sudo -S -p '' launchctl kickstart -k system/%s && echo kicked" % (lbl, shlex.quote(pw), lbl), timeout=60)186 if rc == 9:187 return False188 log(" tunnel %s : %s %s" % (name, lbl, "relancé sur " + alias if "kicked" in out else "ÉCHEC " + (err or out).strip()[-100:]))189 return "kicked" in out190191192def ensure_peer(alias, name=None, log=print, st=None):193 """Nœud raccordé et vivant sur le hub ; sinon relance son interface, sinon le raccorde. Retourne True si OK."""194 from . import nodes195 name, g = gateway(name)196 present, hs = peer_state(alias, name, st)197 if present and hs is not None and hs < HANDSHAKE_OK_S:198 return True199 if present:200 ip = nodes.ip_of(alias, rediscover=False)201 if ip and kick_node_iface(alias, ip, name, log):202 for _ in range(8):203 time.sleep(4)204 present, hs = peer_state(alias, name)205 if hs is not None and hs < 60:206 return True207 if ip and ssh.ok(ip, "test -f /Library/LaunchDaemons/io.maclustr.wireguard-%s.plist" % g["iface"]):208 log(" tunnel %s : %s enregistré mais sans handshake récent (%s)" % (name, alias, "jamais" if hs is None else "%ss" % hs))209 return False210 return peer(alias, name, log)211212213# ------------------------------------------------------------------ affichage ---214215def print_status(name=None):216 name, g = gateway(name)217 st = state(name)218 now = st.get("ts") or int(time.time())219 print("== %s (%s, %s) — WireGuard udp %s, %d pairs" % (name, g["host"], g["place"], st["wg"].get("listenPort"), len(st["wg"]["peers"])))220 for p in sorted(st["wg"]["peers"], key=lambda x: x["ip"]):221 hs = p.get("handshakeS")222 flag = "" if hs is not None and hs < HANDSHAKE_OK_S else " <<< pas de handshake récent"223 print(" %-8s %-12s handshake %-8s rx %8.1f Mo tx %8.1f Mo%s" % (p["alias"], p["ip"], "jamais" if hs is None else "%ss" % hs, p["rxBytes"] / 1048576.0, p["txBytes"] / 1048576.0, flag))224 print("== Caddy : %s — %d routes" % ("actif" if st["caddy"]["active"] else "ARRÊTÉ", len(st["caddy"]["routes"])))225 for r in st["caddy"]["routes"]:226 if r["kind"] == "redirect":227 print(" %-36s → redirection %s" % (r["domain"], r.get("target")))228 else:229 print(" %-36s → %s" % (r["domain"], ", ".join("%s (%s)" % (u["addr"], u.get("alias") or "?") for u in r["upstreams"])))230