LE NOM D UN POOL EST CELUI DE SON DEPOT. Chezlepro-17 devient OPS-Chezlepro : le seed se lisait dans le nom, ce qui obligeait a connaitre le codage — et surtout le nom CHANGEAIT si l index changeait, ce que la renumerotation du site a montre le jour meme. Site-OPS ne derive de rien, et c est le point : les machines du genome ne dependent d aucun index, elles sont l infrastructure SUR laquelle les index vivent. Sans ce bloc elles restaient hors de tout pool. P69 — l amorcage d un tenant designe-t-il le site REEL ? dns_amorcage et artefacts_amorcage sont ecrits a la main, volontairement : au moment ou ils servent la machine ne resout aucun nom. Mais ils designent des machines DU SITE, et n ont pas suivi son renumerotage. La reconstruction du locataire s est arretee sur Failed to update apt cache, a quinze couches de sa cause. La preuve ne juge que les valeurs qui PRETENDENT designer le site : viser 9.9.9.9 est un choix, pas un oubli. LES CLES SORTENT DU POSTE, EN CLAIR, ET C EST RAISONNE. Support perdu : LUKS s en charge. Poste compromis : la seconde couche n aide pas, les originaux sont dans ~/.config sur ce meme poste. Elle coutait une phrase de passe stockee nulle part — le seul point que la procedure ne couvre pas. Option --support-chiffre explicite ; le defaut reste GPG, parce qu un support non chiffre est le cas le plus frequent. Et ma note qui disait les cles sorties depuis le 5 septembre etait fausse : le support ne portait que le depot hors site du 1er. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
364 lines
16 KiB
Python
364 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Sort du poste ce qui n'existe QUE sur le poste — chiffre, puis relu.
|
|
|
|
CE QUE CE SCRIPT PROTEGE, ET POURQUOI C'EST LE PLUS URGENT (mesure du 2026-09-05).
|
|
|
|
Le CODE de Set-OPS est replique trois fois : `eregion`, la forge du site, patient 0. Les
|
|
voutes chiffrees y sont aussi — le coffre est solide.
|
|
|
|
Les CLES qui ouvrent ce coffre, elles, vivent dans six fichiers de `~/.config`, 357
|
|
octets au total, sans aucune copie ailleurs. S'y ajoutent les cles SSH par lesquelles on
|
|
entre sur les hyperviseurs, la frontiere et chaque machine.
|
|
|
|
Ce qu'on perd avec le poste, dans l'ordre de gravite :
|
|
|
|
- poste seul : les mots de passe restic sont encore lisibles sur les machines vivantes
|
|
(`/etc/setops/restic.pass`), donc recuperable — mais douloureux, et on ne peut plus
|
|
rien deployer entre-temps ;
|
|
- poste + une machine : l'etat de cette machine devient illisible ;
|
|
- poste + site : terminal.
|
|
|
|
357 octets valent donc l'installation entiere. C'est le pire rapport valeur/fragilite du
|
|
systeme, et il se ferme en quelques minutes.
|
|
|
|
CE QU'IL N'AFFICHE JAMAIS : le contenu des cles. Ni a l'ecran, ni dans un journal. Il
|
|
n'en montre que le nom, la taille et l'empreinte — de quoi verifier sans divulguer.
|
|
|
|
ECRIRE PUIS RELIRE (D-68). Une sauvegarde de cles qu'on n'a pas rouverte n'est pas une
|
|
sauvegarde : c'est un fichier dont on espere quelque chose. Ce script REDECHIFFRE ce
|
|
qu'il vient d'ecrire et compare les empreintes, une a une.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
MAISON = Path.home()
|
|
|
|
# CE QUI N'EXISTE QUE SUR CE POSTE — declare, pas devine.
|
|
#
|
|
# Les motifs sont volontairement etroits : ce script sort des SECRETS du poste, il ne
|
|
# doit emporter que ce qu'on a decide d'emporter. Un `~/.ssh/*` large embarquerait les
|
|
# cles d'autres projets, qui n'ont rien a faire dans une sauvegarde Set-OPS.
|
|
A_SORTIR = [
|
|
(MAISON / ".config", "setops-vault-*",
|
|
"Cles des voutes — ce qui ouvre TOUS les secrets chiffres du depot."),
|
|
(MAISON / ".ssh", "id_ed25519_ansible_chezlepro",
|
|
"Entree sur les hyperviseurs, la frontiere et la flotte de Chezlepro."),
|
|
(MAISON / ".ssh", "id_ed25519_ansible_technolibre",
|
|
"Entree sur la flotte de Technolibre."),
|
|
(MAISON / ".ssh", "id_ed25519",
|
|
"Cle declaree au plan de certains ecosystemes."),
|
|
(MAISON / ".ssh", "id_git_ed25519",
|
|
"Acces aux depots distants du genome."),
|
|
]
|
|
|
|
|
|
def _empreinte(chemin: Path) -> str:
|
|
return hashlib.sha256(chemin.read_bytes()).hexdigest()
|
|
|
|
|
|
def _recenser() -> list[tuple[Path, str]]:
|
|
trouves: list[tuple[Path, str]] = []
|
|
for dossier, motif, raison in A_SORTIR:
|
|
for f in sorted(dossier.glob(motif)):
|
|
if f.is_file():
|
|
trouves.append((f, raison))
|
|
return trouves
|
|
|
|
|
|
def _refuser_destination_interne(dest: Path) -> None:
|
|
"""Une copie DANS l'infrastructure ne protege de rien — elle boucle.
|
|
|
|
Le depot de sauvegarde du site est chiffre par un mot de passe qui vit dans une
|
|
voute, ouverte par les cles qu'on est en train de sauvegarder. Y deposer ces cles
|
|
fabriquerait un coffre dont la cle est a l'interieur.
|
|
"""
|
|
interdits = [MAISON / "Espace Chezlepro", Path("/srv/restic")]
|
|
for i in interdits:
|
|
try:
|
|
dest.resolve().relative_to(i.resolve())
|
|
except (ValueError, OSError):
|
|
continue
|
|
raise SystemExit(
|
|
f"REFUS : {dest} est DANS l'infrastructure ({i}).\n"
|
|
" Ces cles ouvrent les sauvegardes ; les y ranger ferait un coffre dont la\n"
|
|
" cle est a l'interieur. Viser un support amovible ou une machine tierce.")
|
|
|
|
|
|
LISEZ_MOI = """SI TU LIS CECI, LE POSTE EST PROBABLEMENT MORT.
|
|
==================================================
|
|
|
|
Cette cle contient les SECRETS d'une installation Set-OPS : ce qui ouvre les voutes
|
|
chiffrees, et les cles SSH qui entrent sur les machines. Le code, lui, est ailleurs —
|
|
replique sur plusieurs forges. C'est ici qu'est la partie qu'on ne peut pas refabriquer.
|
|
|
|
CE QU'IL FAUT, ET RIEN D'AUTRE
|
|
------------------------------
|
|
- la phrase de passe (elle n'est PAS sur cette cle, c'est voulu) ;
|
|
- `gpg` et `python3`, presents sur tout Debian ou Ubuntu.
|
|
|
|
LA MANOEUVRE
|
|
------------
|
|
python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien ecrire
|
|
python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place
|
|
|
|
Le script remet chaque fichier a sa place (~/.config ou ~/.ssh) et repose les droits a
|
|
0600. Ce dernier point n'est pas cosmetique : `ssh` REFUSE une cle privee que d'autres
|
|
peuvent lire, et son message ne dit pas qu'il s'agit d'un droit. Il refuse aussi
|
|
d'ecraser une cle deja presente — lance par erreur sur un poste qui marche, il ne
|
|
detruit rien.
|
|
|
|
SI MEME CE SCRIPT NE TOURNE PAS
|
|
-------------------------------
|
|
Trois commandes suffisent. C'est le vrai filet :
|
|
|
|
gpg --output cles.tar --decrypt setops-cles-*.tar.gpg
|
|
mkdir -p ~/.config ~/.ssh && tar -xf cles.tar -C /tmp
|
|
cp /tmp/setops-vault-* ~/.config/ ; cp /tmp/id_* ~/.ssh/
|
|
chmod 700 ~/.ssh ~/.config ; chmod 600 ~/.ssh/id_* ~/.config/setops-vault-*
|
|
|
|
ENSUITE, ESSAYER — PAS SUPPOSER
|
|
-------------------------------
|
|
ssh -o BatchMode=yes ansible@<un hyperviseur> hostname
|
|
python3 scripts/voutes.py etat (depuis le depot, une fois clone)
|
|
|
|
Une cle remise en place qu'on n'a pas essayee n'est pas une cle restauree.
|
|
|
|
OU EST LE RESTE
|
|
---------------
|
|
Le depot Set-OPS vit sur plusieurs forges ; les adresses sont dans `.git/config` de
|
|
n'importe quelle copie. Les sauvegardes des machines vivent sur le depot du site, et
|
|
leur mot de passe restic est dans les voutes que cette cle ouvre.
|
|
"""
|
|
|
|
|
|
def _deposer_compagnons(dest: Path) -> list[str]:
|
|
"""Le script de restauration et son mode d'emploi, A COTE de l'archive.
|
|
|
|
POURQUOI CE N'EST PAS FACULTATIF (2026-09-05). Le jour ou l'on ouvre cette cle, le
|
|
poste est mort. Le depot Set-OPS est replique trois fois — mais le CLONER demande la
|
|
cle SSH, qui est dans l'archive qu'on essaie justement d'ouvrir. Une procedure de
|
|
restauration rangee dans le depot serait donc inaccessible exactement quand elle sert.
|
|
|
|
La cle doit se suffire a elle-meme. On y depose le script, et un LISEZ-MOI qui porte
|
|
en plus les commandes manuelles — pour le cas ou meme le script refuserait de tourner.
|
|
"""
|
|
poses = []
|
|
src = Path(__file__).parent / "restaurer_cles.py"
|
|
if src.is_file():
|
|
(dest / "restaurer_cles.py").write_bytes(src.read_bytes())
|
|
(dest / "restaurer_cles.py").chmod(0o755)
|
|
poses.append("restaurer_cles.py")
|
|
(dest / "LISEZ-MOI-RESTAURATION.txt").write_text(LISEZ_MOI, encoding="utf-8")
|
|
poses.append("LISEZ-MOI-RESTAURATION.txt")
|
|
return poses
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--vers",
|
|
help="Repertoire de destination (support amovible, machine tierce)")
|
|
# VOIR CE QUI SORT AVANT DE LE SORTIR. Ce script emporte des secrets : pouvoir en
|
|
# lire l'inventaire sans rien ecrire n'est pas un confort, c'est ce qui permet de
|
|
# verifier qu'on n'emporte ni trop ni trop peu.
|
|
ap.add_argument("--recenser", action="store_true",
|
|
help="Montrer ce qui sortirait, sans rien ecrire")
|
|
# POUR UNE CLE DEJA FAITE. L'archive n'est pas refaite — on ne redemande pas une
|
|
# phrase de passe et on ne risque pas d'ecraser ce qui est deja verifie.
|
|
ap.add_argument("--compagnons", action="store_true",
|
|
help="Deposer seulement le script de restauration et son mode d'emploi")
|
|
ap.add_argument("--support-chiffre", action="store_true",
|
|
help="le support est DEJA chiffre au repos (LUKS) : ecrire un tar "
|
|
"clair plutot qu'une archive GPG (voir la note dans le code)")
|
|
ap.add_argument("--nom", default="setops-cles",
|
|
help="Nom de base de l'archive (defaut: setops-cles)")
|
|
args = ap.parse_args()
|
|
|
|
if args.compagnons:
|
|
if not args.vers:
|
|
raise SystemExit("REFUS : --vers requis.")
|
|
dest = Path(args.vers).expanduser()
|
|
_refuser_destination_interne(dest)
|
|
if not dest.is_dir():
|
|
raise SystemExit(f"REFUS : {dest} n'existe pas ou n'est pas un repertoire.")
|
|
for nom in _deposer_compagnons(dest):
|
|
print(f" + {dest / nom}")
|
|
print("\n La cle se suffit desormais a elle-meme : script, mode d'emploi, et")
|
|
print(" les commandes manuelles si meme le script refuse de tourner.")
|
|
return 0
|
|
|
|
if not args.recenser:
|
|
if not args.vers:
|
|
raise SystemExit("REFUS : --vers requis (ou --recenser pour voir sans ecrire).")
|
|
dest = Path(args.vers).expanduser()
|
|
_refuser_destination_interne(dest)
|
|
if not dest.is_dir():
|
|
raise SystemExit(f"REFUS : {dest} n'existe pas ou n'est pas un repertoire.")
|
|
|
|
fichiers = _recenser()
|
|
if not fichiers:
|
|
raise SystemExit("REFUS : aucun fichier a sortir — rien ne correspond aux motifs.")
|
|
|
|
print(" CE QUI VA SORTIR DU POSTE :\n")
|
|
attendu: dict[str, str] = {}
|
|
raison_vue: set[str] = set()
|
|
for f, raison in fichiers:
|
|
if raison not in raison_vue:
|
|
print(f" {raison}")
|
|
raison_vue.add(raison)
|
|
emp = _empreinte(f)
|
|
attendu[f.name] = emp
|
|
print(f" {f.name:<44} {f.stat().st_size:>5} o {emp[:16]}…")
|
|
print(f"\n {len(fichiers)} fichier(s), "
|
|
f"{sum(f.stat().st_size for f, _ in fichiers)} octets au total.\n")
|
|
|
|
if args.recenser:
|
|
print(" RECENSEMENT SEUL — rien n'a ete ecrit.")
|
|
return 0
|
|
|
|
# LE SUPPORT EST DEJA CHIFFRE : LA SECONDE COUCHE PROTEGE DE QUOI ? (2026-09-12)
|
|
#
|
|
# La question posee par l'exploitant, et elle se tient. Deux menaces, deux reponses :
|
|
#
|
|
# Le support est PERDU ou VOLE — LUKS s'en charge. Sans la phrase de deverrouillage
|
|
# du volume, il n'y a rien a lire.
|
|
#
|
|
# Le POSTE est compromis — la seconde couche n'aide pas : les originaux sont dans
|
|
# `~/.config` sur ce meme poste. Qui tient le poste tient deja les cles ; la copie
|
|
# chiffree ne lui retire rien.
|
|
#
|
|
# La couche GPG n'ajoute donc presque rien, et coûte une phrase de passe STOCKEE
|
|
# NULLE PART — que la procedure elle-meme designe comme le seul point non couvert :
|
|
# perdue, l'archive est du bruit. On echange un risque de divulgation deja couvert
|
|
# contre un risque de PERTE qui ne l'est pas.
|
|
#
|
|
# `--support-chiffre` est donc un choix legitime, mais il DOIT etre explicite : le
|
|
# defaut reste l'archive GPG, parce qu'un support non chiffre est le cas le plus
|
|
# frequent et que le silence ne doit jamais pencher du cote de la divulgation.
|
|
if args.support_chiffre:
|
|
archive = dest / f"{args.nom}-{os.uname().nodename}.tar"
|
|
if archive.exists():
|
|
raise SystemExit(
|
|
f"REFUS : {archive} existe deja.\n"
|
|
" On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n"
|
|
" La deplacer ou choisir un autre --nom.")
|
|
with tarfile.open(archive, "w") as tar:
|
|
for f, _ in fichiers:
|
|
tar.add(f, arcname=f.name)
|
|
archive.chmod(0o600)
|
|
# RELIRE CE QU'ON VIENT D'ECRIRE, comme pour l'archive chiffree : une sauvegarde
|
|
# qu'on n'a pas relue est une intention.
|
|
with tarfile.open(archive, "r") as tar:
|
|
noms = set(tar.getnames())
|
|
manquants = [f.name for f, _ in fichiers if f.name not in noms]
|
|
if manquants:
|
|
archive.unlink(missing_ok=True)
|
|
raise SystemExit(f"ECHEC de la relecture — archive SUPPRIMEE. "
|
|
f"Manquent : {', '.join(manquants)}")
|
|
print(f" Ecrit EN CLAIR dans l'archive : {archive}")
|
|
print(f" {len(noms)} fichier(s) relus et retrouves.\n")
|
|
print(" LA PROTECTION EST CELLE DU SUPPORT. Ce tar est lisible par quiconque")
|
|
print(" monte ce volume deverrouille. Il n'a sa place que sur un support chiffre")
|
|
print(" au repos, et le volume doit etre DEMONTE quand on ne s'en sert pas.")
|
|
_deposer_compagnons(dest)
|
|
return 0
|
|
|
|
archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg"
|
|
if archive.exists():
|
|
raise SystemExit(
|
|
f"REFUS : {archive} existe deja.\n"
|
|
" On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n"
|
|
" La deplacer ou choisir un autre --nom.")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
brut = Path(tmp) / "cles.tar"
|
|
with tarfile.open(brut, "w") as tar:
|
|
for f, _ in fichiers:
|
|
tar.add(f, arcname=f.name)
|
|
|
|
print(" Une phrase de passe va etre demandee (deux fois : ecriture, puis")
|
|
print(" verification). Elle n'est stockee NULLE PART — c'est a toi de la garder,")
|
|
print(" et de la garder AILLEURS que sur ce support.\n")
|
|
r = subprocess.run(
|
|
["gpg", "--symmetric", "--cipher-algo", "AES256", "--s2k-digest-algo",
|
|
"SHA512", "--output", str(archive), str(brut)])
|
|
if r.returncode != 0 or not archive.exists():
|
|
raise SystemExit("ECHEC du chiffrement — rien n'a ete ecrit.")
|
|
|
|
# ECRIRE PUIS RELIRE. On redechiffre CE QU'ON VIENT D'ECRIRE et on compare les
|
|
# empreintes. Sans ca, on rentrerait chez soi avec un fichier dont on ESPERE
|
|
# qu'il contient les cles.
|
|
print("\n Relecture de ce qui vient d'etre ecrit…\n")
|
|
relu = Path(tmp) / "relu.tar"
|
|
r = subprocess.run(["gpg", "--quiet", "--output", str(relu),
|
|
"--decrypt", str(archive)])
|
|
if r.returncode != 0:
|
|
archive.unlink(missing_ok=True)
|
|
raise SystemExit(
|
|
"ECHEC de la relecture — l'archive a ete SUPPRIMEE.\n"
|
|
" Une sauvegarde de cles qu'on ne sait pas rouvrir est pire que rien :\n"
|
|
" elle donne le sentiment d'etre protege.")
|
|
|
|
ecarts = []
|
|
with tarfile.open(relu) as tar:
|
|
noms = tar.getnames()
|
|
for nom, emp in attendu.items():
|
|
if nom not in noms:
|
|
ecarts.append(f"{nom} : ABSENT de l'archive")
|
|
continue
|
|
membre = tar.extractfile(nom)
|
|
obtenu = hashlib.sha256(membre.read()).hexdigest() if membre else ""
|
|
if obtenu != emp:
|
|
ecarts.append(f"{nom} : empreinte differente")
|
|
for nom in noms:
|
|
if nom not in attendu:
|
|
ecarts.append(f"{nom} : PRESENT en trop dans l'archive")
|
|
|
|
if ecarts:
|
|
archive.unlink(missing_ok=True)
|
|
print(" ECARTS — l'archive a ete SUPPRIMEE :", file=sys.stderr)
|
|
for e in ecarts:
|
|
print(f" ! {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# UN `chmod` QUI ECHOUE NE DOIT PAS ANNULER UNE VERIFICATION REUSSIE.
|
|
#
|
|
# Une cle USB est le plus souvent en FAT32 ou exFAT — des systemes qui n'ont pas de
|
|
# permissions Unix. `chmod` y echoue ou n'a aucun effet, selon les options de montage.
|
|
# Sans cette garde, le script mourrait sur sa DERNIERE ligne, apres avoir ecrit et
|
|
# verifie l'archive : l'exploitant verrait une trace d'erreur et douterait d'un
|
|
# travail pourtant termine. Le pire moment pour semer un doute.
|
|
#
|
|
# On le DIT plutot que de le taire : sur un support sans permissions, l'archive est
|
|
# lisible par quiconque branche la cle. C'est precisement pourquoi elle est chiffree —
|
|
# mais il faut le savoir pour choisir ou ranger la cle.
|
|
try:
|
|
archive.chmod(0o600)
|
|
acces = "0600 (lisible par toi seul)"
|
|
except OSError:
|
|
acces = ("permissions non applicables — support sans droits Unix (FAT/exFAT). "
|
|
"L'archive est lisible par qui branche la cle : c'est le chiffrement "
|
|
"qui protege, pas le support.")
|
|
for nom in _deposer_compagnons(dest):
|
|
print(f" + {nom}")
|
|
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
|
|
print(f" Acces : {acces}")
|
|
print(f" Archive : {archive} ({archive.stat().st_size} octets)\n")
|
|
print(" IL RESTE DEUX GESTES, ET ILS NE SONT PAS FACULTATIFS :")
|
|
print(" 1. Ranger la phrase de passe AILLEURS que ce support. Separes, ils ne")
|
|
print(" valent rien l'un sans l'autre ; ensemble, ils valent l'installation.")
|
|
print(" 2. Faire une SECONDE copie, dans un autre lieu physique. Un support")
|
|
print(" unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,")
|
|
print(" deplace de quelques metres.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|