diff --git a/AGENTS.md b/AGENTS.md index e2d9d3f..86601d5 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 84 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 85 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 1be1c5d..3048ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # CHANGELOG — Set-OPS +## 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 +réseau se livre en deux temps vérifiés : d'abord les **faits** que le site lit chez le +locataire (ici), ensuite ses **flux résolus**, confrontés aux devis de la frontière et de +Proxmox (à suivre). + +**Fait** (`scripts/contexte.py`) : +- `locataire.face_reseau()` publie, calculé **chez le locataire** : son index ; ses machines + (adresse, groupes, paramètres de matérialisation `proxmox_*`) ; ses groupes ; son + administration (l'intrant `nftables_admin_ssh`, son tunnel, ses pairs WireGuard avec leurs + clés **publiques**) ; ses zones publiques (zones, signées, adresse et port de son + primaire) ; la clé publique de sa sauvegarde. Elle porte l'empreinte de ses six sources. +- `verifier_face(site, locataire)` interroge **les consommateurs du site eux-mêmes**, le site + monté : la découverte des locataires (`devis_reseau`), le devis du pare-feu Proxmox (la + flotte, chaque groupe qu'il utilise, l'administration), la frontière (`admin_de`), et + l'inventaire du site (relations du DNS public, comptes de sauvegarde). +- `--face ` l'affiche ; `--verifier-faces` confronte chaque couple. + +**Un faux écart, corrigé avant le commit** : la première comparaison exigeait un ensemble +d'adresses par groupe dans le devis Proxmox, et en signalait 19 absents par locataire. Le +devis ne garde que les ensembles que ses règles **utilisent** (13 chez chacun) ; la +vérification se fait désormais dans ce sens. + +**Deux lectures fragiles du site, que la face réseau fera disparaître à l'étape 3** : +`site_inventaire.py` lit la clé de sauvegarde sous `inventories/principal/` écrit en dur, et +re-dérive l'adresse du primaire DNS avec la séquence 1 par défaut (juste aujourd'hui, les +primaires sont des `-01`). La face réseau prend l'une dans le dossier d'inventaire réel du +locataire, l'autre dans son inventaire. + +**Ce que la vérification ne couvre pas encore** : la matérialisation est recopiée de +l'inventaire, que le clonage lit par Ansible, sans consommateur Python à confronter ; les pairs +WireGuard ne sont vérifiés qu'à travers le tunnel qu'ils ouvrent ; les flux arrivent dans le +second temps. + +**Éprouvé** : +- **P85**, « La face réseau du locataire dit exactement ce que le site en tire » : 2 faces, + aucun écart. +- `test_contexte.py`, 60 contrôles : les deux faces réelles concordent, et une face altérée de + **huit façons** (index, adresse d'une machine, membres d'un groupe, administration déclarée, + tunnel, zones publiques, primaire DNS, clé de sauvegarde) est vue en écart à chaque fois. +- `make verifier` conforme, **85/85**. Les trois documents comptent 85 preuves. + ## 2026-10-04 (80) — Étape 2, première moitié : la fiche du site, et la preuve qu'elle dit vrai **Le chemin** (`docs/conception-contextes.md` §6), étape 2 : générer les fiches **à côté** de diff --git a/docs/devis-services.md b/docs/devis-services.md index 785a402..7644099 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 84 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`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 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 de3567a..af8e576 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 (84 preuves, zéro réseau) +make prouver le dépôt est-il cohérent avec lui-même (85 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 5c6c43e..55f2d26 100644 --- a/scripts/contexte.py +++ b/scripts/contexte.py @@ -361,6 +361,81 @@ class Locataire(Ecosysteme): return None return Site.charger(nom, self.depot.parent) + # LE CONTRAT, SENS LOCATAIRE -> SITE (§2.4) --------------------------------------------- + def face_reseau(self) -> dict: + """Ce que CE locataire demande a son site, en une fiche — les faits, pas encore les flux. + + Le site lisait jusqu'ici six fichiers internes du locataire (relevé du 2026-10-04) : + sa nomenclature, son inventaire genere, ses `group_vars` (`nftables_admin_ssh`, + `serveur_backup_pubkey`), son `plan/acces.yml`, son `plan/domaines.yml` et ce qui + l'accompagne. La face reseau les publie, calcules ICI, chez le locataire, qui seul sait + lire ses fichiers : l'adresse de son primaire DNS vient de son inventaire (le site la + 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. + """ + from inventory_rules import charger_acces, est_groupe_operationnel, tunnel_admin_de + from resoudre_flux import _enfants, _hotes_du_groupe, _ip_par_hote + data = _yaml(self.inventaire()) + hv = _hostvars(self.inventaire()) + ips = _ip_par_hote(data) if data else {} + groupes = {g: sorted(h for h in _hotes_du_groupe(data, g) if h in ips) + for g in sorted(_enfants(data)) if est_groupe_operationnel(g)} if data else {} + machines = { + h: {"ip": ip, + "groupes": sorted(g for g, membres in groupes.items() if h in membres), + "materialisation": {k: v for k, v in sorted((hv.get(h) or {}).items()) + if k.startswith("proxmox_")}} + for h, ip in sorted(ips.items())} + # Les reseaux d'administration que le locataire DECLARE (l'intrant), et son tunnel. + intrant: list[str] = [] + gv_all = self.dossier_inventaire() / "group_vars" / "all" + for fichier in sorted(gv_all.glob("*.yml")) if gv_all.is_dir() else []: + if "vault" in fichier.name: + continue + d = _yaml(fichier) + if isinstance(d, dict) and d.get("nftables_admin_ssh"): + src = d["nftables_admin_ssh"] + intrant = [str(s) for s in src] if isinstance(src, list) else [str(src)] + break + try: + pairs = charger_acces(self.plan / "acces.yml").get("acces_admin_vpn") or {} + except (OSError, ValueError): + pairs = {} + # Ses zones publiques : celles qu'il fait servir par le site (`primaire-cache`). + doms = self.lire_plan("domaines.yml").get("domaines_publics") or {} + apps = self.lire_plan("applications.yml").get("applications") or {} + zones = sorted(z for z, c in doms.items() + if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache") + hote_dns = next((str(a.get("hote")) for a in apps.values() + if str(a.get("groupe")) == "serveur_powerdns"), "") + colocalise = any(str(a.get("groupe")) == "serveur_resolveur" and str(a.get("hote")) == hote_dns + for a in apps.values()) + dns_public = ({"zones": zones, + "signees": sorted(z for z in zones if doms[z].get("dnssec") is True), + "primaire": ips.get(hote_dns, ""), + "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() + 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")} + return { + "locataire": self.nom, + "hebergeur": self.nom_hebergeur(), + "sources": sources, + "index": self.index, + "machines": machines, + "groupes": groupes, + "administration": {"intrant": intrant, "tunnel": tunnel_admin_de(self.depot), + "pairs": {n: {k: c.get(k) for k in ("cle_publique", "adresse", "etat")} + for n, c in sorted(pairs.items()) if isinstance(c, dict)}}, + "dns_public": dns_public, + "sauvegarde": {"cle_publique": cle_sauvegarde}, + } + def decrire(self) -> dict: d = super().decrire() d.update({"inventaire": str(self.inventaire()), "hebergeur": self.nom_hebergeur()}) @@ -453,6 +528,81 @@ def verifier_fiche(site: "Site", locataire: "Locataire", fiche: dict | None = No return ecarts +def verifier_face(site: "Site", locataire: "Locataire", face: dict | None = None) -> list[str]: + """Les ecarts entre la face reseau du locataire et ce que les CONSOMMATEURS DU SITE en tirent + aujourd'hui, en lisant ses fichiers internes. + + On interroge les consommateurs eux-memes, le site monte : la decouverte des locataires + (`devis_reseau`), le devis du pare-feu Proxmox (ses ensembles d'adresses par groupe et + d'administration), la frontiere (`admin_de`), et l'inventaire du site (relations du DNS + public, comptes de sauvegarde). Vide = chacun lirait la face reseau sans rien changer. + """ + import devis_proxmox_fw + import devis_reseau + import site_inventaire + f = face if face is not None else locataire.face_reseau() + ecarts: list[str] = [] + with _monter(site): + decouverts = {nom: n for nom, _p, n in devis_reseau.decouvrir_du_site()} + bloc = next((b for b in devis_proxmox_fw.construire(devis_reseau.decouvrir_du_site())["blocs"] + if b.get("tenant") == locataire.nom), None) + admin_frontiere = sorted(devis_reseau.admin_de(locataire.nom)) + inv = site_inventaire.inventaire() or {} + hv_site = (inv.get("_meta") or {}).get("hostvars") or {} + + # 1. L'index, tel que le site le decouvre. + n = decouverts.get(locataire.nom) + if n is None: + ecarts.append("le site ne decouvre pas ce locataire") + elif n.get("index") != f["index"]: + ecarts.append(f"index : le site decouvre {n.get('index')}, la face dit {f['index']}") + + # 2. Les machines et leurs groupes, tels que le pare-feu Proxmox les range. + ips = {h: m["ip"] for h, m in f["machines"].items()} + if bloc is None: + ecarts.append("le devis Proxmox n'a aucun bloc pour ce locataire") + else: + ens = bloc.get("ipsets") or {} + flotte = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("flotte")), []) + if sorted(flotte) != sorted(set(ips.values())): + ecarts.append(f"flotte : le devis Proxmox range {sorted(flotte)}, la face {sorted(set(ips.values()))}") + # Le devis ne garde que les ensembles que ses regles UTILISENT : chaque groupe qu'il range + # doit exister dans la face avec les memes adresses ; un groupe inutilise n'est pas un ecart. + speciaux = ("flotte", "Internet", "reseaux d'admin") + par_groupe = {str(v.get("role")): sorted(v.get("membres") or []) for v in ens.values() + if not str(v.get("role", "")).startswith(speciaux)} + for g, membres in sorted(par_groupe.items()): + if g not in f["groupes"]: + ecarts.append(f"groupe {g} : range par le devis Proxmox, absent de la face") + elif membres != sorted({ips[h] for h in f["groupes"][g]}): + ecarts.append(f"groupe {g} : le devis Proxmox range {membres}, " + f"la face {sorted({ips[h] for h in f['groupes'][g]})}") + adm = next((v["membres"] for v in ens.values() if str(v.get("role", "")).startswith("reseaux d'admin")), []) + attendu_adm = sorted(set(f["administration"]["intrant"]) | set(f["administration"]["tunnel"])) + if sorted(adm) != attendu_adm: + ecarts.append(f"administration (Proxmox) : le devis admet {sorted(adm)}, la face {attendu_adm}") + + # 3. L'administration, telle que la frontiere la lit. + if admin_frontiere != sorted(f["administration"]["intrant"]): + ecarts.append(f"administration (frontiere) : le site lit {admin_frontiere}, " + f"la face {sorted(f['administration']['intrant'])}") + + # 4. Le DNS public et la sauvegarde, tels que l'inventaire du site les porte. + relations = [r for v in hv_site.values() for r in (v.get("serveur_dns_public_relations") or []) + if r.get("ecosysteme") == locataire.nom] + rel = relations[0] if relations else {} + attendu_dns = f["dns_public"] + vu_dns = ({k: rel.get(k) for k in ("zones", "signees", "primaire", "port")} if rel else {}) + if vu_dns != attendu_dns: + ecarts.append(f"DNS public : l'inventaire du site porte {vu_dns}, la face {attendu_dns}") + comptes = [c for v in hv_site.values() for c in (v.get("serveur_backup_site_locataires") or []) + if c.get("nom") == locataire.nom] + cle_site = comptes[0].get("pubkey") if comptes else "" + if (cle_site or "") != f["sauvegarde"]["cle_publique"]: + ecarts.append("sauvegarde : la cle que le site autorise n'est pas celle que le locataire publie") + 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.""" @@ -534,7 +684,26 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--fiche", metavar="LOCATAIRE", help="la fiche que son hebergeur lui destine") ap.add_argument("--verifier-fiches", action="store_true", help="chaque fiche dit-elle ce que le locataire porte ?") + ap.add_argument("--face", metavar="LOCATAIRE", help="la face reseau qu'il publie pour son site") + ap.add_argument("--verifier-faces", action="store_true", + help="chaque face reseau dit-elle ce que le site en tire ?") args = ap.parse_args(argv) + if args.face: + print(yaml.safe_dump(Locataire.charger(args.face).face_reseau(), allow_unicode=True, + sort_keys=False), end="") + return 0 + if args.verifier_faces: + couples = couples_hebergeur() + if not couples: + print("Aucun site frere : aucune face reseau a verifier.") + 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)) + return 1 if total else 0 if args.fiche: loc = Locataire.charger(args.fiche) site = loc.site() diff --git a/scripts/prouver.py b/scripts/prouver.py index d5629eb..ab3a692 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4692,6 +4692,29 @@ def preuve_fiche_du_site() -> tuple[bool, str]: f"porte (copies, racine, inventaire genere).") +def preuve_face_reseau() -> tuple[bool, str]: + """La face reseau que chaque locataire publie dit-elle ce que les consommateurs du site en + tirent aujourd'hui, en lisant ses fichiers internes ? + + POURQUOI (2026-10-04, `docs/conception-contextes.md` §2.4). Le site lisait six fichiers + internes de ses locataires — nomenclature, inventaire, `group_vars`, acces, domaines. La + face reseau les publie, calculee chez le locataire. Avant que le site la lise a leur place + (etape 3), elle doit dire EXACTEMENT ce que la decouverte, le devis Proxmox, la frontiere + et l'inventaire du site en tirent. Les flux resolus suivront ; ils ne sont pas encore la. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import contexte as _ctx + couples = _ctx.couples_hebergeur() + if not couples: + return True, "Aucun site frere : aucune face reseau a confronter." + ecarts = [f"{l.nom} -> {s.nom} : {e}" for s, l in couples for e in _ctx.verifier_face(s, l)] + if ecarts: + return False, (f"{len(ecarts)} ecart(s) entre la face reseau et ce que le site en tire : " + + " | ".join(ecarts[:4])) + return True, (f"{len(couples)} face(s) reseau : chacune dit exactement ce que le site en tire " + f"(decouverte, devis Proxmox, frontiere, DNS public, sauvegarde).") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4880,6 +4903,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_runbooks_suivent_le_makefile}, {"id": "P84", "titre": "La fiche du site dit exactement ce que chaque locataire porte", "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}, ] diff --git a/scripts/tests/test_contexte.py b/scripts/tests/test_contexte.py index 55db640..8129d5c 100644 --- a/scripts/tests/test_contexte.py +++ b/scripts/tests/test_contexte.py @@ -10,7 +10,9 @@ Sur une arborescence FABRIQUEE (un site, deux locataires, un depot ambigu), on v - un contexte nomme qui ne designe rien est un REFUS, pas un contexte vide. Puis, si les vrais depots sont la : chaque locataire declare par un site porte l'index que ce 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. +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. """ from __future__ import annotations @@ -198,6 +200,44 @@ def fiches() -> None: f"une fiche alteree ({nom}) est vue en ecart") +def faces() -> None: + """La face reseau dit ce que les consommateurs du site en tirent — et chaque ecart se voit.""" + import copy + couples = C.couples_hebergeur() + if not couples: + print("SAUTE aucun site frere : aucune face reseau a confronter") + return + for site, loc in couples: + e = C.verifier_face(site, loc) + verifier(not e, f"face reseau {loc.nom} -> {site.nom} : identique a ce que le site en tire" + + (f" ({e[0]})" if e else "")) + site, loc = couples[0] + juste = loc.face_reseau() + groupe = next(g for g in ("serveur_postfix", "serveur_nginx", "client_backup") if juste["groupes"].get(g)) + hote = juste["groupes"][groupe][0] + + def deplacer_machine(f): + f["machines"][hote]["ip"] = "192.0.2.10" + + def retirer_du_groupe(f): + f["groupes"][groupe] = [h for h in f["groupes"][groupe] if h != hote] + + alterations = { + "index": lambda f: f.__setitem__("index", (f["index"] or 0) + 1), + "adresse d'une machine": deplacer_machine, + f"membres du groupe {groupe}": retirer_du_groupe, + "administration declaree": lambda f: f["administration"].__setitem__("intrant", ["192.0.2.0/24"]), + "tunnel d'administration": lambda f: f["administration"].__setitem__("tunnel", ["192.0.2.128/25"]), + "zones publiques": lambda f: f["dns_public"].__setitem__("zones", ["exemple.invalid"]), + "primaire DNS": lambda f: f["dns_public"].__setitem__("primaire", "192.0.2.53"), + "cle de sauvegarde": lambda f: f["sauvegarde"].__setitem__("cle_publique", "ssh-ed25519 AAAA-fausse"), + } + for nom, alterer in alterations.items(): + f = copy.deepcopy(juste) + alterer(f) + verifier(bool(C.verifier_face(site, loc, f)), f"une face reseau alteree ({nom}) est vue en ecart") + + def main() -> int: with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) @@ -207,6 +247,7 @@ def main() -> int: vrais_depots() monter_restaure() fiches() + faces() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1