remise au client : deux temps, un outil, une garde

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 11:16:27 -04:00
parent 85c7f9710b
commit 0cbb9fdb4a
9 changed files with 1072 additions and 6 deletions

View file

@ -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.**

View file

@ -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

View file

@ -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/<toi>/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.<domaine>/<proprio>/<depot>.wiki.git'
@ -621,6 +631,35 @@ cles-restaurer: ## Remet les cles en place depuis une archive — ARCHIVE=<fichi
python3 scripts/restaurer_cles.py "$(ARCHIVE)" $(if $(FORCER),--forcer)
.PHONY: cles-compagnons cles-restaurer
remise-recenser: ## Ce qu'une remise au client emporterait, sans rien ecrire
python3 scripts/remise.py recenser
remise-paquet: ## Temps 1 : fabrique le paquet de remise, chiffre et relu — VERS=<repertoire>
@# A LANCER SOI-MEME, PAS PAR UN AGENT. `gpg` demande une phrase de passe : elle ne
@# doit passer ni par un journal, ni par le contexte d'un assistant — et c'est elle
@# qui voyage par un AUTRE canal que le support.
@if [ -z "$(VERS)" ]; then \
printf 'Refus: relancer avec VERS=<repertoire de destination>.\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

View file

@ -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._

View file

@ -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é | `<rôle>_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 |

View file

@ -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.

133
docs/remise-au-client.md Normal file
View file

@ -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**.

View file

@ -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},
]

570
scripts/remise.py Normal file
View file

@ -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 <repertoire> # 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-<votre-ecosysteme> 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())