cles : le terrain etait inoccupable — la cle du tenant nait avec ses machines

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-08-30 08:54:17 -04:00
parent f2f6cccd3e
commit bb63865a37
5 changed files with 207 additions and 21 deletions

View file

@ -1,5 +1,54 @@
# CHANGELOG — Set-OPS # 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 ## 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 **54 preuves.** Quinze VM de Chezlepro creees trois a la fois. L'une d'elles a depasse le

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` - **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 ## 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. | | 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). | | 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). | | 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). | | 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). | | 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. | | 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. | | 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 | | 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. | | 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 ## Couverture des affirmations ✅ du registre

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 62 | `roles/*/` | | rôles | 62 | `roles/*/` |
| README de rôles | 62 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | README de rôles | 62 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` | | 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` | | unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 78 | lignes `\| **D-nn** \|` de `decisions-architecture.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 | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -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 # 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 # 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. # 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 return lignes
@ -279,27 +279,93 @@ def parametres_proxmox_hote(data: dict, host: str,
GROUPE_RUNNER_TENANT = "serveur_ops_tenant" GROUPE_RUNNER_TENANT = "serveur_ops_tenant"
def _cle_amorcage(data: dict, host: str) -> str: def _cle_amorcage(data: dict, host: str, inventaire: Path | None = None) -> str:
"""La cle publique du runner du SITE, si cet hote est le runner de son tenant. """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 DEUX CLES, DEUX PORTEES, ET LA DIFFERENCE EST TOUTE L'ARCHITECTURE :
ou ne declare pas de runner : un ecosysteme se materialise alors comme avant, avec
la seule cle de l'exploitant. Degrader, jamais deviner. 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: try:
import underlay as underlay_mod depuis = Path(inventaire).parent if inventaire else None
cle = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}) gv = (depuis or _instance_dir()) / "group_vars" / "all"
.get("runner_cle_publique") or "").strip() 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: except Exception:
return "" pass
if "'" in cle: return []
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." def _instance_dir() -> Path:
) from inventory_rules import instance_courante
return cle return Path(instance_courante())
def _rang_des_couches() -> dict[str, int]: def _rang_des_couches() -> dict[str, int]:

View file

@ -1396,6 +1396,74 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]:
f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") 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]: def preuve_insemination_sans_secret() -> tuple[bool, str]:
"""Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant. """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}, "refs": [], "func": preuve_interne_parle_bordure_se_tait},
{"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant", {"id": "P54", "titre": "L'insemination ne reclame aucun secret du tenant",
"refs": [], "func": preuve_insemination_sans_secret}, "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": [], {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site}, "func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],