From d4bc3e48b7203656e4c48424da0d4c8f026269b7 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 14 Sep 2026 17:47:36 -0400 Subject: [PATCH] nom public : la garde ne regardait qu un cote de la cloture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le plan du site expose observatoire.genese.internal ; serveur_grafana devinait grafana.. Grafana fabriquait son root_url, ses liens d alerte et son URL de retour OIDC avec un nom que rien ne sert. C est mot pour mot le defaut du 2026-09-10, quatre jours plus tard, de l autre cote : instancier derive _hostname depuis ce jour-la et P67 le garde, mais tous deux ne connaissent que l instance montee — donc un locataire. L inventaire du site n en derivait aucun. Quatre devinettes sur cinq tombaient juste par convention, et c est ce qui rendait le defaut invisible. site_inventaire.py derive les cinq noms du plan. P67 lit maintenant les deux inventaires — celui du site est dynamique, donc elle l EXECUTE au lieu de lire sa source. Eprouvee dans les deux sens : 10 services sur 2 inventaires. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- CHANGELOG.md | 64 +++++++++++++++++ docs/audit/preuve-2026-09-14.md | 2 +- scripts/prouver.py | 120 ++++++++++++++++++++++++-------- scripts/site_inventaire.py | 29 ++++++++ 4 files changed, 186 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c71b92..a30ab02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # CHANGELOG — Set-OPS +## 2026-09-14 (17) — La garde regardait un seul cote de la cloture + +L'exploitant, en cherchant son tableau : *« grafana c'etait observatoire.genese.internal »*. +Il avait raison, et Grafana ne le savait pas. + +### Le defaut + +Le plan du site expose `observatoire.genese.internal`. `serveur_grafana` devinait +`grafana.{{ domaine_interne }}` — sa valeur par defaut. Grafana fabriquait donc son +`GF_SERVER_ROOT_URL`, ses liens d'alerte et son URL de retour OIDC avec un nom **que rien +ne sert**. + +C'est MOT POUR MOT le defaut du 2026-09-10, quatre jours plus tard, de l'autre cote de la +cloture. `instancier` derive `_hostname` de l'exposition du plan depuis ce +jour-la, et **P67** le garde. Les deux ne connaissent que l'instance MONTEE — donc un +locataire. L'inventaire du SITE, lui, n'en derivait aucun : + +| groupe expose | le plan dit | le role devinait | +|---|---|---| +| `serveur_forgejo` | `forge.genese.internal` | forge… — juste par convention | +| `serveur_step_ca` | `pki.genese.internal` | pki… — juste | +| `serveur_resolveur` | `dns.genese.internal` | dns… — juste | +| `serveur_backup_site` | `sauvegarde.genese.internal` | juste | +| **`serveur_grafana`** | **`observatoire.genese.internal`** | **`grafana.…` — faux** | + +Quatre devinettes sur cinq tombaient juste, et c'est precisement ce qui rend ce defaut +invisible : *tant que le plan suit la meme convention, la devinette tombe juste et +personne ne voit qu'il y a deux sources*. Le seul nom qui s'ecarte de la convention est le +seul qui ment. + +### Ce qui est corrige + +`site_inventaire.py` derive `_hostname` de l'exposition unique du plan du site — +le meme geste, au meme endroit, que chez un locataire. Les cinq noms viennent maintenant +du plan. + +**P67 regarde les deux inventaires.** Le site a un inventaire DYNAMIQUE : la preuve +l'EXECUTE au lieu de lire un fichier — juger sa source reviendrait a relire le +raisonnement au lieu du resultat. Eprouvee dans les deux sens : sans la derivation, elle +nomme les cinq services fautifs ; avec, elle en compte **10 sur 2 inventaires**. + + Environment=GF_SERVER_DOMAIN=observatoire.genese.internal + Environment=GF_SERVER_ROOT_URL=https://observatoire.genese.internal/ + +### La lecon, et elle est generale + +Une garde posee la ou un defaut s'est montre ne couvre pas la ou il peut se montrer aussi. +Le site et le locataire ont deux inventaires, et le depot en compte plusieurs autres qui +ne regardent qu'un des deux. C'est la meme forme que « une liste qui suit une autre prend +du retard », appliquee aux gardes elles-memes. + +### Et la console etait joignable depuis le debut + +Le poste porte `10.37.0.17/24`, et nftables accepte le port 3000 depuis `10.37.0.0/24`. Il +manquait une ROUTE : sans elle, le noyau envoyait les paquets a la passerelle du LAN avec +l'adresse du LAN comme source, et le pare-feu du site voyait arriver un inconnu. Forcer la +source ne sert a rien — c'est la route qui la choisit. + +Les routes du poste pointaient aussi les deux locataires vers `10.17.0.1`, une passerelle +que **la frontiere ne porte pas** (elle route les tenants par `10.0.4.41` sur le vlan040). +Cinq routes vers un routeur inexistant, dont les `/16` des deux ecosystemes : c'est ce qui +donnait « No route to host » en cherchant la console de Chezlepro. Routes refaites par +l'exploitant ; les trois runners repondent. + ## 2026-09-14 (16) — Le dernier maillon : l'exportateur PostgreSQL, et huit panneaux qui repondent L'entree precedente laissait le tableau de PostgreSQL assemble, charge, et **vide** : diff --git a/docs/audit/preuve-2026-09-14.md b/docs/audit/preuve-2026-09-14.md index 9d0da9e..8c9a118 100644 --- a/docs/audit/preuve-2026-09-14.md +++ b/docs/audit/preuve-2026-09-14.md @@ -79,7 +79,7 @@ | P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 39 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | | P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | | P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 3 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (5 exposition(s)). | -| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 5 service(s) expose(s) portent le nom du plan (5 groupe(s) derive(s)). | +| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 10 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. | | P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. | | P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. | | P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. | diff --git a/scripts/prouver.py b/scripts/prouver.py index 7504981..536486f 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -3389,14 +3389,66 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]: Cette preuve garde la derivation : elle refuse qu'un hote serve un groupe expose sans porter le nom du plan. Elle ne juge pas les groupes a deux expositions — aucun nom canonique n'en sort, et le role garde alors la main. + + ELLE REGARDE LES DEUX COTES DE LA CLOTURE DEPUIS LE 2026-09-14, et elle n'en regardait + qu'un. Ecrite pour l'instance MONTEE, elle ne voyait que des locataires. L'inventaire + du SITE ne derivait donc aucun nom — cinq groupes exposes, cinq devinettes de role. + + Quatre tombaient juste par convention. Le cinquieme non : le plan du site expose + `observatoire.genese.internal`, `serveur_grafana` devinait `grafana.…`, et Grafana + fabriquait son `root_url` et ses liens avec un nom que rien ne sert. Exactement le + defaut du 2026-09-10, quatre jours plus tard, de l'autre cote — parce que la garde + avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi. """ + lots: list[tuple[str, dict, dict]] = [] + inv = RACINE / INV - if not inv.is_file(): - return True, "Aucun inventaire monte : rien a verifier." - instance = instance_courante() - f_app = instance / "plan" / "applications.yml" - if not f_app.is_file(): - return True, "Aucun plan d'applications : rien a verifier." + if inv.is_file(): + instance = instance_courante() + f_app = instance / "plan" / "applications.yml" + if f_app.is_file(): + data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} + enfants = (data.get("all") or {}).get("children") or {} + hv: dict = {} + for etat in ("hotes_actifs", "hotes_planifies"): + for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items(): + hv[nom] = v or {} + lots.append((instance.name, _noms_exposes(f_app), {"enfants": enfants, "hv": hv})) + + # LE SITE A UN INVENTAIRE DYNAMIQUE : on le lui demande, on ne lit pas un fichier. + site = _plan_du_site_applications() + if site: + rendu = _inventaire_du_site() + if rendu: + lots.append(("SITE", _noms_exposes(site), rendu)) + + if not lots: + return True, "Ni instance montee ni plan de site : rien a verifier." + + fautes: list[str] = [] + verifies = 0 + for etiquette, attendu, rendu in lots: + enfants, hv = rendu["enfants"], rendu["hv"] + for groupe, fqdn in sorted(attendu.items()): + for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}): + vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "") + verifies += 1 + if vu != fqdn: + fautes.append(f"[{etiquette}] {hote} porte {groupe} sans " + f"`{groupe}_hostname` = `{fqdn}` " + f"(inventaire : {vu or 'absent'})") + if fautes: + return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de " + "retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6])) + if not verifies: + return True, "Aucun groupe expose : rien a deriver." + return True, (f"{verifies} service(s) expose(s) portent le nom du plan, " + f"sur {len(lots)} inventaire(s) : " + + ", ".join(e for e, _, _ in lots) + ".") + + +def _noms_exposes(f_app) -> dict: + """Groupe -> le nom unique que le plan lui donne. Deux noms : aucun canonique.""" apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} attendu: dict[str, str] = {} for a in apps.values(): @@ -3406,30 +3458,42 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]: g = str(a.get("groupe") or "") if g and len(exp) == 1: attendu[g] = exp[0] - if not attendu: - return True, "Aucun groupe expose par le plan : rien a deriver." + return attendu - data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} - enfants = (data.get("all") or {}).get("children") or {} - hostvars: dict = {} - for etat in ("hotes_actifs", "hotes_planifies"): - for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items(): - hostvars[nom] = v or {} - fautes: list[str] = [] - verifies = 0 - for groupe, fqdn in sorted(attendu.items()): - for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}): - vu = str((hostvars.get(hote) or {}).get(f"{groupe}_hostname") or "") - verifies += 1 - if vu != fqdn: - fautes.append(f"{hote} porte {groupe} sans `{groupe}_hostname` = " - f"`{fqdn}` (inventaire : {vu or 'absent'})") - if fautes: - return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de " - "retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6])) - return True, (f"{verifies} service(s) expose(s) portent le nom du plan " - f"({len(attendu)} groupe(s) derive(s)).") +def _plan_du_site_applications(): + """Le `applications.yml` du site MONTE, ou None s'il n'y en a pas.""" + lien = RACINE / "underlay.yml" + if not lien.exists(): + return None + f = lien.resolve().parent / "plan" / "applications.yml" + return f if f.is_file() else None + + +def _inventaire_du_site(): + """L'inventaire DYNAMIQUE du site, dans la forme que cette preuve consomme. + + On l'EXECUTE au lieu de lire un fichier : c'est un script, et son resultat est la + seule chose qu'Ansible verra. Juger sa source reviendrait a relire le raisonnement + au lieu du resultat. + """ + import json + import subprocess + try: + sortie = subprocess.run( + [sys.executable, str(RACINE / "scripts" / "site_inventaire.py"), "--list"], + capture_output=True, text=True, timeout=120, cwd=RACINE) + except (OSError, subprocess.SubprocessError): + return None + if sortie.returncode != 0: + return None + try: + d = json.loads(sortie.stdout) + except ValueError: + return None + return {"enfants": {g: {"hosts": {h: None for h in (v.get("hosts") or [])}} + for g, v in d.items() if g != "_meta"}, + "hv": (d.get("_meta") or {}).get("hostvars") or {}} def preuve_cle_telechargee_non_vide() -> tuple[bool, str]: diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index cc378ee..7871b35 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -464,6 +464,31 @@ def inventaire() -> dict: if h.get("role") == "frontiere" and str(h.get("etat") or "actif") == "actif"), "frontiere") + # LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14). + # + # `instancier` derive `_hostname` de l'exposition declaree depuis le + # 2026-09-10, et P67 le garde. Les deux ne connaissent que l'instance MONTEE, donc un + # locataire. L'inventaire du site, lui, n'en derivait AUCUN : ses cinq groupes exposes + # tombaient tous sur la devinette de leur role. + # + # Quatre tombaient juste par convention — `forge`, `pki`, `dns`, `sauvegarde`. Le + # cinquieme non : le plan expose `observatoire.genese.internal`, et `serveur_grafana` + # devine `grafana.{{ domaine_interne }}`. Grafana fabriquait donc son `root_url`, ses + # liens d'alerte et son URL de retour OIDC avec un nom que rien ne sert. + # + # C'EST LE MEME DEFAUT QU'IL Y A QUATRE JOURS, de l'autre cote de la cloture. La garde + # avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi. + # + # UNE SEULE EXPOSITION, sinon rien : deux noms ne designent aucun canonique, et le + # role garde alors la main. `setdefault` cote hote : une valeur ecrite a la main dans + # les variables d'une machine gagne encore. + _hostname_par_groupe: dict = {} + for _app in applications.values(): + _exp = [str(f).strip() for f in (_app.get("expose") or []) if str(f).strip()] + _g = str(_app.get("groupe") or "") + if _g and len(_exp) == 1: + _hostname_par_groupe[_g] = _exp[0] + hostvars: dict[str, dict] = {} groupes: dict[str, list[str]] = {} @@ -652,6 +677,10 @@ def inventaire() -> dict: # plan declare pour toutes. **(srv.get("variables") or {}), } + # Nom public de chaque service porte par cet hote, tel que le plan l'expose. + for _groupe_expose in sorted(_groupes_de(nom) & set(_hostname_par_groupe)): + hostvars[nom].setdefault(f"{_groupe_expose}_hostname", + _hostname_par_groupe[_groupe_expose]) for _socle in GROUPE_SOCLE: groupes.setdefault(_socle, []).append(nom) if "serveur_backup_site" in _groupes_de(nom):