From bb63865a3799aa9242d73ae12b8e014becd0fa72 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 30 Aug 2026 08:54:17 -0400 Subject: [PATCH] =?UTF-8?q?cles=20:=20le=20terrain=20etait=20inoccupable?= =?UTF-8?q?=20=E2=80=94=20la=20cle=20du=20tenant=20nait=20avec=20ses=20mac?= =?UTF-8?q?hines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le deploiement lance depuis ops-01 s est arrete au premier geste, sur les quinze machines a la fois : Permission denied (publickey). Les VM neuves n acceptaient que la cle de l exploitant. Celle du runner EST declaree au plan, mais c est le SOCLE qui la depose — et le socle doit etre applique par quelqu un qui peut deja entrer. Boucle fermee : le tenant recevait un terrain qu il ne pouvait pas occuper. DEUX CLES, DEUX PORTEES : la cle du SITE -> sur le SEUL runner du tenant l insemination la cle du TENANT -> sur TOUTES ses machines il va les configurer POSER UNE CLE AU CLONAGE N EST PAS ENTRER CHEZ LE TENANT. C est un parametre de creation, au meme titre que l adresse ou le disque : le site ecrit les conditions de NAISSANCE, il n ouvre aucune session. Le site n obtient aucun acces sur ces machines ; seul le runner du tenant en obtient un. Forme tranchee par l exploitant : le site renseigne le seul runner, qui se charge de toute sa flotte. Le plancher /etc/hosts suit le meme chemin — ops-01 a le sien depuis son insemination et resout ses quinze voisines par leur nom. LA REVOCATION EST HONOREE A LA NAISSANCE : une entree a etat absent n est pas reposee. Sans cette lecture, une cle retiree de la flotte serait ressuscitee sur chaque VM creee ensuite — panne lente, silencieuse, invisible au plan. P55 garde les deux moities : la cle du site ne nait que sur un porteur de serveur_ops_tenant, et aucune machine ne reste sans celle de son tenant. Une frontiere tenue a une seule couche n est pas tenue. Deux controles negatifs. make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on --- CHANGELOG.md | 49 +++++++++++++++ docs/audit/preuve-2026-08-30.md | 5 +- docs/carte-set-ops.md | 2 +- scripts/inventory_host.py | 102 ++++++++++++++++++++++++++------ scripts/prouver.py | 70 ++++++++++++++++++++++ 5 files changed, 207 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5476d1e..4131d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # CHANGELOG — Set-OPS +## 2026-08-30 — Le terrain etait inoccupable : la cle du tenant nait avec ses machines + +**55 preuves.** Le deploiement lance depuis `ops-01` s'est arrete au premier geste, sur les +quinze machines a la fois : + +``` +ops-01 -> toutes : Permission denied (publickey) +``` + +Les VM neuves n'acceptaient qu'une cle : celle de l'exploitant, posee par cloud-init. La +cle du runner **est** declaree au plan (`ssh_baseline_cles_admin`) — mais c'est le SOCLE +qui la depose, et le socle doit etre applique par quelqu'un qui peut deja entrer. *Boucle +fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper.* + +### Deux cles, deux portees, et la difference est toute l'architecture + +``` +la cle du SITE -> sur le SEUL runner du tenant l'insemination +la cle du TENANT -> sur TOUTES ses machines il va les configurer +``` + +**Poser une cle au clonage n'est pas entrer chez le tenant.** C'est un parametre de +creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de +NAISSANCE, il n'ouvre aucune session. La distinction n'est pas rhetorique — le site +n'obtient aucun acces sur ces machines, seul le runner du tenant en obtient un. + +*C'est la forme que l'exploitant a tranchee : le site renseigne le seul runner, qui se +charge ensuite de toute sa flotte. Le plancher `/etc/hosts` suit le meme chemin — `ops-01` +a le sien depuis son insemination, il resout ses quinze voisines par leur nom (verifie : +`obs-01.chezlepro.internal:22` ouvert), et c'est lui qui posera le leur en appliquant le +socle. Le SITE n'a jamais a toucher une machine de tenant.* + +### La revocation est honoree a la naissance + +Une entree passee a `etat: absent` n'est pas reposee sur les VM neuves. Sans cette lecture, +une cle retiree de toute la flotte serait **ressuscitee sur chaque machine creee ensuite** — +une panne lente, silencieuse, et invisible au plan. + +### P55 — le pouvoir se lit dans les cles, pas dans les intentions + +Une VM recoit ses cles a la naissance, et personne ne relit un `authorized_keys` pose il y +a six mois. Si celle du site partait sur toute une flotte, l'hebergeur y gagnerait un acces +que rien ne declare. P55 verifie les deux moities : la cle du site ne nait que sur un +porteur de `serveur_ops_tenant`, et aucune machine ne reste sans celle de son tenant. +*C'est le pendant exact du flux d'insemination — une frontiere tenue a une seule couche +n'est pas tenue.* Deux controles negatifs verifies. + +make verifier : vert. make prouver : CONFORME, 55 OK, 0 echec, 0 saute. + ## 2026-08-30 — Le demarrage declarait en panne ce qui fonctionnait **54 preuves.** Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le diff --git a/docs/audit/preuve-2026-08-30.md b/docs/audit/preuve-2026-08-30.md index 239bcac..f5f4035 100644 --- a/docs/audit/preuve-2026-08-30.md +++ b/docs/audit/preuve-2026-08-30.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (54 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (55 OK · 0 echec · 0 saute) ## Preuves @@ -46,7 +46,7 @@ | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 53 scripts expliques et atteignables, 106 cibles make documentees, 62 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). | -| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (27 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). | | P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | | P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | @@ -67,6 +67,7 @@ | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. | +| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 15 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 15. | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 31c1827..dbd2ab8 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous. | rôles | 62 | `roles/*/` | | README de rôles | 62 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | documents | 38 | `docs/*.md` | -| pièces d'audit | 29 | `docs/audit/*` | +| pièces d'audit | 30 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index da755da..0290d81 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -271,7 +271,7 @@ def parametres_proxmox_hote(data: dict, host: str, # La cle vient du PLAN DU SITE, pas du disque local : on materialise depuis le runner # comme depuis le poste de l'exploitant, et un `lookup` local rendrait deux valeurs # differentes selon qui agit — la VM nee du poste n'accepterait pas le runner. - lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host)}'") + lignes.append(f"SETOPS_CLES_AMORCAGE='{_cle_amorcage(data, host, inventaire)}'") return lignes @@ -279,27 +279,93 @@ def parametres_proxmox_hote(data: dict, host: str, GROUPE_RUNNER_TENANT = "serveur_ops_tenant" -def _cle_amorcage(data: dict, host: str) -> str: - """La cle publique du runner du SITE, si cet hote est le runner de son tenant. +def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str: + """Les cles publiques a poser sur cet hote A SA NAISSANCE. Une par ligne. - Rend '' partout ailleurs — et aussi quand le depot de l'hebergeur n'est pas monte - ou ne declare pas de runner : un ecosysteme se materialise alors comme avant, avec - la seule cle de l'exploitant. Degrader, jamais deviner. + DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE : + + la cle du SITE -> sur le SEUL runner du tenant. C'est l'insemination : le site + amorce une machine, et une seule, puis s'arrete. + la cle du TENANT -> sur TOUTES les machines de ce tenant. C'est son runner qui + les configurera, et il ne peut pas le faire sans y entrer. + + POSER UNE CLE AU CLONAGE N'EST PAS ENTRER CHEZ LE TENANT. C'est un parametre de + creation, au meme titre que l'adresse ou le disque : le site ecrit les conditions de + NAISSANCE de la machine, il n'ouvre aucune session dedans. La distinction n'est pas + rhetorique — le site n'obtient aucun acces sur ces machines, seul le runner du tenant + en obtient un. + + SANS ELLE, LE TERRAIN EST INOCCUPABLE (mesure du 2026-08-30). Quinze VM creees, et le + runner du tenant se voyait refuser l'entree sur les quinze : + + ops-01 -> toutes : Permission denied (publickey) + + Sa cle EST declaree au plan (`ssh_baseline_cles_admin`), mais c'est le SOCLE qui la + depose — et le socle doit etre applique par quelqu'un qui peut deja entrer. Boucle + fermee : le tenant recevait un terrain qu'il ne pouvait pas occuper. + + Rend '' pour ce qui n'est pas trouve, jamais une valeur devinee : un ecosysteme dont + le plan ne declare rien se materialise comme avant, avec la seule cle de l'exploitant. + """ + cles: list[str] = [] + + # 1. LA CLE DU SITE — le runner du tenant, et lui seul. + if GROUPE_RUNNER_TENANT in groupes_hote(data, host): + try: + import underlay as underlay_mod + c = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) + .get("runner_cle_publique") or "").strip() + if c: + cles.append(c) + except Exception: + pass + + # 2. LA CLE DU TENANT — toutes ses machines, y compris son runner (il se joint + # lui-meme comme les autres, et l'exclure creerait un cas particulier de plus). + # + # ELLE VIENT DU PLAN DU TENANT, pas d'un fichier lu sur une machine : materialiser + # depuis le poste et depuis le runner du site doit produire la meme VM. + for entree in _cles_admin_du_plan(inventaire): + if entree not in cles: + cles.append(entree) + + for c in cles: + if "'" in c: + raise ValueError( + f"Une cle publique declaree contient une apostrophe ({host}) : " + "la valeur passe par une commande shell, elle ne peut pas en porter." + ) + return "\n".join(cles) + + +def _cles_admin_du_plan(inventaire: Path | None) -> list[str]: + """Les cles `ssh_baseline_cles_admin` de l'instance, celles a l'etat `present`. + + ON HONORE L'ETAT. Une entree passee a `absent` est une REVOCATION : la reposer a la + naissance ressusciterait sur chaque VM neuve une cle qu'on vient de retirer de toute + la flotte — la panne serait lente, silencieuse, et parfaitement invisible au plan. """ - if GROUPE_RUNNER_TENANT not in groupes_hote(data, host): - return "" try: - import underlay as underlay_mod - cle = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) - .get("runner_cle_publique") or "").strip() + depuis = Path(inventaire).parent if inventaire else None + gv = (depuis or _instance_dir()) / "group_vars" / "all" + for fichier in sorted(gv.glob("*.yml")): + if "vault" in fichier.name: + continue + data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {} + if isinstance(data, dict) and data.get("ssh_baseline_cles_admin"): + return [str(e.get("cle", "")).strip() + for e in data["ssh_baseline_cles_admin"] + if isinstance(e, dict) + and str(e.get("etat", "present")) == "present" + and str(e.get("cle", "")).strip()] except Exception: - return "" - if "'" in cle: - raise ValueError( - f"`runner_cle_publique` du site contient une apostrophe ({host}) : " - "la valeur passe par une commande shell, elle ne peut pas en porter." - ) - return cle + pass + return [] + + +def _instance_dir() -> Path: + from inventory_rules import instance_courante + return Path(instance_courante()) def _rang_des_couches() -> dict[str, int]: diff --git a/scripts/prouver.py b/scripts/prouver.py index f35a089..6a3b930 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,74 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_cle_du_site_bornee_au_runner() -> tuple[bool, str]: + """La cle du SITE ne nait que sur le runner d'un tenant. Celle du tenant, partout chez lui. + + LE POUVOIR SE LIT DANS LES CLES, PAS DANS LES INTENTIONS. Une VM recoit ses cles a la + NAISSANCE, par cloud-init — et personne ne relit un `authorized_keys` pose il y a six + mois. Si la cle du site se mettait a partir sur toutes les machines d'un locataire, + l'hebergeur obtiendrait un acces SSH a sa flotte entiere, en silence, et rien dans le + plan ne le dirait. + + C'est le pendant exact du flux d'insemination : celui-ci vise le groupe + `serveur_ops_tenant`, cette preuve verifie que la CLE suit le meme critere. Une + frontiere tenue a une seule couche n'est pas tenue. + + L'AUTRE MOITIE COMPTE AUSSI : sans la cle du TENANT sur ses machines, son runner recoit + un terrain qu'il ne peut pas occuper — mesure du 2026-08-30, quinze VM et quinze + « Permission denied ». + """ + inv = RACINE / INV + if not inv.is_file(): + return True, "Aucun inventaire monte : rien a verifier." + sys.path.insert(0, str(RACINE / "scripts")) + import inventory_host as ih + data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} + actifs = sorted(ih._hotes_du_groupe(data, "hotes_actifs") + if hasattr(ih, "_hotes_du_groupe") + else (data.get("all", {}).get("children", {}) + .get("hotes_actifs", {}).get("hosts", {}) or {})) + if not actifs: + return True, "Aucun hote actif : rien a verifier." + + cle_site = "" + try: + import underlay as underlay_mod + cle_site = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) + .get("runner_cle_publique") or "").strip() + except Exception: + pass + + runners = set(ih.groupes_hote(data, h) and h for h in actifs + if ih.GROUPE_RUNNER_TENANT in ih.groupes_hote(data, h)) + fautes: list[str] = [] + porteurs_site: list[str] = [] + sans_cle_tenant: list[str] = [] + for h in actifs: + recues = [c for c in ih._cle_amorcage(data, h, inv).split("\n") if c.strip()] + if cle_site and cle_site in recues: + porteurs_site.append(h) + if h not in runners: + fautes.append(f"{h} recoit la cle du SITE sans porter " + f"`{ih.GROUPE_RUNNER_TENANT}` — l'hebergeur y gagnerait " + f"un acces qu'aucun flux ne declare") + if not [c for c in recues if c != cle_site]: + sans_cle_tenant.append(h) + + # Sans cle du tenant, le runner ne peut pas occuper le terrain qu'on lui prepare. + # On ne l'exige que si l'instance en declare une : un plan qui n'en nomme aucune + # se materialise comme avant, avec la seule cle de l'exploitant. + if ih._cles_admin_du_plan(inv) and sans_cle_tenant: + fautes.append(f"{len(sans_cle_tenant)} hote(s) ne recevraient AUCUNE cle de " + f"leur tenant : son runner ne pourrait pas les configurer " + f"({', '.join(sans_cle_tenant[:3])})") + if fautes: + return False, "Les cles de naissance debordent :\n - " + "\n - ".join(fautes) + return True, (f"{len(actifs)} hote(s) : la cle du SITE ne nait que sur " + f"{len(porteurs_site)} runner(s) de tenant, celle du tenant sur " + f"{len(actifs) - len(sans_cle_tenant)}.") + + def preuve_insemination_sans_secret() -> tuple[bool, str]: """Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant. @@ -2145,6 +2213,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_interne_parle_bordure_se_tait}, {"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant", "refs": [], "func": preuve_insemination_sans_secret}, + {"id": "P55", "titre": "La cle du SITE ne nait que sur le runner d'un tenant", + "refs": [], "func": preuve_cle_du_site_bornee_au_runner}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],