insemination : le runner verifie SA cle avant de partir

La cle d amorcage du plan est une transcription de la cle publique du runner du
site. Reconstruire le runner lui donne une paire neuve ; la transcription, elle,
ne bouge pas. Meme algorithme, meme commentaire, materiel different : rien ne
distingue les deux a l oeil.

La garde ne demande rien au reseau — le runner du site EST la machine qui
insemine, sa cle est sous sa main. Elle ne compare que si les deux cles nomment
le meme hote : lancee depuis le poste d un exploitant, elle se tait.

Elle dit aussi ce que la correction seule ne suffit pas a reparer : une machine
deja creee porte la cle perimee, il faut la RECREER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-13 22:04:41 -04:00
parent 4b759110bf
commit db1cb652b7
3 changed files with 86 additions and 2 deletions

View file

@ -354,6 +354,7 @@ inseminer: ansible-runtime ## Le SITE amorce le runner d'un tenant — TENANT=<d
printf '%s\n' "Refus: plusieurs runners declares ($$hote). Nommer lequel avec HOTE=."; \
exit 2; \
fi; \
python3 scripts/verifier_cle_amorcage.py || exit 2; \
printf 'Insemination de %s chez %s — couches sans secret : %s\n\n' "$$hote" "$(TENANT)" "$(COUCHES_INSEMINATION)"; \
for groupe in $(COUCHES_INSEMINATION); do \
printf '=== %s ===\n' "$$groupe"; \

View file

@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 4 pool(s) Proxmox, 40 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 63 scripts expliques et atteignables, 121 cibles make documentees, 68 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 64 scripts expliques et atteignables, 121 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (146 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 45 document(s) declarent leur lecteur (39 genere(s) exempte(s)). |
@ -53,7 +53,7 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 59 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 60 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""La cle d'amorcage inscrite au plan du SITE est-elle CELLE de ce runner ?
LE PLAN PORTE UNE TRANSCRIPTION, ET UNE TRANSCRIPTION PREND DU RETARD (2026-09-13).
`runner_cle_publique`, dans `<SITE>/plan/10-intrants.yml`, recopie la cle publique du
runner du site. Elle est deposee par cloud-init sur le SEUL hote d'un tenant qui porte
`serveur_ops_tenant` — c'est ce qui rend l'insemination possible.
En reconstruisant `site-ops-01`, `setops` a recu une paire NEUVE. Cette ligne, elle, n'a
pas bouge. Les deux se lisent pareil : meme algorithme, meme commentaire final
`setops@site-ops-01.genese.internal`. Seul le materiel differe, et aucun coup d'oeil ne
les distingue.
CE QUE CA A COUTE : quatorze machines clonees, puis `Permission denied (publickey)` sur
la seule que le site devait amorcer. Le message accusait une cle absente ; elle etait la,
au mauvais materiel — et le clone etant deja fait, la correction du plan ne suffit plus :
il faut RECREER la machine, puisque la cle se pose a la naissance.
CETTE GARDE NE DEMANDE RIEN AU RESEAU. Le runner du site EST la machine qui insemine :
sa cle est sous sa main. Deux secondes ici valent la materialisation d'une flotte
entiere la-bas.
ELLE NE S'APPLIQUE QUE LORSQUE LES DEUX CLES NOMMENT LE MEME HOTE. Lancee depuis le poste
d'un exploitant, la cle locale est la SIENNE : comparer la declarerait fausse. Degrader,
jamais deviner — on se tait et on laisse passer.
"""
from __future__ import annotations
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
def materiel_et_hote(ligne: str) -> tuple[str, str]:
"""(materiel de la cle, hote du commentaire) — ('', '') si la ligne n'est pas une cle."""
parts = ligne.split()
return (parts[1], parts[-1]) if len(parts) > 2 else ("", "")
def verifier() -> tuple[bool, str]:
try:
import underlay
plan = str((underlay.lire_plan_site("10-intrants.yml") or {})
.get("runner_cle_publique") or "").strip()
except Exception as e: # pas de site monte
return True, f"aucun plan de site lisible ({e.__class__.__name__}) — rien a comparer."
if not plan:
return True, "le plan du site ne declare aucune cle d'amorcage — rien a comparer."
pub = Path.home() / ".ssh" / "id_ed25519.pub"
if not pub.is_file():
return True, f"{pub} absent — cette machine n'est pas le runner du site."
mat_plan, hote_plan = materiel_et_hote(plan)
mat_local, hote_local = materiel_et_hote(pub.read_text(encoding="utf-8").strip())
if not (hote_plan and hote_plan == hote_local):
return True, f"cle locale de `{hote_local or 'inconnu'}`, plan de `{hote_plan or 'inconnu'}` — pas le meme hote, aucune comparaison."
if mat_plan != mat_local:
return False, (
"la cle d'amorcage du plan n'est PAS celle de ce runner.\n"
f" au plan ...{mat_plan[-20:]}\n"
f" reelle ...{mat_local[-20:]}\n"
"Les deux portent le meme commentaire ; seul le materiel differe.\n"
"\n"
"Toute machine DEJA creee porte la perimee : la corriger ne suffit pas,\n"
"il faut la RECREER — la cle se pose a la naissance.\n"
"\n"
" 1. relever : cat ~/.ssh/id_ed25519.pub\n"
" 2. corriger : runner_cle_publique dans <SITE>/plan/10-intrants.yml\n"
" 3. recreer : make raser HOTE=<runner> ... puis make creer-vm HOTE=<runner>")
return True, f"cle d'amorcage conforme (...{mat_local[-20:]}, {hote_local})."
if __name__ == "__main__":
ok, message = verifier()
print(("OK : " if ok else "Refus: ") + message, file=sys.stdout if ok else sys.stderr)
raise SystemExit(0 if ok else 2)