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:
parent
aa5e4e0897
commit
9af39b1b60
18 changed files with 613 additions and 20 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
9
Makefile
9
Makefile
|
|
@ -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 :
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
123
docs/depots-prives-conception.md
Normal file
123
docs/depots-prives-conception.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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ù
|
||||
|
|
|
|||
|
|
@ -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 ?"
|
||||
|
|
|
|||
43
playbooks/maintenance/forge_cles.yml
Normal file
43
playbooks/maintenance/forge_cles.yml
Normal 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 }}"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: >-
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
191
scripts/forge_cles.py
Normal 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())
|
||||
|
|
@ -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},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
57
scripts/tests/test_forge_cles.py
Normal file
57
scripts/tests/test_forge_cles.py
Normal 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)
|
||||
Loading…
Reference in a new issue