"""`mld harden |--all` : un Mac du cluster doit revenir TOUT SEUL après une coupure de courant ou une mise à jour. Ce que ça règle (idempotent, sudo via ~/dispatch/.sudo*) : 1. alimentation : redémarrage automatique après coupure (autorestart) et après gel (restartfreeze), jamais de veille (sleep/disksleep 0), réveil réseau (womp) ; 2. mises à jour macOS + App Store : aucune installation automatique (un redémarrage surprise couperait les apps) ; 3. Tailscale : `tailscale set --auto-update=false` + Sparkle désactivé — une MAJ relance tailscaled et le nœud disparaît de *.maclustr.io (les noms résolvent vers l'IP Tailscale) même si le LAN et le tunnel vont bien ; 4. ouverture de session automatique (kcpassword + autoLoginUser) : sans session graphique, les LaunchAgents (pm2 startup, gardiens Ka, pousseur, admin-ka) ne démarrent jamais — c'est ce qui a laissé les apps mortes le 2026-09-10. Impossible si FileVault est actif (le disque reste chiffré jusqu'à la saisie du mot de passe) : signalé ; 5. vérifie que pm2 startup (LaunchAgent ou LaunchDaemon) et le LaunchDaemon WireGuard wg1 sont en place. Les nœuds loués (REMOTE_NODES) reçoivent 1, 2, 5 mais pas l'auto-login (session du fournisseur) sauf --autologin. """ import os import shlex from concurrent.futures import ThreadPoolExecutor from . import config, nodes, ssh KC_KEY = [0x7D, 0x89, 0x52, 0x23, 0xD2, 0xBC, 0xDD, 0xEA, 0xA3, 0xB9, 0x1F] def kcpassword(pw): """Encodage /etc/kcpassword (XOR avec la clé Apple, complété à un multiple de 12 octets).""" b = list(pw.encode("utf-8")) + [0] while len(b) % 12: b.append(0) return bytes(x ^ KC_KEY[i % len(KC_KEY)] for i, x in enumerate(b)) def _sudo_pw(alias): pwf = config.sudo_pw_file(alias) return open(pwf).read().strip() if os.path.exists(pwf) else None def script_for(alias, autologin): pw = _sudo_pw(alias) if not pw: return None user = config.user_of(alias) kc = "".join("\\x%02x" % x for x in kcpassword(pw)) # même mot de passe pour sudo et l'ouverture de session s = r''' S(){ echo %(pw)s | sudo -S -p '' "$@"; } echo "== alimentation" S pmset -a sleep 0 disksleep 0 womp 1 >/dev/null 2>&1 S pmset -a autorestart 1 >/dev/null 2>&1 || echo " autorestart non supporté (portable)" S systemsetup -setrestartfreeze on >/dev/null 2>&1 S systemsetup -setrestartpowerfailure on >/dev/null 2>&1 echo " $(pmset -g | grep -E '^ *(autorestart|sleep|disksleep|womp) ' | awk '{print $1"="$2}' | tr '\n' ' ')" echo "== mises à jour macOS / App Store : désactivées" for k in AutomaticCheckEnabled AutomaticDownload AutomaticallyInstallMacOSUpdates ConfigDataInstall CriticalUpdateInstall; do S defaults write /Library/Preferences/com.apple.SoftwareUpdate $k -bool false; done S defaults write /Library/Preferences/com.apple.commerce AutoUpdate -bool false S defaults write /Library/Preferences/com.apple.commerce AutoUpdateRestartRequired -bool false echo " SoftwareUpdate: $(defaults read /Library/Preferences/com.apple.SoftwareUpdate 2>/dev/null | grep -E 'Automatic|Install' | tr -d ' ;' | tr '\n' ' ')" T=/Applications/Tailscale.app/Contents/MacOS/Tailscale if [ -x "$T" ]; then echo "== Tailscale $($T version 2>/dev/null | head -1) : pas de mise à jour automatique" "$T" set --auto-update=false >/dev/null 2>&1 && echo " auto-update off" || echo " ! tailscale set a échoué (session ?)" defaults write io.tailscale.ipn.macsys SUEnableAutomaticChecks -bool false 2>/dev/null defaults write io.tailscale.ipn.macsys SUAutomaticallyUpdate -bool false 2>/dev/null defaults write io.tailscale.ipn.macos SUEnableAutomaticChecks -bool false 2>/dev/null else echo "== Tailscale : absent" fi echo "== ouverture de session automatique (%(user)s)" if fdesetup status 2>/dev/null | grep -q 'On'; then echo " ! FILEVAULT ACTIF : auto-login impossible, le Mac attend un mot de passe au démarrage (fdesetup disable pour lever ça)" elif [ "%(autologin)s" = "1" ]; then printf '%(kc)s' > /tmp/.kc.$$ && S install -m 600 -o root -g wheel /tmp/.kc.$$ /etc/kcpassword; rm -f /tmp/.kc.$$ S defaults write /Library/Preferences/com.apple.loginwindow autoLoginUser -string %(user)s echo " autoLoginUser=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null) kcpassword=$(S stat -f %%z /etc/kcpassword 2>/dev/null) octets" else echo " non touché (nœud loué : session du fournisseur) — autoLoginUser=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null || echo aucun)" fi echo "== démarrage des services" P=$(ls ~/Library/LaunchAgents/pm2.*.plist /Library/LaunchDaemons/pm2.*.plist 2>/dev/null | head -1) [ -n "$P" ] && echo " pm2 startup : $P" || echo " ! pm2 startup ABSENT → mld prepare %(alias)s" if [ -f /Library/LaunchDaemons/io.maclustr.wireguard-wg1.plist ]; then 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; } else echo " wg1 : non raccordé au MacLustr Tunnel (mld tunnel peer %(alias)s si le nœud doit exposer un site)" fi echo " session console : $(who | awk '/console/{print $1" depuis "$3" "$4}' | head -1)" ''' % {"pw": shlex.quote(pw), "user": user, "kc": kc, "autologin": "1" if autologin else "0", "alias": alias} return s def harden(alias, autologin=None, verbose=True): ip = nodes.ip_of(alias, rediscover=False) or nodes.ip_of(alias) if not ip: return alias, False, "injoignable" if autologin is None: autologin = alias not in config.REMOTE_NODES s = script_for(alias, autologin) if not s: return alias, False, "mot de passe sudo absent (%s)" % config.sudo_pw_file(alias) rc, out, err = ssh.run(ip, s, timeout=240) if verbose: print("### %s (%s)" % (alias, ip)) print(out.rstrip()) if err.strip(): print(" stderr: " + err.strip()[-200:]) flags = [] if "FILEVAULT" in out: flags.append("FileVault") if "pm2 startup ABSENT" in out: flags.append("pm2-startup") if "tailscale set a échoué" in out: flags.append("tailscale") return alias, rc == 0, ", ".join(flags) if flags else "ok" def harden_all(aliases=None, autologin=None): lan = nodes.load_lan()["ips"] or nodes.discover(verbose=False) targets = [a for a in config.NODES if a in lan and (not aliases or a in aliases)] with ThreadPoolExecutor(max_workers=12) as ex: results = list(ex.map(lambda a: harden(a, autologin=autologin, verbose=False), targets)) print("%-8s %-6s %s" % ("nœud", "état", "à surveiller")) for a, ok, note in results: print("%-8s %-6s %s" % (a, "ok" if ok else "ÉCHEC", note)) missing = [a for a in config.NODES if a not in lan] if missing: print("non joignables (éteints ?) : %s" % ", ".join(missing)) return results