"""MacLustr Tunnel — le « ngrok maison » du cluster, vu depuis mld. Une passerelle OVH (BHS64 par défaut) porte un hub WireGuard et Caddy (TLS Let's Encrypt). Chaque Mac y est raccordé par une interface WireGuard (LaunchDaemon io.maclustr.wireguard-wg1) et chaque site public est une route Caddy `https:// → :`. Tout se pilote sur la passerelle avec `tunnelctl` : tunnelctl json état machine-lisible (pairs + routes) tunnelctl add : route (idempotent : réécrit sites/.caddy + reload) tunnelctl redirect 308 (apex → www) tunnelctl rm tunnelctl peer add pair WireGuard (IP fixe tirée de /etc/maclustr-tunnel/ipmap) Ici : `ensure_peer(alias)` raccorde un nœud qui ne l'est pas (script assets/wg-node-setup.sh, même recette que `mlt peer`), `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 porte que `tunnel.domain` (+ `gateway`, `redirects`, `websocket`) : l'upstream est TOUJOURS le nœud courant du registre. """ import json import os import shlex import subprocess import time from . import config, ssh ASSETS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets") HANDSHAKE_OK_S = 200 # keepalive 25 s : au-delà de ~3 min sans handshake, le pair est considéré tombé class TunnelError(Exception): pass def gateway(name=None): name = name or config.TUNNEL_DEFAULT if name not in config.TUNNEL_GATEWAYS: raise TunnelError("passerelle inconnue « %s » (connues : %s)" % (name, ", ".join(config.TUNNEL_GATEWAYS))) return name, config.TUNNEL_GATEWAYS[name] def gw_run(name, cmd, timeout=60): """Commande sur la passerelle (ssh direct ubuntu@ip avec la clé de M1M32).""" name, g = gateway(name) try: p = subprocess.run(["ssh"] + ssh.SSH_OPTS + ["%s@%s" % (g["user"], g["host"]), cmd], capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: return 124, "", "timeout après %ss" % timeout return p.returncode, p.stdout, p.stderr def state(name=None): """Sortie de `tunnelctl json` : {"gateway", "ip", "wg": {"peers": [...]}, "caddy": {"active", "routes": [...]}}.""" name, g = gateway(name) rc, out, err = gw_run(name, "sudo -n tunnelctl json", timeout=40) if rc != 0 or not out.strip(): raise TunnelError("passerelle %s (%s) injoignable ou tunnelctl KO : %s" % (name, g["host"], (err or out).strip()[-160:])) return json.loads(out.strip().splitlines()[-1]) def routes(name=None, st=None): st = st or state(name) return {r["domain"]: r for r in st["caddy"]["routes"]} def peers(name=None, st=None): st = st or state(name) return {p["alias"]: p for p in st["wg"]["peers"]} # ------------------------------------------------------------------ routes --- def spec(m, alias): """Route attendue pour l'app `m` tournant sur `alias` → dict ou None si l'app n'est pas publique.""" t = m.get("tunnel") or {} domain = t.get("domain") or m.get("domain") if not domain or not m.get("port"): return None return { "gateway": t.get("gateway") or config.TUNNEL_DEFAULT, "domain": domain, "upstreams": ["%s:%s" % (alias, m["port"])], "redirects": list(t.get("redirects") or []), "websocket": bool(t.get("websocket")), } def _route_matches(route, want): """La route Caddy actuelle pointe-t-elle déjà vers les upstreams voulus (comparaison par alias:port) ?""" if not route or route.get("kind") != "proxy": return False cur = sorted("%s:%s" % (u.get("alias") or u["addr"].split(":")[0], u["addr"].split(":")[-1]) for u in route.get("upstreams", [])) return cur == sorted(want) def ensure_route(m, alias, log=print, st=None): """Fait pointer https:// vers : (et pose les redirections). Retourne le spec, ou None si l'app n'est pas publique.""" s = spec(m, alias) if not s: return None name, g = gateway(s["gateway"]) cur = routes(name, st) r = cur.get(s["domain"]) if _route_matches(r, s["upstreams"]): log(" tunnel %s : route https://%s → %s déjà en place" % (name, s["domain"], ", ".join(s["upstreams"]))) else: args = " ".join(shlex.quote(x) for x in s["upstreams"]) + (" --websocket" if s["websocket"] else "") rc, out, err = gw_run(name, "sudo -n tunnelctl add %s %s" % (shlex.quote(s["domain"]), args), timeout=90) if rc != 0: raise TunnelError("route %s → %s refusée par %s : %s" % (s["domain"], args, name, (err or out).strip()[-200:])) 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 "")) for red in s["redirects"]: rr = cur.get(red) if rr and rr.get("kind") == "redirect" and rr.get("target", "").rstrip("/").endswith(s["domain"]): continue rc, out, err = gw_run(name, "sudo -n tunnelctl redirect %s %s" % (shlex.quote(red), shlex.quote(s["domain"])), timeout=90) log(" tunnel %s : redirection %s → %s %s" % (name, red, s["domain"], "ok" if rc == 0 else "ÉCHEC " + (err or out).strip()[-120:])) return s def remove_route(m, log=print): """Retire la route (et ses redirections) d'une app retirée du cluster : le domaine tombe sur la page 404 MacLustr.""" t = m.get("tunnel") or {} domain = t.get("domain") or m.get("domain") if not domain: return False name, g = gateway(t.get("gateway")) for d in [domain] + list(t.get("redirects") or []): rc, out, err = gw_run(name, "sudo -n tunnelctl rm %s" % shlex.quote(d), timeout=60) log(" tunnel %s : route %s %s" % (name, d, "retirée" if rc == 0 else "ÉCHEC " + (err or out).strip()[-120:])) return True # ------------------------------------------------------------------- pairs --- def peer_state(alias, name=None, st=None): """(présent, handshake_s) — handshake None = jamais.""" p = peers(name, st).get(alias) if not p: return False, None return True, p.get("handshakeS") def peer(alias, name=None, log=print): """Raccorde un Mac au hub : installe WireGuard (Homebrew) + LaunchDaemon, enregistre la clé sur la passerelle (= `mlt peer`).""" from . import nodes name, g = gateway(name) ip = nodes.ip_of(alias) if not ip: raise TunnelError("nœud %s injoignable" % alias) 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)) parts = out.split() if rc != 0 or len(parts) < 2: raise TunnelError("alias %s absent de /etc/maclustr-tunnel/ipmap sur %s (l'ajouter : « %s %s. »)" % (alias, name, alias, g["subnet"])) wg_ip, hub_pub = parts[0], parts[1] pwf = config.sudo_pw_file(alias) pw = open(pwf).read().strip() if os.path.exists(pwf) else None if not pw: raise TunnelError("mot de passe sudo de %s absent (%s)" % (alias, pwf)) script = open(os.path.join(ASSETS, "wg-node-setup.sh")).read() ssh.write_remote_file(ip, "/tmp/mld-wg-node-setup.sh", script, mode="755") log(" tunnel %s : raccordement de %s (%s, %s) …" % (name, alias, wg_ip, g["iface"])) 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) pub = [l.split()[-1] for l in out.splitlines() if "PUBKEY" in l] if not pub: raise TunnelError("wg-node-setup.sh sur %s n'a pas renvoyé de clé publique : %s" % (alias, (err or out).strip()[-300:])) rc, out, err = gw_run(name, "sudo -n tunnelctl peer add %s %s" % (shlex.quote(alias), shlex.quote(pub[-1])), timeout=60) if rc != 0: raise TunnelError("tunnelctl peer add %s : %s" % (alias, (err or out).strip()[-200:])) for _ in range(12): time.sleep(4) present, hs = peer_state(alias, name) if present and hs is not None and hs < 60: log(" tunnel %s : %s raccordé (%s, handshake %ss)" % (name, alias, wg_ip, hs)) return True log(" tunnel %s : %s enregistré (%s), handshake pas encore vu — keepalive 25 s" % (name, alias, wg_ip)) return True def kick_node_iface(alias, ip, name=None, log=print): """Relance le LaunchDaemon WireGuard du nœud (interface tombée après un réveil réseau, par ex.).""" name, g = gateway(name) pwf = config.sudo_pw_file(alias) pw = open(pwf).read().strip() if os.path.exists(pwf) else None if not pw: return False lbl = "io.maclustr.wireguard-%s" % g["iface"] 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) if rc == 9: return False log(" tunnel %s : %s %s" % (name, lbl, "relancé sur " + alias if "kicked" in out else "ÉCHEC " + (err or out).strip()[-100:])) return "kicked" in out def ensure_peer(alias, name=None, log=print, st=None): """Nœud raccordé et vivant sur le hub ; sinon relance son interface, sinon le raccorde. Retourne True si OK.""" from . import nodes name, g = gateway(name) present, hs = peer_state(alias, name, st) if present and hs is not None and hs < HANDSHAKE_OK_S: return True if present: ip = nodes.ip_of(alias, rediscover=False) if ip and kick_node_iface(alias, ip, name, log): for _ in range(8): time.sleep(4) present, hs = peer_state(alias, name) if hs is not None and hs < 60: return True if ip and ssh.ok(ip, "test -f /Library/LaunchDaemons/io.maclustr.wireguard-%s.plist" % g["iface"]): log(" tunnel %s : %s enregistré mais sans handshake récent (%s)" % (name, alias, "jamais" if hs is None else "%ss" % hs)) return False return peer(alias, name, log) # ------------------------------------------------------------------ affichage --- def print_status(name=None): name, g = gateway(name) st = state(name) now = st.get("ts") or int(time.time()) print("== %s (%s, %s) — WireGuard udp %s, %d pairs" % (name, g["host"], g["place"], st["wg"].get("listenPort"), len(st["wg"]["peers"]))) for p in sorted(st["wg"]["peers"], key=lambda x: x["ip"]): hs = p.get("handshakeS") flag = "" if hs is not None and hs < HANDSHAKE_OK_S else " <<< pas de handshake récent" 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)) print("== Caddy : %s — %d routes" % ("actif" if st["caddy"]["active"] else "ARRÊTÉ", len(st["caddy"]["routes"]))) for r in st["caddy"]["routes"]: if r["kind"] == "redirect": print(" %-36s → redirection %s" % (r["domain"], r.get("target"))) else: print(" %-36s → %s" % (r["domain"], ", ".join("%s (%s)" % (u["addr"], u.get("alias") or "?") for u in r["upstreams"])))