cles : sortir du poste ce qui n existe qu au poste
Some checks are pending
verifier / verifier (push) Waiting to run

Le code est replique trois fois (eregion, forge du site, patient 0) et les
voutes chiffrees y sont aussi — le coffre est solide. Les CLES qui l ouvrent
vivaient dans neuf fichiers, 1644 octets, sans copie ailleurs.

Poste seul : les mots de passe restic restent lisibles sur les machines vivantes,
donc recuperable mais douloureux. Poste + une machine : l etat de cette machine
devient illisible. Poste + site : terminal.

make cles-recenser montre ce qui sortirait sans rien ecrire — nom, taille,
empreinte, JAMAIS le contenu. make cles-exporter chiffre en AES256 puis
REDECHIFFRE ce qu il vient d ecrire et compare les empreintes une a une : une
sauvegarde de cles qu on n a pas rouverte n est pas une sauvegarde.

A lancer par l exploitant lui-meme : gpg demande une phrase de passe, elle ne
doit passer ni par un journal ni par le contexte d un assistant.

Trois refus, eprouves en les faisant echouer : destination dans l infrastructure
(un coffre dont la cle est dedans), archive existante (elle est peut-etre la
seule), archive illisible (supprimee). Le premier essai du premier refus etait
faux — le shell developpait HOME avant que je le remplace, l instrument mesurait
ailleurs que la cible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-09-05 09:42:01 -04:00
parent 42becd0c02
commit bebdb84212
6 changed files with 435 additions and 2 deletions

View file

@ -1,5 +1,60 @@
# CHANGELOG — Set-OPS
## 2026-09-05 — 1 644 octets valent l'installation, et ils n'existaient qu'a un endroit
**56 preuves.** En cherchant par quoi reprendre, une mesure a change l'ordre des
priorites : le CODE de Set-OPS est replique trois fois — `eregion`, la forge du site,
patient 0 — et les voutes chiffrees y sont aussi. Le coffre est solide.
Les CLES qui l'ouvrent vivaient dans neuf fichiers de `~/.config` et `~/.ssh`, **1 644
octets au total, sans aucune copie ailleurs**. C'est le pire rapport valeur/fragilite de
l'installation : six cles de voute qui ouvrent tous les secrets — jetons Proxmox et
OPNsense, mots de passe de step-ca, des bases, et les `vault_restic_password` — plus les
cles SSH par lesquelles on entre partout.
Ce qu'on perdait avec le poste, sans exagerer la gravite :
- **le poste seul** : les mots de passe restic restent lisibles sur les machines vivantes
(`/etc/setops/restic.pass`) — recuperable, mais douloureux, et plus aucun deploiement
possible entre-temps ;
- **le poste et une machine** : l'etat de cette machine devient illisible ;
- **le poste et le site** : terminal.
### `make cles-recenser` et `make cles-exporter`
Le recensement montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, **jamais
le contenu**. L'export met le tout dans une archive chiffree (AES256, phrase de passe
symetrique) sur un support choisi.
**A LANCER SOI-MEME.** `gpg` demande une phrase de passe : elle ne doit passer ni par un
journal, ni par le contexte d'un assistant. La cible le dit dans son propre commentaire.
ECRIRE PUIS RELIRE (D-68), applique a ce qui compte le plus : l'outil REDECHIFFRE ce
qu'il vient d'ecrire et compare les empreintes une a une. Une sauvegarde de cles qu'on
n'a pas rouverte n'est pas une sauvegarde, c'est un fichier dont on espere quelque chose.
### Trois refus, eprouves en les faisant echouer
- **destination dans l'infrastructure** — ces cles ouvrent les sauvegardes ; les y ranger
ferait un coffre dont la cle est a l'interieur. `~/Espace Chezlepro` et `/srv/restic`
sont refuses nommement ;
- **archive existante** — on n'ecrase pas une sauvegarde de cles : elle est peut-etre la
seule ;
- **archive illisible** — l'archive est SUPPRIMEE. Elle donnerait le sentiment d'etre
protege sans l'etre.
Les trois ont ete essayes sur des fichiers factices avant d'approcher les vraies cles —
et le premier essai du premier refus etait FAUX : le shell developpait `$HOME` avant que
je le remplace, l'instrument mesurait donc ailleurs que la cible. Reteste correctement,
le refus tire. Encore une fois : verifier d'ou l'instrument mesure.
### Ce que l'outil ne couvre pas, et qui reste a l'humain
La phrase de passe (perdue, l'archive est du bruit) et la seconde copie dans un autre
lieu. Un support unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,
deplace de quelques metres. Ces deux gestes sont ecrits dans la sortie du script et dans
`docs/sortir-les-cles-du-poste.md` — pas en note de bas de page.
## 2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne
**56 preuves.** Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga,

View file

@ -562,6 +562,19 @@ site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declaree
python3 scripts/orchestrer.py verifier
.PHONY: flux flux-verifier
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
python3 scripts/exporter_cles.py --recenser
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
@# dans son propre terminal est la seule facon de s'en assurer.
@if [ -z "$(VERS)" ]; then \
printf 'Refus: relancer avec VERS=<repertoire de destination>.\n'; \
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
cacher-paquets: ## Tire les paquets NON DEBIAN dans le cache du controleur (a faire en ligne)
@# CE QUI REND LA RECONSTRUCTION HORS LIGNE POSSIBLE. Trois depots tiers sont en
@# HTTPS et contournent le cache du site (`Acquire::https::Proxy "DIRECT"`). Sans

View file

@ -0,0 +1,93 @@
# Preuve de conformite — Set-OPS — 2026-09-05
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ❌ NON CONFORME (55 OK · 1 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 35 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 80 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 56 scripts expliques et atteignables, 111 cibles make documentees, 65 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (32 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 52 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ❌ ECHEC | La carte d'orientation ne dit plus vrai :
- « pieces d'audit » : la carte annonce 34, le depot en compte 35 |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-05._

View file

@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|---|---|---|
| rôles | 65 | `roles/*/` |
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 34 | `docs/audit/*` |
| documents | 39 | `docs/*.md` |
| pièces d'audit | 35 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 79 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -0,0 +1,64 @@
> **Pour qui :** l'exploitant, le jour où il réalise que 1 644 octets valent toute
> l'installation. À faire une fois, puis à refaire quand une clé change.
# Sortir les clés du poste
## Ce qui est en jeu
Le **code** de Set-OPS est répliqué trois fois : `eregion`, la forge du site, patient 0.
Les **voûtes chiffrées** y sont aussi. Le coffre est solide.
Les **clés** qui l'ouvrent vivent dans neuf fichiers de ce poste — 1 644 octets — sans
aucune copie ailleurs. S'y ajoutent les clés SSH par lesquelles on entre sur les
hyperviseurs, la frontière et chaque machine.
Ce qu'on perd avec le poste, par ordre de gravité :
| Ce qui disparaît | Conséquence |
|---|---|
| Le poste seul | Les mots de passe restic restent lisibles sur les machines vivantes (`/etc/setops/restic.pass`) — récupérable, mais douloureux, et plus aucun déploiement possible entre-temps |
| Le poste **et** une machine | L'état de cette machine devient illisible |
| Le poste **et** le site | Terminal |
## La manœuvre
```bash
make cles-recenser # voir ce qui sortirait, sans rien écrire
make cles-exporter VERS=/media/…/CLE # sortir, chiffrer, RELIRE
```
**Lance-la toi-même**, dans ton terminal. `gpg` demande une phrase de passe : elle ne doit
passer ni par un journal, ni par le contexte d'un assistant. C'est la seule façon de
s'en assurer.
L'outil refuse trois choses, et chacune a été éprouvée en la faisant échouer :
- une destination **dans** l'infrastructure — ces clés ouvrent les sauvegardes ; les y
ranger ferait un coffre dont la clé est à l'intérieur ;
- **écraser** une archive existante — elle est peut-être la seule ;
- une archive qu'il **n'arrive pas à rouvrir** — elle est alors supprimée. Une sauvegarde
de clés qu'on ne sait pas ouvrir est pire que rien : elle donne le sentiment d'être
protégé.
Il ne montre jamais le contenu des clés — seulement leur nom, leur taille et leur
empreinte. De quoi vérifier sans divulguer.
## Les deux gestes qui restent, et qui ne sont pas facultatifs
**1. La phrase de passe va ailleurs que le support.** Séparés, ils ne valent rien l'un
sans l'autre ; ensemble, ils valent l'installation. Un papier dans un autre lieu, ou un
gestionnaire de mots de passe qui n'est pas sur ce poste.
**2. Une seconde copie, dans un autre lieu physique.** Un support unique dans un tiroir
unique, c'est le problème qu'on vient de fermer, déplacé de quelques mètres.
## Quand recommencer
Quand une voûte est créée ou sa clé changée (`voutes.py`), quand une paire SSH de runner
est refaite, et à l'arrivée d'un écosystème. `make cles-recenser` dit en une seconde si
l'archive rangée est encore complète : compare le nombre de fichiers et les empreintes.
## Ce que ça ne couvre pas
La **phrase de passe** elle-même : si elle est perdue, l'archive est du bruit. C'est le
prix du chiffrement, et c'est pour ça que le geste 1 n'est pas décoratif.

208
scripts/exporter_cles.py Normal file
View file

@ -0,0 +1,208 @@
#!/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.")
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")
ap.add_argument("--nom", default="setops-cles",
help="Nom de base de l'archive (defaut: setops-cles)")
args = ap.parse_args()
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
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
archive.chmod(0o600)
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
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())