From 85c7f9710b0ad167c7fc9af0e35fd10031729284 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 15 Sep 2026 18:37:38 -0400 Subject: [PATCH] P79 : une derivation qui ne trouve rien ne passe plus pour un succes Sept replis des deux derniers jours, un temoin chacun, lu hors de la derivation qu'il juge : jumeaux d'amorcage, patte de zone, edge d'exposition, certificat bouchon, rechargement, sortie vers l'Internet, regles d'hote par instance. Eprouvee par reinjection de chaque faute ; a trouve deux flux-genere perimes. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- CHANGELOG.md | 57 ++++++++ docs/audit/preuve-2026-09-15.md | 7 +- docs/devis-services.md | 2 +- scripts/prouver.py | 233 ++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a154012..3d7b48f 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 78 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 79 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 14289a2..afc7b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # CHANGELOG — Set-OPS +## 2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes + +En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est +revenu sous sept noms. Chaque fois un repli rendait un SUCCES au lieu d'un refus : +deploiement vert, devis muet, harnais vert. *Une derivation qui ne trouve rien ne se +distinguait pas d'une derivation qui n'a rien a trouver.* + +### Ce que P79 regarde + +| Temoin | Le repli qu'il attrape | +|---|---| +| jumeaux | `dns_amorcage` derive sans `artefacts_amorcage` : le gabarit garde son ancien proxy apt | +| patte | une zone occupee absente de `opnsense_if_zones` : regles posees sur la patte plate | +| edge | une exposition qui retombe sur son propre groupe dans un ecosysteme qui A un edge | +| bouchon | l'edge du site sert `ssl-cert-snakeoil.pem`, faute de group_vars | +| rechargement | un edge qui renouvelle son certificat sans recharger nginx | +| internet | une sortie vers un role absent traduite en sortie vers l'Internet | +| flux | les regles d'hote rejouees pour CHAQUE locataire, pas seulement l'instance montee | + +Chaque temoin lit sa source directement — inventaire, plan, table ecrite, `meta/flux.yml` +— et ne passe jamais par la derivation qu'il juge. La lecon de P43 et de P67 : une garde +qui reproduit le raisonnement qu'elle verifie ne verifie rien. + +Le temoin des flux rejoue `resoudre_flux.py nftables` dans un dossier temporaire qui +reprend l'instance par liens : rien n'est ecrit dans les depots. + +### Eprouvee en lui remettant chaque faute sous les yeux + +Chaque temoin prend ses donnees en parametre. Les sept fautes ont ete reinjectees une a +une dans les donnees reelles du site (zone retiree de la table, `edge` retire de +`domaines.yml`, certificat, rechargement et jumeau retires de l'inventaire, regle +`!SETOPS_INTERNES` sur 636 ajoutee au devis) : sept refus, et zero sur les donnees saines. +Sans edge, le repli « le service se sert lui-meme » reste juste, et la garde se tait. + +### Et elle a trouve deux ecarts reels des sa premiere execution + + OPS-Chezlepro-lab 14 regles de machines retirees du plan, aucune pour ops-01 + OPS-Patient0 4 regles perimees (admin en 10.17.0.0/24, sans le refus + `admin-prohibited`), aucune pour ops-01 + +Les deux dataient d'avant le renumerotage du site : `make flux` n'avait jamais ete relance +avec eux montes. Regeneres par `SETOPS_INSTANCE`, sans basculer l'instance. Ce sont des +apercus, rien n'est applique a une machine. + +### Ce qu'elle ne couvre pas + +La collision des noms publics par groupe (P67 la garde). `client_pki` qui rend +`changed=0` sans comparer ses SAN : cela se mesure sur la machine (`make +certificats-plan`). Un registre facultatif exige par `include_vars` : celui-la echouait +bruyamment. + +### Au passage + +Les gabarits de voute de `OPS-Patient0` et `OPS-Chezlepro-lab` portaient +`vault_setops_console_oidc` depuis la session precedente, jamais commite, et le premier +avait un commentaire coupe de sa cle. Remis en forme et commites. + ## 2026-09-15 (6) — Les trois consoles repondent, chacune derriere sa propre serrure console.genese.internal 401 vestibule HTTP Basic (pas d'annuaire) diff --git a/docs/audit/preuve-2026-09-15.md b/docs/audit/preuve-2026-09-15.md index fd46fc1..38f6508 100644 --- a/docs/audit/preuve-2026-09-15.md +++ b/docs/audit/preuve-2026-09-15.md @@ -7,13 +7,13 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (77 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (78 OK · 0 echec · 1 saute) ## Preuves | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| -| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. | +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | | P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK | | P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | @@ -69,7 +69,7 @@ | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | -| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (78 preuves, 68 roles, 41 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (79 preuves, 68 roles, 41 groupes). | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). | @@ -91,6 +91,7 @@ | P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 155 gabarits de role : tous se rendent. | | P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | | P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | +| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen | ## Couverture des affirmations ✅ du registre diff --git a/docs/devis-services.md b/docs/devis-services.md index 6a66321..feedec4 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 78 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 79 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/scripts/prouver.py b/scripts/prouver.py index b240813..a257108 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -3928,6 +3928,237 @@ def preuve_amorcage_suit_le_site() -> tuple[bool, str]: return True, (f"{verifies} adresse(s) d'amorcage designent bien une machine du site.") +# --- LES REPLIS SILENCIEUX (P79) ----------------------------------------------------- +# +# Chaque temoin ci-dessous prend ses donnees en PARAMETRE : on peut lui remettre la faute +# sous les yeux sans toucher au depot, et c'est ainsi qu'ils ont ete eprouves. + +def _yaml_ou_vide(chemin: Path) -> dict: + """Le contenu d'un fichier YAML, ou {} s'il n'existe pas (registre facultatif).""" + if not chemin.is_file(): + return {} + return yaml.safe_load(chemin.read_text(encoding="utf-8")) or {} + + +def _repli_pattes(serveurs: dict, if_zones: dict) -> list[str]: + """Chaque zone OCCUPEE par une machine active du site a sa patte a la frontiere. + + Un site qui n'a pas encore de table par zone est plat, et la patte unique est alors + juste. Des que la table existe, une zone qui y manque retombe sur la patte plate. + """ + if not if_zones: + return [] + occupees: dict[str, list[str]] = {} + for nom, s in (serveurs or {}).items(): + if isinstance(s, dict) and str(s.get("etat", "actif")) == "actif" and s.get("reseau"): + occupees.setdefault(str(s["reseau"]), []).append(str(nom)) + return [f"patte : la zone `{z}` ({', '.join(sorted(n))}) manque a `opnsense_if_zones` " + f"— ses regles tomberaient sur la patte plate, justes et jamais rencontrees" + for z, n in sorted(occupees.items()) if z not in if_zones] + + +def _repli_edge(nom: str, applications: dict, domaines: dict, a_un_edge: bool) -> list[str]: + """Dans un ecosysteme qui A un edge, aucune exposition ne retombe sur son propre groupe. + + Le temoin n'est pas la derivation : la presence d'un edge vient de l'inventaire, et les + edges legitimes de ce que `domaines.yml` ECRIT. Le repli « le service se sert lui-meme » + n'est juste que la ou il n'y a personne pour le servir. + """ + if not a_un_edge: + return [] + from inventory_rules import expositions_des_applications + doms = (domaines or {}).get("domaines_publics") or {} + ecrits = {str(c["edge"]) for c in doms.values() if isinstance(c, dict) and c.get("edge")} + return [f"edge : {nom} — `{e['fqdn']}` retombe sur son propre groupe (`{e['edge']}`) " + f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront" + for e in expositions_des_applications(applications, domaines) + if e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits] + + +def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]: + """Ce que l'inventaire DYNAMIQUE du site donne a ses machines, sans group_vars pour + rattraper un defaut de role.""" + def membres(g: str) -> set[str]: + return set(((enfants.get(g) or {}).get("hosts")) or {}) + + fautes: list[str] = [] + pki = membres("client_pki") + for h in sorted(membres("serveur_nginx")): + v = hv.get(h) or {} + cert = str(v.get("serveur_nginx_certificat") or "") + if not cert or "snakeoil" in cert: + fautes.append(f"bouchon : {h} sert `{cert or 'le defaut du role, snakeoil'}` " + f"— du TLS qui ressemble a du TLS") + if h in pki and "nginx" not in (v.get("client_pki_reload_services") or []): + fautes.append(f"rechargement : {h} renouvelle son certificat sans recharger " + f"nginx — le nouveau reste sur disque, l'ancien est servi") + if membres("serveur_artefacts"): + for h, v in sorted(hv.items()): + if (v or {}).get("dns_amorcage") and not (v or {}).get("artefacts_amorcage"): + fautes.append(f"jumeaux : {h} recoit `dns_amorcage` sans " + f"`artefacts_amorcage` — le gabarit garde son ancien proxy apt") + return fautes + + +def _repli_devis_internet(flux: dict, roles_presents: set[str], regles: list[dict]) -> list[str]: + """Une sortie qui NOMME un role absent du site ne devient pas une sortie vers l'Internet. + + Le temoin lit les `meta/flux.yml` : les ports qu'un role n'emploie QUE vers des roles + absents ne doivent porter aucune regle `!SETOPS_INTERNES`. Un port qu'il emploie aussi + vers un pair lointain (`externe`, un depot) y a sa place, et il est ecarte. + """ + connus = {d.name for d in (RACINE / "roles").iterdir() if d.is_dir()} + fautes: set[str] = set() + for role in sorted(roles_presents): + absents, lointains = set(), set() + for fl in flux.get(role, []): + if fl.get("sens") != "egress": + continue + pairs = fl.get("pair") if isinstance(fl.get("pair"), list) else [fl.get("pair")] + ports = fl["port"] if isinstance(fl.get("port"), list) else [fl.get("port")] + nommes = {str(p) for p in pairs} & connus + if nommes and not nommes & roles_presents: + absents.update(str(p) for p in ports) + elif not nommes: + lointains.update(str(p) for p in ports) + suspects = absents - lointains + for r in regles: + if (str(r.get("tenant")) == "SITE" and r.get("role") == role + and str(r.get("destination")) == "!SETOPS_INTERNES"): + for port in sorted(suspects & {str(p) for p in (r.get("ports") or [])}): + fautes.add(f"internet : {role} sort vers l'Internet sur {port}, un port " + f"qu'il n'emploie que vers un role absent du site") + return sorted(fautes) + + +def _flux_genere_perime(base: Path) -> list[str]: + """Les regles d'hote d'un ecosysteme, rejouees pour LUI — pas pour l'instance montee. + + `make flux` n'ecrit que pour l'instance montee. On rejoue la generation dans un dossier + temporaire qui reprend l'instance par liens, et l'on compare : rien n'est ecrit dans le + depot. + """ + import os + import subprocess + import tempfile + reel = base / "flux-genere" + with tempfile.TemporaryDirectory(prefix="p79-") as tmp: + for e in base.iterdir(): + if e.name not in ("flux-genere", ".git"): + os.symlink(e, Path(tmp) / e.name) + r = subprocess.run([sys.executable, str(RACINE / "scripts" / "resoudre_flux.py"), "nftables"], + env=dict(os.environ, SETOPS_INSTANCE=tmp), capture_output=True, + text=True, cwd=RACINE, timeout=300) + if r.returncode != 0: + derniere = (r.stderr.strip().splitlines() or ["sans message"])[-1] + return [f"flux : {base.name} — la generation echoue ({derniere[:100]})"] + gen = Path(tmp) / "flux-genere" + a = {p.name for p in reel.glob("*.nft")} if reel.is_dir() else set() + b = {p.name for p in gen.glob("*.nft")} if gen.is_dir() else set() + differents = sorted(n for n in a & b if (reel / n).read_bytes() != (gen / n).read_bytes()) + fautes = [] + if b - a: + fautes.append(f"flux : {base.name} — aucune regle generee pour {', '.join(sorted(b - a))}") + if a - b: + fautes.append(f"flux : {base.name} — regles d'une machine retiree du plan : " + f"{', '.join(sorted(a - b))}") + if differents: + fautes.append(f"flux : {base.name} — perimees par rapport au plan : {', '.join(differents)}") + return fautes + + +def preuve_une_derivation_vide_se_dit() -> tuple[bool, str]: + """Une derivation qui ne trouve rien ne passe pas pour une derivation qui n'a rien a trouver. + + POURQUOI (2026-09-14 et 15). En deux jours, pour monter l'edge du site puis les trois + consoles, les memes defauts sont revenus sous des noms differents. Chaque fois, un + repli a rendu un SUCCES au lieu d'un refus : deploiement vert, devis muet, harnais vert. + + 1. `site_inventaire` derivait `dns_amorcage` sans son jumeau `artefacts_amorcage` : les + machines nees avant le renumerotage gardaient le proxy apt du gabarit. + 2. Une zone manquait a `opnsense_if_zones` : `_if_de()` retombait sur la patte plate, + 20 regles posees et jamais rencontrees. + 3. L'edge d'une exposition retombait sur le groupe de l'application : vhost de 43 + octets, certificat sans les noms publies. + 4. L'edge du site servait `ssl-cert-snakeoil.pem`, le defaut du role, faute de + group_vars dans un inventaire dynamique. + 5. Un certificat renouvele sans `client_pki_reload_services` : servi perime. + 6. Une sortie vers un role absent du site devenait une sortie vers l'Internet. + 7. `make flux`, lance avec un locataire monte, n'a rien regenere pour l'autre : 502 + derriere un TLS parfait. + + Chaque temoin lit sa source DIRECTEMENT (inventaire, plan, registre, table ecrite) et + ne passe pas par la derivation qu'il juge : une garde qui reproduit le raisonnement + qu'elle verifie ne verifie rien (cf. P43, P67). + + CE QU'ELLE NE COUVRE PAS. La collision des noms publics par groupe (P67 la garde). + `client_pki` qui rend `changed=0` sans comparer ses SAN a ceux qu'il derive : cela se + mesure sur la machine (`make certificats-plan`), pas dans le depot. Et un registre + facultatif exige par `include_vars` : celui-la echouait bruyamment, ce n'etait pas un + repli. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import instances as mod_instances + + fautes: list[str] = [] + couverts: list[str] = [] + + for i in mod_instances.decouvrir(): + if i.get("site"): + continue + base = RACINE.parent / i["nom"] + inv = next((base / "inventories" / x / "hosts.yml" for x in ("principal", "production") + if (base / "inventories" / x / "hosts.yml").is_file()), None) + if inv is None: + continue + enfants = ((_yaml_ou_vide(inv).get("all") or {}).get("children")) or {} + a_un_edge = bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {}) + fautes += _repli_edge(i["nom"], _yaml_ou_vide(base / "plan" / "applications.yml"), + _yaml_ou_vide(base / "plan" / "domaines.yml"), a_un_edge) + gv_nginx = inv.parent / "group_vars" / "serveur_nginx.yml" + if a_un_edge and "nginx" not in (_yaml_ou_vide(gv_nginx).get("client_pki_reload_services") or []): + fautes.append(f"rechargement : {i['nom']} — l'edge renouvelle son certificat sans " + f"recharger nginx") + if (base / "flux-genere").is_dir(): + fautes += _flux_genere_perime(base) + couverts.append(i["nom"]) + + lien = RACINE / "underlay.yml" + if lien.exists(): + site = lien.resolve().parent + inventaire = _inventaire_du_site() + if inventaire is None: + fautes.append(f"site : l'inventaire dynamique de {site.name} ne s'execute pas — " + f"rien de ce qu'il derive ne peut etre juge") + else: + enfants, hv = inventaire["enfants"], inventaire["hv"] + fautes += _repli_edge(site.name, _yaml_ou_vide(site / "plan" / "applications.yml"), + _yaml_ou_vide(site / "plan" / "domaines.yml"), + bool(((enfants.get("serveur_nginx") or {}).get("hosts")) or {})) + fautes += _repli_hotes_du_site(enfants, hv) + fautes += _repli_pattes(_yaml_ou_vide(site / "plan" / "serveurs.yml").get("serveurs") or {}, + _yaml_ou_vide(site / "opnsense.yml").get("opnsense_if_zones") or {}) + import contextlib + import io + import devis_opnsense as D + import resoudre_flux as RF + with contextlib.redirect_stderr(io.StringIO()): + regles = D.construire(D.decouvrir_du_site()).get("regles") or [] + presents = {g for g, v in enfants.items() if (v.get("hosts") or {})} + fautes += _repli_devis_internet(RF.charger_flux(), presents, regles) + couverts.append(site.name) + + if fautes: + return False, (f"{len(fautes)} repli(s) silencieux — une derivation vide rend un " + f"succes :\n - " + "\n - ".join(fautes[:10]) + + ("\n - …" if len(fautes) > 10 else "")) + if not couverts: + return True, "Aucun ecosysteme decouvert : aucune derivation a juger." + return True, (f"{len(couverts)} ecosysteme(s) ({', '.join(couverts)}) : pattes, edges, " + f"certificats, rechargements, jumeaux d'amorcage, sorties du devis et regles " + f"d'hote par instance — aucune derivation vide ne passe pour un succes.") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4104,6 +4335,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_panneaux_assembles}, {"id": "P78", "titre": "Un consommateur de base suit le verrou TLS de son serveur", "refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls}, + {"id": "P79", "titre": "Replis silencieux : une derivation vide ne passe pas pour un succes", + "refs": [], "func": preuve_une_derivation_vide_se_dit}, ]