From caee04385b3f364b02fdcfccb3ed1e48074dcf18 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sat, 22 Aug 2026 17:13:48 -0400 Subject: [PATCH] voutes : le monde physique a la sienne, les tenants n'en portent plus les cles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ». CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des neuf copies, appliquee aux secrets. CE QUI EST POSE : - `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces (jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants. - `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site non encore migre continue de fonctionner sur son ancienne voute. - `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster. La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres. - Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur. - `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18). EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster repond, le devis de placement est conforme pour les deux ecosystemes, le devis de frontiere se genere (86 objets), le SDN est convergent. UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat. Co-Authored-By: Claude Opus 5 --- Makefile | 3 ++ playbooks/proxmox/cloner_vm_debian.yml | 20 +++++++++++ scripts/appliquer_opnsense.py | 22 ++++++------ scripts/proxmox_api.py | 49 ++++++++++++++++++++++---- scripts/voute.py | 14 +++++--- 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 9755709..7de4a87 100644 --- a/Makefile +++ b/Makefile @@ -836,6 +836,9 @@ cloner-vm: ansible-runtime ## Clone une VM depuis le gabarit dore — HOTE= [[ -f "$$f" ]] && vault_file="$$f" && break 2; \ done; \ done; \ + if [[ -z "$$vault_file" && -L underlay.yml ]]; then \ + vault_file="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + fi; \ if [[ -n "$$vault_file" && -f "$$vault_file" ]]; then \ read -r premiere_ligne < "$$vault_file" || true; \ case "$$premiere_ligne" in \ diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 94c757f..7bd788d 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -22,6 +22,12 @@ # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent # (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge. setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml" + # LA VOUTE DE L'UNDERLAY — les secrets du MONDE PHYSIQUE (jeton d'API du cluster). + # Meme derivation que le fichier ci-dessus : le symlink `underlay.yml` designe deja + # l'hebergeur, on n'a rien a redeclarer. Depuis le 2026-08-22 ces secrets ne vivent + # plus dans la voute d'un tenant : recopies chez chacun, ils ne pouvaient plus etre + # revoques isolement (docs/frontiere-physique-virtuel.md). + setops_voute_underlay: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/underlay.vault.yml" tasks: - name: Verifier les fichiers de variables Proxmox optionnels @@ -50,6 +56,11 @@ file: "{{ setops_proxmox_hebergeur }}" when: proxmox_fichier_hebergeur.stat.exists + - name: Verifier la voute de l'underlay + ansible.builtin.stat: + path: "{{ setops_voute_underlay }}" + register: proxmox_voute_underlay + - name: Charger les secrets Proxmox Vault block: - name: Charger les secrets de la voute Proxmox @@ -60,6 +71,15 @@ - item.stat.exists - item.stat.path is search('vault') no_log: true + + # APRES celle du tenant, donc elle GAGNE : quand les deux portent la cle, c'est + # celle de l'hebergeur qui fait foi. C'est le sens de la frontiere entre les deux + # mondes, et ca rend la migration d'un site sans coupure. + - name: Charger les secrets du monde physique (voute de l'underlay) + ansible.builtin.include_vars: + file: "{{ setops_voute_underlay }}" + when: proxmox_voute_underlay.stat.exists + no_log: true rescue: - name: Expliquer l echec de chargement du Vault ansible.builtin.fail: diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index 54bbd38..aca5585 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -39,21 +39,19 @@ RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import devis_opnsense as devis_mod # noqa: E402 +from proxmox_api import voute as voute_proxmox # noqa: E402 def _voute(base: Path) -> dict: - """Secrets d'API de la frontiere, lus par ansible-vault. Rien n'est journalise.""" - for nom in ("principal", "production", "lab"): - p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" - if p.is_file(): - r = subprocess.run(["ansible-vault", "view", str(p)], - capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit( - "Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" - + r.stderr.strip()[:300]) - return yaml.safe_load(r.stdout) or {} - raise SystemExit(f"Aucune voute trouvee sous {base}/inventories/*/group_vars/all/.") + """Secrets d'API de la frontiere — SOURCE UNIQUE, partagee avec l'acces au cluster. + + La frontiere appartient au MONDE PHYSIQUE : sa cle d'API vit dans la voute de + l'underlay, chez l'hebergeur, pas chez un locataire (2026-08-22, cf. + docs/frontiere-physique-virtuel.md). `proxmox_api.voute` porte deja cette regle et son + repli sur la voute du tenant pendant la migration : la reecrire ici aurait fait une + dixieme copie le jour meme ou l'on refermait les neuf autres. + """ + return voute_proxmox(base) class Frontiere: diff --git a/scripts/proxmox_api.py b/scripts/proxmox_api.py index b4f68bc..bac2373 100644 --- a/scripts/proxmox_api.py +++ b/scripts/proxmox_api.py @@ -30,17 +30,52 @@ def hebergeur() -> Path: return lien.resolve().parent +def _lire_voute(p: Path) -> dict: + r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" + + r.stderr.strip()[:300]) + return yaml.safe_load(r.stdout) or {} + + +def voute_underlay(base: Path) -> dict: + """Les secrets du MONDE PHYSIQUE : `underlay.vault.yml`, chez l'hebergeur. + + LA VOUTE DE L'UNDERLAY EST DISTINCTE DE CELLE DES TENANTS (2026-08-22). Le jeton d'API + du cluster et la cle de la frontiere appartiennent a l'hebergeur, pas a l'organisation + hebergee. Recopies dans la voute de chaque tenant, ils ne pouvaient plus etre revoques + isolement : retirer l'acces d'un locataire revenait a le retirer a tous. + + C'est la faute des neuf copies de la resolution d'instance, appliquee aux secrets. On + ferme la meme porte, du meme geste : une source, pas N copies. + + Rend {} si le fichier n'existe pas — l'appelant retombe alors sur la voute du tenant, + le temps que les sites migrent. + """ + p = base / "underlay.vault.yml" + return _lire_voute(p) if p.is_file() else {} + + def voute(base: Path) -> dict: - """Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque.""" + """Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque. + + L'ordre compte : la voute de l'UNDERLAY d'abord (monde physique), celle du tenant + ensuite. Un site migre est donc lu correctement, un site qui ne l'est pas encore + continue de fonctionner — et le jour ou les deux portent la cle, c'est celle de + l'hebergeur qui fait foi, ce qui est le sens de la frontiere. + """ + fusion: dict = {} for nom in ("principal", "production", "lab"): p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" if p.is_file(): - r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True) - if r.returncode != 0: - raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n" - + r.stderr.strip()[:300]) - return yaml.safe_load(r.stdout) or {} - raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.") + fusion = _lire_voute(p) + break + physique = voute_underlay(base) + if not fusion and not physique: + raise SystemExit(f"Aucune voute sous {base} (ni underlay.vault.yml, " + f"ni inventories/*/group_vars/all/vault.yml).") + fusion.update(physique) + return fusion class Cluster: diff --git a/scripts/voute.py b/scripts/voute.py index 2f81a4e..bf20606 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -39,8 +39,13 @@ INSTANCE = instance_courante() ROLES = RACINE / "roles" MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)") -# Secrets d'acces a l'hyperviseur : attendus au gabarit, mais hors motif vault_*. -SECRETS_HORS_MOTIF = {"proxmox_api_token_id", "proxmox_api_token_secret"} +# Secrets du MONDE PHYSIQUE : ils n'ont pas le prefixe `vault_`, et depuis le 2026-08-22 +# ils ne vivent PLUS dans la voute d'un tenant mais dans celle de l'underlay, chez +# l'hebergeur (docs/frontiere-physique-virtuel.md). Un tenant qui les porte encore est un +# reste de migration, pas une exigence — d'ou leur retrait des attentes du gabarit. +SECRETS_MONDE_PHYSIQUE = {"proxmox_api_token_id", "proxmox_api_token_secret", + "vault_opnsense_api_key", "vault_opnsense_api_secret"} +SECRETS_HORS_MOTIF: set[str] = set() def _inventaire_dir() -> Path | None: @@ -284,7 +289,8 @@ def main() -> int: return 0 manquants = sorted(set(exiges) - presentes) - inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF) + inutiles = sorted(presentes - set(exiges) - SECRETS_HORS_MOTIF + - SECRETS_MONDE_PHYSIQUE) if manquants: print(f"erreur: {len(manquants)} secret(s) exige(s) par le plan mais ABSENT(s) " @@ -302,7 +308,7 @@ def main() -> int: reelles = cles_voute_reelle() absentes_reelles: list[str] = [] if reelles is not None: - attendues = set(exiges) | SECRETS_HORS_MOTIF + attendues = (set(exiges) | SECRETS_HORS_MOTIF) - SECRETS_MONDE_PHYSIQUE absentes_reelles = sorted(attendues - reelles) if absentes_reelles: print(f"erreur: {len(absentes_reelles)} secret(s) absent(s) de la VOUTE REELLE "