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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 12:53:14 -04:00
parent 2bd761b85f
commit 4855cda294
6 changed files with 179 additions and 3 deletions

View file

@ -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-<date>-<poste>.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 <dossier des dépôts>`. 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

View file

@ -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=<repertoire> NOM=<base>
@if [ -z "$(VERS)" ]; then printf '%s\n' 'Refus: VERS=<repertoire du support> 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=<repertoire>
@# 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=<repertoi
printf 'Ex. make cles-exporter VERS=/media/danallaire/CLE\n'; exit 2; fi
python3 scripts/exporter_cles.py --vers "$(VERS)"
.PHONY: cles-recenser cles-exporter
.PHONY: cles-recenser cles-exporter voutes-recenser voutes-exporter
depot-hors-site: ## Copie le depot de sauvegarde du site HORS du site — VERS=<repertoire>
@# 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

View file

@ -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]

View file

@ -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=<support>`,
`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 <dossier des dépôts>`. **À 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

View file

@ -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 "<dossier ou sont clones les depots>"
Sans elles, les cles n'ouvrent rien : chaque secret des ecosystemes serait a refaire.
ENSUITE, ESSAYER — PAS SUPPOSER
-------------------------------
ssh -o BatchMode=yes ansible@<un hyperviseur> hostname

119
scripts/exporter_voutes.py Normal file
View file

@ -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-<date>-<poste>.tar -C "<dossier des depots>"
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/<support>" --nom setops-voutes-<date>
"""
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 \"<dossier des depots>\"")
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:]))