forge : chaque locataire publie la cle qui lira son depot, le site sait l'enregistrer

Debut du chantier des depots prives (docs/depots-prives-conception.md). La face reseau
publie genome: {depot, cle_lecture} ; P95 exige que la cle soit connue de la flotte, et
l'armement refuse une voute qui en remettrait une autre. make forge-cles enregistre
chaque cle en lecture seule sur le seul depot que la face nomme, retire une cle
perimee, ne touche jamais celle du runner du site. Flux SSH vers la forge du site
depuis les seuls runners des locataires (paire runners_locataires).

Rien n'est encore applique sur le terrain ; les depots restent publics.

95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-11 19:59:07 -04:00
parent aa5e4e0897
commit 9af39b1b60
18 changed files with 613 additions and 20 deletions

View file

@ -1,5 +1,41 @@
# CHANGELOG — Set-OPS
## 2026-10-11 (132) — Chaque locataire publie la clé qui lira son dépôt ; la forge du site peut l'entendre
**95 preuves.** `make verifier` passe. P95 est nouvelle, et `test_forge_cles.py` épingle ce
que la cible écrirait. Rien n'est encore appliqué sur le terrain : ni clé, ni pare-feu, ni
frontière.
### Le début du chantier des dépôts privés
`docs/depots-prives-conception.md` décrit le chantier : sur la forge du site, les dépôts des
locataires et celui du site sont publics, et chaque locataire peut lire le plan de l'autre
(mesuré le 2026-10-11 depuis le runner de Technolibre). Ils deviendront privés ; chaque runner
de locataire lira le sien avec une clé de déploiement en lecture seule. Daniel a retenu les
trois recommandations : clé de déploiement en SSH, sur le `sshd` de la machine de la forge,
puis preuve par une reconstruction complète de Technolibre. Cette entrée pose ce qu'il faut
pour éprouver la lecture sur un runner, sans rien basculer.
- **La face publie le dépôt et la clé.** Chaque locataire déclare
`serveur_ops_tenant_cle_publique`, la clé publique de son runner, et sa face réseau publie
`genome: {depot, cle_lecture}`. P95 exige que cette clé soit l'une des clés
d'administration de sa flotte. L'armement refuse une voûte qui remettrait au runner une
autre clé que celle-là. Les deux faces ont été republiées : seul ce bloc s'ajoute.
- **Le site enregistre la clé.** `make forge-cles` lance `scripts/forge_cles.py` sur le runner
du site, qui lit les faces et enregistre chaque clé en lecture seule sur le seul dépôt que la
face nomme. Il retire une clé périmée publiée sous le même titre, et refuse une clé de
lecture enregistrée en écriture. La clé du runner du site n'est jamais touchée. Sans
`CONFIRMER=true`, la cible ne fait que montrer. Elle s'ajoute à l'assistant « Tenir un site
en état ».
- **Le flux est déclaré.** La forge du site accepte le SSH depuis les seuls runners des
locataires hébergés (paire nouvelle `runners_locataires`, lue dans les faces). Le pare-feu de
`site-forge-01` gagne une ligne pour `10.17.19.41` et `10.23.19.41` ; la frontière, une
règle depuis l'alias des runners, déjà employé pour l'insémination.
**Limite.** Les dépôts sont encore publics, et les runners clonent toujours en HTTPS anonyme.
Restent : éprouver la lecture SSH depuis le runner de Technolibre, puis le colis
d'insémination, les remotes SSH, `genome_etat.yml`, la sonde `genome-servi` et la bascule.
## 2026-10-11 (131) — Chaque console dit ce qui l'attend, à un seul endroit
**94 preuves.** `make verifier` passe. `test_portee_console.py` compte deux cas de plus, dont

View file

@ -326,6 +326,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py
python3 scripts/tests/test_portee_console.py
python3 scripts/tests/test_forge_cles.py
python3 scripts/tests/test_devis_placement.py
python3 scripts/tests/test_sonder.py
python3 scripts/tests/test_fraicheur_icinga.py
@ -1379,6 +1380,14 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D
SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
.PHONY: forge-cles
forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lecture que sa face publie — CONFIRMER=true pour ecrire
@set -e; \
voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \
supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS)
.PHONY: depots-perimes
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true
@# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (94 OK · 0 echec · 0 saute)
- **Verdict** : ✅ CONFORME (95 OK · 0 echec · 0 saute)
## Preuves
@ -21,7 +21,7 @@
| 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 : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 122 flux, schéma + matrice OK. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 123 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. |
@ -43,33 +43,33 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, 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, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 78 scripts expliques et atteignables, 151 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (148 cle(s) declaree(s) par l'instance). |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 79 scripts expliques et atteignables, 152 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (149 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 50 document(s) declarent leur lecteur (48 genere(s) exempte(s)). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 51 document(s) declarent leur lecteur (48 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 (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (11 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 : 42 role(s) serveur/client tous nommes, 42 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, 97 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 : 74 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 75 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 3 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal). |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 174 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 175 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 102 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 (140 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 277 regles `pass`), tous non consignes et tous motives. |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 103 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 (141 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 278 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 : WAN muet, interfa |
| 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 (94 preuves, 69 roles, 42 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (95 preuves, 69 roles, 42 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 `e9215a2` (publie le 2026-10-07). |
@ -95,7 +95,7 @@
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 145 etapes, 150 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 146 etapes, 151 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. |
| P84 | La fiche du site dit exactement ce que chaque locataire porte | — | ✅ OK | 2 fiche(s) de site : chacune dit exactement ce que son locataire porte (copies, racine, inventaire genere). |
| P85 | La face reseau du locataire dit exactement ce que le site en tire | — | ✅ OK | 2 face(s) reseau : chacune dit exactement ce que le site en tire (decouverte, devis Proxmox, frontiere, DNS public, sauvegarde). |
| P86 | Machine par machine, le locataire et Proxmox admettent les memes entrees | — | ✅ OK | 2 locataire(s) : machine par machine et port par port, nftables et Proxmox admettent les memes sources. |
@ -107,6 +107,7 @@
| P92 | Chaque locataire a publie sa face reseau a jour | — | ✅ OK | 2 locataire(s) : face reseau publiee, a jour. |
| P93 | La face publie, machine par machine, les parametres de clonage de l'inventaire | — | ✅ OK | 26 machine(s) : la face publie exactement les parametres de clonage de l'inventaire. |
| P94 | Creer, raser et placer un locataire par sa face visent les memes machines | — | ✅ OK | 2 locataire(s) : creer, raser et placer visent par la face les memes machines que par l'instance montee. |
| P95 | Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait | — | ✅ OK | 2 locataire(s) : depot et cle de lecture publies, cle connue de leur flotte. |
## Couverture des affirmations ✅ du registre

View file

@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous.
|---|---|---|
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 46 | `docs/*.md` |
| documents | 47 | `docs/*.md` |
| pièces d'audit | 53 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
@ -36,7 +36,7 @@ README de rôles). Cette page comble ces deux trous.
| Sujet | Documents |
|---|---|
| **Autorité / gouvernance** | `AGENTS.md` (source d'autorité), `CLAUDE.md`, `docs/MISE-A-JOUR-CODEX-CLAUDE.md` |
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) |
| **Le modèle (plan)** | `docs/architecture-set-ops.md` (survol) → `docs/plan-et-generation.md` (à fond) → `docs/meta-classe.md` (concept) → `docs/conception-contextes.md` (site et locataire : deux classes, un contrat) → `docs/depots-prives-conception.md` (chaque runner ne lit que ses dépôts ; chantier) |
| **Services, maturité, dette** | `docs/catalogue-services.md` (**la carte de maturité + la cruft y sont déjà**) |
| **Exploitation / VM** | `docs/vm-lifecycle.md`, `docs/procedure-template-debian13-proxmox.md`, `docs/config-proxmox.md`, `docs/nomenclature-vm.md`, `docs/multi-instances.md` |
| **Conceptions de domaine** | `docs/identite-sso.md`, `docs/courriel-conception.md`, `docs/bindings-conception.md`, `docs/dns-interne.md`, `docs/dimensionnement-ressources.md`, `docs/integrations-vm.md` |

View file

@ -0,0 +1,123 @@
# Dépôts privés sur la forge du site — conception du chantier
> **Pour qui :** le mainteneur qui décide de ce chantier puis le conduit, et quiconque se demande
> pourquoi un runner ne lit que ses propres dépôts.
Préparé le 2026-10-11. **Rien n'est changé tant que les décisions D1 à D3 ne sont pas prises.**
## 1. Le constat
Sur la forge du site, l'organisation `genome` porte cinq dépôts, tous **publics** :
| Dépôt | Contenu | Qui en a besoin |
|---|---|---|
| `set-ops-public` | le moteur | tous les runners |
| `set-ops-modeles` | les modèles génériques | le runner du site |
| `site-chezlepro` | la carte de la fabric, le plan du site | le runner du site |
| `ops-chezlepro` | le plan, l'inventaire et la face de Chezlepro | son runner, le runner du site |
| `ops-technolibre` | le plan, l'inventaire et la face de Technolibre | son runner, le runner du site |
Mesuré le 2026-10-11 : depuis le runner de Technolibre, `git ls-remote` sans identifiant lit la
tête de `ops-chezlepro` (`b4fbd85`) et de `site-chezlepro` (`51dae24`). Chaque locataire peut donc
lire le plan de l'autre, et la carte du site. **Aucun n'en a besoin** : chaque runner de locataire
ne clone que le moteur et son propre dépôt ; ce que le site lui donne arrive par la fiche déposée
dans son dépôt.
D'où ça vient : le 2026-09-12, à la première reconstruction du site, les dépôts créés privés ont
cassé le clonage anonyme des runners. Ils sont passés publics, avec l'argument que seuls ceux
qui ont franchi la frontière joignent la forge (`scripts/forge_amorcer.py`). Les autres locataires
en font partie.
## 2. La cible
- **Publics** : `set-ops-public` et `set-ops-modeles`. Ils sont génériques, identiques pour tous.
- **Privés** : `site-chezlepro` et chaque `ops-<locataire>`.
- **Le runner du site** lit et pousse tous les dépôts avec sa clé, déjà enregistrée comme clé de
déploiement en écriture par `genome_pousser.yml`. Mesuré le 2026-10-11 : il lit les cinq dépôts
en SSH, avec une vérification stricte de la clé d'hôte. Rien à créer pour lui.
- **Le runner d'un locataire** lit son seul dépôt, avec une clé de déploiement **en lecture
seule** : la clé publique de son runner, déjà déclarée au plan (`ssh_baseline_cles_admin`) et
dont la privée lui est remise à l'armement depuis sa voûte (`vault_runner_cle_ssh`).
- **Aucun secret ne change de main.** Le locataire publie la clé publique de son runner dans sa
face réseau ; le site l'enregistre sur le dépôt de ce locataire, avec ses propres
identifiants d'administration de la forge (`vault_forgejo_admin`, déjà dans sa voûte).
## 3. Le nœud : l'insémination
Le site amorce le runner d'un locataire **avant** que celui-ci ait reçu sa clé stable (elle
arrive à l'armement, geste humain, après). Pendant l'insémination, le rôle `serveur_ops` clone
pourtant le dépôt du locataire : avec un dépôt privé, ce clonage échoue. Même chose au premier
déploiement du runner du site, dont la clé est neuve.
**Remède proposé : le colis.** Quand un runner ne peut pas encore lire un dépôt privé, le
contrôleur qui joue `serveur_ops` (le runner du site pendant l'insémination, le poste
ailleurs) lui apporte ce dépôt en `git bundle`. Le mécanisme existe déjà : `genome_colis.py` porte
le génome du poste au runner du site. Le runner clone le colis, puis pointe `origin` vers l'URL
SSH de la forge. Ses tirages suivants passent par sa clé, une fois armé.
## 4. Les décisions à prendre
**D1 — Comment le runner d'un locataire lit-il son dépôt ?**
- *Clé de déploiement en SSH (recommandé).* Le runner continue de tirer lui-même ; il garde son
autonomie (émancipation, `make` lancé sur le runner, garde `verifier_genome_a_jour`). Coût : un
flux nouveau, du runner de chaque locataire vers la forge du site, en SSH.
- *Colis seulement.* Aucun flux nouveau, aucune clé de déploiement pour les locataires : le
contrôleur apporte toujours le dépôt. Mais le runner ne tire plus son dépôt lui-même, et sa
garde de génome n'a plus de forge à laquelle se comparer.
**D2 — Sur quelle écoute SSH ?**
- *Le `sshd` de la machine, port 22 (recommandé pour commencer).* Le runner du site et le poste
l'utilisent déjà pour pousser ; l'utilisateur `git` y est contraint par la forge. Le flux
n'ouvre que les adresses des runners de locataires, pas leurs supernets.
- *Le serveur SSH intégré de Forgejo, sur un port dédié.* Il n'expose pas le `sshd` de la
machine, mais sa cohabitation avec le `sshd` existant reste à éprouver avant de s'y fier.
**D3 — La preuve.** Ce chantier touche l'insémination : je propose de le prouver par une
reconstruction complète de Technolibre (raser, créer, inséminer, armer, monter), dépôts
privés, avant de reprendre le lot 3b.
## 5. Ce qui change dans le code
| Pièce | Changement |
|---|---|
| `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` |
| `roles/serveur_ops` | URL SSH et clé du runner pour les dépôts privés ; colis du contrôleur quand le runner ne peut pas encore les lire ; clé d'hôte de la forge épinglée |
| `contexte.py` (face) | la face publie la clé publique de lecture du runner (`genome.cle_lecture`) |
| `contexte.py` (fiche) | la fiche du site publie la clé d'hôte SSH de sa forge, que le runner du locataire épingle |
| nouvelle cible `forge-cles` | le site enregistre, d'après les faces, la clé de lecture de chaque locataire sur son dépôt, et retire celles qui ne sont plus publiées |
| `forge_amorcer.py` | crée les dépôts privés comme privés |
| flux | `serveur_forge_site` : entrée SSH depuis les runners des locataires hébergés (paire nouvelle, résolue depuis les faces) ; `serveur_ops` : sortie correspondante |
| `genome_etat.yml` | lit la forge par SSH ou authentifié : l'API anonyme répondrait 404 pour un dépôt privé |
| `sonde-genome-servi` | compte les dépôts de l'organisation authentifié, sinon elle ne verrait plus que les deux publics |
Inchangés : `sonde-genome-a-jour` (son repère ne compare que `set-ops-public`), le poste (il
pousse déjà en SSH), la console (`etat_forge` lit `origin`, qui deviendra l'URL SSH).
## 6. Les preuves à écrire
- **Hors ligne (`make prouver`)** : chaque dépôt `instance`, `tenant` ou `hebergeur` est déclaré
privé ; chaque face publie une clé de lecture, et cette clé est dans `ssh_baseline_cles_admin`.
- **Sur le terrain (`make forge-etancheite`)**, avec témoins :
- sans identifiant, un dépôt privé refuse et un dépôt public répond ;
- le runner d'un locataire lit son dépôt et **ne lit pas** celui de l'autre ;
- les clés de déploiement de chaque dépôt sont exactement : runner du site en écriture, runner
du locataire en lecture.
- **Par reconstruction** : D3.
## 7. L'ordre de bascule
Chaque étape se vérifie avant la suivante, et la bascule se défait d'un appel par dépôt
(repasser public).
1. **Éprouver** sur la forge du site, sans rien basculer : enregistrer la clé de lecture du
runner de Technolibre sur `ops-technolibre`, ouvrir le flux pour ce seul runner, et mesurer
qu'il lit son dépôt en SSH.
2. **Construire** le code et les preuves (§5, §6) ; `make verifier`.
3. **Déployer sans basculer** : flux et frontière (`make frontiere-appliquer CONFIRMER=true`,
lancé par Daniel), clés de déploiement, remotes SSH sur les trois runners. Les dépôts sont
encore publics : chaque runner doit déjà tirer en SSH.
4. **Basculer** `site-chezlepro`, `ops-chezlepro` et `ops-technolibre` en privé. Mesurer avec
`make forge-etancheite`, puis la veille des trois consoles.
5. **Prouver** par la reconstruction de Technolibre (D3).
6. Reprendre le lot 3b : les demandes vivent alors dans le dépôt de chaque locataire, lisibles
par lui et par le site seulement.

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 94 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 95 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.

View file

@ -45,6 +45,7 @@
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
| `serveur_forge_site` | ingress | 22 | tcp | runners_locataires | ssh | Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui du site. |
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
| `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
@ -133,7 +134,7 @@
- **clair** : 52 flux
- **n-a** : 8 flux
- **ssh** : 8 flux
- **ssh** : 9 flux
- **starttls** : 6 flux
- **tls** : 10 flux
- **tls-cible** : 2 flux

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole :
```
make prouver le dépôt est-il cohérent avec lui-même (94 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (95 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -219,6 +219,12 @@ runbooks:
Deposer chez chaque locataire la fiche que le site lui destine. Son runner n'a pas
le depot du site : c'est par elle que son inventaire se genere sans lui. A commiter
dans le depot de chaque locataire.
- cible: forge-cles
nature: ecriture
fixes: {CONFIRMER: "true"}
pourquoi: >-
Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et
retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive.
- cible: site-verifier
nature: mesure
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"

View file

@ -0,0 +1,43 @@
---
# LE SITE ENREGISTRE LA CLE DE LECTURE DE CHAQUE LOCATAIRE SUR SON DEPOT (2026-10-11).
#
# Voir `docs/depots-prives-conception.md` et `scripts/forge_cles.py`. Le script tourne SUR le
# runner du site : il y lit les faces que le site tire (et rien d'autre de ses locataires), et
# il y joint la forge. Le mot de passe d'administration vient de la voute du site, par
# l'environnement, et n'est jamais affiche.
#
# make forge-cles montre ce qui serait fait
# make forge-cles CONFIRMER=true l'ecrit, puis relit
- name: Les cles de lecture des locataires, sur la forge du site
hosts: serveur_ops_site
gather_facts: false
become: true
become_user: "{{ serveur_ops_utilisateur | default('setops') }}"
vars:
forge_cles_racine: "{{ serveur_ops_racine | default('/opt/setops') }}"
forge_cles_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}"
forge_cles_confirmer: "{{ lookup('env', 'CONFIRMER') == 'true' }}"
tasks:
- name: Exiger le mot de passe d'administration de la forge (voute du site)
ansible.builtin.assert:
that: vault_forgejo_admin | default('') | length > 0
fail_msg: "vault_forgejo_admin absent : la voute du site n'est pas chargee."
no_log: true
- name: Confronter les faces aux cles de la forge
ansible.builtin.command:
argv: "{{ ['python3', 'scripts/forge_cles.py']
+ (['--confirmer'] if (forge_cles_confirmer | bool) else []) }}"
chdir: "{{ forge_cles_racine }}/{{ forge_cles_moteur }}"
environment:
FORGEJO_ADMIN: "{{ vault_forgejo_admin }}"
PATH: "{{ forge_cles_racine }}/venv/bin:/usr/local/bin:/usr/bin:/bin"
# En simulation, le script ne fait que montrer (sans --confirmer) : il peut tourner.
check_mode: false
register: forge_cles
changed_when: "'ajoutee' in forge_cles.stdout or 'retiree' in forge_cles.stdout"
failed_when: forge_cles.rc not in [0]
- name: Ce que la forge porte
ansible.builtin.debug:
msg: "{{ forge_cles.stdout_lines }}"

View file

@ -39,3 +39,22 @@ flux:
# de le demander : seul le registre distingue « j'ouvre » de « j'autorise une source
# de plus sur l'écoute d'un autre ».
partage: true
# LA LECTURE DE SON DEPOT PAR LE RUNNER D'UN LOCATAIRE (2026-10-11,
# `docs/depots-prives-conception.md`). Les depots des locataires deviennent prives : chacun
# se lit avec une cle de deploiement en lecture seule, et une cle de deploiement ne passe
# que par SSH. `runners_locataires` : les seuls runners, lus dans les faces publiees — pas
# les supernets. L'utilisateur `git` de ce `sshd` est contraint par la forge : la cle n'y
# ouvre aucune session, seulement le depot qui l'a enregistree.
- sens: ingress
port: 22
protocole: tcp
pair: runners_locataires
chiffrement: ssh
raison: >-
Chaque runner de locataire lit son propre depot du genome, prive, avec sa cle de
deploiement en lecture seule. Il ne lit ni le depot d'un autre locataire, ni celui
du site.
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le `sshd` du socle, comme
# `serveur_ops_tenant` (voir P33).
partage: true

View file

@ -227,6 +227,23 @@
- serveur_ops_tenant_ssh_cle | length > 0
- not ansible_check_mode
# LA CLE QUI LIT LE DEPOT DU GENOME (2026-10-11). Le site l'enregistre sur le depot de ce
# locataire d'apres sa face (`serveur_ops_tenant_cle_publique`) : si la voute en remettait une
# autre, le runner arme ne pourrait plus tirer son plan, et l'echec viendrait bien plus tard.
- name: Exiger que la cle du runner soit celle que la face publie pour lire le genome
ansible.builtin.assert:
that:
- (serveur_ops_tenant_ssh_publique.stdout.split()[:2] | join(' '))
== (serveur_ops_tenant_cle_publique.split()[:2] | join(' '))
fail_msg: >-
La cle SSH du runner (issue de la voute) n'est PAS `serveur_ops_tenant_cle_publique` :
la forge du site n'autorise que celle-ci a lire le depot de ce locataire. Corriger la
declaration (puis republier la face), ou la voute.
when:
- serveur_ops_tenant_ssh_cle | length > 0
- serveur_ops_tenant_cle_publique | default('') | length > 0
- not ansible_check_mode
- name: Dire qu'aucune identite n'est conservee
ansible.builtin.debug:
msg: >-

View file

@ -406,6 +406,16 @@ class Locataire(Ecosysteme):
sort_keys=False), encoding="utf-8")
return chemin
def genome_publie(self) -> dict:
"""Le depot du genome de ce locataire sur la forge de son site, et la cle publique de
son runner qui le lira (`serveur_ops_tenant_cle_publique`). Lus dans SES variables du
runner — la declaration meme que le role `serveur_ops` clone."""
gv = _yaml(self.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
depot = next((str(d.get("depot")) for d in (gv.get("serveur_ops_depots") or [])
if isinstance(d, dict) and d.get("role") == "instance"), "")
cle = " ".join(str(gv.get("serveur_ops_tenant_cle_publique") or "").split()[:2])
return {"depot": depot, "cle_lecture": cle}
def face_publiee(self) -> dict | None:
"""La face reseau qu'il a publiee, ou None."""
return _yaml(self.depot / NOM_FICHIER_FACE_RESEAU) or None
@ -577,6 +587,12 @@ class Locataire(Ecosysteme):
if isinstance(c, dict)}},
"dns_public": dns_public,
"sauvegarde": {"cle_publique": cle_sauvegarde},
# SON DEPOT DU GENOME, ET LA CLE QUI LE LIT (2026-10-11,
# `docs/depots-prives-conception.md`). Le depot devient prive sur la forge du site ;
# le site enregistre cette cle publique comme cle de deploiement EN LECTURE sur ce
# seul depot. Une cle publique, rien de secret : la privee vit dans la voute du
# locataire et n'arrive au runner qu'a l'armement.
"genome": self.genome_publie(),
# Ses valeurs de placement (noeud, stockage, pont par defaut, gabarit, options de
# clone) : non sensibles, les jetons vivent dans sa voute. Le devis du placement les
# confronte au cluster.

View file

@ -971,7 +971,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
alias["SETOPS_RUNNERS_TENANTS"] = {
"type": "host",
"contenu": _runners_tenants,
"description": "Runners des tenants heberges — cible de l'insemination",
"description": "Runners des tenants heberges — insemination, lecture de leur depot",
}
# LES AUTORITATIFS DES LOCATAIRES — la source du DNS public du site (2026-09-16).
@ -1035,6 +1035,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# parce qu'une regle porte une source.
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
for _n2, p2, n2 in tenants]
elif _entrant and "runners_locataires" in _pairs_fl:
# LES SEULS RUNNERS DES LOCATAIRES (2026-10-11) : chacun lit son depot
# du genome en SSH. Ils entrent, comme tout locataire, par le lien de
# TRANSIT ; la source est l'alias de leurs adresses, pas un supernet.
# Aucun runner declare, aucune regle — jamais le repli.
if not _runners_tenants:
continue
_portees = [("SETOPS_RUNNERS_TENANTS", if_transit)]
elif _entrant and "flotte" in _pairs_fl:
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
#

191
scripts/forge_cles.py Normal file
View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Le site enregistre, sur le depot de chaque locataire, la cle qui le lira. Rien d'autre.
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Les depots des locataires deviennent
prives sur la forge du site. Chaque runner de locataire lit LE SIEN avec une cle de deploiement
EN LECTURE SEULE : la cle publique que sa face reseau publie (`genome.cle_lecture`). Le site
l'enregistre avec ses propres identifiants d'administration de la forge ; aucun secret ne passe
du locataire au site, ni l'inverse.
CE QUE LE SCRIPT TOUCHE, EXACTEMENT. Les seules cles de deploiement intitulees
`lecture — runner de <locataire>`, sur le seul depot que la face nomme :
- absente -> ajoutee, en lecture seule ;
- perimee (autre cle sous le meme titre) -> retiree : une cle qui n'est plus publiee est une
revocation, la garder ouvrirait le depot a un runner disparu ;
- presente en ECRITURE -> refus : une cle de lecture qui pourrait pousser n'est pas a corriger
en silence.
La cle du runner du SITE (`runner du site — ...`, posee par `genome_pousser.yml`) n'est jamais
touchee. Seuls les locataires dont la face nomme CE site comme hebergeur sont traites.
Lance sur le runner du site (il porte les faces et joint la forge), par `make forge-cles`.
Le mot de passe d'administration arrive par l'environnement (`FORGEJO_ADMIN`), depuis la voute
du site ; il n'est jamais affiche.
Usage :
FORGEJO_ADMIN=... python3 scripts/forge_cles.py # montre ce qui serait fait
FORGEJO_ADMIN=... python3 scripts/forge_cles.py --confirmer
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
PREFIXE_TITRE = "lecture — runner de "
def titre_de(locataire: str) -> str:
return f"{PREFIXE_TITRE}{locataire}"
def _deux(cle: str) -> str:
"""Le type et le corps d'une cle publique : le commentaire ne l'identifie pas."""
return " ".join(str(cle or "").split()[:2])
def demandes(site_nom: str | None = None) -> list[dict]:
"""(locataire, depot, cle) de chaque locataire dont la face nomme ce site hebergeur."""
import contexte as _ctx
sortie = []
for site, loc in _ctx.couples_hebergeur():
if site_nom and site.nom != site_nom:
continue
face = loc.face_publiee() or {}
if face.get("hebergeur") != site.nom:
continue
g = face.get("genome") or {}
sortie.append({"locataire": loc.nom, "depot": str(g.get("depot") or ""),
"cle": _deux(g.get("cle_lecture"))})
return sortie
def plan(voulues: list[dict], existantes: dict[str, list[dict]]) -> tuple[list, list, list]:
"""Ce qu'il faut ajouter, retirer, et refuser. Pur : aucun appel a la forge.
`existantes` : depot -> cles de deploiement telles que l'API les rend
({id, title, key, read_only}).
"""
ajouter, retirer, refus = [], [], []
for v in voulues:
if not v["depot"] or not v["cle"]:
refus.append(f"{v['locataire']} : sa face ne publie ni depot ni cle de lecture")
continue
cles = existantes.get(v["depot"], [])
titre = titre_de(v["locataire"])
meme_cle = [k for k in cles if _deux(k.get("key")) == v["cle"]]
if any(not k.get("read_only", True) for k in meme_cle):
refus.append(f"{v['locataire']} : sa cle de lecture est enregistree EN ECRITURE sur "
f"{v['depot']} — a retirer a la main, apres verification")
continue
if not meme_cle:
ajouter.append(v)
for k in cles:
if k.get("title") == titre and _deux(k.get("key")) != v["cle"]:
retirer.append({**v, "id": k.get("id")})
return ajouter, retirer, refus
class Forge:
def __init__(self, base: str, organisation: str, mot_de_passe: str,
utilisateur: str = "forgejo-admin"):
self.base = base.rstrip("/")
self.org = organisation
self.auth = base64.b64encode(f"{utilisateur}:{mot_de_passe}".encode()).decode()
self.ctx = ssl.create_default_context()
def _api(self, chemin: str, methode: str = "GET", corps: dict | None = None):
req = urllib.request.Request(f"{self.base}/api/v1{chemin}", method=methode,
data=json.dumps(corps).encode() if corps is not None else None)
req.add_header("Authorization", f"Basic {self.auth}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=20, context=self.ctx) as r:
texte = r.read().decode() or "null"
return r.status, json.loads(texte)
except urllib.error.HTTPError as e:
return e.code, (e.read().decode(errors="replace") or "")[:200]
def cles(self, depot: str) -> list[dict]:
st, rep = self._api(f"/repos/{self.org}/{depot}/keys?limit=50")
if st != 200:
raise RuntimeError(f"lecture des cles de {depot} : HTTP {st} {rep}")
return rep or []
def ajouter(self, depot: str, titre: str, cle: str) -> int:
return self._api(f"/repos/{self.org}/{depot}/keys", "POST",
{"title": titre, "key": cle, "read_only": True})[0]
def retirer(self, depot: str, ident: int) -> int:
return self._api(f"/repos/{self.org}/{depot}/keys/{ident}", "DELETE")[0]
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--confirmer", action="store_true")
ap.add_argument("--forge", default=os.environ.get("SETOPS_FORGE_URL", ""),
help="https://forge.<domaine> (defaut : derive du plan du site)")
ap.add_argument("--organisation", default="genome")
ap.add_argument("--site", help="ne traiter que les locataires de ce site")
a = ap.parse_args(argv)
mdp = os.environ.get("FORGEJO_ADMIN", "")
if not mdp:
print("REFUS : FORGEJO_ADMIN absent (mot de passe d'administration de la forge, voute du site).",
file=sys.stderr)
return 2
base = a.forge
if not base:
import underlay as underlay_mod
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
if not dom:
print("REFUS : ni --forge ni `domaine_interne` au plan du site.", file=sys.stderr)
return 2
base = f"https://forge.{dom}"
voulues = demandes(a.site)
if not voulues:
print("Aucun locataire ne nomme ce site hebergeur : rien a enregistrer.")
return 0
forge = Forge(base, a.organisation, mdp)
existantes = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]}
ajouter, retirer, refus = plan(voulues, existantes)
for v in voulues:
etat = ("a ajouter" if v in ajouter else "refus" if any(v["locataire"] in r for r in refus)
else "deja enregistree")
print(f" {v['locataire']:<18} {v['depot']:<18} {v['cle'][:32]}… {etat}")
for x in retirer:
print(f" {x['locataire']:<18} {x['depot']:<18} cle perimee #{x['id']} a retirer")
for r in refus:
print(f" REFUS {r}")
if not a.confirmer:
print("\nRien n'a ete ecrit. Relancer avec --confirmer (make forge-cles CONFIRMER=true).")
return 1 if refus else 0
echecs = 0
for x in retirer:
st = forge.retirer(x["depot"], x["id"])
echecs += st not in (204, 404)
print(f" retiree {x['depot']} #{x['id']} : HTTP {st}")
for v in ajouter:
st = forge.ajouter(v["depot"], titre_de(v["locataire"]), v["cle"])
echecs += st not in (201,)
print(f" ajoutee {v['depot']} ({v['locataire']}) : HTTP {st}")
# RELIRE, PAS SE FIER AU CODE DE RETOUR (D-68).
relu = {v["depot"]: forge.cles(v["depot"]) for v in voulues if v["depot"]}
reste_a, reste_r, _ = plan(voulues, relu)
if reste_a or reste_r or echecs or refus:
print(f"\nECART apres ecriture : {len(reste_a)} a ajouter, {len(reste_r)} a retirer, "
f"{echecs} echec(s), {len(refus)} refus.")
return 1
print("\nChaque depot de locataire porte exactement la cle de lecture que sa face publie.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -4920,6 +4920,47 @@ def preuve_clonage_publie() -> tuple[bool, str]:
return True, f"{n} machine(s) : la face publie exactement les parametres de clonage de l'inventaire."
def preuve_cle_lecture_genome() -> tuple[bool, str]:
"""Chaque locataire publie-t-il son depot du genome et la cle de son runner qui le lira —
et cette cle est-elle l'une de celles que sa flotte connait ?
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Les depots des locataires
deviennent prives sur la forge du site : chaque runner lit le sien avec une cle de
deploiement en lecture seule, que le site enregistre d'apres la face. Une cle absente
laisserait le runner sans genome apres l'insemination ; une cle hors de
`ssh_baseline_cles_admin` serait une identite que l'armement ne remet pas — la forge
attendrait une cle que le runner n'a jamais.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import contexte as _ctx
locataires = sorted({l for _s, l in _ctx.couples_hebergeur()}, key=lambda l: l.nom)
if not locataires:
return True, "Aucun locataire frere : aucune cle de lecture a confronter."
fautes = []
for l in locataires:
g = l.genome_publie()
if not g["depot"]:
fautes.append(f"{l.nom} : aucun depot `role: instance` dans `serveur_ops_depots`")
if not g["cle_lecture"]:
fautes.append(f"{l.nom} : `serveur_ops_tenant_cle_publique` absente")
continue
gv = l.dossier_inventaire() / "group_vars" / "all"
connues = set()
for f in sorted(gv.glob("*.yml")) if gv.is_dir() else []:
if "vault" in f.name:
continue
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
for e in (d.get("ssh_baseline_cles_admin") or []) if isinstance(d, dict) else []:
if isinstance(e, dict) and str(e.get("etat", "present")) == "present":
connues.add(" ".join(str(e.get("cle", "")).split()[:2]))
if g["cle_lecture"] not in connues:
fautes.append(f"{l.nom} : la cle de lecture n'est pas dans `ssh_baseline_cles_admin`")
if fautes:
return False, f"{len(fautes)} ecart(s) : " + " | ".join(fautes[:4])
return True, (f"{len(locataires)} locataire(s) : depot et cle de lecture publies, cle connue "
f"de leur flotte.")
def preuve_materialisation_par_la_face() -> tuple[bool, str]:
"""Les commandes du site qui nomment leur locataire tirent-elles de sa face les memes
intrants que l'ancien chemin ?
@ -5150,6 +5191,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_clonage_publie},
{"id": "P94", "titre": "Creer, raser et placer un locataire par sa face visent les memes machines",
"refs": [], "func": preuve_materialisation_par_la_face},
{"id": "P95", "titre": "Chaque locataire publie la cle qui lira son depot du genome, et sa flotte la connait",
"refs": [], "func": preuve_cle_lecture_genome},
]

View file

@ -77,9 +77,14 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-
# POURQUOI PAS LE NOM DU ROLE. `serveur_powerdns` existe AUSSI au site (`site-dns-01`) : une
# sortie declaree « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme, et
# jamais ceux des locataires. Le mot ne peut designer que les bonnes machines.
# `runners_locataires` — LES RUNNERS DES LOCATAIRES QUE CE SITE HEBERGE (2026-10-11), vus du
# site. Chacun lit SON depot du genome sur la forge du site, en SSH, avec une cle de
# deploiement en lecture seule (`docs/depots-prives-conception.md`). Des HOTES, lus dans les
# faces publiees — jamais les supernets : ouvrir le SSH de la forge a toutes les machines d'un
# locataire en ferait un droit d'entree general.
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
"voisins_site", "fabric", "frontiere", "runner_site",
"dns_public_site", "primaires_dns_locataires"}
"dns_public_site", "primaires_dns_locataires", "runners_locataires"}
GROUPE_EDGE = "serveur_nginx"
@ -400,7 +405,7 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
if p in ("admin", "voisins_site", "runner_site", "dns_public_site",
"primaires_dns_locataires"):
"primaires_dns_locataires", "runners_locataires"):
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
@ -475,9 +480,27 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
srcs |= set(_adresses_runner_site())
if "dns_public_site" in demandes:
srcs |= set(_adresses_dns_public_site())
if "runners_locataires" in demandes and site:
srcs |= set(_adresses_runners_locataires())
return sorted(srcs)
def _adresses_runners_locataires() -> list[str]:
"""Les runners des locataires que CE site heberge, lus dans leurs faces publiees.
La MEME lecture que la frontiere (`devis_opnsense.cibles_du_tenant`), sur la MEME liste
de locataires (`decouvrir_du_site`) : le pare-feu de la forge et celui de la bordure ne
doivent pas pouvoir nommer deux ensembles differents. Rend [] sans site monte.
"""
try:
import devis_opnsense
import devis_reseau
return sorted({ip for nom, _p, _n in devis_reseau.decouvrir_du_site()
for ip in devis_opnsense.cibles_du_tenant("serveur_ops_tenant", nom)})
except Exception:
return []
def _adresses_runner_site() -> list[str]:
"""L'adresse du runner du SITE — la source du flux d'insemination.

View file

@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Le plan de `forge_cles.py` : ajouter la cle publiee, retirer la perimee, refuser l'ecriture.
Pur : aucun appel a la forge. Ce que le script ECRIT est rendu par `plan()` ; s'il se trompe
ici, il se trompe sur la forge — c'est donc ici qu'on l'epingle.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts"))
import forge_cles as fc # noqa: E402
CLE = "ssh-ed25519 AAAALECTURE"
AUTRE = "ssh-ed25519 AAAAANCIENNE"
SITE = "ssh-ed25519 AAAASITE"
def voulue(depot="ops-essai", cle=CLE):
return {"locataire": "OPS-Essai", "depot": depot, "cle": cle}
class TestPlan(unittest.TestCase):
def test_une_cle_absente_s_ajoute(self):
a, r, x = fc.plan([voulue()], {"ops-essai": []})
self.assertEqual((len(a), r, x), (1, [], []))
def test_une_cle_deja_la_ne_change_rien(self):
cles = [{"id": 1, "title": fc.titre_de("OPS-Essai"), "key": CLE + " setops@ops-01", "read_only": True}]
self.assertEqual(fc.plan([voulue()], {"ops-essai": cles}), ([], [], []))
def test_une_cle_perimee_sous_le_meme_titre_se_retire(self):
cles = [{"id": 7, "title": fc.titre_de("OPS-Essai"), "key": AUTRE, "read_only": True}]
a, r, x = fc.plan([voulue()], {"ops-essai": cles})
self.assertEqual(len(a), 1)
self.assertEqual([y["id"] for y in r], [7])
def test_la_cle_du_runner_du_site_n_est_jamais_touchee(self):
cles = [{"id": 3, "title": "runner du site — site-ops-01", "key": SITE, "read_only": False}]
a, r, x = fc.plan([voulue()], {"ops-essai": cles})
self.assertEqual(r, [], "la cle d'ecriture du site n'est pas une cle de lecture perimee")
def test_une_cle_de_lecture_en_ecriture_est_refusee(self):
cles = [{"id": 4, "title": "autre", "key": CLE, "read_only": False}]
a, r, x = fc.plan([voulue()], {"ops-essai": cles})
self.assertEqual((a, r), ([], []))
self.assertEqual(len(x), 1)
def test_une_face_sans_cle_est_refusee(self):
a, r, x = fc.plan([voulue(cle="")], {})
self.assertEqual((a, r, len(x)), ([], [], 1))
if __name__ == "__main__":
unittest.main(verbosity=2)