diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe4317..a860218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/Makefile b/Makefile index 4fa28a8..a08dfc0 100644 --- a/Makefile +++ b/Makefile @@ -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= + @# 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=.\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 diff --git a/docs/audit/preuve-2026-09-05.md b/docs/audit/preuve-2026-09-05.md new file mode 100644 index 0000000..ca0b233 --- /dev/null +++ b/docs/audit/preuve-2026-09-05.md @@ -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._ diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index a06bcbb..90334ac 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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 | diff --git a/docs/sortir-les-cles-du-poste.md b/docs/sortir-les-cles-du-poste.md new file mode 100644 index 0000000..0947844 --- /dev/null +++ b/docs/sortir-les-cles-du-poste.md @@ -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. diff --git a/scripts/exporter_cles.py b/scripts/exporter_cles.py new file mode 100644 index 0000000..293eda9 --- /dev/null +++ b/scripts/exporter_cles.py @@ -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())