Set-OPS-Public/scripts/forge_etancheite.py
Daniel Allaire d7717a4f98 serveur_ops : les depots prives se lisent en SSH, ou arrivent en colis
Mesure sur le terrain : chaque runner lit son depot en SSH avec sa cle de deploiement,
et la forge lui refuse les autres, alors meme que les depots sont publics. Le role ne
clone plus en HTTPS que les depots publics ; les depots instance, tenant et hebergeur
passent en SSH, cle d'hote epinglee, commande SSH posee dans leur config git. Avant
l'armement, le controleur les apporte en git bundle. La cle d'hote de la forge est
declaree au plan du site et offerte par la fiche. genome_etat et genome_pousser lisent
l'API authentifies ; la sonde genome-servi compte les publics ; forge_amorcer cree
prives les depots des ecosystemes et du site. make forge-etancheite mesure le tout.

Les depots restent publics.

95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:33:38 -04:00

150 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ?
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Le plan d'un ecosysteme et la carte
du site sont prives sur la forge du site ; chaque runner de locataire lit le sien avec une cle
de deploiement en lecture seule. Deux mesures, depuis deux endroits :
SANS IDENTIFIANT, depuis ce poste (HTTPS) :
- un depot PRIVE doit EXIGER une authentification. Un refus RESEAU ne compte pas : il ne
dit rien de la forge, seulement du chemin (une sonde rapporte son errno) ;
- un depot PUBLIC (le moteur, les modeles) doit repondre : un ecosysteme neuf le clone
sans rien detenir.
DEPUIS CHAQUE RUNNER DE LOCATAIRE (SSH, sa cle, la cle d'hote epinglee) :
- il lit SON depot ;
- la forge lui REFUSE les autres depots prives (« not authorized »).
Le resultat se lit ligne par ligne ; le code de sortie vaut 1 au moindre ecart.
Usage :
python3 scripts/forge_etancheite.py (make forge-etancheite)
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
def classer_https(rc: int, sortie: str) -> str:
"""'lu', 'authentification', ou 'reseau' — ce que dit un `git ls-remote` anonyme."""
if rc == 0:
return "lu"
s = sortie.lower()
if any(m in s for m in ("could not read username", "terminal prompts disabled",
"authentication failed", "401", "403")):
return "authentification"
return "reseau"
def classer_ssh(rc: int, sortie: str) -> str:
"""'lu', 'refuse' (la forge a juge), ou 'reseau' (personne n'a juge)."""
if rc == 0:
return "lu"
s = sortie.lower()
if "not authorized" in s or "permission denied" in s or "does not exist" in s:
return "refuse"
return "reseau"
def _site_et_depots():
import underlay as underlay_mod
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
depots = []
for srv in (plan.get("serveurs") or {}).values():
d = (srv.get("variables") or {}).get("serveur_ops_depots")
if d:
depots = list(d)
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
racine_ac = (underlay_mod.chemin() or Path()).resolve().parent / "ac-racine-site.crt"
return dom, depots, racine_ac
def mesurer_https(dom: str, depots: list[dict], racine_ac: Path) -> list[str]:
ecarts = []
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_ASKPASS="/bin/false")
if racine_ac.is_file():
env["GIT_SSL_CAINFO"] = str(racine_ac)
print(f"Sans identifiant, depuis ce poste (https://forge.{dom}) :")
for d in depots:
nom, prive = str(d["depot"]), str(d.get("role")) in ROLES_PRIVES
r = subprocess.run(["git", "ls-remote", f"https://forge.{dom}/genome/{nom}.git", "HEAD"],
capture_output=True, text=True, timeout=30, env=env)
verdict = classer_https(r.returncode, r.stdout + r.stderr)
attendu = "authentification" if prive else "lu"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {nom:<18} {'prive ' if prive else 'public'} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu})"))
if not ok:
ecarts.append(f"{nom} : {verdict}, attendu {attendu}")
return ecarts
def mesurer_runners(depots: list[dict]) -> list[str]:
import contexte as _ctx
import yaml
prives = [str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES]
ecarts = []
for site, loc in _ctx.couples_hebergeur():
face = loc.face_publiee() or {}
if face.get("hebergeur") != site.nom:
continue
sien = str((face.get("genome") or {}).get("depot") or "")
runners = sorted((face.get("groupes") or {}).get("serveur_ops_tenant") or [])
gv = yaml.safe_load((loc.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
.read_text(encoding="utf-8")) or {}
dest = next((str(x.get("dest")) for x in gv.get("serveur_ops_depots") or []
if x.get("role") == "instance"), "")
if not (sien and runners and dest):
ecarts.append(f"{loc.nom} : face sans depot, sans runner, ou plan sans `role: instance`")
continue
print(f"\nDepuis le runner de {loc.nom} ({runners[0]}), par l'URL de son origin :")
# LA COMMANDE SSH DU DEPOT LUI-MEME (`core.sshCommand`, posee par serveur_ops) : on
# mesure ce que le runner fait vraiment, pas une commande refaite a cote.
script = (f"cd /opt/setops/{dest} && base=$(git remote get-url origin) && base=${{base%/*}} && "
f"for n in {' '.join(prives)}; do "
"if out=$(git ls-remote \"$base/$n.git\" HEAD 2>&1 >/dev/null); then echo \"MESURE $n lu\"; "
"else echo \"MESURE $n $(echo $out | tr '\\n' ' ')\"; fi; done")
r = subprocess.run(["ansible", "-i", str(loc.inventaire()), runners[0], "-b",
"--become-user", "setops", "-m", "shell", "-a", script],
capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
mesures = {}
for ligne in r.stdout.splitlines():
if ligne.startswith("MESURE "):
_m, n, reste = ligne.split(" ", 2)
mesures[n] = ("lu", "") if reste.strip() == "lu" else (classer_ssh(1, reste), reste.strip())
if not mesures:
ecarts.append(f"{loc.nom} : aucune mesure rendue par {runners[0]} ({(r.stderr or r.stdout)[-160:]})")
print(f" ECART aucune mesure rendue")
continue
for n in prives:
verdict, detail = mesures.get(n, ("absent", ""))
attendu = "lu" if n == sien else "refuse"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {n:<18} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu}) {detail[:90]}"))
if not ok:
ecarts.append(f"{loc.nom} -> {n} : {verdict}, attendu {attendu}")
return ecarts
def main() -> int:
dom, depots, racine_ac = _site_et_depots()
if not dom or not depots:
print("REFUS : aucun site monte, ou son plan ne declare pas `serveur_ops_depots`.")
return 2
ecarts = mesurer_https(dom, depots, racine_ac) + mesurer_runners(depots)
if ecarts:
print(f"\nNON ETANCHE : {len(ecarts)} ecart(s).")
return 1
print("\nETANCHE : chaque runner lit son depot, aucun celui d'un autre ; le public reste public.")
return 0
if __name__ == "__main__":
raise SystemExit(main())