From 4855cda294b7861124b16a337b43cfd488a3e02b Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 28 Sep 2026 12:53:14 -0400 Subject: [PATCH] voutes : elles sortent du poste, dans leur propre archive, relues exporter_voutes.py et make voutes-exporter : les six voutes (gitignorees, sur aucune forge) sont sur la cle USB, a cote des cles ; restauration eprouvee. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++++++ Makefile | 9 ++- docs/runbooks-construction.yml | 14 +++- docs/sortir-les-cles-du-poste.md | 8 +++ scripts/exporter_cles.py | 9 +++ scripts/exporter_voutes.py | 119 +++++++++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 scripts/exporter_voutes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6eeb5..041e0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG — Set-OPS +## 2026-09-28 (6) — Les voûtes sortent du poste, à côté des clés qui les ouvrent + +**Ce qui manquait.** La clé USB portait les CLÉS des voûtes, pas les voûtes. Or elles sont +gitignorées : sur aucune forge, seulement sur le poste et sur le runner de chaque +écosystème. Poste et runner perdus ensemble, les clés restaurées n'ouvraient rien, et +chaque secret était à régénérer. La runbook `cles` affirmait pourtant « les voûtes sortent +chiffrées ». + +**`scripts/exporter_voutes.py`** (`make voutes-recenser`, `make voutes-exporter VERS=…`). +Une archive à part, `setops-voutes--.tar` : `restaurer_cles.py` range chaque +clé d'après son seul nom, et les voûtes s'appellent presque toutes `vault.yml` — elles s'y +seraient écrasées. Chaque voûte garde donc son chemin relatif aux dépôts ; restauration : +`tar -xf … -C `. Pas de seconde couche : elles sont déjà chiffrées par +une clé qui n'est pas dans cette archive, et le script REFUSE tout fichier dont l'en-tête +n'est pas `$ANSIBLE_VAULT`. Il refuse aussi une destination dans l'infrastructure et +l'écrasement d'une archive existante, puis relit empreinte par empreinte. + +**Fait et éprouvé** : six voûtes (Chezlepro, Chezlepro-lab ×2, Technolibre, les deux +sites) sur la clé USB, 80 Ko ; extraites dans un répertoire temporaire, chacune rouverte +avec les clés (33, 25, 2, 37, 20 et 14 entrées), puis détruites. Le mode d'emploi de la +clé, la runbook `cles` et `sortir-les-cles-du-poste.md` le disent désormais. À refaire +après toute écriture dans une voûte. + ## 2026-09-28 (5) — L'exportateur PostgreSQL de Chezlepro, et où vivent vraiment les voûtes **Le dernier critique de Chezlepro.** `obs-01!collecte` : Prometheus scrutait diff --git a/Makefile b/Makefile index b527714..6fc0fd6 100644 --- a/Makefile +++ b/Makefile @@ -590,6 +590,13 @@ site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declaree cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire) python3 scripts/exporter_cles.py --recenser +voutes-recenser: ## Montre les voutes de ce poste (gitignorees : sur aucune forge), sans rien ecrire + python3 scripts/exporter_voutes.py --recenser + +voutes-exporter: ## Sort les voutes (deja chiffrees) vers un support, relues — VERS= NOM= + @if [ -z "$(VERS)" ]; then printf '%s\n' 'Refus: VERS= requis.'; exit 2; fi + python3 scripts/exporter_voutes.py --vers "$(VERS)" $(if $(NOM),--nom "$(NOM)") + cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS= @# A LANCER SOI-MEME, PAS PAR UN AGENT. `gpg` demande une phrase de passe : elle ne @# doit passer ni par un journal, ni par le contexte d'un assistant. Taper la commande @@ -599,7 +606,7 @@ cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS= @# Le depot heberge la racine de l'AC, la forge du genome, et l'etat de CHAQUE @# locataire — sur une seule machine, dans un seul batiment. La copie est opaque diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 3ec583a..b0db4d0 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -704,8 +704,9 @@ runbooks: portee: poste doc: docs/sortir-les-cles-du-poste.md but: >- - Ce qui n'existe QUE sur le poste meurt avec lui. Les voutes sortent chiffrees, sur un - support qu'on relit — et on refait l'operation a chaque voute nouvelle. + Ce qui n'existe QUE sur le poste meurt avec lui : les CLES, et les VOUTES qu'elles + ouvrent (gitignorees, donc sur aucune forge). Les deux sortent, sur un support qu'on + relit — et on refait l'operation a chaque voute nouvelle ou modifiee. etapes: - cible: cles-recenser nature: mesure @@ -714,6 +715,15 @@ runbooks: nature: ecriture variables: [VERS] pourquoi: "Sortir les cles, chiffrees, et les RELIRE apres ecriture." + - cible: voutes-recenser + nature: mesure + pourquoi: "Les voutes de ce poste, et leur empreinte. Une voute en clair est refusee avant tout." + - cible: voutes-exporter + nature: ecriture + variables: [VERS] + pourquoi: >- + Sortir les voutes, deja chiffrees, dans leur propre archive, et la RELIRE. Sans + elles, les cles restaurees n'ouvrent rien (2026-09-28). - cible: cles-compagnons nature: ecriture variables: [VERS] diff --git a/docs/sortir-les-cles-du-poste.md b/docs/sortir-les-cles-du-poste.md index a6e7d50..b8e27c5 100644 --- a/docs/sortir-les-cles-du-poste.md +++ b/docs/sortir-les-cles-du-poste.md @@ -10,6 +10,14 @@ Le **code** de Set-OPS est répliqué **deux fois** : `eregion` et la forge du s ce poste et sur le runner de son écosystème, qui en reçoit une copie chiffrée par `serveur_ops_tenant`. *(Corrigé le 2026-09-28 : cette page les disait sur les forges.)* +**Elles sortent donc aussi, dans une archive à part** (`make voutes-exporter VERS=`, +`scripts/exporter_voutes.py`) : déjà chiffrées par `ansible-vault`, elles n'ont pas besoin +d'une seconde couche ; le script refuse tout fichier dont l'en-tête n'est pas +`$ANSIBLE_VAULT`, garde le chemin de chaque voûte (elles s'appellent presque toutes +`vault.yml`) et relit l'archive empreinte par empreinte. Restauration : +`tar -xf setops-voutes-*.tar -C `. **À refaire après toute écriture +dans une voûte**, comme les clés après toute voûte nouvelle. + > **Ce chiffre était trois, et il a baissé sans que rien ne le signale.** Un coffre répliqué deux fois reste > solide — mais c'est la **redondance du génome** qui a diminué, pas le chiffrement, et > c'est exactement ce que la page *Filiation, signatures & témoins* appelle la vraie mesure diff --git a/scripts/exporter_cles.py b/scripts/exporter_cles.py index edbc1f2..ef34096 100644 --- a/scripts/exporter_cles.py +++ b/scripts/exporter_cles.py @@ -126,6 +126,15 @@ Trois commandes suffisent. C'est le vrai filet : cp /tmp/setops-vault-* ~/.config/ ; cp /tmp/id_* ~/.ssh/ chmod 700 ~/.ssh ~/.config ; chmod 600 ~/.ssh/id_* ~/.config/setops-vault-* +LES VOUTES, DANS UNE AUTRE ARCHIVE +--------------------------------- +Les cles ci-dessus OUVRENT les voutes ; les voutes elles-memes (chiffrees, gitignorees, +donc sur aucune forge) sont dans `setops-voutes-*.tar`. Elles gardent leur chemin : + + tar -xf setops-voutes-*.tar -C "" + +Sans elles, les cles n'ouvrent rien : chaque secret des ecosystemes serait a refaire. + ENSUITE, ESSAYER — PAS SUPPOSER ------------------------------- ssh -o BatchMode=yes ansible@ hostname diff --git a/scripts/exporter_voutes.py b/scripts/exporter_voutes.py new file mode 100644 index 0000000..11dd2f9 --- /dev/null +++ b/scripts/exporter_voutes.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Sort les VOUTES du poste — la moitie du coffre que `exporter_cles.py` n'emporte pas. + +CE QUI MANQUAIT (mesure du 2026-09-28). La cle USB portait les CLES qui ouvrent les voutes, +pas les voutes elles-memes. Or les voutes sont gitignorees : elles ne sont sur AUCUNE forge. +Chacune vit sur ce poste et sur le runner de son ecosysteme, et nulle part ailleurs. Poste +et runner perdus ensemble, les cles restauraient l'acces a... rien : tous les secrets de +l'ecosysteme etaient a regenerer. + +UNE ARCHIVE A PART, PAS DANS CELLE DES CLES. `restaurer_cles.py` range chaque cle d'apres +son seul NOM (`~/.config` ou `~/.ssh`). Les voutes s'appellent presque toutes `vault.yml` : +elles s'y ecraseraient. Ici, chaque voute garde son chemin relatif aux depots — +`OPS-Chezlepro/inventories/principal/group_vars/all/vault.yml` — et se restaure par : + + tar -xf setops-voutes--.tar -C "" + +PAS DE SECONDE COUCHE DE CHIFFREMENT, et c'est fonde : chaque voute est deja chiffree +(AES256, `ansible-vault`) par une cle qui n'est PAS dans cette archive. Le script REFUSE +donc tout fichier dont l'en-tete n'est pas `$ANSIBLE_VAULT` : une voute dechiffree par +accident ne sortira jamais du poste par ce chemin. + +ECRIRE PUIS RELIRE (D-68) : l'archive est rouverte et l'empreinte de CHAQUE voute comparee +a l'original. Au moindre ecart, elle est supprimee. + + python3 scripts/exporter_voutes.py --recenser + python3 scripts/exporter_voutes.py --vers "/media/" --nom setops-voutes- +""" +from __future__ import annotations + +import argparse +import hashlib +import os +import sys +import tarfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from exporter_cles import _refuser_destination_interne # noqa: E402 + +DEPOTS = Path(__file__).resolve().parents[2] +MOTIFS = ("*/inventories/*/group_vars/**/*vault.yml", "*/*.vault.yml") +ENTETE = b"$ANSIBLE_VAULT" + + +def _empreinte(chemin: Path) -> str: + return hashlib.sha256(chemin.read_bytes()).hexdigest() + + +def recenser() -> list[Path]: + trouves = {f for m in MOTIFS for f in DEPOTS.glob(m) if f.is_file()} + return sorted(trouves) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--recenser", action="store_true", help="montrer, sans rien ecrire") + ap.add_argument("--vers", help="repertoire de destination (support amovible)") + ap.add_argument("--nom", default="setops-voutes", help="nom de base de l'archive") + args = ap.parse_args(argv) + + voutes = recenser() + en_clair = [f for f in voutes if not f.read_bytes().startswith(ENTETE)] + print(f"\n LES VOUTES DE CE POSTE ({DEPOTS}) :\n") + for f in voutes: + etat = "chiffree" if f not in en_clair else "EN CLAIR — REFUSEE" + print(f" {str(f.relative_to(DEPOTS)):<66} {f.stat().st_size:>6} o " + f"{_empreinte(f)[:16]}… {etat}") + print(f"\n {len(voutes)} voute(s).") + if en_clair: + raise SystemExit("\n REFUS : une voute n'est pas chiffree. Rien ne sort du poste tant " + "qu'elle ne l'est pas.") + if not voutes: + raise SystemExit(" Aucune voute trouvee : rien a sortir.") + if args.recenser: + print("\n RECENSEMENT SEUL — rien n'a ete ecrit.\n") + return 0 + if not args.vers: + raise SystemExit(" --vers est requis pour ecrire.") + + dest = Path(args.vers).expanduser() + _refuser_destination_interne(dest) + if not dest.is_dir(): + raise SystemExit(f" REFUS : {dest} n'est pas un repertoire (support monte ?).") + archive = dest / f"{args.nom}-{os.uname().nodename}.tar" + if archive.exists(): + raise SystemExit(f" REFUS : {archive} existe deja. On n'ecrase pas une sauvegarde.") + + attendu = {str(f.relative_to(DEPOTS)): _empreinte(f) for f in voutes} + with tarfile.open(archive, "w") as tar: + for f in voutes: + tar.add(f, arcname=str(f.relative_to(DEPOTS))) + archive.chmod(0o600) + + ecarts = [] + with tarfile.open(archive, "r") as tar: + noms = tar.getnames() + for nom, emp in attendu.items(): + membre = tar.extractfile(nom) if nom in noms else None + if membre is None: + ecarts.append(f"{nom} : ABSENT") + elif hashlib.sha256(membre.read()).hexdigest() != emp: + ecarts.append(f"{nom} : empreinte differente") + ecarts += [f"{n} : EN TROP" for n in noms if n not in attendu] + if ecarts: + archive.unlink(missing_ok=True) + print("\n ECARTS — l'archive a ete SUPPRIMEE :", file=sys.stderr) + for e in ecarts: + print(f" ! {e}", file=sys.stderr) + return 1 + + print(f"\n Ecrit : {archive}") + print(f" {len(attendu)} voute(s) relue(s), empreintes identiques a l'original.") + print(f" Restaurer : tar -xf \"{archive.name}\" -C \"\"") + print(" Les CLES qui les ouvrent sont dans l'archive `setops-cles-*` — pas dans celle-ci.\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))