diff --git a/CHANGELOG.md b/CHANGELOG.md index 708e69d..eab7af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-08-28 — `make inseminer` : le geste sort de mes mains et entre dans le depot + +**54 preuves.** L'insemination a d'abord ete conduite A LA MAIN depuis le runner du site — +donc hors du depot, donc sans preuve, ce que ce projet refuse. Elle a maintenant sa cible : + +``` +make inseminer TENANT=OPS-Chezlepro # l'hote se DERIVE +``` + +**`TENANT=` plutot que le symlink `instance`.** Le runner du SITE materialise et amorce +PLUSIEURS locataires ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce +tenant. Il en **nomme** un par commande. C'est aussi ce qui borne le couplage que +`creer-vm` imposait en silence : rien ne disait sur quels tenants ce lien avait le droit de +pointer, ni jusqu'a quand. + +**L'hote se derive, il ne se tape pas** : c'est celui qui porte `serveur_ops_tenant`. Meme +critere que le flux d'insemination et que la cle SSH du runner — *le meme mot borne les +trois pouvoirs*. Un ecosysteme qui n'en declare aucun est refuse, avec la raison : +« l'insemination vise LE RUNNER d'un tenant, jamais une machine ordinaire ». + +### P54 — la ligne de partage, gardee la ou elle glisserait sans bruit + +`COUCHES_INSEMINATION` vaut `serveur_debian serveur_ops`, et ce n'est pas une commodite : +**ce sont les deux seules couches qui ne reclament aucun secret**. Le site ne detient pas +la voute d'un tenant, et ne doit jamais la detenir. + +Le jour ou l'on ajouterait une couche « pour aller un peu plus loin », le deploiement +echouerait chez le tenant sur une valeur vide — *un message qui ne dit pas qu'un pouvoir a +ete franchi*. P54 lit les roles reellement appliques et refuse toute citation de `vault_*`. +Controle negatif eloquent : ajouter `client_pki`, la couche **suivante**, la fait echouer, +parce qu'elle reclame le secret de l'autorite du tenant. + +### Un garde-fou existant a intercepte une insemination mal dirigee + +Premier essai sur un second tenant : + +``` +REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee +``` + +Le `make` parent **exporte** `SETOPS_INVENTAIRE`, derive de l'instance montee ; ma +resolution en heritait et visait l'inventaire d'un AUTRE ecosysteme. Le refus vient +d'`inventory_rules`, pas de la cible — et c'est exactement l'erreur qu'un runner de site, +qui sert plusieurs locataires, commettrait en silence. `env -u SETOPS_INVENTAIRE` ferme la +fuite. + +*La cible dit aussi ce qu'elle ne fait pas : la machine amorcee n'est PAS armee, ni voute +ni cle, et l'armer est le geste d'un humain.* + +make verifier : vert. make prouver : CONFORME, 54 OK, 0 echec, 0 saute. + ## 2026-08-28 — `make sonder` : rapporter ce qui distingue, au lieu d'« échec » **53 preuves.** Trois faux diagnostics en une journée, **tous dus à l'instrument, aucun au diff --git a/Makefile b/Makefile index 3d3c833..cd39eae 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,19 @@ INVENTAIRE_LAB ?= $(firstword $(wildcard $(SETOPS_INSTANCE)/inventories/lab/host INVENTAIRE_PRODUCTION ?= $(SETOPS_INVENTAIRE) FICHIER_INVENTAIRE ?= $(SETOPS_INVENTAIRE) FICHIER_DEPENDANCES ?= docs/dependances-groupes.yml + +# LES COUCHES DE L'INSEMINATION — CE QUE LE SITE POSE CHEZ UN TENANT, ET RIEN DE PLUS. +# +# Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa premiere machine +# et lui donner de quoi continuer. Le runner du SITE le fait — socle, moteur, plan, +# plancher de resolution — puis s'arrete. +# +# CES DEUX-LA ET AUCUNE AUTRE, parce qu'elles ne demandent AUCUN SECRET. Le site ne +# detient pas la voute d'un tenant, et ne doit jamais la detenir : ce qui vient apres +# (`client_pki`, `serveur_ops_tenant`...) reclame des valeurs qu'il n'a pas. La liste +# n'est donc pas une commodite, c'est la LIGNE DE PARTAGE DU POUVOIR, et la preuve P54 +# refuse toute couche qui y ferait entrer un secret. +COUCHES_INSEMINATION ?= serveur_debian serveur_ops GROUPE_MODELE ?= modeles_vm GROUPE_DEBIAN ?= serveur_debian GROUPE_HOTES_ACTIFS ?= hotes_actifs @@ -266,6 +279,65 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine # n'existe pas », « une politique refuse » et « la frontiere se tait » dans le meme mot. +# L'INSEMINATION, CODIFIEE (2026-08-28). Elle a d'abord ete conduite A LA MAIN depuis le +# runner du site — donc hors du depot, donc sans preuve, ce que ce projet refuse. +# +# `TENANT=` PLUTOT QUE LE SYMLINK `instance`. Le runner du SITE materialise et amorce +# PLUSIEURS tenants ; pointer un lien global sur l'un d'eux le ferait se prendre pour ce +# tenant. Il en NOMME un par commande, et `SETOPS_INSTANCE` suffit a le dire — verifie le +# 2026-08-28. C'est aussi ce qui borne le couplage que `creer-vm` imposait en silence : +# rien ne disait sur quels tenants ce lien avait le droit de pointer, ni jusqu'a quand. +# `env -u SETOPS_INVENTAIRE` EST OBLIGATOIRE, ET UN GARDE-FOU L'A PROUVE. +# +# Le make parent EXPORTE SETOPS_INVENTAIRE, derive de l'instance montee. Sans le retirer, +# la resolution du tenant demande heritait de l'inventaire d'un AUTRE ecosysteme : +# +# REFUS : SETOPS_INVENTAIRE designe un inventaire HORS de l'instance demandee +# +# Le refus vient d'inventory_rules, pas d'ici — il a intercepte une insemination qui +# aurait vise le mauvais ecosysteme (2026-08-28). C'est exactement le genre d'erreur +# qu'un runner de site, qui sert plusieurs locataires, commettrait en silence. +.PHONY: inseminer +inseminer: ansible-runtime ## Le SITE amorce le runner d'un tenant — TENANT= [HOTE=] + @set -e; \ + if [[ -z "$(TENANT)" ]]; then \ + printf '%s\n' 'Refus: relancer avec TENANT=, ex. TENANT=OPS-Chezlepro.'; \ + exit 2; \ + fi; \ + if [[ ! -d "../$(TENANT)/plan" ]]; then \ + printf '%s\n' 'Refus: ../$(TENANT)/plan introuvable — ce n est pas un ecosysteme.'; \ + exit 2; \ + fi; \ + inv="$$(env -u SETOPS_INVENTAIRE SETOPS_INSTANCE=../$(TENANT) python3 -c 'import sys; sys.path.insert(0,"scripts"); from inventory_rules import inventaire_de; print(inventaire_de() or "")')"; \ + if [[ -z "$$inv" || ! -f "$$inv" ]]; then \ + printf '%s\n' "Refus: aucun inventaire pour $(TENANT). Le generer chez lui (make instancier)."; \ + exit 2; \ + fi; \ + hote="$(HOTE)"; \ + if [[ -z "$$hote" ]]; then \ + hote="$$(python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1])) or {}; p=[d.get("all",{})]; out=[]; \ +[ (out.extend((n.get("children") or {}).get("serveur_ops_tenant",{}).get("hosts",{}) or {}), p.extend((n.get("children") or {}).values())) for n in iter(lambda: p.pop() if p else None, None) ]; print(" ".join(sorted(set(out))))' "$$inv")"; \ + fi; \ + if [[ -z "$$hote" ]]; then \ + printf '%s\n' "Refus: $(TENANT) ne declare aucun hote portant serveur_ops_tenant."; \ + printf '%s\n' "L insemination vise LE RUNNER d un tenant, jamais une machine ordinaire."; \ + exit 2; \ + fi; \ + if (( $$(wc -w <<< "$$hote") > 1 )); then \ + printf '%s\n' "Refus: plusieurs runners declares ($$hote). Nommer lequel avec HOTE=."; \ + exit 2; \ + fi; \ + 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"; \ + SETOPS_INSTANCE="../$(TENANT)" SETOPS_INVENTAIRE="$$inv" \ + ansible-playbook -i "$$inv" "$(DOSSIER_PLAYBOOKS_GROUPES)/$$groupe.yml" --limit "$$hote"; \ + done; \ + printf '\n%s\n' "$$hote porte desormais le moteur, son plan et la carte de la fabric."; \ + printf '%s\n' "IL N EST PAS ARME : ni voute, ni cle — le site ne les detient pas."; \ + printf '%s\n' "L armer est le geste d un humain, depuis son poste :"; \ + printf '%s\n' " make appliquer GROUPE=serveur_ops_tenant (voir docs/filiation-emancipation.md)" + .PHONY: sonder sonder: ## Sonde une cible et DIT ce qui distingue — CIBLE= [PORTS="22 443"] @if [[ -z "$(CIBLE)" ]]; then \ diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 48b44ae..4bd20be 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (53 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (54 OK · 0 echec · 0 saute) ## Preuves @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 30 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 19, 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 | 53 scripts expliques et atteignables, 105 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). | | 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)). | @@ -66,6 +66,7 @@ | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | 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. | ## Couverture des affirmations ✅ du registre diff --git a/scripts/prouver.py b/scripts/prouver.py index 3b53c6c..f35a089 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,62 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_insemination_sans_secret() -> tuple[bool, str]: + """Ce que le SITE pose chez un tenant ne demande AUCUN secret de ce tenant. + + POURQUOI. Un ecosysteme neuf ne s'amorce pas lui-meme : quelqu'un doit poser sa + premiere machine. Le runner du SITE le fait — socle, moteur, plan, plancher — puis + s'arrete. Il s'arrete parce qu'il N'A PAS LA CLE, pas parce qu'une regle le lui + interdit : la voute d'un tenant ne vit dans aucun depot et son mot de passe sur aucune + de ses machines. + + Cette preuve garde la ligne a l'endroit ou elle pourrait glisser SANS BRUIT : le jour + ou l'on ajouterait une couche a `COUCHES_INSEMINATION` pour « aller un peu plus loin », + et ou cette couche reclamerait un `vault_*`. Le deploiement echouerait alors chez le + tenant, sur une valeur vide — un message qui ne dit pas qu'un POUVOIR a ete franchi. + + Elle mesure la propriete, pas l'intention : les roles reellement appliques, lus dans + leurs playbooks, ne doivent citer aucune variable de voute. + """ + mk = RACINE / "Makefile" + txt = mk.read_text(encoding="utf-8") + m = re.search(r"^COUCHES_INSEMINATION \?=(.*)$", txt, re.M) + if not m: + return False, "`COUCHES_INSEMINATION` n'est pas declare dans le Makefile." + groupes = m.group(1).split() + if not groupes: + return False, "`COUCHES_INSEMINATION` est vide : le site ne poserait rien." + + fautes: list[str] = [] + roles_vus: list[str] = [] + for g in groupes: + pb = RACINE / "playbooks" / "groupes" / f"{g}.yml" + if not pb.is_file(): + fautes.append(f"{g} : aucun playbook `playbooks/groupes/{g}.yml`") + continue + joue = yaml.safe_load(pb.read_text(encoding="utf-8")) or [] + roles = [r if isinstance(r, str) else (r or {}).get("role") + for r in ((joue[0] if joue else {}) or {}).get("roles") or []] + for role in [r for r in roles if r]: + roles_vus.append(role) + d = RACINE / "roles" / role + if not d.is_dir(): + fautes.append(f"{g} : role introuvable `{role}`") + continue + for f in sorted(list(d.rglob("*.yml")) + list(d.rglob("*.j2"))): + texte = f.read_text(encoding="utf-8", errors="ignore") + trouves = sorted(set(re.findall(r"\bvault_[a-z0-9_]+", texte))) + if trouves: + fautes.append( + f"{g} -> {role} cite {', '.join(trouves[:3])} dans " + f"{f.relative_to(RACINE)} : le SITE ne detient pas cette voute") + if fautes: + return False, ("L'insemination reclamerait un secret du tenant :\n - " + + "\n - ".join(fautes)) + return True, (f"{len(groupes)} couche(s) d'insemination ({', '.join(groupes)}), " + f"{len(set(roles_vus))} role(s) applique(s), aucun secret de tenant reclame.") + + def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]: """L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE. @@ -2087,6 +2143,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer}, {"id": "P53", "titre": "L'interne refuse a voix haute, la bordure se tait", "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": "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": [],