SPB Git forge
29commits 1branches 0releases
684.0 KBsize
maindefault branch
2 days agolast push
Python 95.8% Shell 4.2%
11.2 KB · 230 lines python
Raw Blame History
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