From 0cbb9fdb4a1904edc8ae05f3befa4c8537e936dd Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 16 Sep 2026 11:16:27 -0400 Subject: [PATCH] remise au client : deux temps, un outil, une garde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Livrer se terminait par une phrase — tes cles te seront remises separement — et rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee, secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un second temps echu, un second temps declare fait sans revocation au plan, et un secret dans un fichier versionne. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- CHANGELOG.md | 54 +++ Makefile | 39 +++ docs/audit/preuve-2026-09-16.md | 116 +++++++ docs/carte-set-ops.md | 5 +- docs/devis-services.md | 2 +- docs/remise-au-client.md | 133 ++++++++ scripts/prouver.py | 157 ++++++++- scripts/remise.py | 570 ++++++++++++++++++++++++++++++++ 9 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 docs/audit/preuve-2026-09-16.md create mode 100644 docs/remise-au-client.md create mode 100644 scripts/remise.py diff --git a/AGENTS.md b/AGENTS.md index 3d7b48f..8d1d306 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 79 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 80 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index afc7b47..56c423a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # CHANGELOG — Set-OPS +## 2026-09-16 (1) — Remettre un ecosysteme : une procedure, un outil, une garde + +Livrer se terminait par une phrase : *« tes cles te seront remises separement »*. Ce qui +se passait ensuite n'etait ecrit nulle part — ni ce qu'on remet, ni dans quel ordre, ni +**ce qu'on garde**. Le geste qui donne le controle d'une organisation etait le seul geste +lourd du depot sans procedure, sans outil et sans preuve. + +Et il portait une faute qui ne se serait vue de personne : les cles vivent toutes dans le +meme dossier (`~/.config/setops-vault-*`). Remettre « les cles » d'un revers de main, +c'est remettre celles du SITE et celles des autres locataires. + +### Deux temps, et ils ne se confondent pas + +| | Ce qui passe | Ce que le client peut | +|---|---|---| +| **Temps 1 — l'identite** | la cle de SA voute, sa voute chiffree, la racine de SON AC | creer, retirer, habiliter ses gens — des le premier jour | +| **Temps 2 — la machine** | sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de passe, les secrets tournent | tout, y compris se passer de nous | + +Le second temps applique a une LIVRAISON ce que `migration-tenant.md` applique deja a un +DEPART : **revoquer, pas transmettre**. Sans lui, l'hebergeur garde a vie l'acces aux +secrets d'un client qui se croit chez lui — et personne ne decide jamais de le garder : +on oublie de le rendre, et le silence transforme l'oubli en etat de fait. + +### Ce que l'outil refuse + +`scripts/remise.py` reprend la forme d'`exporter_cles.py` — ecrire puis RELIRE, ne jamais +afficher une valeur, refuser une destination dans l'infrastructure — et y ajoute deux +refus propres a la remise : partir **sans la racine de l'AC** (le client apprendrait a +cliquer sur « continuer quand meme »), et emporter autre chose que **l'ecosysteme monte**. +Cette derniere garde tient en une ligne, `_cle_de_voute()`, qui ne retient que le role +`instance` parmi les trois que `voutes.py` nomme. + +`remise-recleer` **mesure avant d'estampiller** : une cle presente, une cle revoquee, une +cle de voute differente de celle remise. Un registre qui dirait « revoque » pendant que le +plan garde la cle de l'hebergeur flatterait tout le monde. + +### Le registre, et une question ouverte depuis longtemps + +`remise.yml` se pose chez le locataire a cote de `parente.yml` : *de qui il descend* d'un +cote, *a qui il appartient* de l'autre. Il porte des empreintes SHA256, jamais des +valeurs — il est versionne et pousse sur trois forges. + +**Il declare enfin le responsable designe.** D-18 le decide depuis longtemps ; +`migration-tenant.md` §9 listait « ou est-il declare ? » parmi ses questions ouvertes. La +reponse est la seule qui ne devine rien : c'est la personne qui RECOIT, nommee au moment +ou elle recoit. + +### P80 + +Elle refuse un registre incomplet, un second temps **echu** et non fait, un second temps +declare fait pendant que le plan ne revoque aucune cle, et un secret qui se serait glisse +dans un fichier versionne. Elle ne juge PAS un ecosysteme sans registre : le lab, patient +0 et l'ecosysteme de l'hebergeur ne seront jamais remis a personne. + ## 2026-09-15 (7) — P79 : une derivation qui ne trouve rien ne passe plus pour un succes En deux jours, pour monter l'edge du site puis les trois consoles, le meme defaut est diff --git a/Makefile b/Makefile index 6945c29..3e53d4a 100644 --- a/Makefile +++ b/Makefile @@ -233,6 +233,16 @@ aide: ## Affiche l'aide detaillee du moteur (au-dela de cette liste) @printf '%s\n' ' make flux # -> docs/registre-flux.md + instance/flux-genere/*.nft' @printf '%s\n' ' make flux-verifier # valide schema + coherence de matrice' @printf '%s\n' '' + @printf '%s\n' 'Remise au client — EN DEUX TEMPS (docs/remise-au-client.md)' + @printf '%s\n' ' Temps 1, l IDENTITE : le client gouverne ses gens des le premier jour.' + @printf '%s\n' ' make remise-recenser # ce qui partirait, sans rien ecrire' + @printf '%s\n' ' make ca-racine ; make ca-empreinte # sa racine d AC, et le temoin a comparer' + @printf '%s\n' ' make remise-paquet VERS=/media//REMISE' + @printf '%s\n' ' make remise-inscrire RECU_PAR="Prenom Nom" COURRIEL="…"' + @printf '%s\n' ' Temps 2, la MACHINE : sa cle entre, la notre sort, la voute est re-cletee.' + @printf '%s\n' ' make remise-verifier # second temps du, ou echu ?' + @printf '%s\n' ' make remise-recleer CONFIRMER=true # mesure la revocation, puis estampille' + @printf '%s\n' '' @printf '%s\n' 'Wiki pedagogique' @printf '%s\n' ' Publier wiki/ dans le wiki Forgejo (source versionnee -> vue browsable):' @printf '%s\n' ' make wiki-publier WIKI_REMOTE=https://forge.//.wiki.git' @@ -621,6 +631,35 @@ cles-restaurer: ## Remet les cles en place depuis une archive — ARCHIVE= + @# 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 — et c'est elle + @# qui voyage par un AUTRE canal que le support. + @if [ -z "$(VERS)" ]; then \ + printf 'Refus: relancer avec VERS=.\n'; \ + printf 'Ex. make remise-paquet VERS=/media/danallaire/REMISE\n'; exit 2; fi + python3 scripts/remise.py paquet --vers "$(VERS)" $(if $(SUPPORT_CHIFFRE),--support-chiffre) + +remise-inscrire: ## Inscrit la remise chez le locataire — RECU_PAR="…" COURRIEL="…" [DANS=30] + @if [ -z "$(RECU_PAR)" ] || [ -z "$(COURRIEL)" ]; then \ + printf 'Refus: relancer avec RECU_PAR="Prenom Nom" COURRIEL="…".\n'; \ + printf 'Le responsable designe (D-18) se NOMME au moment de la remise.\n'; exit 2; fi + python3 scripts/remise.py inscrire --recu-par "$(RECU_PAR)" --courriel "$(COURRIEL)" \ + $(if $(DANS),--dans "$(DANS)",) + +remise-verifier: ## Etat de la remise : temps 1 fait ? temps 2 du, ou echu ? + python3 scripts/remise.py verifier + +remise-recleer: ## Temps 2 : mesure la revocation reelle, puis estampille — CONFIRMER=true + @# IL MESURE AVANT D'ESTAMPILLER. Un registre qui dirait « revoque » pendant que le + @# plan garde la cle de l'hebergeur flatterait tout le monde : c'est le seul mensonge + @# que ce fichier puisse porter sans que personne ne s'en apercoive. + python3 scripts/remise.py recleer $(if $(filter true,$(CONFIRMER)),--confirmer) + +.PHONY: remise-recenser remise-paquet remise-inscrire remise-verifier remise-recleer 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-16.md b/docs/audit/preuve-2026-09-16.md new file mode 100644 index 0000000..60a4c12 --- /dev/null +++ b/docs/audit/preuve-2026-09-16.md @@ -0,0 +1,116 @@ +# Preuve de conformite — Set-OPS — 2026-09-16 + +> 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** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` +- **Verdict** : ✅ CONFORME (79 OK · 0 echec · 1 saute) + +## Preuves + +| # | Preuve | Affirmations | Statut | Detail | +|---|---|---|---|---| +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | +| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK | +| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, 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), 1 nom(s) surveille(s) sans reference orpheline. | +| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. | +| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 111 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 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | +| 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 : 31 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 29 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 : 6 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 : 14 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, 4 tenant(s), 52 groupe(s), 96 regle(s). | +| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 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 : 5 pool(s) Proxmox, 43 VM placee(s), aucun nom ni VMID en collision. | +| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, 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 | 68 scripts expliques et atteignables, 128 cibles make documentees, 68 roles avec README. | +| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (144 cle(s) declaree(s) par l'instance). | +| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 36 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 47 document(s) declarent leur lecteur (41 genere(s) exempte(s)). | +| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). | +| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 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 : 41 role(s) serveur/client tous nommes, 41 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, 96 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 : 64 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 8 machine(s) du plan retrouvees, 182 regle(s) du site. | +| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 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 | — | ✅ OK | La carte designe 95 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | +| 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 (129 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 272 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 — 13 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), 9 role(s) applique(s), aucun secret de tenant reclame. | +| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. | +| 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. | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (80 preuves, 68 roles, 41 groupes). | +| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | +| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | +| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). | +| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | +| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). | +| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s | +| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 40 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | +| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | +| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | +| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. | +| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. | +| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. | +| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. | +| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. | +| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. | +| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 6 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). | +| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. | +| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | +| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 155 gabarits de role : tous se rendent. | +| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | +| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | +| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen | +| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. | + +## 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-16._ diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 17f00c4..b41c6b0 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 | 68 | `roles/*/` | | README de rôles | 68 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | -| documents | 42 | `docs/*.md` | -| pièces d'audit | 46 | `docs/audit/*` | +| documents | 43 | `docs/*.md` | +| pièces d'audit | 47 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | @@ -75,6 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Exploitation de l'hébergeur | ses **opérations** n'appartiennent à aucun tenant et restent **hors overlay** | **à moitié construit** : les *VM* du site ont leur inventaire (`scripts/site_inventaire.py`), leur socle, leur durcissement, leurs sauvegardes et leur supervision (`site-mon-01`, 2026-09-02). Les *équipements* — hyperviseurs, commutateurs, frontière — n'ont toujours ni inventaire, ni sauvegarde de configuration, ni supervision | `hebergeur-exploitation.md` | | Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` | | Accès & habilitations | Set-OPS **amorce** un accès sysadmin puis se retire ; les appartenances aux groupes ne sont **jamais réconciliées** — c'est une personne qui gouverne | **construit et éprouvé** (2026-08-08) : rôle `amorcage_acces` (idempotence par existence, D-67), groupes projetés en rôles par `serveur_keycloak`, `meta/acces.yml` dans les 5 rôles web ; chaîne LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 | `autorisation.md` (§6 = runbook de reprise) | +| **Remise au client** | remettre un écosystème se fait en **deux temps** : l'*identité* le jour de la livraison (sa clé de voûte, sa voûte, sa racine d'AC), la *machine* à l'échéance (sa clé SSH entre, la nôtre sort, la voûte est re-clétée). Le registre déclare enfin le **responsable désigné** (D-18) | `scripts/remise.py` (`make remise-paquet`, `remise-inscrire`, `remise-recleer`) ; registre `remise.yml` chez le locataire ; garde **P80** | `remise-au-client.md` | | SDN EVPN | ajouter un tenant implique **1 zone + 6 VNets + 6 sous-réseaux**, tous dérivés du seed | `scripts/devis_sdn.py` (`make devis-sdn`) ; nommage dérivé du seed (`t17`, `t17serv`), ≤ 8 caractères ; garde **P30** | `sdn-evpn.md` §2 | | Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 | | Routage | **aucun commutateur ne route** : la frontière est le seul équipement L3 ; les switches commutent | `passerelle` dit qui porte la passerelle, le SVI se dérive du rôle du porteur | `decisions-architecture.md` D-49/50 | diff --git a/docs/devis-services.md b/docs/devis-services.md index feedec4..5d79071 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 79 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 80 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/docs/remise-au-client.md b/docs/remise-au-client.md new file mode 100644 index 0000000..82082c3 --- /dev/null +++ b/docs/remise-au-client.md @@ -0,0 +1,133 @@ +> **Pour qui :** l'hébergeur, le jour où il remet un écosystème à celui qui en est le +> propriétaire. À lire **avant** de fabriquer le paquet, pas pendant. + +# Remettre un écosystème à son propriétaire + +## 1. Le problème que ce document ferme + +La livraison se terminait par une phrase : *« tes clés te seront remises séparément »*. +Ce qui se passait ensuite n'était écrit nulle part — ni ce qu'on remet, ni dans quel +ordre, ni **ce qu'on garde**. Le geste qui donne le contrôle d'une organisation était le +seul geste lourd du dépôt sans procédure, sans outil et sans garde. + +Et il portait une faute silencieuse : les clés vivent toutes dans le même dossier +(`~/.config/setops-vault-*`). Remettre « les clés » d'un revers de main, c'est remettre +celles du site et celles des autres locataires. Personne ne s'en apercevrait — ni celui +qui donne, ni celui qui reçoit. + +## 2. Les deux temps, et pourquoi ils ne se confondent pas + +> **Temps 1 — l'identité.** Le client reçoit de quoi gouverner **ses gens** tout de suite. +> **Temps 2 — la machine.** À une date convenue, il reçoit le pouvoir sur **ses serveurs**, +> et l'hébergeur le perd. + +Ce découpage n'est pas une précaution d'hébergeur : c'est ce qui rend les deux gestes +honnêtes. + +| | Temps 1 | Temps 2 | +|---|---|---| +| Ce qui passe | la clé de **sa** voûte, sa voûte chiffrée, la racine de **son** AC | sa clé SSH entre, celle de l'hébergeur sort, la voûte change de mot de passe, les secrets tournent | +| Ce que le client peut | créer, retirer, habiliter des personnes — sans nous | tout, y compris se passer de nous | +| Ce que l'hébergeur garde | l'accès **machine**, parce qu'il exploite encore | rien qui ne lui soit redonné | +| Quand | le jour de la livraison | à l'échéance inscrite (30 jours par défaut) | + +Le second temps applique à une livraison ce que +[`migration-tenant.md`](migration-tenant.md) §6 étape 8 applique déjà à un départ : +**révoquer, pas transmettre**. Sans lui, l'hébergeur garde **à vie** l'accès aux secrets +d'un client qui se croit chez lui — et aucune procédure ne rattrape ça après coup. + +## 3. Temps 1 — le paquet + +``` +make ca-racine # la racine de SON AC, et son empreinte +make ca-empreinte # la même, lue SUR l'AC : le témoin à comparer + +make remise-recenser # ce qui partirait, sans rien écrire +make remise-paquet VERS=/media/…/CLE +make remise-inscrire RECU_PAR="Prénom Nom" COURRIEL="…" +``` + +**Lance `remise-paquet` toi-même**, dans ton terminal : `gpg` demande une phrase de passe, +et elle ne doit passer ni par un journal, ni par le contexte d'un assistant. + +L'outil **refuse** quatre choses, et chacune ferme une faute réelle : + +- **une destination dans l'infrastructure** — le dépôt de sauvegarde est chiffré par un + mot de passe qui vit dans la voûte que ce paquet ouvre ; l'y déposer ferait un coffre + dont la clé est à l'intérieur ; +- **écraser un paquet existant** — c'est peut-être celui qu'on vient de vérifier ; +- **partir sans la racine de l'AC** — sans elle, le client apprend à cliquer sur + « continuer quand même », ce qui vaut pire que pas de TLS du tout ; +- **un paquet qu'il n'arrive pas à rouvrir** — il est alors supprimé. Un paquet de remise + qu'on ne sait pas rouvrir donne le sentiment d'avoir remis. + +Il n'emporte **que l'écosystème monté** : la clé du site et celles des autres locataires +vivent dans le même dossier, et c'est une seule ligne de code qui les en écarte +(`remise.py:_cle_de_voute`). Il n'affiche jamais le contenu d'un secret — noms, tailles, +empreintes SHA256, rien d'autre. + +**Deux gestes restent, et ils n'ont pas d'outil :** transmettre la phrase de passe par un +**autre canal** que le support, et transmettre l'empreinte de l'AC de la même façon. +Séparés, le support et la phrase ne valent rien l'un sans l'autre. + +## 4. Temps 2 — le re-clé + +**L'ordre ne se permute pas.** Retirer sa propre clé avant que celle du client soit posée +ferme l'écosystème à tout le monde, et le seul moyen de le réparer est justement celui +qu'on vient de retirer. + +1. **La clé du client entre** — son entrée dans `ssh_baseline_cles_admin`, `etat: present`. +2. **Celle de l'hébergeur sort** — `etat: absent` sur son entrée. On ne la supprime pas du + plan : une entrée retirée n'est plus appliquée, donc la clé **resterait** sur les + machines. `absent` la fait *retirer*. +3. **Déployer**, pour que le plan devienne l'état des machines. +4. **La voûte change de mot de passe** — `ansible-vault rekey`, la nouvelle clé étant + choisie par le client. Le mot de passe de l'hébergeur ne se *communique* pas. +5. **Les secrets applicatifs tournent** — `voute.py saisir --remplacer`, sans écho, puis + déploiement. +6. **Estampiller** : `make remise-recleer CONFIRMER=true`. + +`remise-recleer` **mesure avant d'estampiller**, et refuse si l'un des trois faits manque : +une clé présente, une clé révoquée, une clé de voûte différente de celle remise au temps 1. +Un registre qui dirait « révoqué » pendant que le plan garde la clé de l'hébergeur serait +le seul mensonge que ce fichier puisse porter sans que personne ne s'en aperçoive — parce +qu'il flatte tout le monde. + +## 5. Le registre — `remise.yml` chez le locataire + +Généré, versionné, dans le dépôt du locataire, à côté de `parente.yml` : *de qui il +descend* d'un côté, *à qui il appartient* de l'autre. + +Il porte l'organisation, la date du temps 1, qui a remis, **qui a reçu**, les empreintes +SHA256 des pièces remises, l'échéance du temps 2 et son constat. **Aucun secret**, par +construction : une empreinte prouve qu'on a remis *ce fichier-là* sans rien révéler de son +contenu. + +> **Il déclare enfin le responsable désigné.** D-18 décide depuis longtemps que chaque +> locataire en a un ; `migration-tenant.md` §9 laissait ouverte la question « **où est-il +> déclaré ?** ». La réponse est ici, et elle est la seule qui ne devine rien : c'est la +> personne qui **reçoit**. + +## 6. La garde + +**P80** lit les registres de tous les écosystèmes et refuse trois états : + +- un registre incomplet — remis à personne, ou sans échéance ; +- un temps 2 **échu** et non fait : l'hébergeur garde l'accès machine d'un client qui se + croit chez lui, et le silence le laisserait devenir un état de fait ; +- un temps 2 déclaré fait pendant que le plan ne révoque **aucune** clé. + +Elle ne juge **pas** un écosystème sans registre : tous ne sont pas remis, et beaucoup ne +le seront jamais — le lab, patient 0, l'écosystème de l'hébergeur lui-même. + +## 7. Ce que cette procédure ne couvre pas + +- **Ce que le client fait de son paquet.** Une clé remise sur un support qu'il laisse + dans un tiroir déverrouillé n'est plus notre affaire, et le LISEZ-MOI le lui dit. +- **La rotation des secrets applicatifs**, qui reste un geste humain : `voute.py` ne + génère pas les valeurs, il les reçoit sans écho. Un script qui engendrerait et écrirait + tout seul connaîtrait ce qu'il écrit. +- **La preuve que l'hébergeur ne peut plus entrer.** Le plan déclare la révocation et le + déploiement l'applique ; le vérifier *depuis l'extérieur* demande d'essayer d'entrer, + donc une machine vivante. C'est le même partage que partout ici : le dépôt prouve ce + qu'il a **demandé**, `make emancipation-prouver` prouve ce qui **tient**. diff --git a/scripts/prouver.py b/scripts/prouver.py index a257108..640fe0e 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1849,8 +1849,10 @@ def preuve_zones_inverses_couvrent_l_occupe() -> tuple[bool, str]: # Ce que la carte nomme a juste titre et que ce depot ne contient pas : le produit de -# `make instancier`, qui vit dans le depot d'un ecosysteme. -ARTEFACTS_GENERES = {"hosts.yml"} +# `make instancier`, qui vit dans le depot d'un ecosysteme. `remise.yml` (2026-09-16) est +# de la meme famille — ecrit par `make remise-inscrire` chez le LOCATAIRE, a cote de +# `parente.yml` : de qui il descend d'un cote, a qui il appartient de l'autre. +ARTEFACTS_GENERES = {"hosts.yml", "remise.yml"} def _sans_accents(texte: str) -> str: @@ -4159,6 +4161,155 @@ def preuve_une_derivation_vide_se_dit() -> tuple[bool, str]: f"d'hote par instance — aucune derivation vide ne passe pour un succes.") +def preuve_remise_tenue() -> tuple[bool, str]: + """Un ecosysteme remis l'est vraiment — et son second temps ne s'oublie pas. + + POURQUOI (2026-09-16). Remettre un ecosysteme se terminait par une phrase — « tes cles + te seront remises separement » — et rien n'ecrivait ce qui se passait ensuite. Le geste + qui donne le controle d'une organisation etait le seul geste lourd du depot sans + procedure, sans outil et sans garde. + + LA REMISE SE FAIT EN DEUX TEMPS (`docs/remise-au-client.md`) : l'IDENTITE d'abord — le + client gouverne ses gens des le premier jour — puis la MACHINE, a une echeance + convenue : sa cle SSH entre, celle de l'hebergeur sort, la voute change de mot de + passe. + + CE QUE CETTE PREUVE REFUSE, et chaque refus ferme un etat qui s'installerait tout seul : + + - un registre INCOMPLET : remis a personne, ou sans echeance. Une remise dont on ne + sait pas a qui elle a ete faite n'est pas une remise, c'est un depot ; + - un second temps ECHU et non fait. C'est le defaut qui se produit par simple + ecoulement du temps : personne ne decide de garder l'acces machine d'un client — + on oublie de le rendre, et le silence transforme l'oubli en etat de fait ; + - un second temps DECLARE FAIT pendant que le plan ne revoque AUCUNE cle + d'administration. C'est le seul mensonge que ce fichier puisse porter sans qu'on + s'en apercoive, parce qu'il flatte tout le monde ; + - un SECRET dans le registre. Il est versionne et pousse sur trois forges : il porte + des empreintes, jamais des valeurs. + + CE QU'ELLE NE JUGE PAS. Un ecosysteme SANS registre : tous ne sont pas remis, et + plusieurs ne le seront jamais — le lab, patient 0, l'ecosysteme de l'hebergeur + lui-meme. Exiger un registre partout ferait du bruit la ou il n'y a rien a tenir. + + ET CE QU'ELLE NE PEUT PAS PROUVER : que l'hebergeur ne puisse PLUS entrer. Le plan + declare la revocation, le deploiement l'applique ; le verifier depuis l'exterieur + demande d'essayer d'entrer, donc une machine vivante. Meme partage que partout ici. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import datetime as _dt2 + + import instances as mod_instances + + # CE QUI DISTINGUE UN SECRET D'UN NOM LONG (mesure du 2026-09-16, sur le premier + # registre sain). Mon premier motif prenait toute chaine de 24 caracteres sans espace + # pour un secret — donc `setops-vault-ops-technolibre`, qui est un NOM DE FICHIER, et + # que le registre doit nommer. La preuve refusait alors la remise la plus normale, et + # ce faux positif masquait les cinq autres verdicts. + # + # Ce qu'on cherche vraiment : de l'ENTROPIE — un melange de casses et de chiffres, ou + # l'alphabet de base64. Un nom de fichier, une organisation, une date et un courriel + # n'en ont aucune ; une empreinte SHA256 est le contenu legitime de ce fichier. + motif_empreinte = re.compile(r"^[0-9a-f]{64}$") + + def _opaque(v: str) -> bool: + if motif_empreinte.match(v) or len(v) < 20: + return False + if not re.fullmatch(r"[A-Za-z0-9+/=_.-]+", v): + return False + melange = (any(c.islower() for c in v) and any(c.isupper() for c in v) + and any(c.isdigit() for c in v)) + return melange or bool(re.fullmatch(r"[A-Za-z0-9+/]{24,}={0,2}", v)) + + def _valeurs(n): + if isinstance(n, dict): + for k, v in n.items(): + yield from _valeurs(k) + yield from _valeurs(v) + elif isinstance(n, list): + for v in n: + yield from _valeurs(v) + elif isinstance(n, str): + yield n + + fautes: list[str] = [] + tenus: list[str] = [] + aujourd_hui = _dt2.date.today() + + for i in mod_instances.decouvrir(): + base = RACINE.parent / i["nom"] + f = base / "remise.yml" + if not f.is_file(): + continue + try: + r = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("remise") or {} + except Exception as e: # noqa: BLE001 + fautes.append(f"{i['nom']} : registre de remise illisible ({type(e).__name__})") + continue + + t1 = r.get("temps_1") or {} + t2 = r.get("temps_2") or {} + recu = (t1.get("recu_par") or {}) + if not t1.get("remis_le"): + fautes.append(f"{i['nom']} : registre sans date de remise") + if not (recu.get("nom") and recu.get("courriel")): + fautes.append(f"{i['nom']} : remis a personne — le responsable designe (D-18) " + f"n'est pas nomme") + if not t2.get("du_le"): + fautes.append(f"{i['nom']} : aucune echeance pour le second temps — la " + f"revocation n'a alors jamais de retard, donc jamais lieu") + + for v in _valeurs(r): + if _opaque(v): + fautes.append(f"{i['nom']} : le registre porte une valeur opaque " + f"({v[:8]}…) — il est versionne et pousse ; il porte des " + f"empreintes, jamais des secrets") + break + + # Les cles d'administration declarees au plan — lues ici, pas demandees a l'outil + # de remise : une garde qui interroge ce qu'elle surveille ne surveille rien. + revoquees = 0 + for nom in ("principal", "production", "lab"): + intrants = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml" + if not intrants.is_file(): + continue + try: + d = yaml.safe_load(intrants.read_text(encoding="utf-8")) or {} + except Exception: # noqa: BLE001 + continue + if "ssh_baseline_cles_admin" in d: + revoquees = sum(1 for e in (d.get("ssh_baseline_cles_admin") or []) + if isinstance(e, dict) and str(e.get("etat", "present")) == "absent") + break + + if t2.get("fait_le"): + if not revoquees: + fautes.append(f"{i['nom']} : le registre dit le second temps fait le " + f"{t2['fait_le']}, et le plan ne revoque AUCUNE cle " + f"d'administration") + tenus.append(f"{i['nom']} (remis et re-clete)") + elif t2.get("du_le"): + try: + echeance = _dt2.date.fromisoformat(str(t2["du_le"])) + except ValueError: + fautes.append(f"{i['nom']} : echeance du second temps illisible " + f"({t2['du_le']})") + continue + if echeance < aujourd_hui: + fautes.append(f"{i['nom']} : second temps DU DEPUIS " + f"{(aujourd_hui - echeance).days} jour(s) ({echeance}) — " + f"l'hebergeur garde l'acces machine d'un client qui se croit " + f"chez lui") + else: + tenus.append(f"{i['nom']} (second temps du le {echeance})") + + if fautes: + return False, ("Remise non tenue :\n - " + "\n - ".join(fautes[:6]) + + ("\n - …" if len(fautes) > 6 else "")) + if not tenus: + return True, "Aucun ecosysteme remis a un client : rien a tenir." + return True, f"{len(tenus)} remise(s) tenue(s) : {', '.join(tenus)}." + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -4337,6 +4488,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls}, {"id": "P79", "titre": "Replis silencieux : une derivation vide ne passe pas pour un succes", "refs": [], "func": preuve_une_derivation_vide_se_dit}, + {"id": "P80", "titre": "Remise au client : inscrite, nommee, et son second temps a l'heure", + "refs": [], "func": preuve_remise_tenue}, ] diff --git a/scripts/remise.py b/scripts/remise.py new file mode 100644 index 0000000..d8669a0 --- /dev/null +++ b/scripts/remise.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""Remet un ecosysteme a son proprietaire — le paquet, le registre, et la garde. + +POURQUOI CE SCRIPT EXISTE (2026-09-16). Livrer un ecosysteme se terminait par une phrase : +« tes cles te seront remises separement ». Ce qui se passait ensuite n'etait ecrit nulle +part — ni ce qu'on remet, ni dans quel ordre, ni ce qu'on garde. Un geste qui donne le +controle d'une organisation ne peut pas etre le seul geste du depot sans procedure, sans +outil et sans preuve. + +LA REMISE SE FAIT EN DEUX TEMPS, et ce decoupage est tout le sujet. + + TEMPS 1 — l'IDENTITE. Le client recoit de quoi gouverner ses gens tout de suite : la + cle de SA voute, sa voute chiffree, la racine de SON autorite. Des la premiere + connexion il cree, retire et habilite sans nous. L'hebergeur garde l'acces MACHINE, + parce qu'il exploite encore. + + TEMPS 2 — la MACHINE. A une date convenue : la cle SSH du client entre au plan, celle + de l'hebergeur en sort, la voute change de mot de passe, les secrets applicatifs + tournent. C'est l'etape 8 de `docs/migration-tenant.md` — « revoquer, pas + transmettre » — appliquee a une livraison plutot qu'a un depart. + +CE QUE CE SCRIPT NE FAIT JAMAIS : + + - il n'affiche AUCUNE valeur de secret. Ni a l'ecran, ni dans le registre, ni dans un + journal : seulement des noms, des tailles et des empreintes SHA256 ; + - il n'emporte QUE l'ecosysteme monte. La cle du SITE et celles des autres locataires + vivent dans le meme dossier `~/.config` ; un motif large les embarquerait toutes, et + remettre a un client les secrets de ses voisins serait la faute que cette procedure + existe pour rendre impossible ; + - il ne fait pas tourner les secrets a la place d'un humain. Une valeur se saisit par + `voute.py saisir --remplacer`, sans echo. Un script qui engendre et ecrit tout seul + connait ce qu'il ecrit ; + - il ne DECLARE pas le temps 2 fait parce qu'on le lui dit. Il le MESURE dans le plan : + la cle du client presente, celle de l'hebergeur absente, la cle de voute changee. + +Usage : + python3 scripts/remise.py recenser # ce qui partirait, sans rien ecrire + python3 scripts/remise.py paquet --vers # temps 1 : fabrique et relit + python3 scripts/remise.py inscrire --recu-par "…" --courriel "…" [--dans N_JOURS] + python3 scripts/remise.py verifier # etat de la remise (code de sortie) + python3 scripts/remise.py recleer --confirmer # temps 2 : mesure, puis estampille +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import os +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE / "scripts")) + +from inventory_rules import ecriture_atomique, instance_courante # noqa: E402 +from voutes import voutes # noqa: E402 + +MAISON = Path.home() +FICHIER_REMISE = "remise.yml" + +# Le delai par defaut du second temps. Ni une regle de droit ni un caprice : un mois laisse +# au client le temps d'ouvrir son compte et de vivre avec son ecosysteme, et reste assez +# court pour qu'une revocation oubliee se voie encore comme un oubli plutot que comme un +# etat de fait. +DELAI_TEMPS2_JOURS = 30 + + +# --- Ce qui appartient a CET ecosysteme, et rien d'autre ----------------------------- + +def _instance() -> Path: + base = instance_courante() + if not base.exists(): + raise SystemExit("REFUS : aucune instance montee (symlink `instance/` ou " + "SETOPS_INSTANCE). Une remise vise UN ecosysteme, jamais la flotte.") + return base.resolve() + + +def _cle_de_voute() -> Path: + """La cle de la voute de l'instance MONTEE — jamais celle du site ni d'un voisin. + + `voutes()` nomme les trois roles (instance, hebergeur, voisin) ; on ne retient que le + premier. C'est la seule ligne qui empeche une remise d'emporter la flotte. + """ + for role, _nom, chemin in voutes(): + if role == "instance": + if not chemin.is_file(): + raise SystemExit( + f"REFUS : la cle de voute de cet ecosysteme est absente ({chemin}).\n" + " Sans elle, le client recevrait une voute qu'il ne peut pas ouvrir.") + return chemin + raise SystemExit("REFUS : aucune voute d'instance nommee (`voutes.py etat`).") + + +def _voute_chiffree(base: Path) -> Path | None: + for nom in ("principal", "production", "lab"): + p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml" + if p.is_file(): + return p + return None + + +def _racine_ac() -> Path | None: + """La racine de l'AC, si elle a ete recuperee. PUBLIQUE — elle voyage en clair. + + On ne la va PAS la chercher soi-meme : `make ca-racine` interroge une machine vivante + et affiche l'empreinte a comparer. Un script de remise qui l'irait chercher en silence + priverait l'operateur du seul geste qui distingue sa racine d'une racine interceptee. + """ + for p in (Path.cwd() / "root_ca.crt", RACINE / "root_ca.crt"): + if p.is_file(): + return p + return None + + +def _empreinte(f: Path) -> str: + return hashlib.sha256(f.read_bytes()).hexdigest() + + +def _recenser(base: Path) -> list[tuple[Path, str, str]]: + """(fichier, nature, raison). `nature` vaut `secret` ou `public`.""" + pieces: list[tuple[Path, str, str]] = [ + (_cle_de_voute(), "secret", + "La cle de SA voute — ce qui ouvre les secrets de CET ecosysteme, et d'aucun autre."), + ] + v = _voute_chiffree(base) + if v: + pieces.append((v, "chiffre", + "Sa voute, telle qu'elle est versionnee : chiffree, inutile sans la cle.")) + ca = _racine_ac() + if ca: + pieces.append((ca, "public", + "La racine de SON autorite de certification — publique, a installer.")) + return pieces + + +# --- Le registre : ce qui a ete remis, a qui, et ce qui reste du ---------------------- + +def _chemin_registre(base: Path) -> Path: + return base / FICHIER_REMISE + + +def _lire_registre(base: Path) -> dict: + p = _chemin_registre(base) + if not p.is_file(): + return {} + return (yaml.safe_load(p.read_text(encoding="utf-8")) or {}).get("remise") or {} + + +def _ecrire_registre(base: Path, remise: dict) -> Path: + entete = ( + "# REMISE de cet ecosysteme — a qui il a ete remis, et ce qui reste du.\n" + "#\n" + "# GENERE par `make remise-inscrire` / `make remise-recleer`. Ne pas editer a la\n" + "# main : reinscrire.\n" + "#\n" + "# IL NE PORTE AUCUN SECRET, par construction : des noms, des tailles et des\n" + "# empreintes SHA256. Une empreinte prouve qu'on a remis CE fichier-la sans rien\n" + "# reveler de son contenu.\n" + "#\n" + "# Le second temps — `recleer` — n'est pas une formalite : tant qu'il n'a pas eu\n" + "# lieu, l'hebergeur garde l'acces machine d'un client qui, lui, se croit chez lui.\n" + "---\n") + cible = _chemin_registre(base) + with ecriture_atomique(cible) as f: + f.write(entete) + yaml.safe_dump({"remise": remise}, f, default_flow_style=False, + sort_keys=False, allow_unicode=True) + return cible + + +def _organisation(base: Path) -> str: + for nom in ("principal", "production", "lab"): + f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml" + if f.is_file(): + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + if d.get("organisation"): + return str(d["organisation"]) + return base.name + + +# --- Le plan : ce qu'il dit des cles d'administration -------------------------------- + +def _cles_admin_du_plan(base: Path) -> list[dict]: + for nom in ("principal", "production", "lab"): + f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml" + if f.is_file(): + d = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + if "ssh_baseline_cles_admin" in d: + return list(d.get("ssh_baseline_cles_admin") or []) + return [] + + +def _etat_des_cles(base: Path) -> tuple[list[str], list[str]]: + """(presentes, absentes) — les raisons declarees, pas les cles elles-memes.""" + presentes, absentes = [], [] + for e in _cles_admin_du_plan(base): + if not isinstance(e, dict): + continue + etiquette = str(e.get("pourquoi") or str(e.get("cle", ""))[:40]) + (absentes if str(e.get("etat", "present")) == "absent" else presentes).append(etiquette) + return presentes, absentes + + +# --- Les commandes -------------------------------------------------------------------- + +def cmd_recenser(_args) -> int: + base = _instance() + pieces = _recenser(base) + print(f" REMISE DE : {_organisation(base)} ({base.name})\n") + for f, nature, raison in pieces: + print(f" [{nature}] {raison}") + print(f" {f.name:<44} {f.stat().st_size:>6} o {_empreinte(f)[:16]}…") + if not _racine_ac(): + print("\n MANQUE la racine de l'AC (`root_ca.crt`) — elle est PUBLIQUE et le") + print(" client en a besoin pour que son navigateur cesse de crier :") + print(" make ca-racine puis make ca-empreinte (a comparer)") + print("\n CE QUI NE PART PAS, ET C'EST VOULU : la cle du SITE, celles des autres") + print(" locataires, et les cles SSH de l'hebergeur. Elles vivent dans le meme") + print(" dossier ; seul l'ecosysteme monte est emporte.") + print("\n RECENSEMENT SEUL — rien n'a ete ecrit.") + return 0 + + +def _refuser_destination_interne(dest: Path) -> None: + """Une remise DANS l'infrastructure ne remet rien : elle boucle. + + Meme refus que `exporter_cles.py`, et pour la meme raison : le depot de sauvegarde est + chiffre par un mot de passe qui vit dans la voute que ce paquet ouvre. + """ + for i in (MAISON / "Espace Chezlepro" / "DépôtsSurForge", Path("/srv/restic")): + try: + dest.resolve().relative_to(i.resolve()) + except (ValueError, OSError): + continue + raise SystemExit( + f"REFUS : {dest} est DANS l'infrastructure ({i}).\n" + " Un paquet de remise se depose sur un support remis en main propre,\n" + " pas dans les depots que ce meme paquet permet d'ouvrir.") + + +LISEZ_MOI = """PAQUET DE REMISE — VOTRE ECOSYSTEME +=================================== + +Ce support porte ce qui vous rend maitre de votre ecosysteme. Il ne contient AUCUN +mot de passe en clair : la phrase de passe de l'archive vous est transmise par un +autre canal, et sans elle ce support est du bruit. + +OUVRIR L'ARCHIVE + + gpg --decrypt setops-remise-*.tar.gpg > remise.tar + tar -xvf remise.tar + +CE QUE VOUS Y TROUVEZ + + setops-vault- la CLE de votre voute. A ranger dans + ~/.config/ , droits 0600, et NULLE PART ailleurs. + vault.yml votre voute, chiffree. Inutile sans la cle. + root_ca.crt la racine de votre autorite de certification. + PUBLIQUE : elle s'installe, elle ne se cache pas. + +REMETTRE LA CLE EN PLACE + + mkdir -p ~/.config && umask 077 + cp setops-vault-* ~/.config/ + chmod 600 ~/.config/setops-vault-* + +VERIFIER LA RACINE AVANT DE L'INSTALLER + + openssl x509 -in root_ca.crt -noout -subject -fingerprint -sha256 + +Comparez cette empreinte avec celle qui vous a ete transmise separement. Installer une +autorite, c'est lui donner le droit de signer n'importe quel nom pour votre navigateur : +la comparaison est ce qui distingue votre racine d'une racine interceptee. + +CE QUI RESTE A FAIRE, ET QUI N'EST PAS FACULTATIF + + 1. Ouvrir votre compte et changer le mot de passe a usage unique (votre document de + livraison, section 3). + 2. Ranger la phrase de passe de cette archive AILLEURS que sur ce support. Separes, + ils ne valent rien l'un sans l'autre ; ensemble, ils valent votre ecosysteme. + 3. Faire une seconde copie de ce support, dans un autre lieu physique. + +CE QUE VOTRE HEBERGEUR DETIENT ENCORE + +Tant que le SECOND TEMPS de la remise n'a pas eu lieu, votre hebergeur garde l'acces +machine a votre ecosysteme — c'est ce qui lui permet de l'exploiter pour vous. Ce second +temps fait entrer VOTRE cle, sortir la sienne, et change le mot de passe de votre voute. +Sa date est inscrite dans le registre de remise de votre depot (remise.yml). +""" + + +def cmd_paquet(args) -> int: + base = _instance() + 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.") + + pieces = _recenser(base) + if not _racine_ac(): + raise SystemExit( + "REFUS : la racine de l'AC (`root_ca.crt`) n'est pas la.\n" + " Sans elle, le client installe un ecosysteme dont son navigateur refuse\n" + " tous les certificats — et il apprendra a cliquer sur « continuer quand\n" + " meme », ce qui vaut pire que pas de TLS du tout.\n" + " La recuperer : make ca-racine puis make ca-empreinte") + + org = _organisation(base) + attendu = {f.name: _empreinte(f) for f, _, _ in pieces} + print(f" REMISE DE : {org} ({base.name})\n") + for f, nature, _ in pieces: + print(f" [{nature:<7}] {f.name:<40} {_empreinte(f)[:16]}…") + + suffixe = "tar" if args.support_chiffre else "tar.gpg" + archive = dest / f"setops-remise-{base.name.lower()}.{suffixe}" + if archive.exists(): + raise SystemExit( + f"REFUS : {archive} existe deja.\n" + " On n'ecrase pas un paquet de remise : il est peut-etre celui qu'on vient\n" + " de verifier. Le deplacer, ou choisir une autre destination.") + + with tempfile.TemporaryDirectory() as tmp: + brut = Path(tmp) / "remise.tar" + with tarfile.open(brut, "w") as tar: + for f, _, _ in pieces: + tar.add(f, arcname=f.name) + + if args.support_chiffre: + # LE SUPPORT EST DEJA CHIFFRE AU REPOS (LUKS). Meme raisonnement que + # `exporter_cles.py` : la seconde couche ne protege que du vol du support, que + # LUKS couvre deja, au prix d'une phrase de passe stockee nulle part. Le choix + # reste EXPLICITE : le silence ne doit jamais pencher du cote de la divulgation. + brut.replace(archive) + relu_noms = set(tarfile.open(archive).getnames()) + manquants = [n for n in attendu if n not in relu_noms] + if manquants: + archive.unlink(missing_ok=True) + raise SystemExit(f"ECHEC de la relecture — paquet SUPPRIME. " + f"Manquent : {', '.join(manquants)}") + print(f"\n Ecrit EN CLAIR : {archive}") + print(" LA PROTECTION EST CELLE DU SUPPORT — demonte-le quand il ne sert pas.") + else: + print("\n Une phrase de passe va etre demandee (deux fois : ecriture, puis") + print(" verification). Elle n'est stockee NULLE PART — elle se transmet au") + print(" client par un AUTRE canal que 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 (D-68). Un paquet qu'on n'a pas rouvert n'est pas un + # paquet : c'est un fichier dont on espere quelque chose. Et celui-la, on ne + # le rouvrira qu'une fois chez le client, quand il sera trop tard pour refaire. + 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 — le paquet a ete SUPPRIME.\n" + " Un paquet de remise qu'on ne sait pas rouvrir est pire que rien :\n" + " il donne le sentiment d'avoir remis.") + 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 du paquet") + continue + membre = tar.extractfile(nom) + if (hashlib.sha256(membre.read()).hexdigest() if membre else "") != emp: + ecarts.append(f"{nom} : empreinte differente") + for nom in noms: + if nom not in attendu: + ecarts.append(f"{nom} : PRESENT en trop dans le paquet") + if ecarts: + archive.unlink(missing_ok=True) + print(" ECARTS — le paquet a ete SUPPRIME :", file=sys.stderr) + for e in ecarts: + print(f" ! {e}", file=sys.stderr) + return 1 + + (dest / "LISEZ-MOI-REMISE.txt").write_text(LISEZ_MOI, encoding="utf-8") + try: + archive.chmod(0o600) + acces = "0600" + except OSError: + acces = "support sans droits Unix (FAT/exFAT) — c'est le chiffrement qui protege" + + print(f" VERIFIE : {len(attendu)} piece(s) relues, empreintes identiques.") + print(f" Paquet : {archive} ({acces})") + print(f" + {dest / 'LISEZ-MOI-REMISE.txt'}") + print("\n IL RESTE DEUX GESTES, et le second n'a pas d'outil :") + print(" 1. inscrire la remise : make remise-inscrire RECU_PAR=\"…\" COURRIEL=\"…\"") + print(" 2. transmettre la phrase de passe par un AUTRE canal que ce support.") + return 0 + + +def cmd_inscrire(args) -> int: + base = _instance() + pieces = _recenser(base) + ancien = _lire_registre(base) + aujourd_hui = dt.date.today() + echeance = aujourd_hui + dt.timedelta(days=int(args.dans)) + + remise = { + "organisation": _organisation(base), + "ecosysteme": base.name, + "temps_1": { + "remis_le": aujourd_hui.isoformat(), + "remis_par": args.remis_par or os.environ.get("USER", "inconnu"), + "recu_par": {"nom": args.recu_par, "courriel": args.courriel}, + "pieces": [{"fichier": f.name, "nature": nature, + "empreinte_sha256": _empreinte(f)} for f, nature, _ in pieces], + }, + # LE RESPONSABLE DESIGNE (D-18) EST DECLARE ICI, ET NULLE PART AILLEURS. + # `docs/migration-tenant.md` le decide depuis longtemps et laisse ouverte la + # question « ou est-il declare ». La remise est le moment ou la reponse existe : + # c'est la personne qui RECOIT, et la nommer ailleurs qu'au moment de la remise + # reviendrait a la deviner. + "responsable_designe": {"nom": args.recu_par, "courriel": args.courriel}, + "temps_2": { + "du_le": echeance.isoformat(), + "fait_le": (ancien.get("temps_2") or {}).get("fait_le"), + "gestes": [ + "la cle SSH du client entre au plan (`etat: present`)", + "celle de l'hebergeur en sort (`etat: absent`), et le deploiement l'applique", + "la voute change de mot de passe (`ansible-vault rekey`), cle chez le client", + "les secrets applicatifs tournent (`voute.py saisir --remplacer`), puis deploiement", + ], + }, + } + cible = _ecrire_registre(base, remise) + print(f"{cible} inscrit — {len(pieces)} piece(s) remises a {args.recu_par}.") + print(f" Second temps du le {echeance.isoformat()} " + f"({args.dans} jours). `make remise-verifier` le rappellera.") + return 0 + + +def _verdict(base: Path) -> tuple[int, list[str]]: + r = _lire_registre(base) + if not r: + return 0, [f"{base.name} : aucune remise inscrite — cet ecosysteme n'a pas ete remis."] + lignes, code = [], 0 + t1 = r.get("temps_1") or {} + t2 = r.get("temps_2") or {} + lignes.append(f"{r.get('organisation', base.name)} — temps 1 remis le " + f"{t1.get('remis_le', '?')} a {(t1.get('recu_par') or {}).get('nom', '?')}") + + presentes, absentes = _etat_des_cles(base) + if t2.get("fait_le"): + lignes.append(f" temps 2 : fait le {t2['fait_le']}") + # LA CONTRADICTION QU'ON CHERCHE : un registre qui dit « revoque » pendant que le + # plan garde la cle de l'hebergeur. C'est le seul mensonge que ce fichier puisse + # porter sans qu'on s'en apercoive, parce qu'il flatte tout le monde. + if not absentes: + code = 1 + lignes.append(" CONTRADICTION : le registre dit le temps 2 fait, et le plan " + "ne revoque AUCUNE cle d'administration.") + else: + du = t2.get("du_le") + if du and dt.date.fromisoformat(str(du)) < dt.date.today(): + code = 1 + retard = (dt.date.today() - dt.date.fromisoformat(str(du))).days + lignes.append(f" temps 2 : DU DEPUIS {retard} jour(s) ({du}) — l'hebergeur garde " + f"l'acces machine d'un client qui se croit chez lui.") + else: + lignes.append(f" temps 2 : du le {du or '?'} — pas encore fait.") + lignes.append(f" cles d'administration au plan : {len(presentes)} presente(s), " + f"{len(absentes)} revoquee(s)") + return code, lignes + + +def cmd_verifier(_args) -> int: + base = _instance() + code, lignes = _verdict(base) + for l in lignes: + print(l) + return code + + +def cmd_recleer(args) -> int: + """Le second temps : on MESURE le plan, puis on estampille. Jamais l'inverse.""" + base = _instance() + r = _lire_registre(base) + if not r: + raise SystemExit("REFUS : aucune remise inscrite — le second temps n'a rien a suivre.\n" + " Commencer par `make remise-inscrire`.") + presentes, absentes = _etat_des_cles(base) + cle_voute = _cle_de_voute() + empreinte_inscrite = next( + (p.get("empreinte_sha256") for p in ((r.get("temps_1") or {}).get("pieces") or []) + if p.get("fichier") == cle_voute.name), None) + + manques = [] + if not absentes: + manques.append("aucune cle d'administration n'est declaree `etat: absent` — celle " + "de l'hebergeur doit sortir du plan, puis le deploiement l'applique") + if not presentes: + manques.append("aucune cle d'administration `present` — le client doit avoir la " + "sienne AVANT qu'on retire la notre, sinon plus personne n'entre") + if empreinte_inscrite and _empreinte(cle_voute) == empreinte_inscrite: + manques.append("la cle de voute est IDENTIQUE a celle remise au temps 1 — la voute " + "n'a pas ete re-cletee (`ansible-vault rekey`)") + + if manques: + print(" LE SECOND TEMPS N'EST PAS FAIT — rien n'a ete estampille :", file=sys.stderr) + for m in manques: + print(f" ! {m}", file=sys.stderr) + print("\n L'ordre, et il ne se permute pas : la cle du client entre, puis la " + "notre sort,\n puis la voute est re-cletee, puis les secrets tournent, puis " + "on deploie.\n Voir docs/remise-au-client.md §4.", file=sys.stderr) + return 1 + + if not args.confirmer: + print(" Tout est mesure comme fait. Estampiller le registre :") + print(" make remise-recleer CONFIRMER=true") + return 0 + + r.setdefault("temps_2", {})["fait_le"] = dt.date.today().isoformat() + r["temps_2"]["constate"] = { + "cles_presentes": presentes, + "cles_revoquees": absentes, + "cle_de_voute_changee": True, + } + cible = _ecrire_registre(base, r) + print(f"{cible} — second temps estampille le {r['temps_2']['fait_le']}.") + print(" L'hebergeur n'a plus d'acces machine declare a cet ecosysteme.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="commande", required=True) + sub.add_parser("recenser", help="Ce qui partirait, sans rien ecrire.") + + pp = sub.add_parser("paquet", help="Temps 1 : fabrique le paquet, et le relit.") + pp.add_argument("--vers", required=True, help="Repertoire de destination (support remis).") + pp.add_argument("--support-chiffre", action="store_true", + help="Le support est DEJA chiffre au repos (LUKS) : tar clair.") + + pi = sub.add_parser("inscrire", help="Inscrit la remise dans le depot du locataire.") + pi.add_argument("--recu-par", required=True, help="Qui recoit — le responsable designe.") + pi.add_argument("--courriel", required=True, help="Son courriel.") + pi.add_argument("--remis-par", default=None, help="Qui remet (defaut : $USER).") + pi.add_argument("--dans", default=str(DELAI_TEMPS2_JOURS), + help=f"Jours avant le second temps (defaut : {DELAI_TEMPS2_JOURS}).") + + sub.add_parser("verifier", help="Etat de la remise (code de sortie).") + pr = sub.add_parser("recleer", help="Temps 2 : mesure le plan, puis estampille.") + pr.add_argument("--confirmer", action="store_true", help="Estampiller le registre.") + + args = ap.parse_args(argv) + return { + "recenser": cmd_recenser, "paquet": cmd_paquet, "inscrire": cmd_inscrire, + "verifier": cmd_verifier, "recleer": cmd_recleer, + }[args.commande](args) + + +if __name__ == "__main__": + raise SystemExit(main())