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:
parent
85c7f9710b
commit
0cbb9fdb4a
9 changed files with 1072 additions and 6 deletions
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
54
CHANGELOG.md
54
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
|
||||
|
|
|
|||
39
Makefile
39
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/<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
|
||||
|
|
|
|||
116
docs/audit/preuve-2026-09-16.md
Normal file
116
docs/audit/preuve-2026-09-16.md
Normal 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 | [0m[0m |
|
||||
| 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._
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
133
docs/remise-au-client.md
Normal 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**.
|
||||
|
|
@ -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
570
scripts/remise.py
Normal 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())
|
||||
Loading…
Reference in a new issue