Set-OPS-Public/scripts/restaurer_cles.py
Daniel Allaire c0f610be33 patient 0 efface : l index 29 est libere, et le site n ouvre plus rien a 10.29.0.0/16
Ses machines n existaient plus depuis le 2026-09-06 (D-83), mais son plan
restait sur disque : la federation lui reservait l index 29 et quatre machines
du site lui ouvraient SSH, apt, DNS et HTTPS. Les commentaires et documents
vivants gardent leur lecon sans le nommer ; les archives restent telles quelles.

Pas encore sur le reseau : les regles regenerees attendent le runner du site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:55:27 -04:00

141 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Remet en place les cles sorties par `exporter_cles.py`. Autonome, sans le depot.
POURQUOI CE SCRIPT DOIT POUVOIR VIVRE SUR LA CLE USB.
Le jour ou l'on s'en sert, le poste est mort. Le depot Set-OPS est replique — eregion et la
forge du site — mais le CLONER demande la cle SSH, qui est justement dans
l'archive qu'on essaie d'ouvrir. Une procedure de restauration qui vit dans le depot
serait donc inaccessible exactement quand elle sert.
`exporter_cles.py` en depose donc une copie A COTE DE L'ARCHIVE, avec un mode d'emploi.
La cle USB se suffit a elle-meme : gpg, python3, et les trois commandes manuelles ecrites
dans le LISEZ-MOI si meme ce script refuse de tourner.
CE QU'IL FAIT DE PLUS QU'UN `tar -x` :
- il remet CHAQUE fichier a sa place (`~/.config` ou `~/.ssh`, selon son nom) ;
- il repose les permissions a 0600. Sans ca, `ssh` REFUSE une cle privee lisible par
d'autres — « UNPROTECTED PRIVATE KEY FILE » — et le message n'aide pas a comprendre
qu'il s'agit d'un droit, pas d'une cle invalide. Une archive extraite depuis un
support FAT arrive systematiquement dans cet etat ;
- il REFUSE d'ecraser une cle existante. Lance par erreur sur un poste qui fonctionne,
il ne doit rien detruire.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
MAISON = Path.home()
def _destination(nom: str) -> Path:
"""Ou remettre un fichier — deduit de son nom, pas d'un chemin enregistre.
L'archive ne garde que les noms de base : un chemin absolu la rendrait dependante du
compte qui l'a creee, et on restaure souvent sur une machine neuve, parfois sous un
autre nom d'utilisateur.
"""
if nom.startswith("setops-vault"):
return MAISON / ".config" / nom
if nom.startswith("id_"):
return MAISON / ".ssh" / nom
raise SystemExit(f"REFUS : {nom} n'est ni une cle de voute ni une cle SSH connue.")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("archive", help="Chemin de l'archive (.tar ou .tar.gpg)")
ap.add_argument("--lister", action="store_true",
help="Montrer ce que l'archive contient, sans rien ecrire")
ap.add_argument("--forcer", action="store_true",
help="Ecraser une cle deja presente (a n'utiliser qu'en connaissance)")
args = ap.parse_args()
arch = Path(args.archive).expanduser()
if not arch.is_file():
raise SystemExit(f"REFUS : {arch} introuvable.")
# DEUX FORMES D'ARCHIVE, ET IL FAUT LES DEUX (mesure du 2026-09-13).
#
# `exporter_cles --support-chiffre` ecrit un TAR CLAIR quand le support est deja
# chiffre au repos (LUKS) : empiler GPG par-dessus ajouterait une phrase de passe a
# perdre sans rien proteger de plus. Cette capacite a ete ajoutee cote EXPORT sans
# l'etre cote RESTAURATION — et ce script, qui est ce qui rend la cle autonome, ne
# savait lire que du GPG.
#
# L'echec etait particulierement mauvais : gpg disait « aucune donnee OpenPGP valable »
# puis le script concluait « phrase de passe erronee, ou archive abimee ». Il accusait
# une phrase de passe qu'on n'avait jamais posee, sur une archive parfaitement saine.
#
# ON RECONNAIT LA FORME, on ne la demande pas : `tarfile.is_tarfile` lit l'en-tete.
# Un drapeau a passer serait une chose de plus a savoir le jour ou l'on restaure —
# c'est-a-dire le jour ou l'on sait le moins.
with tempfile.TemporaryDirectory() as tmp:
if tarfile.is_tarfile(arch):
print(" Archive EN CLAIR — la protection etait celle du support (LUKS).\n")
clair = arch
else:
clair = Path(tmp) / "cles.tar"
print(" Archive chiffree. La phrase de passe va etre demandee.\n")
r = subprocess.run(["gpg", "--quiet", "--output", str(clair),
"--decrypt", str(arch)])
if r.returncode != 0:
raise SystemExit(
"\nECHEC du dechiffrement.\n"
" Phrase de passe erronee, ou archive abimee. gpg verifie l'integrite\n"
" (AES256 + MDC) : s'il refuse, le contenu n'est pas fiable.")
with tarfile.open(clair) as tar:
noms = [n for n in tar.getnames() if "/" not in n and not n.startswith("..")]
if args.lister:
print(f" {len(noms)} fichier(s) dans l'archive :\n")
for n in sorted(noms):
m = tar.getmember(n)
print(f" {n:<44} {m.size:>5} o -> {_destination(n)}")
print("\n LISTE SEULE — rien n'a ete ecrit.")
return 0
# ON REGARDE AVANT D'ECRIRE. Refuser a mi-parcours laisserait la moitie des
# cles en place et l'autre non — un etat que personne ne sait diagnostiquer.
deja = [n for n in noms if _destination(n).exists()]
if deja and not args.forcer:
print(" REFUS : ces cles existent deja ici —", file=sys.stderr)
for n in sorted(deja):
print(f" ! {_destination(n)}", file=sys.stderr)
print("\n Rien n'a ete ecrit. Ce poste a peut-etre deja ses cles.\n"
" Si l'ecrasement est VOULU, relancer avec --forcer.", file=sys.stderr)
return 1
poses = []
for n in sorted(noms):
dest = _destination(n)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.parent.chmod(0o700)
membre = tar.extractfile(n)
if membre is None:
continue
dest.write_bytes(membre.read())
# 0600 SANS CONDITION : `ssh` refuse une cle privee que d'autres peuvent
# lire, et `ansible-vault` n'est pas plus tolerant. Une archive extraite
# depuis un support FAT arrive toujours avec de mauvais droits.
dest.chmod(0o600)
poses.append(dest)
print(f" {len(poses)} cle(s) remises en place, en 0600 :\n")
for d in poses:
print(f" {d}")
print("\n VERIFIER MAINTENANT, PAS PLUS TARD :")
print(" ssh -o BatchMode=yes ansible@<un hyperviseur> hostname")
print(" python3 scripts/voutes.py etat")
print("\n Une cle remise en place qu'on n'a pas essayee n'est pas une cle restauree.")
return 0
if __name__ == "__main__":
raise SystemExit(main())