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>
150 lines
6.7 KiB
Python
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())
|