spb/maclustr-dispatch
Public
Python 95.8%
Shell 4.2%
1"""`mld harden <nœud>|--all` : un Mac du cluster doit revenir TOUT SEUL après une coupure de courant ou une mise à jour.23Ce que ça règle (idempotent, sudo via ~/dispatch/.sudo*) :4 1. alimentation : redémarrage automatique après coupure (autorestart) et après gel (restartfreeze), jamais de veille5 (sleep/disksleep 0), réveil réseau (womp) ;6 2. mises à jour macOS + App Store : aucune installation automatique (un redémarrage surprise couperait les apps) ;7 3. Tailscale : `tailscale set --auto-update=false` + Sparkle désactivé — une MAJ relance tailscaled et le nœud disparaît8 de *.maclustr.io (les noms résolvent vers l'IP Tailscale) même si le LAN et le tunnel vont bien ;9 4. ouverture de session automatique (kcpassword + autoLoginUser) : sans session graphique, les LaunchAgents (pm2 startup,10 gardiens Ka, pousseur, admin-ka) ne démarrent jamais — c'est ce qui a laissé les apps mortes le 2026-09-10.11 Impossible si FileVault est actif (le disque reste chiffré jusqu'à la saisie du mot de passe) : signalé ;12 5. vérifie que pm2 startup (LaunchAgent ou LaunchDaemon) et le LaunchDaemon WireGuard wg1 sont en place.13Les nœuds loués (REMOTE_NODES) reçoivent 1, 2, 5 mais pas l'auto-login (session du fournisseur) sauf --autologin.14"""15import os16import shlex17from concurrent.futures import ThreadPoolExecutor18from . import config, nodes, ssh1920KC_KEY = [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F]212223def kcpassword(pw):24 """Encodage /etc/kcpassword (XOR avec la clé Apple, complété à un multiple de 12 octets)."""25 b = list(pw.encode("utf-8")) + [0]26 while len(b) % 12:27 b.append(0)28 return bytes(x ^ KC_KEY[i % len(KC_KEY)] for i, x in enumerate(b))293031def _sudo_pw(alias):32 pwf = config.sudo_pw_file(alias)33 return open(pwf).read().strip() if os.path.exists(pwf) else None343536def script_for(alias, autologin):37 pw = _sudo_pw(alias)38 if not pw:39 return None40 user = config.user_of(alias)41 kc = "".join("\\x%02x" % x for x in kcpassword(pw)) # même mot de passe pour sudo et l'ouverture de session42 s = r'''43S(){ echo %(pw)s | sudo -S -p '' "$@"; }44echo "== alimentation"45S pmset -a sleep 0 disksleep 0 womp 1 >/dev/null 2>&146S pmset -a autorestart 1 >/dev/null 2>&1 || echo " autorestart non supporté (portable)"47S systemsetup -setrestartfreeze on >/dev/null 2>&148S systemsetup -setrestartpowerfailure on >/dev/null 2>&149echo " $(pmset -g | grep -E '^ *(autorestart|sleep|disksleep|womp) ' | awk '{print $1"="$2}' | tr '\n' ' ')"50echo "== mises à jour macOS / App Store : désactivées"51for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do S defaults write /Library/Preferences/com.apple.SoftwareUpdate $k -bool false; done52S defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false53S defaults write /Library/Preferences/com.apple.commerce AutoUpdateRestartRequired -bool false54echo " SoftwareUpdate: $(defaults read /Library/Preferences/com.apple.SoftwareUpdate 2>/dev/null | grep -E 'Automatic|Install' | tr -d ' ;' | tr '\n' ' ')"55T=/Applications/Tailscale.app/Contents/MacOS/Tailscale56if [ -x "$T" ]; then57 echo "== Tailscale $($T version 2>/dev/null | head -1) : pas de mise à jour automatique"58 "$T" set --auto-update=false >/dev/null 2>&1 && echo " auto-update off" || echo " ! tailscale set a échoué (session ?)"59 defaults write io.tailscale.ipn.macsys SUEnableAutomaticChecks -bool false 2>/dev/null60 defaults write io.tailscale.ipn.macsys SUAutomaticallyUpdate -bool false 2>/dev/null61 defaults write io.tailscale.ipn.macos SUEnableAutomaticChecks -bool false 2>/dev/null62else63 echo "== Tailscale : absent"64fi65echo "== ouverture de session automatique (%(user)s)"66if fdesetup status 2>/dev/null | grep -q 'On'; then67 echo " ! FILEVAULT ACTIF : auto-login impossible, le Mac attend un mot de passe au démarrage (fdesetup disable pour lever ça)"68elif [ "%(autologin)s" = "1" ]; then69 printf '%(kc)s' > /tmp/.kc.$$ && S install -m 600 -o root -g wheel /tmp/.kc.$$ /etc/kcpassword; rm -f /tmp/.kc.$$70 S defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string %(user)s71 echo " autoLoginUser=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null) kcpassword=$(S stat -f %%z /etc/kcpassword 2>/dev/null) octets"72else73 echo " non touché (nœud loué : session du fournisseur) — autoLoginUser=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null || echo aucun)"74fi75echo "== démarrage des services"76P=$(ls ~/Library/LaunchAgents/pm2.*.plist /Library/LaunchDaemons/pm2.*.plist 2>/dev/null | head -1)77[ -n "$P" ] && echo " pm2 startup : $P" || echo " ! pm2 startup ABSENT → mld prepare %(alias)s"78if [ -f /Library/LaunchDaemons/io.maclustr.wireguard-wg1.plist ]; then79 ifconfig 2>/dev/null | grep -q 'inet 10\.67\.0\.' && echo " wg1 (tunnel BHS64) : monté" || { echo " wg1 : plist présent mais interface absente → relance"; S launchctl kickstart -k system/io.maclustr.wireguard-wg1; }80else81 echo " wg1 : non raccordé au MacLustr Tunnel (mld tunnel peer %(alias)s si le nœud doit exposer un site)"82fi83echo " session console : $(who | awk '/console/{print $1" depuis "$3" "$4}' | head -1)"84''' % {"pw": shlex.quote(pw), "user": user, "kc": kc, "autologin": "1" if autologin else "0", "alias": alias}85 return s868788def harden(alias, autologin=None, verbose=True):89 ip = nodes.ip_of(alias, rediscover=False) or nodes.ip_of(alias)90 if not ip:91 return alias, False, "injoignable"92 if autologin is None:93 autologin = alias not in config.REMOTE_NODES94 s = script_for(alias, autologin)95 if not s:96 return alias, False, "mot de passe sudo absent (%s)" % config.sudo_pw_file(alias)97 rc, out, err = ssh.run(ip, s, timeout=240)98 if verbose:99 print("### %s (%s)" % (alias, ip))100 print(out.rstrip())101 if err.strip():102 print(" stderr: " + err.strip()[-200:])103 flags = []104 if "FILEVAULT" in out:105 flags.append("FileVault")106 if "pm2 startup ABSENT" in out:107 flags.append("pm2-startup")108 if "tailscale set a échoué" in out:109 flags.append("tailscale")110 return alias, rc == 0, ", ".join(flags) if flags else "ok"111112113def harden_all(aliases=None, autologin=None):114 lan = nodes.load_lan()["ips"] or nodes.discover(verbose=False)115 targets = [a for a in config.NODES if a in lan and (not aliases or a in aliases)]116 with ThreadPoolExecutor(max_workers=12) as ex:117 results = list(ex.map(lambda a: harden(a, autologin=autologin, verbose=False), targets))118 print("%-8s %-6s %s" % ("nœud", "état", "à surveiller"))119 for a, ok, note in results:120 print("%-8s %-6s %s" % (a, "ok" if ok else "ÉCHEC", note))121 missing = [a for a in config.NODES if a not in lan]122 if missing:123 print("non joignables (éteints ?) : %s" % ", ".join(missing))124 return results125