diff --git a/AGENTS.md b/AGENTS.md index 20a9679..4c2f363 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 89 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 90 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 6b2d220..bb58006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # CHANGELOG — Set-OPS +## 2026-10-05 (89) — Étape 2 terminée : les sorties, et le contrat entier tient + +**Le chemin** (`docs/conception-contextes.md` §6), étape 2, frontière, dernier temps : ce qui +sort de chez un locataire vers l'Internet. Avec lui, **l'étape 2 est complète** : chaque chose +que le site lit chez un locataire est publiée par le locataire, et une preuve vérifie que la +publication dit exactement ce que le site en tire. + +**Fait** : +- `resoudre_flux.py` relève, avec les mêmes conditions que les entrées (groupe opérationnel, + `flux_applicable`), les **sorties vers l'extérieur** de chaque machine, et les publie dans + son `*.connectivite.json` (`sorties_externes` : protocole, port, rôle). Ses machines + acceptent tout en sortie : il ne les relevait pas, la frontière les filtre ; +- flux régénérés chez les deux locataires : les 13 fichiers de connectivité de chacun gagnent + le champ, **aucun `.nft` ne bouge**. La sonde `connectivite` l'ignore ; +- la face réseau reprend ces sorties ; `verifier_sorties(site, locataire)` les confronte, + machine par machine, aux règles de sortie de la frontière vers « tout sauf l'interne », et + signale toute sortie vers une autre destination. + +**Éprouvé** : +- **P90**, « Ce qui sort d'un locataire vers l'Internet, la frontière le tient de lui » : aucun + écart. Comparés chez Technolibre : 43 sorties publiées (machine × port), 7 règles de sortie + (le système en 80, 443 et ICMP ; Postfix en 25 ; PowerDNS et le résolveur en 53). Les deux + sorties fausses de `serveur_ops` étaient déjà retirées ((85), (87)). +- `test_contexte.py`, 91 contrôles : une sortie retirée ou ajoutée est vue à la frontière. +- `make verifier` conforme, **90/90**. Les trois documents comptent 90 preuves. + +**Le bilan de l'étape 2** : sept preuves (P84 à P90) ; trois choses que le locataire +**jetait** ou **ne relevait pas** et publie désormais (les clients nommés d'un port public, +le drapeau `poste`, ses sorties) ; deux sorties fausses retirées de la frontière, dont un +passage entre locataires ; une garde sur la passerelle de zone. Prochaine étape (3) : les +consommateurs du site lisent les fiches au lieu des fichiers internes, à résultat identique. + ## 2026-10-05 (88) — La passerelle n'est pas une porte : une garde sur l'API Proxmox **Le reste ouvert de (87)** : chez un locataire en SDN, la passerelle de chaque zone est une diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index a185ce3..6671ed8 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -179,7 +179,8 @@ croisée, plus aucun recalcul. 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`). La frontière, en trois temps : les identités -(P87), les entrées publiques (P88) et l'administration (P89) sont faites ; restent les sorties. +(P87), les entrées publiques (P88), l'administration (P89) et les sorties (P90) sont faites. +**L'étape 2 est terminée** (2026-10-05). 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 3bc6564..03c237d 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 89 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 90 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 f045d69..5d69057 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 (89 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (90 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 3077753..0ca82bb 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -445,7 +445,8 @@ class Locataire(Ecosysteme): d = json.loads(c.read_text(encoding="utf-8")) entrees = [dict(e, poste=ouvert_au_poste(e)) if "0.0.0.0/0" in (e.get("sources") or []) else dict(e) for e in d.get("entrees") or []] - flux[h] = {"entrees": entrees, "administration": d.get("admin_ssh") or []} + flux[h] = {"entrees": entrees, "administration": d.get("admin_ssh") or [], + "sorties_externes": d.get("sorties_externes") or []} sources = {str(Path(*f.relative_to(self.depot).parts)): _empreinte(f) for f in ( self.plan / "nomenclature.yml", self.inventaire(), self.plan / "acces.yml", self.plan / "domaines.yml", self.plan / "applications.yml", @@ -894,6 +895,52 @@ def verifier_administration(site: "Site", locataire: "Locataire", face: dict | N return ecarts +def verifier_sorties(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: + """La frontiere, troisieme temps (seconde moitie) : ce qui SORT de chez le locataire vers + l'Internet. + + Machine par machine, les regles de sortie vers « tout sauf l'interne » (`!SETOPS_INTERNES`) + doivent dire ce que la face reseau publie dans `sorties_externes` : les memes (machine, + protocole, port). Le locataire relevait ses entrees pour ses propres pare-feux ; ses sorties, + que ses machines acceptent toutes, il ne les relevait pas — il les publie depuis le + 2026-10-05, pour la frontiere. + """ + import ipaddress + f = face if face is not None else locataire.face_reseau() + etq, devis = _devis_frontiere(site, locataire) + if not etq: + return ["la frontiere ne decouvre pas ce locataire"] + alias = devis.get("alias") or {} + ips = sorted({m["ip"] for m in f["machines"].values()}) + + def adresses(nom: str) -> set[str]: + a = alias.get(nom) or {} + if a.get("type") == "network": + reseaux = [ipaddress.ip_network(str(c), strict=False) for c in a.get("contenu") or []] + return {ip for ip in ips if any(ipaddress.ip_address(ip) in r for r in reseaux)} + return {str(c) for c in a.get("contenu") or []} + + publiees = {(f["machines"][h]["ip"], str(s.get("proto")), _ICMP.get(str(s.get("port")), str(s.get("port")))) + for h, fl in (f.get("flux") or {}).items() for s in fl.get("sorties_externes") or []} + vues = {(ip, str(r.get("protocole")), _ICMP.get(str(p), str(p))) + for r in devis.get("regles") or [] + if r.get("tenant") == locataire.nom and r.get("sens") == "out" + and str(r.get("destination")) == "!SETOPS_INTERNES" + for ip in adresses(str(r.get("source"))) for p in r.get("ports") or []} + ecarts: list[str] = [] + for x in sorted(publiees - vues): + ecarts.append(f"{x[0]} {x[1]}/{x[2]} : le locataire sort vers l'Internet, la frontiere ne le laisse pas") + for x in sorted(vues - publiees): + ecarts.append(f"{x[0]} {x[1]}/{x[2]} : la frontiere laisse sortir, le locataire ne le publie pas") + autres = [r for r in devis.get("regles") or [] + if r.get("tenant") == locataire.nom and r.get("sens") == "out" + and str(r.get("destination")) != "!SETOPS_INTERNES"] + for r in autres: + ecarts.append(f"sortie vers {r.get('destination')} ({r.get('protocole')}/{','.join(r.get('ports') or [])}, " + f"{r.get('role')}) : ni l'Internet, ni publiee par le locataire") + 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. @@ -1052,7 +1099,8 @@ def main(argv: list[str] | None = None) -> int: ("flux ", verifier_flux(site, loc, face)), ("frontiere (identites)", verifier_frontiere(site, loc, face)), ("frontiere (entrees publiques)", verifier_entrees_publiques(site, loc, face)), - ("frontiere (administration)", verifier_administration(site, loc, face))): + ("frontiere (administration)", verifier_administration(site, loc, face)), + ("frontiere (sorties)", verifier_sorties(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 2e0a589..0066810 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4808,6 +4808,28 @@ def preuve_frontiere_administration() -> tuple[bool, str]: f"l'administration atteint exactement ce que leur face reseau lui ouvre.") +def preuve_frontiere_sorties() -> tuple[bool, str]: + """Les regles de sortie d'un locataire vers l'Internet disent-elles ce que sa face reseau + publie (`sorties_externes`) ? + + POURQUOI (2026-10-05, `docs/conception-contextes.md` §2.4). Dernier temps de la frontiere. + Le locataire ne relevait pas ses sorties (ses machines les acceptent toutes) ; il les publie + desormais, et la frontiere doit les laisser passer, elles et rien d'autre. En chemin, deux + sorties de `serveur_ops` se sont revelees fausses et ont ete retirees (85, 87). + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucune sortie a confronter." + ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_sorties(s, l)] + if ecarts: + return False, (f"{len(ecarts)} ecart(s) entre la frontiere et ce que le locataire publie comme " + f"sorties : " + " | ".join(ecarts[:4])) + return True, (f"{len(couples)} locataire(s) : la frontiere laisse sortir exactement ce que leur " + f"face reseau publie.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -5006,6 +5028,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_frontiere_entrees_publiques}, {"id": "P89", "titre": "Ce que l'administration atteint chez un locataire, la frontiere le tient de lui", "refs": [], "func": preuve_frontiere_administration}, + {"id": "P90", "titre": "Ce qui sort d'un locataire vers l'Internet, la frontiere le tient de lui", + "refs": [], "func": preuve_frontiere_sorties}, ] diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index ca82f69..277d3b6 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -785,12 +785,24 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - hote_par_ip = {ip: h for h, ip in ip_par_hote.items()} sorties: dict[str, dict[tuple, dict]] = {} entrees: dict[str, list[dict]] = {} + # CE QUE CHAQUE VM DEMANDE A L'EXTERIEUR (2026-10-05). Ses machines acceptent tout en + # sortie : nftables n'en a pas besoin, et ce generateur ne les relevait donc pas. Mais la + # FRONTIERE, elle, filtre ces sorties, et la face reseau du locataire doit les lui dire + # (docs/conception-contextes.md). Relevees ICI, avec les memes conditions que les entrees. + sorties_externes: dict[str, set[tuple]] = {} for hote in sorted(actifs_noms): groupes = [g for g, membres in _enfants(data).items() if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)] regles: list[str] = list(regles_admin) for g in sorted(groupes): for fl in flux_par_role.get(g, []): + if fl.get("sens") == "egress" and "externe" in _pairs(fl): + if flux_applicable(fl, g, dossier_ecosysteme, + (_enfants(data).get(g, {}).get("hosts") or {}).get(hote)): + for pt in (fl["port"] if isinstance(fl["port"], list) else [fl["port"]]): + sorties_externes.setdefault(hote, set()).add( + (str(fl.get("protocole", "tcp")).lower(), str(pt), g)) + continue if fl.get("sens") != "ingress": continue if not flux_applicable(fl, g, dossier_ecosysteme, @@ -914,6 +926,8 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - key=lambda x: (x["vers"], x["proto"], x["port"])), "entrees": entrees.get(hote, []), "admin_ssh": list(admin_ssh), + "sorties_externes": [{"proto": pr, "port": po, "role": ro} + for pr, po, ro in sorted(sorties_externes.get(hote, set()))], }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") ecrits.append(chemin) for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))): diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 0f7040e..5e2ad45 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -17,7 +17,7 @@ Proxmox admettent les memes entrees, et des flux alteres (quatre facons) sont vu la frontiere designe chaque locataire par les memes identites, et une identite alteree (cinq facons) se voit ; ce qui entre depuis l'Internet concorde, et une entree alteree (quatre facons) se voit ; ce que l'administration atteint concorde, et une administration alteree (quatre -facons) se voit. +facons) se voit ; ce qui sort vers l'Internet concorde, et une sortie alteree se voit. """ from __future__ import annotations @@ -395,6 +395,36 @@ def administration() -> None: f"une administration alteree ({nom}) est vue a la frontiere") +def sorties() -> None: + """Ce qui sort de chez chaque locataire vers l'Internet : la frontiere dit ce que la face + publie — et chaque alteration se voit.""" + import copy + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune sortie a confronter") + return + for site, loc in couples: + e = C.verifier_sorties(site, loc) + verifier(not e, f"sorties {loc.nom} : la frontiere laisse sortir exactement ce que la face publie" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + face = loc.face_reseau() + hote = next(h for h, f in face["flux"].items() if f.get("sorties_externes")) + verifier(sum(len(f.get("sorties_externes") or []) for f in face["flux"].values()) > 0, + "la face publie les sorties vers l'Internet de chaque machine") + + def retirer(f): + f["flux"][hote]["sorties_externes"] = f["flux"][hote]["sorties_externes"][1:] + + def ajouter(f): + f["flux"][hote]["sorties_externes"].append({"proto": "tcp", "port": "6667", "role": "x"}) + + for nom, alterer in (("une sortie retiree", retirer), ("une sortie ajoutee", ajouter)): + f = copy.deepcopy(face) + alterer(f) + verifier(bool(C.verifier_sorties(site, loc, f)), f"une sortie alteree ({nom}) est vue a la frontiere") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -409,6 +439,7 @@ def main() -> int: frontiere() entrees_publiques() administration() + sorties() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1