diff --git a/CHANGELOG.md b/CHANGELOG.md index a04c1df..f1911c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # CHANGELOG — Set-OPS +## 2026-08-28 — Une voûte, une clé : la séparation avant la distribution + +**52 preuves.** Décision de l'exploitant : **chaque runner est maître de sa voûte et en +détient la clé.** C'est ce qui rend un runner autonome — et donc ce qui rend l'émancipation +atteignable. Mais elle exigeait un préalable, mesuré ce matin : + +``` +UN SEUL mot de passe ouvrait les SIX voûtes de la flotte + SITE-Chezlepro (la fabric) · OPS-Chezlepro · OPS-Chezlepro-lab (×2) + OPS-Patient0 · OPS-Technolibre +``` + +**Distribuer avant de séparer aurait été strictement pire que le statu quo.** Poser « la » +clé sur chaque runner, c'était rendre chaque runner capable d'ouvrir les autres : +compromettre le plus petit locataire donnait les secrets de l'hébergeur. *L'ordre n'est pas +un détail de mise en œuvre, c'est ce qui décide si la mesure protège ou expose.* + +Cinq clés, une par écosystème, sous `~/.config/setops-vault-`. +Vérification croisée après rechiffrement — la diagonale, et rien qu'elle : + +``` +voûte \ clé chezlepro lab patient0 site technolibre ANCIENNE +ops-chezlepro OUVRE . . . . refusée +ops-chezlepro-lab . OUVRE . . . refusée +ops-patient0 . . OUVRE . . refusée +site-chezlepro . . . OUVRE . refusée +ops-technolibre . . . . OUVRE refusée +``` + +### Un seul mot de passe ne pouvait plus suffire, et pas pour la raison qu'on croit + +`cloner_vm_debian.yml` charge **deux voûtes dans la même exécution** : celle du tenant, +puis celle de l'underlay — les identifiants Proxmox appartiennent à l'hébergeur, le reste +au locataire. `ANSIBLE_VAULT_PASSWORD_FILE` n'en porte qu'une. + +`ANSIBLE_VAULT_IDENTITY_LIST` en porte plusieurs et les essaie toutes sur un bloc chiffré +sans étiquette. **Un seul `export` dans le Makefile suffit donc pour les 28 appels à +`ansible-playbook`, sans en toucher un seul.** La liste se dérive de l'instance montée, de +son site et des dépôts frères (`scripts/voutes.py`). + +*Ce qui borne le pouvoir n'est pas cette liste mais la **présence** des fichiers.* Sur le +poste de l'exploitant, toutes les clés sont là — c'est l'humain qui les détient toutes, et +des preuves comme P03 lisent les inventaires de tous les écosystèmes frères. Sur un runner, +une seule clé existe, et la même fonction n'en nomme qu'une. **Le code est identique, le +pouvoir ne l'est pas.** + +### Deux preuves ont immédiatement dit ce qui manquait + +**P03** (diff-vide de *toutes* les instances) est tombée dès la séparation : elle lit les +inventaires des écosystèmes voisins, donc il lui faut leurs clés. C'est elle qui a établi +que la liste devait couvrir le voisinage sur le poste — je l'avais d'abord limitée à +l'instance montée et à son site. + +**P16** s'est mise à **sauter** : sa garde ne connaissait que `ANSIBLE_VAULT_PASSWORD_FILE`. +*Une preuve sautée se lit beaucoup trop facilement comme une preuve passée* — c'est le +même défaut que le vert sur un périmètre vide, en plus discret. + +### Ce que ça coûte, et qui est assumé + +Le chiffré et sa clé cohabiteront sur la même machine dès que les runners recevront la +leur. Voler le disque d'un runner suffira, là où ça ne donnait rien. Le pari tient parce +que le périmètre est borné : **le runner d'un tenant ne peut ouvrir que ce tenant.** Trois +contreparties en découlent — mode `0600`, la clé hors du chemin de sauvegarde ordinaire, et +un runner durci comme un détenteur d'état. + +*Les six voûtes ont été sauvegardées telles quelles avant rechiffrement. L'ancienne clé +maîtresse n'ouvre plus rien et reste sur le poste : la retirer est une décision, pas un +nettoyage.* + +make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec, 0 saute — sans +`ANSIBLE_VAULT_PASSWORD_FILE`. + ## 2026-08-28 — L'insémination aboutit, et trois tests qui ne gardaient rien **52 preuves.** Le flux était déclaré et appliqué ; il manquait l'**identité**. Le runner diff --git a/Makefile b/Makefile index 8b0afdc..2fe6874 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,22 @@ export ANSIBLE_SSH_CONTROL_PATH_DIR ?= $(CURDIR)/.ansible/cp export ANSIBLE_SSH_ARGS ?= -F /dev/null -o ControlMaster=no export SETOPS_INSTANCE ?= instance + +# UNE VOUTE, UNE CLE — ET ANSIBLE LES TIENT TOUTES A LA FOIS (2026-08-28). +# +# Un seul mot de passe ouvrait les six voutes de la flotte, celle de la fabric comprise. +# Depuis la separation, chaque ecosysteme a la sienne, sous +# `~/.config/setops-vault-`. +# +# UN `ANSIBLE_VAULT_PASSWORD_FILE` NE PEUT PLUS SUFFIRE : `cloner_vm_debian.yml` charge +# la voute du TENANT puis celle de l'UNDERLAY dans la meme execution — les identifiants +# Proxmox appartiennent a l'hebergeur, le reste au locataire. `ANSIBLE_VAULT_IDENTITY_LIST` +# porte plusieurs secrets et les essaie tous ; un seul export suffit donc pour les 28 +# appels a `ansible-playbook`, sans en toucher un seul. +# +# `?=` : une valeur deja posee dans l'environnement gagne — la GUI et les runners peuvent +# nommer leurs propres cles sans que ce fichier ait a les connaitre. +export ANSIBLE_VAULT_IDENTITY_LIST ?= $(shell python3 scripts/voutes.py identites 2>/dev/null) # Inventaire de l'instance : un seul par instance dans le modèle « séparation par # instance ». Détection rétro-compatible : principal > production > lab. # Surchargeable : make … SETOPS_INVENTAIRE=chemin/hosts.yml @@ -636,7 +652,7 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou python3 scripts/orchestrer.py verifier; \ python3 scripts/orchestrer.py ecrire; \ vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \ - if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \ + if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \ if [[ -t 0 ]]; then \ read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \ vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \ @@ -685,7 +701,7 @@ deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des cou # clairement plutot que de pendre sur une invite que personne ne lit. define VAULT_UNE_FOIS vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \ - if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \ + if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \ if [[ -t 0 ]]; then \ read -r -s -p 'Mot de passe du vault Ansible (demande une seule fois): ' mdp; echo; \ vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \ @@ -928,7 +944,7 @@ verifier-deploiement: ansible-runtime ## Verifie l'etat de la flotte apres deplo exit 2; \ fi; \ vault_chiffre="$$(grep -rlsIF '$$ANSIBLE_VAULT' $(dir $(INVENTAIRE_PRODUCTION))group_vars 2>/dev/null | head -1 || true)"; \ - if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \ + if [[ -n "$$vault_chiffre" && -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \ if [[ -t 0 ]]; then \ read -r -s -p 'Mot de passe du vault Ansible: ' mdp; echo; \ vf="$$(mktemp)"; printf '%s' "$$mdp" > "$$vf"; chmod 600 "$$vf"; \ @@ -1010,7 +1026,7 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE= read -r premiere_ligne < "$$vault_file" || true; \ case "$$premiere_ligne" in \ '$$ANSIBLE_VAULT'*) \ - if [[ -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}" ]]; then \ + if [[ -z "$${ANSIBLE_VAULT_PASSWORD_FILE:-}$${ANSIBLE_VAULT_IDENTITY_LIST:-}" ]]; then \ vault_args+=( --ask-vault-pass ); \ fi; \ ;; \ diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 62339f5..919716a 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -30,7 +30,7 @@ | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 hotes, 36 groupes (inventaire dechiffre et parse). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. | @@ -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 | 29 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 18, 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 | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 52 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 40 exigence(s) de role, toutes satisfaites (126 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)). | @@ -53,7 +53,7 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 36 role(s) serveur/client tous nommes, 37 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 : 47 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 48 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 : 5 machine(s) du plan retrouvees, 57 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | diff --git a/scripts/prouver.py b/scripts/prouver.py index 1d45ad0..a9ce337 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -2014,8 +2014,15 @@ PREUVES: list[dict] = [ def _vault_requis_absent() -> bool: - """Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni.""" - if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"): + """Vrai si l'inventaire chiffre un group_vars et qu'aucun mot de passe n'est fourni. + + DEUX FACONS DE FOURNIR UNE CLE DEPUIS LA SEPARATION DES VOUTES (2026-08-28) : l'unique + `ANSIBLE_VAULT_PASSWORD_FILE`, et `ANSIBLE_VAULT_IDENTITY_LIST`, qui en porte + plusieurs — indispensable des qu'une execution touche deux voutes (le tenant et + l'underlay). N'en regarder qu'une faisait SAUTER P16 alors que la cle etait la, et + une preuve sautee se lit trop facilement comme une preuve passee. + """ + if os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"): return False gv = (RACINE / INV).parent / "group_vars" if not gv.exists(): diff --git a/scripts/voutes.py b/scripts/voutes.py new file mode 100755 index 0000000..cc21c40 --- /dev/null +++ b/scripts/voutes.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Les voutes de cet ecosysteme, et la cle de chacune. + +UNE VOUTE, UNE CLE (2026-08-28). Un seul mot de passe ouvrait les six voutes de la +flotte — celle de la fabric comprise. Tant que c'etait vrai, poser « la » cle sur chaque +runner aurait rendu chaque runner capable d'ouvrir les autres : compromettre le plus +petit locataire, c'etait obtenir les secrets de l'hebergeur. La separation precede donc +la distribution, et cet ordre n'est pas negociable. + +CE FICHIER NE LIT AUCUN SECRET. Il ne fait que dire OU chaque cle se trouve, et laisse +Ansible l'ouvrir. Les chemins sont des pointeurs, jamais des valeurs. + +DEUX VOUTES DANS LA MEME EXECUTION, ET C'EST LE CAS COURANT : `cloner_vm_debian.yml` +charge celle du tenant PUIS celle de l'underlay — les identifiants Proxmox appartiennent +a l'hebergeur, le reste au locataire. Un unique `ANSIBLE_VAULT_PASSWORD_FILE` ne peut +donc plus suffire. Ansible sait tenir plusieurs secrets a la fois +(`ANSIBLE_VAULT_IDENTITY_LIST`) et les essaie tous sur un bloc chiffre sans etiquette : +c'est exactement ce qu'il nous faut, et ca n'oblige a toucher aucun des 28 appels. + +CONVENTION : ~/.config/setops-vault- + OPS-Chezlepro -> ~/.config/setops-vault-ops-chezlepro + SITE-Chezlepro -> ~/.config/setops-vault-site-chezlepro + +Usage : + python3 scripts/voutes.py identites # la liste pour ANSIBLE_VAULT_IDENTITY_LIST + python3 scripts/voutes.py etat # ce qui est present, ce qui manque +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from inventory_rules import instance_courante # noqa: E402 +import underlay as underlay_mod # noqa: E402 + +PREFIXE = "setops-vault-" + + +def _config() -> Path: + """Le dossier des cles. `XDG_CONFIG_HOME` respecte, `~/.config` par defaut.""" + return Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) + + +def slug(nom: str) -> str: + return nom.strip().lower() + + +def cle_de(nom_depot: str) -> Path: + return _config() / f"{PREFIXE}{slug(nom_depot)}" + + +def _freres() -> list[str]: + """Les depots d'ecosysteme poses a cote du moteur — ceux qui portent un `plan/`. + + MEME DECOUVERTE QUE LE RESTE DU DEPOT : des dossiers freres, pas de registre. + """ + racine = Path(__file__).resolve().parents[1] + return sorted(d.name for d in racine.parent.iterdir() + if d.is_dir() and (d / "plan").is_dir()) + + +def voutes() -> list[tuple[str, str, Path]]: + """[(role, nom du depot, chemin de la cle)] — tout ce que CETTE machine peut ouvrir. + + L'ORDRE COMPTE PEU POUR ANSIBLE (il essaie tous les secrets) mais beaucoup pour un + humain qui lit la sortie : ce qu'on pilote, le terrain, puis le voisinage. + + POURQUOI LE VOISINAGE Y FIGURE, ET POURQUOI CE N'EST PAS UNE FUITE. La liste nomme + des CHEMINS, pas des secrets : une cle absente du disque n'est pas listee, et la + machine ne peut ouvrir que ce qu'elle detient. C'est donc la PRESENCE des fichiers + qui borne le pouvoir, jamais cette fonction. + + Sur le poste de l'exploitant, toutes les cles sont la, et c'est voulu : c'est + l'humain qui les detient toutes, et des preuves comme P03 lisent les inventaires de + TOUS les ecosystemes freres. Sur un runner, une seule cle existe — la sienne — et la + meme fonction n'en nomme qu'une. Le code est identique, le pouvoir ne l'est pas. + """ + out: list[tuple[str, str, Path]] = [] + vus: set[str] = set() + + def ajouter(role: str, nom: str) -> None: + if nom and nom not in vus: + vus.add(nom) + out.append((role, nom, cle_de(nom))) + + try: + inst = instance_courante() + if inst is not None: + ajouter("instance", Path(inst).resolve().name) + except Exception: + pass + c = underlay_mod.chemin() + if c is not None: + ajouter("hebergeur", c.resolve().parent.name) + for nom in _freres(): + ajouter("voisin", nom) + return out + + +def identites() -> str: + """`etiquette@chemin,...` pour ANSIBLE_VAULT_IDENTITY_LIST — les cles PRESENTES. + + Une cle absente est SAUTEE plutot que declaree : Ansible refuse de demarrer si un + fichier d'identite n'existe pas, et l'echec porterait alors sur une voute que + l'execution n'ouvre peut-etre meme pas. Ce qui manque se lit par `voutes.py etat`. + """ + return ",".join(f"{slug(nom)}@{chemin}" for _, nom, chemin in voutes() if chemin.is_file()) + + +def etat() -> int: + v = voutes() + if not v: + print("Aucune voute a nommer : ni instance montee, ni underlay.") + return 0 + manquantes = 0 + for role, nom, chemin in v: + present = chemin.is_file() + manquantes += 0 if present else 1 + print(f"{role:<11} {nom:<20} {'cle presente' if present else 'CLE ABSENTE '} {chemin}") + if manquantes: + print(f"\n{manquantes} cle(s) absente(s). En creer une :", + "umask 077; openssl rand -base64 48 | tr -d '\\n' > ", sep="\n ") + return 1 if manquantes else 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("commande", choices=("identites", "etat")) + args = ap.parse_args(argv) + if args.commande == "identites": + print(identites()) + return 0 + return etat() + + +if __name__ == "__main__": + raise SystemExit(main())