From 78c2c25761e0a0738d6f3bb970f09cb9abaa813f Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 4 Oct 2026 23:28:06 -0400 Subject: [PATCH] contexte : etape 2, la frontiere premier temps, les identites (P87) La face reseau porte ses zones ; verifier_frontiere confronte supernet, administration, tunnel, alias de groupe, routes et traduction sortante a la face reseau et a la fiche du site. Aucun ecart ; cinq alterations vues. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 28 ++++++++ docs/conception-contextes.md | 3 +- docs/devis-services.md | 2 +- docs/responsabilites-locataire-hebergeur.md | 2 +- scripts/contexte.py | 76 ++++++++++++++++++++- scripts/prouver.py | 25 +++++++ scripts/tests/test_contexte.py | 33 ++++++++- 8 files changed, 165 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fe64d3..8890cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 86 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 87 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa18af..783e947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # CHANGELOG — Set-OPS +## 2026-10-04 (83) — Étape 2 : la frontière, premier temps (les identités) + +**Le chemin** (`docs/conception-contextes.md` §6), étape 2, dernier consommateur : la +frontière. Découpée en trois temps avec l'exploitant : les **identités** (ici), les **entrées +publiques** (redirections, règles depuis Internet), les **sorties et l'administration**. + +**Fait** (`scripts/contexte.py`) : +- la face réseau porte ses **zones** : un sous-réseau par catégorie de sa nomenclature, dérivé + de son index par `sous_reseau_de`, comme la frontière et le SDN le dérivent ; +- `verifier_frontiere(site, locataire)` confronte les identités par lesquelles le devis de la + frontière désigne le locataire à ce que sa face réseau et la fiche du site portent : le + supernet (ses zones), l'administration (que la frontière range par interface, gestion et + VPN), le tunnel, **chaque alias de groupe** (8 chez chaque locataire : les groupes qui ont + des flux de bordure), une route par zone, et la traduction sortante par l'adresse publique + que le site attribue ; +- les notes que le devis imprime (« flux `externe` sans `port_public` », « sortie vers un + groupe absent de ce site ») sont retenues pendant la vérification : utiles à qui lit le + devis, du bruit dans une preuve. Elles serviront au temps suivant. + +**Éprouvé** : +- **P87**, « La frontière désigne chaque locataire par les identités qu'il publie » : aucun + écart chez les deux locataires. +- `test_contexte.py`, 73 contrôles : une identité altérée de **cinq façons** (zones, + administration déclarée, tunnel, membres d'un groupe, adresse publique attribuée) est vue à + la frontière. La vérification compare bien les 8 alias de groupe de Technolibre, contrôlé à + part avant d'écrire la preuve. +- `make verifier` conforme, **87/87**. Les trois documents comptent 87 preuves. + ## 2026-10-04 (82) — Étape 2 : les flux de chaque machine, et une information que le locataire jetait **Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site : les flux. diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index 0dcd07a..e62d320 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -178,7 +178,8 @@ croisée, plus aucun recalcul. (P85) et les flux de chaque machine (P86) existent, et disent exactement ce que les lectures croisées produisent. La première mesure des flux a trouvé une information que le locataire jetait : les clients nommés d'un port aussi public, que Proxmox doit admettre nommément. Il -les publie désormais (`sources_declarees`). Reste la frontière. +les publie désormais (`sources_declarees`). La frontière, en trois temps : les identités +(P87) sont faites ; restent les entrées publiques, puis les sorties et l'administration. Méthodes du contrat : `site.fiche_pour(locataire)`, `locataire.face_reseau()`. Une classe n'ouvre jamais les fichiers de l'autre ; une preuve vérifiera la règle. diff --git a/docs/devis-services.md b/docs/devis-services.md index 216790a..3ed59b5 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 86 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 87 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/docs/responsabilites-locataire-hebergeur.md b/docs/responsabilites-locataire-hebergeur.md index f79c96f..3169030 100644 --- a/docs/responsabilites-locataire-hebergeur.md +++ b/docs/responsabilites-locataire-hebergeur.md @@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri Aucune de ces responsabilités n'est laissée à la parole : ``` -make prouver le dépôt est-il cohérent avec lui-même (86 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (87 preuves, zéro réseau) make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80) make certificats-plan ce que le disque porte contre ce que la mémoire sert make expositions-plan chaque service publié répond-il, et depuis où diff --git a/scripts/contexte.py b/scripts/contexte.py index 2efc08f..2c35e66 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -434,11 +434,18 @@ class Locataire(Ecosysteme): self.dossier_inventaire() / "group_vars" / "serveur_backup.yml")} sources.update({f"flux-genere/{h}.connectivite.json": _empreinte(self.depot / "flux-genere" / f"{h}.connectivite.json") for h in flux}) + # Ses zones : un sous-reseau par categorie de sa nomenclature, derive de son index + # (`sous_reseau_de`, comme la frontiere et le SDN le derivent). + from inventory_rules import sous_reseau_de + cats = self.nomenclature().get("categories") or {} + zones = ([sous_reseau_de(self.index, int(z)) for z in sorted(cats, key=int)] + if self.index is not None else []) return { "locataire": self.nom, "hebergeur": self.nom_hebergeur(), "sources": sources, "index": self.index, + "zones": zones, "machines": machines, "groupes": groupes, "administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot), @@ -714,6 +721,72 @@ def verifier_flux(site: "Site", locataire: "Locataire", face: dict | None = None return ecarts +def verifier_frontiere(site: "Site", locataire: "Locataire", face: dict | None = None, + fiche: dict | None = None) -> list[str]: + """La frontiere, premier temps : les IDENTITES par lesquelles elle designe ce locataire. + + Ses alias (supernet, administration par interface, tunnel, un alias par groupe qui a des + flux de bordure), ses routes, et sa traduction sortante. Chacun doit dire ce que la face + reseau du locataire (zones, groupes, administration) et la fiche du site (adresse publique + attribuee) portent deja. Les entrees publiques et les sorties sont les temps suivants. + """ + import re + import devis_opnsense + import devis_reseau + f = face if face is not None else locataire.face_reseau() + fi = fiche if fiche is not None else site.fiche_pour(locataire) + with _monter(site): + tenants = devis_reseau.decouvrir_du_site() + etq = next((p + str(n["index"]) for nom, p, n in tenants if nom == locataire.nom), None) + # Le devis imprime ses notes (flux sans `port_public`, sorties vers un absent) : utiles a + # qui le lit, du bruit dans une preuve. On les retient ici. + import io + with contextlib.redirect_stderr(io.StringIO()): + devis = devis_opnsense.construire(tenants) if etq else {} + if not etq: + return ["la frontiere ne decouvre pas ce locataire"] + ecarts: list[str] = [] + alias = devis.get("alias") or {} + ips = {h: m["ip"] for h, m in f["machines"].items()} + + # Le supernet : ses zones. + sup = (alias.get(f"SETOPS_TENANT_{etq}") or {}).get("contenu") + if sorted(sup or []) != sorted(f["zones"]): + ecarts.append(f"SETOPS_TENANT_{etq} : la frontiere porte {sup}, la face {f['zones']}") + # L'administration : l'intrant, que la frontiere range par interface, et le tunnel. + adm = sorted({c for k, v in alias.items() if re.fullmatch(rf"SETOPS_ADMIN_{etq}_(?!TUNNEL)\w+", k) + for c in v.get("contenu") or []}) + if adm != sorted(f["administration"]["intrant"]): + ecarts.append(f"administration : la frontiere range {adm}, la face {sorted(f['administration']['intrant'])}") + tun = sorted((alias.get(f"SETOPS_ADMIN_{etq}_TUNNEL") or {}).get("contenu") or []) + if tun != sorted(f["administration"]["tunnel"]): + ecarts.append(f"tunnel : la frontiere porte {tun}, la face {sorted(f['administration']['tunnel'])}") + # Un alias par groupe : ses membres sont ceux du groupe dans la face. + for k, v in sorted(alias.items()): + m = re.search(r"portant (\S+) \(instance (\S+)\)", str(v.get("description", ""))) + if not m or m.group(2) != locataire.nom: + continue + role = m.group(1) + attendu = sorted({ips[h] for h in f["groupes"].get(role, [])}) + if role not in f["groupes"]: + ecarts.append(f"{k} : groupe {role} inconnu de la face") + elif sorted(v.get("contenu") or []) != attendu: + ecarts.append(f"{k} : la frontiere porte {sorted(v.get('contenu') or [])}, la face {attendu}") + # Les routes : une par zone. + routes = sorted(r["reseau"] for r in devis.get("routes") or [] if r.get("tenant") == locataire.nom) + if routes != sorted(f["zones"]): + ecarts.append(f"routes : la frontiere route {routes}, la face a pour zones {sorted(f['zones'])}") + # La traduction sortante : sa flotte sort par l'adresse que le site lui attribue. + nat = [x for x in devis.get("nat") or [] if x.get("tenant") == locataire.nom] + cibles = sorted(str(x.get("cible")) for x in nat) + if cibles != [str(fi["attribue"]["ip_publique"])]: + ecarts.append(f"traduction sortante : la frontiere fait sortir par {cibles}, " + f"la fiche attribue {fi['attribue']['ip_publique']}") + if any(x.get("source") != f"SETOPS_TENANT_{etq}" for x in nat): + ecarts.append("traduction sortante : la source n'est pas le supernet du locataire") + return ecarts + + def couples_hebergeur() -> list[tuple["Site", "Locataire"]]: """Chaque (site, locataire) dont ce site est l'hebergeur ACTIF — les seuls qui ont une fiche a verifier : un site de reprise ne depose rien tant que la bascule n'a pas eu lieu.""" @@ -812,7 +885,8 @@ def main(argv: list[str] | None = None) -> int: for site, loc in couples: face = loc.face_reseau() for quoi, e in (("faits", verifier_face(site, loc, face)), - ("flux ", verifier_flux(site, loc, face))): + ("flux ", verifier_flux(site, loc, face)), + ("frontiere (identites)", verifier_frontiere(site, loc, face))): total += len(e) print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom} ({quoi})" + "".join(f"\n - {x}" for x in e)) diff --git a/scripts/prouver.py b/scripts/prouver.py index 2895726..7cb0421 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4740,6 +4740,29 @@ def preuve_flux_concordent() -> tuple[bool, str]: f"Proxmox admettent les memes sources.") +def preuve_frontiere_identites() -> tuple[bool, str]: + """Les alias, les routes et la traduction sortante par lesquels la frontiere designe chaque + locataire disent-ils ce que sa face reseau et la fiche du site portent ? + + POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). La frontiere est le dernier + consommateur du site qui lit l'interieur des locataires. Premier temps de sa bascule : les + identites — supernet (ses zones), administration par interface, tunnel, un alias par groupe + qui a des flux de bordure, une route par zone, la sortie par l'adresse publique attribuee. + Les entrees publiques et les sorties suivront. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucune frontiere a confronter." + ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_frontiere(s, l)] + if ecarts: + return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie : " + + " | ".join(ecarts[:4])) + return True, (f"{len(couples)} locataire(s) : la frontiere les designe par les identites qu'ils " + f"publient (supernet, administration, tunnel, groupes, routes, sortie).") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4932,6 +4955,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_face_reseau}, {"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees", "refs": [], "func": preuve_flux_concordent}, + {"id": "P87", "titre": "La frontiere designe chaque locataire par les identites qu'il publie", + "refs": [], "func": preuve_frontiere_identites}, ] diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index bb52168..296a11a 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -13,7 +13,9 @@ site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque heberge exactement ce que son locataire porte, et une fiche alteree (sept facons) est vue en ecart ; la face reseau de chaque locataire dit ce que les consommateurs du site en tirent, et une face alteree (huit facons) est vue en ecart ; machine par machine, le pare-feu du locataire et celui de -Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vus en ecart. +Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vus en ecart ; +la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq +facons) se voit. """ from __future__ import annotations @@ -282,6 +284,34 @@ def flux() -> None: verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart") +def frontiere() -> None: + """La frontiere designe chaque locataire par les memes identites que sa face reseau et la + fiche du site — et chaque identite alteree se voit.""" + import copy + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune frontiere a confronter") + return + for site, loc in couples: + e = C.verifier_frontiere(site, loc) + verifier(not e, f"frontiere {loc.nom} : alias, routes et traduction sortante concordent" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + face, fiche = loc.face_reseau(), site.fiche_pour(loc) + groupe = next(g for g in ("serveur_postfix", "serveur_dovecot", "serveur_nginx") if face["groupes"].get(g)) + alterations = { + "zones": lambda f, fi: f.__setitem__("zones", f["zones"][:-1]), + "administration declaree": lambda f, fi: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]), + "tunnel": lambda f, fi: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]), + f"membres du groupe {groupe}": lambda f, fi: f["groupes"].__setitem__(groupe, []), + "adresse publique attribuee": lambda f, fi: fi["attribue"].__setitem__("ip_publique", "192.0.2.60"), + } + for nom, alterer in alterations.items(): + f, fi = copy.deepcopy(face), copy.deepcopy(fiche) + alterer(f, fi) + verifier(bool(C.verifier_frontiere(site, loc, f, fi)), f"une identite alteree ({nom}) est vue a la frontiere") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -293,6 +323,7 @@ def main() -> int: fiches() faces() flux() + frontiere() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1