diff --git a/AGENTS.md b/AGENTS.md index 86601d5..9fe64d3 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 85 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 86 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 3048ccc..bfa18af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # CHANGELOG — Set-OPS +## 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. +La face réseau porte désormais les flux de **chaque machine**, ceux que consomme le pare-feu +Proxmox. Ceux de la **frontière** (interfaces, alias, traduction d'adresses, flux entre site +et locataires) restent à faire. + +**Fait** (`scripts/contexte.py`) : +- la face réseau reprend, pour chaque machine, ce que le locataire a **déjà résolu** pour ses + propres pare-feux (`flux-genere/.connectivite.json`) : chaque entrée admise et ses + réseaux d'administration ; +- `verifier_flux(site, locataire)` compare, machine par machine et port par port, les sources + admises par **nftables** (le locataire) et par **Proxmox** (le devis du site). Les deux + écrivent la même politique autrement : règle par groupe contre sources réunies, ensembles + nommés contre adresses en clair, « Internet » (`0.0.0.0/0` contre « tout sauf RFC 1918 », + doublé d'une règle « administration (poste) »), l'administration à part, l'ICMP nommé + différemment. Ces différences sont neutralisées, et décrites dans le code. + +**Ce que la première mesure a trouvé** : 3 écarts par locataire, tous sur `edge-mta-01` +(25, 465, 587). Ce n'était pas une divergence de politique, mais une **information perdue** : +quand un port est à la fois public et ouvert à des clients internes (`[externe, client_smtp]`), +`resoudre_flux.py` écrivait `0.0.0.0/0` et **jetait** la liste des clients nommés. Juste pour +l'hôte, qui accepte de partout ; insuffisant pour Proxmox, dont l'Internet exclut les plages +privées et qui doit admettre ces clients nommément. Telle quelle, la face réseau n'aurait pas +suffi au site. + +**Corrigé à la source** (décision de l'exploitant) : `resoudre_flux.py` publie, à côté de +`0.0.0.0/0`, les `sources_declarees`. Flux régénérés chez les deux locataires : seul +`edge-mta-01.connectivite.json` change, **aucun `.nft` ne bouge**. La sonde `connectivite`, +qui lit ces fichiers sur les machines, ignore le champ nouveau ; le fichier sera recopié au +prochain passage de `nftables_baseline`, sans effet. + +**Éprouvé** : +- **P86**, « Machine par machine, le locataire et Proxmox admettent les mêmes entrées » : + aucun écart chez les deux locataires. +- `test_contexte.py`, 66 contrôles : des flux altérés de **quatre façons** (une source retirée, + un port ajouté, les sources déclarées effacées, l'administration retirée) sont vus en écart. +- `make verifier` conforme, **86/86**. Les trois documents comptent 86 preuves. + ## 2026-10-04 (81) — Étape 2, seconde moitié (les faits) : la face réseau du locataire **Le chemin** (`docs/conception-contextes.md` §6), étape 2, sens locataire → site. La face diff --git a/docs/conception-contextes.md b/docs/conception-contextes.md index f18fca5..0dcd07a 100644 --- a/docs/conception-contextes.md +++ b/docs/conception-contextes.md @@ -174,6 +174,12 @@ croisée, plus aucun recalcul. - **Chaque fiche porte l'empreinte de sa source**, et une preuve de chaque côté vérifie que la fiche reçue correspond à ce que l'autre a publié. +**Où en est l'étape 2 (2026-10-04).** La fiche du site (P84), les faits de la face réseau +(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. + 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 7644099..216790a 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 85 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`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 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 af8e576..f79c96f 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 (85 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (86 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 55f2d26..2efc08f 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -373,7 +373,8 @@ class Locataire(Ecosysteme): re-derivait, sequence 1 par defaut), sa cle de sauvegarde de SON dossier d'inventaire (le site ecrivait `principal` en dur). - LES FLUX RESOLUS sont la seconde moitie de l'etape 2 ; ils ne sont pas encore ici. + LES FLUX de chaque machine viennent de ce que le locataire a deja resolu pour ses + propres pare-feux (`flux-genere/*.connectivite.json`). Ceux de la frontiere suivront. """ from inventory_rules import charger_acces, est_groupe_operationnel, tunnel_admin_de from resoudre_flux import _enfants, _hotes_du_groupe, _ip_par_hote @@ -418,10 +419,21 @@ class Locataire(Ecosysteme): "port": 5300 if colocalise else 53} if zones else {}) cle_sauvegarde = str(_yaml(self.dossier_inventaire() / "group_vars" / "serveur_backup.yml") .get("serveur_backup_pubkey") or "").strip() + # LES FLUX DE CHAQUE MACHINE, tels que le locataire les a deja resolus pour ses propres + # pare-feux (`make flux` -> `flux-genere/.connectivite.json`) : chaque entree + # admise (protocole, port, sources), plus ses reseaux d'administration. + flux = {} + for h in sorted(ips): + c = self.depot / "flux-genere" / f"{h}.connectivite.json" + if c.is_file(): + d = json.loads(c.read_text(encoding="utf-8")) + flux[h] = {"entrees": d.get("entrees") or [], "administration": d.get("admin_ssh") 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", 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}) return { "locataire": self.nom, "hebergeur": self.nom_hebergeur(), @@ -434,6 +446,7 @@ class Locataire(Ecosysteme): for n, c in sorted(pairs.items()) if isinstance(c, dict)}}, "dns_public": dns_public, "sauvegarde": {"cle_publique": cle_sauvegarde}, + "flux": flux, } def decrire(self) -> dict: @@ -603,6 +616,104 @@ def verifier_face(site: "Site", locataire: "Locataire", face: dict | None = None return ecarts +# LES DEUX PARE-FEUX ECRIVENT LA MEME POLITIQUE AUTREMENT. On les ramene a une forme commune : +# pour chaque machine, pour chaque (protocole, port), l'ENSEMBLE des sources admises, chacune +# etant une adresse, `EXTERNE` ou `ADMIN`. Les differences d'ecriture, toutes neutralisees ici : +# - Proxmox ecrit une regle par groupe source ; le locataire reunit les sources d'un port ; +# - Proxmox nomme un ensemble (`+t23-srv-nginx`) ou ecrit les adresses en clair, separees +# par des virgules (la fabric vers Loki, le DNS public du site) ; +# - « Internet » : `0.0.0.0/0` chez le locataire, l'ensemble « tout sauf RFC 1918 » chez +# Proxmox, double d'une regle « administration (poste) » sur le meme port ; +# - l'administration : une liste a part (`admin_ssh`) et des entrees ordinaires chez le +# locataire, l'ensemble « reseaux d'administration » chez Proxmox ; +# - l'ICMP : `frag-needed` d'un cote, `fragmentation-needed` de l'autre. +# Un port ouvert a tous ET a des clients nommes porte ces derniers dans `sources_declarees` +# (depuis le 2026-10-04) : sans eux, Proxmox ne saurait pas qui admettre en plus d'Internet. +EXTERNE, ADMIN = "EXTERNE", "ADMIN" +_ICMP = {"frag-needed": "fragmentation-needed"} + + +def _entrees_locataire(face: dict) -> dict[str, dict]: + out: dict[str, dict] = {} + for h, f in (face.get("flux") or {}).items(): + admin = set(f.get("administration") or []) + par_port = out.setdefault(h, {}) + for e in f.get("entrees") or []: + cle = (str(e.get("proto")), _ICMP.get(str(e.get("port")), str(e.get("port")))) + atomes = par_port.setdefault(cle, set()) + # `sources_declarees` : les clients nommes d'un port aussi ouvert a tous (`0.0.0.0/0`). + for s in list(e.get("sources") or []) + list(e.get("sources_declarees") or []): + atomes.add(EXTERNE if s == "0.0.0.0/0" else ADMIN if s in admin else str(s)) + if admin: + par_port.setdefault(("tcp", "22"), set()).add(ADMIN) + return out + + +def _entrees_proxmox(bloc: dict) -> dict[str, dict]: + ens = bloc.get("ipsets") or {} + regles = {g["nom"]: g.get("regles") or [] for g in bloc.get("groupes") or []} + out: dict[str, dict] = {} + for a in bloc.get("affectations") or []: + par_port = out.setdefault(a["hote"], {}) + for g in a.get("groupes") or []: + for r in regles.get(g, []): + if r.get("action") != "ACCEPT" or r.get("sens") != "IN": + continue + if str(r.get("via", "")) == "administration (poste)": + continue # le double d'une regle `internet` sur le meme port + source = str(r.get("source", "")) + if source.startswith("+"): + e = ens.get(source[1:]) or {} + role = str(e.get("role", "")) + atomes = ({EXTERNE} if role.startswith("Internet") else + {ADMIN} if role.startswith("reseaux d'admin") else + {str(m) for m in e.get("membres") or []}) + else: + atomes = {s.strip() for s in source.split(",") if s.strip()} + for p in str(r.get("dport") or r.get("icmp_type")).split(","): + cle = (str(r.get("proto")), _ICMP.get(p.strip(), p.strip())) + par_port.setdefault(cle, set()).update(atomes) + return out + + +def verifier_flux(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: + """Machine par machine : le pare-feu du locataire (nftables, resolu par lui) et celui de + Proxmox (devise par le site) admettent-ils les memes entrees ? + + C'est le constat 2 de la conception : les flux etaient calcules DEUX FOIS, chacun avec sa + version du moteur. Vide = les deux pare-feux disent la meme chose, et le site pourra, a + l'etape 3, deviser Proxmox depuis la face reseau sans rien recalculer. + """ + import devis_proxmox_fw + import devis_reseau + f = face if face is not None else locataire.face_reseau() + with _monter(site): + bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"] + if b.get("tenant") == locataire.nom), None) + if bloc is None: + return ["le devis Proxmox n'a aucun bloc pour ce locataire"] + loc, pve = _entrees_locataire(f), _entrees_proxmox(bloc) + ecarts: list[str] = [] + for h in sorted(set(loc) | set(pve)): + if h not in loc: + ecarts.append(f"{h} : filtre par Proxmox, sans flux publie par le locataire") + continue + if h not in pve: + ecarts.append(f"{h} : flux publies par le locataire, aucune affectation Proxmox") + continue + + for cle in sorted(set(loc[h]) | set(pve[h])): + l, p = loc[h].get(cle, set()), pve[h].get(cle, set()) + if l == p: + continue + port = f"{cle[0]}/{cle[1]}" + if l - p: + ecarts.append(f"{h} {port} : le locataire admet {sorted(l - p)}, Proxmox non") + if p - l: + ecarts.append(f"{h} {port} : Proxmox admet {sorted(p - l)}, le locataire non") + 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.""" @@ -699,10 +810,12 @@ def main(argv: list[str] | None = None) -> int: return 0 total = 0 for site, loc in couples: - e = verifier_face(site, loc) - total += len(e) - print(f"{'OK ' if not e else 'ECART'} {loc.nom} -> {site.nom}" - + "".join(f"\n - {x}" for x in e)) + face = loc.face_reseau() + for quoi, e in (("faits", verifier_face(site, loc, face)), + ("flux ", verifier_flux(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)) return 1 if total else 0 if args.fiche: loc = Locataire.charger(args.fiche) diff --git a/scripts/prouver.py b/scripts/prouver.py index ab3a692..2895726 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4715,6 +4715,31 @@ def preuve_face_reseau() -> tuple[bool, str]: f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).") +def preuve_flux_concordent() -> tuple[bool, str]: + """Le pare-feu de chaque machine (nftables, resolu par le locataire) et celui de Proxmox + (devise par le site) admettent-ils les memes entrees ? + + POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4, constat 2). Les flux etaient + calcules DEUX FOIS, par le locataire et par le site, chacun avec sa version du moteur. La + face reseau publie ceux du locataire (`flux-genere/*.connectivite.json`). Machine par + machine et port par port, les sources admises doivent etre les memes, une fois neutralisees + les differences d'ecriture (decrites dans `contexte.py`). Premiere mesure : 3 ecarts par + locataire, tous sur le courriel — le locataire jetait les clients internes d'un port aussi + public ; il les publie desormais (`sources_declarees`). + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucun flux a confronter." + ecarts = [f"{l.nom} : {e}" for s, l in couples for e in _ctx.verifier_flux(s, l)] + if ecarts: + return False, (f"{len(ecarts)} ecart(s) entre le pare-feu des machines et celui de Proxmox : " + + " | ".join(ecarts[:4])) + return True, (f"{len(couples)} locataire(s) : machine par machine et port par port, nftables et " + f"Proxmox admettent les memes sources.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4905,6 +4930,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_fiche_du_site}, {"id": "P85", "titre": "La face reseau du locataire dit exactement ce que le site en tire", "refs": [], "func": preuve_face_reseau}, + {"id": "P86", "titre": "Machine par machine, le locataire et Proxmox admettent les memes entrees", + "refs": [], "func": preuve_flux_concordent}, ] diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 7845fd8..ca82f69 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -867,9 +867,16 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - continue # lo deja accepte regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept" f" # {g}: {fl.get('raison','')}") - entrees.setdefault(hote, []).append( - {"proto": proto, "port": str(port), "role": g, - "sources": list(srcs) if saddr else ["0.0.0.0/0"]}) + entree = {"proto": proto, "port": str(port), "role": g, + "sources": list(srcs) if saddr else ["0.0.0.0/0"]} + # « TOUT LE MONDE » NE DOIT PAS EFFACER QUI ETAIT NOMME (2026-10-04). L'hote + # accepte de partout, et c'est juste pour lui ; mais le pare-feu de Proxmox, + # dont l'Internet exclut les plages privees, doit admettre les clients + # internes NOMMEMENT (le 25 de Postfix : `client_smtp`). Les jeter ici + # privait la face reseau du locataire de ce que le site doit en lire. + if not saddr and srcs: + entree["sources_declarees"] = sorted(str(s) for s in srcs) + entrees.setdefault(hote, []).append(entree) # Testable depuis la source : TCP, et l'ICMP « echo » (ping). L'UDP ne se # « connecte » pas ; les autres types ICMP ne se provoquent pas. if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"): diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 8129d5c..bb52168 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -12,7 +12,8 @@ Puis, si les vrais depots sont la : chaque locataire declare par un site porte l site lui attribue, et nomme un hebergeur qui existe ; la fiche de chaque hebergeur dit 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. +(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. """ from __future__ import annotations @@ -238,6 +239,49 @@ def faces() -> None: verifier(bool(C.verifier_face(site, loc, f)), f"une face reseau alteree ({nom}) est vue en ecart") +def flux() -> None: + """Machine par machine, le pare-feu du locataire et celui de Proxmox admettent les memes + entrees — et une face dont les flux sont alteres se voit.""" + import copy + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucun flux a confronter") + return + for site, loc in couples: + e = C.verifier_flux(site, loc) + verifier(not e, f"flux {loc.nom} : le locataire et Proxmox admettent les memes entrees" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + juste = loc.face_reseau() + mta = next(h for h, f in juste["flux"].items() + if any(x.get("sources_declarees") for x in f["entrees"])) + autre = next(h for h, f in juste["flux"].items() + if any(len(x.get("sources") or []) > 1 for x in f["entrees"])) + + def retirer_une_source(f): + e = next(x for x in f["flux"][autre]["entrees"] if len(x.get("sources") or []) > 1) + e["sources"] = e["sources"][1:] + + def ajouter_un_port(f): + f["flux"][autre]["entrees"].append({"proto": "tcp", "port": "6666", "role": "x", + "sources": ["192.0.2.66"]}) + + def effacer_declarees(f): + for x in f["flux"][mta]["entrees"]: + x.pop("sources_declarees", None) + + def retirer_admin(f): + f["flux"][autre]["administration"] = [] + + for nom, alterer in (("une source retiree", retirer_une_source), + ("un port ajoute", ajouter_un_port), + (f"sources declarees effacees sur {mta}", effacer_declarees), + ("administration retiree", retirer_admin)): + f = copy.deepcopy(juste) + alterer(f) + verifier(bool(C.verifier_flux(site, loc, f)), f"des flux alteres ({nom}) sont vus en ecart") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -248,6 +292,7 @@ def main() -> int: monter_restaure() fiches() faces() + flux() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1