serveur_ops : les depots prives se lisent en SSH, ou arrivent en colis

Mesure sur le terrain : chaque runner lit son depot en SSH avec sa cle de deploiement,
et la forge lui refuse les autres, alors meme que les depots sont publics. Le role ne
clone plus en HTTPS que les depots publics ; les depots instance, tenant et hebergeur
passent en SSH, cle d'hote epinglee, commande SSH posee dans leur config git. Avant
l'armement, le controleur les apporte en git bundle. La cle d'hote de la forge est
declaree au plan du site et offerte par la fiche. genome_etat et genome_pousser lisent
l'API authentifies ; la sonde genome-servi compte les publics ; forge_amorcer cree
prives les depots des ecosystemes et du site. make forge-etancheite mesure le tout.

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 20:33:38 -04:00
parent 9af39b1b60
commit d7717a4f98
18 changed files with 485 additions and 17 deletions

View file

@ -1,5 +1,51 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-10-11 (133) — Chaque runner lit son seul dépôt : mesuré, puis écrit dans le rôle
**95 preuves.** `make verifier` passe. `test_forge_cles.py` couvre aussi le classement des
mesures : un refus réseau n'est jamais pris pour une protection. Les dépôts restent publics.
### Mesuré sur le terrain (étape 1 du chantier)
`make forge-cles CONFIRMER=true` a enregistré les deux clés de lecture (HTTP 201), et un
second passage ne trouve rien à faire. Le pare-feu de `site-forge-01` a été simulé puis
appliqué. Daniel a appliqué la frontière : une règle créée, rien de retiré. Depuis chaque
runner, en SSH, avec la clé d'hôte de la forge épinglée (lue sur sa machine par l'accès
d'administration), le runner lit son propre dépôt, et la forge refuse les trois autres :
celui de l'autre locataire, celui du site, et le moteur. Ce refus vaut **alors que les dépôts
sont encore publics** : une clé de déploiement ne vaut que pour le dépôt qui l'a enregistrée.
Le moteur reste donc cloné en HTTPS sans identifiant.
Une première mesure accusait la forge à tort (« No ED25519 host key is known ») : le fichier
`known_hosts` temporaire, créé par root en mode 600, était illisible pour `setops`. C'était
l'instrument, pas la forge.
### Ce que le rôle sait faire maintenant
- **Dépôts privés en SSH.** `serveur_ops` ne clone plus en HTTPS que les dépôts publics. Les
dépôts `instance`, `tenant` et `hebergeur` passent en SSH, avec la clé du runner et la clé
d'hôte épinglée. La commande SSH est posée dans leur configuration git : la console, la
garde du génome et `make monter` la reprennent sans rien savoir de tout ceci.
- **Le colis d'insémination.** Quand le runner ne lit pas encore un dépôt privé (à
l'insémination, avant que l'armement lui remette sa clé), le contrôleur, qui porte ce dépôt,
le lui apporte en `git bundle`. Le runner le clone ou avance en avance rapide, puis vise la
forge en SSH. Le rôle dit quels dépôts sont venus par colis.
- **La clé d'hôte de la forge** est déclarée dans le plan du site (`forge_ssh_cle_hote`),
exposée au runner du site, offerte aux locataires par leur fiche, et copiée dans leurs
variables, comparée à l'original par `make site-intrants`. Les deux fiches ont été
redéposées.
- **Les lecteurs anonymes.** `genome_etat.yml` et `genome_pousser.yml` lisent l'API de la forge
avec le compte d'administration de la voûte du site : en anonyme, un dépôt privé
répondrait 404. La sonde `genome-servi` ne compte plus que les dépôts publics (au moins le
moteur). `forge_amorcer.py` crée privés les dépôts des écosystèmes et du site.
- **`make forge-etancheite`**, lancé du poste : sans identifiant, chaque dépôt privé doit
exiger une authentification et chaque dépôt public répondre ; depuis chaque runner, il lit
son dépôt et pas les autres. Lancée avant la bascule, elle compte 7 écarts, comme attendu.
**Limite.** Le colis n'a pas encore servi sur le terrain ; la bascule n'est pas faite. Les
étapes suivantes : déployer le rôle sur les trois runners (dépôts encore publics), éprouver le
colis sur un runner, basculer, mesurer, puis reconstruire Technolibre.
## 2026-10-11 (132) — Chaque locataire publie la clé qui lira son dépôt ; la forge du site peut l'entendre ## 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 **95 preuves.** `make verifier` passe. P95 est nouvelle, et `test_forge_cles.py` épingle ce

View file

@ -1388,6 +1388,10 @@ forge-cles: ## Le site enregistre sur le depot de chaque locataire la cle de lec
CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \ CONFIRMER="$(CONFIRMER)" ansible-playbook -i scripts/site_inventaire.py \
playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS) playbooks/maintenance/forge_cles.yml "$${supp[@]}" $(ARGS)
.PHONY: forge-etancheite
forge-etancheite: ansible-runtime ## La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ? (mesure, aucune ecriture)
python3 scripts/forge_etancheite.py
.PHONY: depots-perimes .PHONY: depots-perimes
depots-perimes: ansible-runtime _instance-requise ## Depots qu'un runner ne declare plus : regarde, et retire avec CONFIRMER=true 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 : @# DESTRUCTIF SUR CONFIRMATION SEULEMENT. Sans `CONFIRMER=true`, ce geste REGARDE :

View file

@ -43,8 +43,8 @@
| 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. | | 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 | | 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. | | 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 | 79 scripts expliques et atteignables, 152 cibles make documentees, 69 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 80 scripts expliques et atteignables, 153 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). | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (150 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). | | 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 | 51 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). | | 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). |
@ -53,7 +53,7 @@
| 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. | | 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. | | 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. | | 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 : 75 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 76 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). | | 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, 175 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. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
@ -85,7 +85,7 @@
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 artefact(s) direct(s) tenus par le depot du site. | | P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 27 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. | | 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. | | 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 : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). | | P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 11 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 ». | | P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal ». |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | | 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 | 189 gabarits de role : tous se rendent. | | P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 189 gabarits de role : tous se rendent. |
@ -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. | | 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. | | 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 | | 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, 146 etapes, 151 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, 147 etapes, 152 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). | | 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). | | 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. | | 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. |

View file

@ -3,7 +3,7 @@
> **Pour qui :** le mainteneur qui décide de ce chantier puis le conduit, et quiconque se demande > **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. > 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.** Préparé le 2026-10-11. Décisions D1 à D3 prises le même jour par Daniel : les trois recommandations.
## 1. Le constat ## 1. Le constat
@ -42,6 +42,22 @@ en font partie.
face réseau ; le site l'enregistre sur le dépôt de ce locataire, avec ses propres 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). identifiants d'administration de la forge (`vault_forgejo_admin`, déjà dans sa voûte).
### Éprouvé le 2026-10-11 (étape 1)
Clés de lecture enregistrées par `make forge-cles`, flux posé sur `site-forge-01` et à la
frontière. Depuis chaque runner, `git ls-remote` en SSH, clé d'hôte de la forge épinglée (lue
sur sa machine par l'accès d'administration) :
| Depuis | son dépôt | l'autre locataire | `site-chezlepro` | `set-ops-public` |
|---|---|---|---|---|
| runner de Technolibre | lu (`0172dd8`) | refusé | refusé | refusé |
| runner de Chezlepro | lu (`74d5567`) | refusé | refusé | refusé |
Le refus vient de la forge (« Deploy Key … is not authorized to read »), **alors que les dépôts
sont encore publics** : une clé de déploiement ne vaut que pour le dépôt qui l'a enregistrée.
Conséquence : le moteur, public, continue de se cloner en HTTPS sans identifiant ; seuls les
dépôts privés passent par SSH.
## 3. Le nœud : l'insémination ## 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 Le site amorce le runner d'un locataire **avant** que celui-ci ait reçu sa clé stable (elle
@ -78,6 +94,10 @@ privés, avant de reprendre le lot 3b.
## 5. Ce qui change dans le code ## 5. Ce qui change dans le code
État au 2026-10-11 : tout ce tableau est écrit, sauf la bascule elle-même. La clé d'hôte de
la forge est déclarée dans le plan du site (`forge_ssh_cle_hote`), offerte par la fiche
(`serveur_ops_forge_cle_hote`) et copiée par chaque locataire. Le moteur reste cloné en HTTPS.
| Pièce | Changement | | Pièce | Changement |
|---|---| |---|---|
| `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` | | `serveur_ops_depots` | un drapeau `prive: true` sur les dépôts `instance`, `tenant` et `hebergeur` |
@ -88,7 +108,7 @@ privés, avant de reprendre le lot 3b.
| `forge_amorcer.py` | crée les dépôts privés comme privés | | `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 | | 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é | | `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 | | `sonde-genome-servi` | ne compte plus que les dépôts publics (au moins le moteur) : c'est ce qu'un écosystème neuf clone sans rien détenir ; les privés sont suivis par `genome-etat`, `forge-cles` et les runners |
Inchangés : `sonde-genome-a-jour` (son repère ne compare que `set-ops-public`), le poste (il 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). pousse déjà en SSH), la console (`etat_forge` lit `origin`, qui deviendra l'URL SSH).

View file

@ -225,6 +225,13 @@ runbooks:
pourquoi: >- pourquoi: >-
Enregistrer sur le depot de chaque locataire la cle de lecture que sa face publie, et 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. retirer celle qui ne l'est plus. Sans elle, son runner ne lit plus son depot prive.
- cible: forge-etancheite
nature: mesure
portee: poste
pourquoi: >-
Sans identifiant, chaque depot prive exige une authentification et le moteur
repond ; chaque runner de locataire lit son depot et pas celui d'un autre. Du
poste, qui voit le site et les runners.
- cible: site-verifier - cible: site-verifier
nature: mesure nature: mesure
pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?" pourquoi: "Le playbook du site correspond-il encore aux couches declarees ?"

View file

@ -61,6 +61,11 @@
url: >- url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }} | default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false validate_certs: false
status_code: [200, 404] status_code: [200, 404]
loop: "{{ genome_compares }}" loop: "{{ genome_compares }}"

View file

@ -216,11 +216,16 @@
- name: Demander à la forge ce qu'elle porte maintenant - name: Demander à la forge ce qu'elle porte maintenant
# On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui # On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui
# répond, et la lecture est anonyme — aucun justificatif n'entre ici. # répond. Lecture authentifiée depuis le 2026-10-11 (dépôts privés).
ansible.builtin.uri: ansible.builtin.uri:
url: >- url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }} | default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }}
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false validate_certs: false
status_code: [200] status_code: [200]
loop: "{{ genome_a_pousser }}" loop: "{{ genome_a_pousser }}"
@ -265,6 +270,11 @@
url: >- url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/git/refs/tags | default('genome') }}/{{ item.depot }}/git/refs/tags
# AUTHENTIFIE (2026-10-11) : les depots des locataires et du site deviennent prives, et
# l'API anonyme y repondrait 404 — un « absent » qui n'en serait pas un.
url_username: forgejo-admin
url_password: "{{ vault_forgejo_admin }}"
force_basic_auth: true
validate_certs: false validate_certs: false
status_code: [200, 404] status_code: [200, 404]
loop: "{{ genome_a_pousser }}" loop: "{{ genome_a_pousser }}"

View file

@ -12,7 +12,10 @@ serveur_forge_site_port: "{{ serveur_forgejo_port | default(443) }}"
# `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs. # `serveur_ops_depots` nomme des depots sous ce proprietaire, pas ailleurs.
serveur_forge_site_organisation: "genome" serveur_forge_site_organisation: "genome"
# COMBIEN DE DEPOTS AU MINIMUM. Un site sert au moins le moteur et sa propre carte ; en # COMBIEN DE DEPOTS PUBLICS AU MINIMUM. La sonde lit l'API SANS identifiant : elle voit ce
# dessous de deux, la forge n'a pas de quoi faire naitre un ecosysteme. On ne fixe pas un # qu'un ecosysteme neuf peut cloner sans rien detenir. Depuis le 2026-10-11, la carte du
# compte exact : le nombre de locataires change, la sonde ne doit pas changer avec. # site et les plans des locataires sont PRIVES (`docs/depots-prives-conception.md`) : il
serveur_forge_site_sonde_depots_minimum: 2 # reste au moins le moteur. Les depots prives se surveillent ailleurs — `make genome-etat`,
# `make forge-cles`, et chaque runner qui les tire. On ne fixe pas un compte exact : le
# nombre de depots change, la sonde ne doit pas changer avec.
serveur_forge_site_sonde_depots_minimum: 1

View file

@ -75,7 +75,7 @@ nombre=${lecture%% *}
vides=${lecture#*|} vides=${lecture#*|}
if (( nombre < MINIMUM )); then if (( nombre < MINIMUM )); then
echo "La forge du genome ne porte que ${nombre} depot(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner." echo "La forge du genome ne sert que ${nombre} depot(s) public(s) pour « ${ORG} » (minimum ${MINIMUM}) : un ecosysteme neuf n'a rien a cloner."
exit 2 exit 2
fi fi

View file

@ -101,6 +101,29 @@ serveur_ops_forge_amont_ac_depot: "{{ serveur_ops_racine }}/.ac-amont.crt"
serveur_ops_depots: serveur_ops_depots:
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" } - { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
# --- LES DEPOTS PRIVES (2026-10-11, `docs/depots-prives-conception.md`) --------
#
# Le plan d'un ecosysteme et la carte du site ne regardent que leur runner — et le runner du
# site. Sur la forge du site, ils sont PRIVES : chaque runner les lit en SSH, avec SA cle,
# enregistree comme cle de deploiement en lecture sur son seul depot (`make forge-cles`).
# Le moteur et les modeles restent publics, et se clonent comme avant, en HTTPS anonyme :
# une cle de deploiement ne vaut que pour le depot qui l'a enregistree (mesure du jour).
serveur_ops_roles_prives: ["instance", "tenant", "hebergeur"]
# La cle d'hote SSH de la forge, epinglee : le runner sait a qui il parle avant de presenter
# sa cle. Au site, elle vient de son plan (`forge_ssh_cle_hote`) ; chez un locataire, de sa
# fiche (`serveur_ops_forge_cle_hote`, copie verifiee par `make site-intrants`).
serveur_ops_forge_cle_hote: ""
serveur_ops_forge_ssh_hote: "{{ serveur_ops_forge_url | urlsplit('hostname') }}"
serveur_ops_forge_known_hosts: "{{ serveur_ops_racine }}/.ssh/known_hosts_forge"
serveur_ops_cle_privee: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}"
# Ou le CONTROLEUR fabrique ses colis avant de les porter (jamais dans un depot).
serveur_ops_colis_local: "{{ lookup('env', 'TMPDIR') | default('/tmp', true) }}"
# La commande SSH des depots prives, posee dans LEUR config git (`core.sshCommand`) : la
# console, la garde du genome et `make monter` la reprennent sans rien savoir de tout ceci.
serveur_ops_ssh_prive: >-
ssh -i {{ serveur_ops_cle_privee }} -o BatchMode=yes -o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }}
# --- LES DEUX SYMLINKS (D-80) ------------------------------------------------ # --- LES DEUX SYMLINKS (D-80) ------------------------------------------------
# #
# `instance` dit QUEL tenant on pilote. # `instance` dit QUEL tenant on pilote.

View file

@ -349,7 +349,8 @@
version: "{{ item.branche | default('main') }}" version: "{{ item.branche | default('main') }}"
update: true update: true
force: false force: false
loop: "{{ serveur_ops_depots }}" # LES DEPOTS PUBLICS SEULEMENT (2026-10-11). Les prives suivent, plus bas, en SSH.
loop: "{{ serveur_ops_depots | rejectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop_control: loop_control:
label: "{{ item.depot }} → {{ item.dest }}" label: "{{ item.depot }} → {{ item.dest }}"
become: true become: true
@ -360,6 +361,162 @@
delay: 6 delay: 6
until: serveur_ops_clone is succeeded until: serveur_ops_clone is succeeded
# --- LES DEPOTS PRIVES : EN SSH, OU EN COLIS TANT QUE LE RUNNER NE PEUT PAS ---------------
#
# (2026-10-11, `docs/depots-prives-conception.md`.) Le plan d'un ecosysteme et la carte du
# site sont prives sur la forge du site. Le runner les lit en SSH, avec SA cle, que le site a
# enregistree comme cle de deploiement sur ce seul depot (`make forge-cles`).
#
# MAIS PAS ENCORE A L'INSEMINATION. Le site amorce le runner d'un locataire AVANT l'armement,
# donc avant que la voute lui remette sa cle stable : la forge ne le connait pas. Le
# controleur qui joue ce role — le runner du site, ou le poste — porte deja ce depot, frere
# du moteur ; il le lui apporte en COLIS (`git bundle`). Le runner pointe ensuite `origin`
# vers la forge en SSH : une fois arme, il tire lui-meme. Rien n'est devine : si le
# controleur n'a pas le depot, on refuse.
- name: Exiger la cle d'hote de la forge pour lire un depot prive
ansible.builtin.assert:
that: serveur_ops_forge_cle_hote | length > 0
fail_msg: >-
Ce runner lit des depots prives ({{ serveur_ops_depots
| selectattr('role', 'in', serveur_ops_roles_prives) | map(attribute='depot') | join(', ') }}),
mais `serveur_ops_forge_cle_hote` est vide. Au site : `forge_ssh_cle_hote` dans son plan.
Chez un locataire : la copier depuis sa fiche (`make site-intrants`).
when: serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list | length > 0
- name: Epingler la cle d'hote de la forge
ansible.builtin.copy:
content: "{{ serveur_ops_forge_ssh_hote }} {{ serveur_ops_forge_cle_hote | trim }}\n"
dest: "{{ serveur_ops_forge_known_hosts }}"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
when: serveur_ops_forge_cle_hote | length > 0
- name: Le runner lit-il deja chaque depot prive en SSH ?
ansible.builtin.command:
argv: [git, ls-remote, "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git", HEAD]
environment:
GIT_SSH_COMMAND: "{{ serveur_ops_ssh_prive }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop_control:
label: "{{ item.depot }}"
register: serveur_ops_prives_ssh
changed_when: false
failed_when: false
check_mode: false
- name: Tirer en SSH les depots prives que le runner peut lire
ansible.builtin.git:
repo: "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
dest: "{{ serveur_ops_racine }}/{{ item.item.dest }}"
version: "{{ item.item.branche | default('main') }}"
key_file: "{{ serveur_ops_cle_privee }}"
ssh_opts: "-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }} -o IdentitiesOnly=yes"
accept_hostkey: false
update: true
force: false
loop: "{{ serveur_ops_prives_ssh.results | selectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (SSH)"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_prives_tires
- name: Faire le colis des depots prives que le runner ne lit pas encore (controleur)
ansible.builtin.command:
argv: [git, -C, "{{ serveur_ops_freres }}/{{ item.item.dest }}", bundle, create,
"{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle",
"refs/heads/{{ item.item.branche | default('main') }}"]
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
delegate_to: localhost
become: false
changed_when: false
when: not ansible_check_mode
vars:
serveur_ops_freres: "{{ role_path | dirname | dirname | dirname }}"
register: serveur_ops_colis_faits
- name: Assurer le dossier des colis sur le runner
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.colis"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0700"
when:
- not ansible_check_mode
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length > 0
- name: Porter les colis au runner
ansible.builtin.copy:
src: "{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle"
dest: "{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0600"
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
when: not ansible_check_mode
- name: Recevoir chaque colis (clone neuf, ou avance rapide), puis viser la forge en SSH
ansible.builtin.shell: |
set -eu
d="{{ serveur_ops_racine }}/{{ item.item.dest }}"
c="{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
b="{{ item.item.branche | default('main') }}"
git bundle verify -q "$c"
if [ ! -d "$d/.git" ]; then
git clone -q --branch "$b" "$c" "$d"
echo "CLONE"
else
avant=$(git -C "$d" rev-parse HEAD)
git -C "$d" fetch -q "$c" "refs/heads/$b:refs/remotes/colis/$b"
git -C "$d" merge -q --ff-only "colis/$b"
[ "$avant" = "$(git -C "$d" rev-parse HEAD)" ] || echo "AVANCE"
fi
git -C "$d" remote set-url origin "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
git -C "$d" fetch -q origin 2>/dev/null && git -C "$d" branch -q --set-upstream-to "origin/$b" || true
rm -f "$c"
args:
executable: /bin/bash
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }} (colis)"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_colis_recus
changed_when: "'CLONE' in serveur_ops_colis_recus.stdout or 'AVANCE' in serveur_ops_colis_recus.stdout"
- name: Dire quels depots prives sont venus par colis
ansible.builtin.debug:
msg: >-
{{ item.item.depot }} : le runner ne le lit pas encore en SSH ({{ (item.stderr | default('')
| trim).splitlines()[-1:] | join('') }}). Apporte en colis par le controleur. S'il est
arme et que ca dure : `make forge-cles` au site, et la cle de sa voute.
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
loop_control:
label: "{{ item.item.depot }}"
- name: Poser la commande SSH de la forge dans chaque depot prive
community.general.git_config:
name: core.sshCommand
scope: local
repo: "{{ serveur_ops_racine }}/{{ item.dest }}"
value: "{{ serveur_ops_ssh_prive | trim }}"
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
loop_control:
label: "{{ item.depot }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20). # CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
# #
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas # SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas

View file

@ -46,6 +46,12 @@ sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402 import underlay as underlay_mod # noqa: E402
# LES DEPOTS QUI NAISSENT PRIVES (2026-10-11, `docs/depots-prives-conception.md`) : le plan
# d'un ecosysteme et la carte du site ne regardent que leur runner et celui du site. La MEME
# liste que `serveur_ops_roles_prives` du role, qui les clone en SSH.
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
def _depots_declares() -> list[dict]: def _depots_declares() -> list[dict]:
"""`serveur_ops_depots` du plan du SITE — la source que le runner lit.""" """`serveur_ops_depots` du plan du SITE — la source que le runner lit."""
import yaml import yaml
@ -128,8 +134,10 @@ def main() -> int:
f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.") f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.")
return 2 return 2
prives = {str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES}
if not a.confirmer: if not a.confirmer:
print(f"\n{len(prets)} depot(s) seraient crees puis pousses.\n" print(f"\n{len(prets)} depot(s) seraient crees puis pousses, dont prives : "
f"{', '.join(sorted(prives)) or 'aucun'}.\n"
f"Relancer avec : make forge-amorcer CONFIRMER=true") f"Relancer avec : make forge-amorcer CONFIRMER=true")
return 0 return 0
@ -206,7 +214,12 @@ def main() -> int:
st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth) st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth)
if st == 404: if st == 404:
st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST", st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST",
{"name": nom, "private": False}, auth) {"name": nom, "private": nom in prives}, auth)
# PRIVE OU PUBLIC SELON CE QU'IL PORTE (2026-10-11). Le moteur et les modeles
# restent publics ; le plan d'un ecosysteme et la carte du site naissent prives,
# et leurs runners les clonent en SSH (`roles/serveur_ops`, colis compris). Le
# recit de 2026-09-12 ci-dessous reste vrai pour les depots publics :
#
# PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12). # PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12).
# #
# Cree en `private` au premier essai — par reflexe. Le runner clone # Cree en `private` au premier essai — par reflexe. Le runner clone

View file

@ -159,7 +159,9 @@ def main(argv: list[str] | None = None) -> int:
for v in voulues: for v in voulues:
etat = ("a ajouter" if v in ajouter else "refus" if any(v["locataire"] in r for r in refus) etat = ("a ajouter" if v in ajouter else "refus" if any(v["locataire"] in r for r in refus)
else "deja enregistree") else "deja enregistree")
print(f" {v['locataire']:<18} {v['depot']:<18} {v['cle'][:32]}… {etat}") # LA FIN DE LA CLE, PAS SON DEBUT : toutes les ed25519 commencent par les memes 32
# caracteres, et deux lignes differentes s'affichaient identiques (2026-10-11).
print(f" {v['locataire']:<18} {v['depot']:<18} …{v['cle'][-16:]} {etat}")
for x in retirer: for x in retirer:
print(f" {x['locataire']:<18} {x['depot']:<18} cle perimee #{x['id']} a retirer") print(f" {x['locataire']:<18} {x['depot']:<18} cle perimee #{x['id']} a retirer")
for r in refus: for r in refus:

150
scripts/forge_etancheite.py Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""La forge du site laisse-t-elle chaque runner lire ses depots, et personne d'autre ?
POURQUOI (2026-10-11, `docs/depots-prives-conception.md`). Le plan d'un ecosysteme et la carte
du site sont prives sur la forge du site ; chaque runner de locataire lit le sien avec une cle
de deploiement en lecture seule. Deux mesures, depuis deux endroits :
SANS IDENTIFIANT, depuis ce poste (HTTPS) :
- un depot PRIVE doit EXIGER une authentification. Un refus RESEAU ne compte pas : il ne
dit rien de la forge, seulement du chemin (une sonde rapporte son errno) ;
- un depot PUBLIC (le moteur, les modeles) doit repondre : un ecosysteme neuf le clone
sans rien detenir.
DEPUIS CHAQUE RUNNER DE LOCATAIRE (SSH, sa cle, la cle d'hote epinglee) :
- il lit SON depot ;
- la forge lui REFUSE les autres depots prives (« not authorized »).
Le resultat se lit ligne par ligne ; le code de sortie vaut 1 au moindre ecart.
Usage :
python3 scripts/forge_etancheite.py (make forge-etancheite)
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts"))
ROLES_PRIVES = {"instance", "tenant", "hebergeur"}
def classer_https(rc: int, sortie: str) -> str:
"""'lu', 'authentification', ou 'reseau' — ce que dit un `git ls-remote` anonyme."""
if rc == 0:
return "lu"
s = sortie.lower()
if any(m in s for m in ("could not read username", "terminal prompts disabled",
"authentication failed", "401", "403")):
return "authentification"
return "reseau"
def classer_ssh(rc: int, sortie: str) -> str:
"""'lu', 'refuse' (la forge a juge), ou 'reseau' (personne n'a juge)."""
if rc == 0:
return "lu"
s = sortie.lower()
if "not authorized" in s or "permission denied" in s or "does not exist" in s:
return "refuse"
return "reseau"
def _site_et_depots():
import underlay as underlay_mod
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
depots = []
for srv in (plan.get("serveurs") or {}).values():
d = (srv.get("variables") or {}).get("serveur_ops_depots")
if d:
depots = list(d)
dom = str((underlay_mod.lire_plan_site("10-intrants.yml") or {}).get("domaine_interne") or "")
racine_ac = (underlay_mod.chemin() or Path()).resolve().parent / "ac-racine-site.crt"
return dom, depots, racine_ac
def mesurer_https(dom: str, depots: list[dict], racine_ac: Path) -> list[str]:
ecarts = []
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_ASKPASS="/bin/false")
if racine_ac.is_file():
env["GIT_SSL_CAINFO"] = str(racine_ac)
print(f"Sans identifiant, depuis ce poste (https://forge.{dom}) :")
for d in depots:
nom, prive = str(d["depot"]), str(d.get("role")) in ROLES_PRIVES
r = subprocess.run(["git", "ls-remote", f"https://forge.{dom}/genome/{nom}.git", "HEAD"],
capture_output=True, text=True, timeout=30, env=env)
verdict = classer_https(r.returncode, r.stdout + r.stderr)
attendu = "authentification" if prive else "lu"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {nom:<18} {'prive ' if prive else 'public'} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu})"))
if not ok:
ecarts.append(f"{nom} : {verdict}, attendu {attendu}")
return ecarts
def mesurer_runners(depots: list[dict]) -> list[str]:
import contexte as _ctx
import yaml
prives = [str(d["depot"]) for d in depots if str(d.get("role")) in ROLES_PRIVES]
ecarts = []
for site, loc in _ctx.couples_hebergeur():
face = loc.face_publiee() or {}
if face.get("hebergeur") != site.nom:
continue
sien = str((face.get("genome") or {}).get("depot") or "")
runners = sorted((face.get("groupes") or {}).get("serveur_ops_tenant") or [])
gv = yaml.safe_load((loc.dossier_inventaire() / "group_vars" / "serveur_ops.yml")
.read_text(encoding="utf-8")) or {}
dest = next((str(x.get("dest")) for x in gv.get("serveur_ops_depots") or []
if x.get("role") == "instance"), "")
if not (sien and runners and dest):
ecarts.append(f"{loc.nom} : face sans depot, sans runner, ou plan sans `role: instance`")
continue
print(f"\nDepuis le runner de {loc.nom} ({runners[0]}), par l'URL de son origin :")
# LA COMMANDE SSH DU DEPOT LUI-MEME (`core.sshCommand`, posee par serveur_ops) : on
# mesure ce que le runner fait vraiment, pas une commande refaite a cote.
script = (f"cd /opt/setops/{dest} && base=$(git remote get-url origin) && base=${{base%/*}} && "
f"for n in {' '.join(prives)}; do "
"if out=$(git ls-remote \"$base/$n.git\" HEAD 2>&1 >/dev/null); then echo \"MESURE $n lu\"; "
"else echo \"MESURE $n $(echo $out | tr '\\n' ' ')\"; fi; done")
r = subprocess.run(["ansible", "-i", str(loc.inventaire()), runners[0], "-b",
"--become-user", "setops", "-m", "shell", "-a", script],
capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
mesures = {}
for ligne in r.stdout.splitlines():
if ligne.startswith("MESURE "):
_m, n, reste = ligne.split(" ", 2)
mesures[n] = ("lu", "") if reste.strip() == "lu" else (classer_ssh(1, reste), reste.strip())
if not mesures:
ecarts.append(f"{loc.nom} : aucune mesure rendue par {runners[0]} ({(r.stderr or r.stdout)[-160:]})")
print(f" ECART aucune mesure rendue")
continue
for n in prives:
verdict, detail = mesures.get(n, ("absent", ""))
attendu = "lu" if n == sien else "refuse"
ok = verdict == attendu
print(f" {'OK ' if ok else 'ECART'} {n:<18} -> {verdict}"
+ ("" if ok else f" (attendu : {attendu}) {detail[:90]}"))
if not ok:
ecarts.append(f"{loc.nom} -> {n} : {verdict}, attendu {attendu}")
return ecarts
def main() -> int:
dom, depots, racine_ac = _site_et_depots()
if not dom or not depots:
print("REFUS : aucun site monte, ou son plan ne declare pas `serveur_ops_depots`.")
return 2
ecarts = mesurer_https(dom, depots, racine_ac) + mesurer_runners(depots)
if ecarts:
print(f"\nNON ETANCHE : {len(ecarts)} ecart(s).")
return 1
print("\nETANCHE : chaque runner lit son depot, aucun celui d'un autre ; le public reste public.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -4465,6 +4465,7 @@ let selMachine = null;
['artefacts_amorcage', 'Cache d\'artefacts', 'paquets apt'], ['artefacts_amorcage', 'Cache d\'artefacts', 'paquets apt'],
['setops_depot_binaires', 'Dépôt des roues et collections', 'ce que le runner installe sans Internet'], ['setops_depot_binaires', 'Dépôt des roues et collections', 'ce que le runner installe sans Internet'],
['serveur_ops_forge_amont', 'Forge du génome', 'le moteur que mon runner suit'], ['serveur_ops_forge_amont', 'Forge du génome', 'le moteur que mon runner suit'],
['serveur_ops_forge_cle_hote', 'Clé d\'hôte SSH de la forge', 'épinglée par mon runner pour lire mon dépôt privé'],
['client_backup_cible', 'Dépôt de sauvegarde', 'reçoit mon chiffré ; la vérification reste chez moi'], ['client_backup_cible', 'Dépôt de sauvegarde', 'reçoit mon chiffré ; la vérification reste chez moi'],
['dns_public_site', 'DNS public secondaire', ''], ['dns_public_site', 'DNS public secondaire', ''],
['dns_public_nom', 'Nom du DNS public', ''], ['dns_public_nom', 'Nom du DNS public', ''],

View file

@ -152,6 +152,9 @@ def contrat() -> dict:
"serveur_ops_forge_amont": f"https://{forge}" if forge else "", "serveur_ops_forge_amont": f"https://{forge}" if forge else "",
"forge_amont_ac": str((underlay_mod.chemin() or Path()).resolve().parent "forge_amont_ac": str((underlay_mod.chemin() or Path()).resolve().parent
/ "ac-racine-site.crt"), / "ac-racine-site.crt"),
# ET A QUI L'ON PARLE EN SSH, QUAND LE DEPOT EST PRIVE (2026-10-11) : la cle d'hote de
# la forge, que le runner epingle avant de presenter sa cle de deploiement.
"serveur_ops_forge_cle_hote": str(intrants.get("forge_ssh_cle_hote") or ""),
# OU DEPOSER SON ETAT — par NOM : le depot sert plusieurs locataires et peut bouger # OU DEPOSER SON ETAT — par NOM : le depot sert plusieurs locataires et peut bouger
"client_backup_cible": _nom_expose("serveur_backup_site", domaine), "client_backup_cible": _nom_expose("serveur_backup_site", domaine),
# QUI TIRE SES ZONES PUBLIQUES — le seul serveur a qui le primaire notifie, et le # QUI TIRE SES ZONES PUBLIQUES — le seul serveur a qui le primaire notifie, et le
@ -183,6 +186,7 @@ OU_LE_LOCATAIRE_LE_DIT = {
"artefacts_amorcage": ("all/10-intrants.yml", "artefacts_amorcage"), "artefacts_amorcage": ("all/10-intrants.yml", "artefacts_amorcage"),
"setops_depot_binaires": ("all/10-intrants.yml", "setops_depot_binaires"), "setops_depot_binaires": ("all/10-intrants.yml", "setops_depot_binaires"),
"serveur_ops_forge_amont": ("serveur_ops.yml", "serveur_ops_forge_amont"), "serveur_ops_forge_amont": ("serveur_ops.yml", "serveur_ops_forge_amont"),
"serveur_ops_forge_cle_hote": ("serveur_ops.yml", "serveur_ops_forge_cle_hote"),
"client_backup_cible": ("client_backup.yml", "client_backup_cible"), "client_backup_cible": ("client_backup.yml", "client_backup_cible"),
"dns_public_site": ("all/10-intrants.yml", "dns_public_site"), "dns_public_site": ("all/10-intrants.yml", "dns_public_site"),
"ip_publique_site": ("all/10-intrants.yml", "ip_publique_site"), "ip_publique_site": ("all/10-intrants.yml", "ip_publique_site"),

View file

@ -435,6 +435,10 @@ def inventaire() -> dict:
for cle in ("domaine_interne", "organisation", "dns_amorcage"): for cle in ("domaine_interne", "organisation", "dns_amorcage"):
if intrants.get(cle): if intrants.get(cle):
communes[cle] = str(intrants[cle]) communes[cle] = str(intrants[cle])
# LA CLE D'HOTE DE LA FORGE DU SITE (2026-10-11) : le runner du site l'epingle pour lire
# en SSH les depots prives, comme ceux des locataires l'epinglent depuis leur fiche.
if intrants.get("forge_ssh_cle_hote"):
communes["serveur_ops_forge_cle_hote"] = str(intrants["forge_ssh_cle_hote"])
# L'identite de sauvegarde DU SITE (moitie publique). Le depot du site l'autorise # L'identite de sauvegarde DU SITE (moitie publique). Le depot du site l'autorise
# pour ses propres machines ; les locataires ont chacun la leur, ailleurs. # pour ses propres machines ; les locataires ont chacun la leur, ailleurs.
if intrants.get("backup_pubkey"): if intrants.get("backup_pubkey"):

View file

@ -53,5 +53,24 @@ class TestPlan(unittest.TestCase):
self.assertEqual((a, r, len(x)), ([], [], 1)) self.assertEqual((a, r, len(x)), ([], [], 1))
class TestClassement(unittest.TestCase):
"""`forge_etancheite` : un refus RESEAU n'est jamais lu comme une protection."""
def test_https(self):
import forge_etancheite as fe
self.assertEqual(fe.classer_https(0, ""), "lu")
self.assertEqual(fe.classer_https(128, "fatal: could not read Username for 'https://x'"),
"authentification")
self.assertEqual(fe.classer_https(128, "fatal: unable to access: Connection timed out"), "reseau")
def test_ssh(self):
import forge_etancheite as fe
self.assertEqual(fe.classer_ssh(0, ""), "lu")
self.assertEqual(fe.classer_ssh(1, "Forgejo: Deploy Key: 4 is not authorized to read genome/x"),
"refuse")
self.assertEqual(fe.classer_ssh(1, "ssh: connect to host 10.37.33.11 port 22: No route to host"),
"reseau")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)