insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge

L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux
declarations, aux deux bouts, et rien d'autre :

    serveur_ops_site     egress  22/tcp -> serveur_ops_tenant
    serveur_ops_tenant   ingress 22/tcp <- runner_site         partage: true

ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme
ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute
la flotte du tenant.

L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable :
`creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer
son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree
pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui
reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant.

LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le
pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote
site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une
seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle
utile. La declaration du tenant pose sa regle nftables, et elle seule :

    ip saddr { 10.0.31.11 } tcp dport 22 accept

L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain
deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25.

Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE.

P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les
trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue.

DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le
sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a
refuse d'emettre vers un alias vide.

UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec
un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par
`bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne
joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans
`bande_basse_de`, aucun chevauchement n'est tolere.

make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-08-28 13:09:28 -04:00
parent c6f2cb1dd9
commit d3ba520500
9 changed files with 319 additions and 65 deletions

View file

@ -1,5 +1,82 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-28 — Le lien d'insémination, déclaré — et un test rouge depuis trois jours
**52 preuves.** L'insémination avait un nom depuis ce matin ; elle n'avait pas de flux. Le
runner du SITE doit joindre le runner d'un tenant pour l'amorcer — socle, moteur, plan,
plancher de résolution — et ce chemin s'exerçait jusqu'ici sans être déclaré nulle part.
**Deux déclarations, aux deux bouts, et rien d'autre.**
```
serveur_ops_site egress 22/tcp -> serveur_ops_tenant
serveur_ops_tenant ingress 22/tcp <- runner_site partage: true
```
Étroit par construction : il vise le **groupe** `serveur_ops_tenant`, qu'un écosystème ne
pose que sur une machine. Déclaré au socle, il aurait ouvert le SSH du site vers toute la
flotte du tenant ; déclaré sur ce rôle, sa portée est celle du groupe — et le groupe est
écrit au plan du tenant.
L'en-tête de `roles/serveur_ops_site/meta/flux.yml` disait *« ce rôle n'entre JAMAIS chez
un tenant »*. C'était une frontière qu'on ne pouvait pas tenir : `creer-vm` exige
`_instance-requise`, et le runner du site avait déjà dû basculer son symlink `instance`
sur `OPS-Chezlepro` pour matérialiser ses VM. *Déclarer ne crée pas ce pouvoir — ça rend
limitable un pouvoir qui s'exerçait sans borne.*
Ce qui reste interdit n'est pas une règle mais un **fait** : le runner du SITE ne détient
pas la voûte d'un tenant — elle vit hors dépôt — et n'en connaît pas le mot de passe. Il
pose tout ce qui est public ; **l'humain arme**. Le pouvoir du site s'arrête là où il n'a
pas la clé.
### La règle est émise d'un seul côté, et ce n'est pas celui qu'on croit
Le paquet part du réseau de l'hébergeur vers la zone du tenant : il pénètre le pare-feu
**par la patte du site**, pas par le lien de transit. La règle appartient donc au côté
SITE du devis. L'émettre aussi depuis la déclaration `ingress` du tenant aurait produit
une seconde règle attachée à la mauvaise interface — *jamais évaluée, impossible à
distinguer d'une règle utile, et comptée comme telle par tout ce qui audite ce devis.*
La déclaration du tenant n'est pas perdue : c'est elle qui pose la règle `nftables` sur la
machine visée, et elle seule. Une ligne, sur un seul hôte, dont la source **dérive du plan
du site** :
```
ip saddr { 10.0.31.11 } tcp dport 22 accept # serveur_ops_tenant: Insémination…
```
*Écrire cette adresse à la main aurait survécu au prochain déplacement du runner sans que
rien ne le dise — le site a déjà déplacé ses machines une fois, le 2026-08-25.*
**Plan de la frontière : 2 objets à créer, 0 à retirer, 126 inchangés.** Rien n'est appliqué.
### P41 appliquée au plan du site
`resoudre_flux` avait besoin du plan du SITE à son tour, que `devis_opnsense` portait en
privé. Les trois lecteurs déménagent dans `underlay` — le module qui sait déjà où vit
l'hébergeur — et l'appelant délègue. *Deux recensements du même objet finissent toujours
par diverger, et la divergence se lit « tout va bien ».*
**Deux gardes ont fait leur travail au passage**, et méritent d'être nommées : P33 a refusé
`ingress 22` sur un hôte qui porte déjà le sshd du socle — la réponse était `partage: true`,
le mot qui distingue *ouvrir une écoute* d'*autoriser une source de plus sur celle d'un
autre*, exactement comme `serveur_backup`. Et le devis a refusé d'émettre une règle vers un
alias vide plutôt que d'en poser une sans destination.
### Un test rouge depuis trois jours, que personne ne pouvait voir
`test_adressage_derive` construisait un site avec un `index`. Or **un SITE n'a pas
d'index** — `add94f2` l'a retiré le 2026-08-25, remplacé par `bande_basse_de`, porté par
chaque *réseau* et non par le site. Le test est resté rouge du 25 au 28.
Personne ne l'a vu parce que **le geste quotidien est `make prouver`, qui ne joue pas les
tests** — `make verifier` le fait, et il n'avait pas été rejoué. *Un test rouge que rien ne
lit ne garde plus rien.* Remis sur le contrat actuel, et sa contrepartie ajoutée : un
réseau qui ne **dit pas** de quel tenant il occupe la bande basse n'a droit à aucun
chevauchement — ne pas déclarer ne peut pas valoir permission.
make verifier : vert de bout en bout. make prouver : CONFORME, 52 OK, 0 echec.
## 2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance ## 2026-08-28 — L'insémination : la parenté est la seule exception au zéro-confiance
**52 preuves.** Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un **52 preuves.** Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un

View file

@ -6,8 +6,8 @@
> [`docs/audit/README.md`](README.md), et le registre trace : > [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute) - **Verdict** : ❌ NON CONFORME (51 OK · 1 echec · 0 saute)
## Preuves ## Preuves
@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. | | 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). | | P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 37 groupes classes, aucun cycle, aucune arete en arriere. | | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 37 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 35 rôles, 92 flux, schéma + matrice OK. | | P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 35 rôles, 94 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | | 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 | playbook: playbooks/proxmox/cloner_vm_debian.yml | | P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -37,7 +37,7 @@
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). | | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 11 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). | | P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | | P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 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. |
@ -45,7 +45,7 @@
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 40 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 40 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). | | P33 | Aucune collision de port entre roles co-localises | — | ❌ ECHEC | rc=1 : decrivent la MEME ecoute. |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (26 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 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 | 5 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) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | | P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
@ -55,14 +55,14 @@
| 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 : 47 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 47 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 57 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 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). | | 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 | | 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). | | 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 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 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 (110 lignes). | | 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 (112 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 121 regles `pass`), tous non consignes et tous motives. | | 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 122 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 | | 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. | | 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. |

View file

@ -71,8 +71,10 @@
| `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. | | `serveur_ops` | egress | 443 | tcp | voisins_site | tls-requis | Cloner le genome depuis la forge du site, quand cet ecosysteme n'heberge pas la sienne. |
| `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. | | `serveur_ops` | egress | 8006 | tcp | externe | tls-requis | API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant. |
| `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. | | `serveur_ops_site` | egress | 22 | tcp | fabric | ssh | Shell des hyperviseurs : ce que l'API ne couvre pas — configuration reseau, ponts, deplacement de disques. Un pouvoir distinct de l'API, donc declare a part. |
| `serveur_ops_site` | egress | 22 | tcp | serveur_ops_tenant | ssh | Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne transporte aucun secret : la voute est remise par un humain. |
| `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. | | `serveur_ops_site` | egress | 443 | tcp | fabric | tls-requis | API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain injoignable. |
| `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. | | `serveur_ops_site` | egress | 8006 | tcp | fabric | tls-requis | API de l'hyperviseur : créer, cloner et détruire les VM de la fabric. Le seul flux par lequel un écosystème peut en matérialiser un autre. |
| `serveur_ops_tenant` | ingress | 22 | tcp | runner_site | ssh | Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan, plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette machine seule, jamais vers le reste de l'écosystème. |
| `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). | | `serveur_postfix` | ingress | 25 | tcp | externe, client_smtp | starttls | SMTP entrant : courrier externe (MX) et notifications internes (client_smtp). |
| `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. | | `serveur_postfix` | ingress | 587 | tcp | flotte | starttls | Soumission authentifiée (submission) pour les agents internes qui envoient du courrier. |
| `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. | | `serveur_postfix` | egress | 24 | tcp | serveur_dovecot | tls-requis | Remise finale par LMTP au mailstore (Dovecot), en TLS vérifié. |
@ -103,7 +105,7 @@
- **clair** : 35 flux - **clair** : 35 flux
- **n-a** : 3 flux - **n-a** : 3 flux
- **ssh** : 5 flux - **ssh** : 7 flux
- **starttls** : 6 flux - **starttls** : 6 flux
- **tls** : 9 flux - **tls** : 9 flux
- **tls-cible** : 2 flux - **tls-cible** : 2 flux

View file

@ -2,9 +2,25 @@
# Flux réseau du runner de SITE. Voir docs/flux-conception.md. # Flux réseau du runner de SITE. Voir docs/flux-conception.md.
# #
# Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de # Les flux du POUVOIR DE MATERIALISER : API de l'hyperviseur, shell des noeuds, API de
# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS # la frontiere. Chacun est un pouvoir distinct, donc declare a part.
# chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles #
# existent. # CE ROLE N'ENTRE PAS CHEZ UN TENANT — SAUF SUR SON RUNNER, POUR L'AMORCER (2026-08-28).
#
# Cette ligne disait « JAMAIS », et c'etait une frontiere qu'on ne pouvait pas tenir. Un
# ecosysteme neuf ne s'amorce pas lui-meme : sa premiere machine doit recevoir le socle,
# le moteur, son plan et son plancher de resolution avant de pouvoir configurer quoi que
# ce soit. Sans ce flux, ce travail retombe sur un humain qui le fait a la main — donc
# hors du depot, donc sans preuve.
#
# LE COUPLAGE EXISTAIT DEJA, NON DECLARE : `creer-vm` exige `_instance-requise`, et le
# runner du site doit donc basculer son symlink `instance` sur le depot du tenant qu'il
# materialise. Le declarer ne cree pas un pouvoir, il rend limitable un pouvoir qui
# s'exercait sans borne.
#
# CE QU'IL RESTE INTERDIT, ET QUI N'EST PAS UNE REGLE MAIS UN FAIT : armer. Le runner du
# SITE ne detient pas la voute d'un tenant — elle vit hors depot — et n'en connait pas le
# mot de passe. Il peut poser tout ce qui est public ; c'est un HUMAIN qui remet le
# secret, et c'est la que le pouvoir du site s'arrete. Voir docs/filiation-emancipation.md.
flux: flux:
- sens: egress - sens: egress
port: 8006 port: 8006
@ -38,3 +54,19 @@ flux:
API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux API de la frontiere OPNsense : poser les alias et les regles qui ouvrent les flux
du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain du tenant qu'on materialise. Preparer le terrain sans cela laisserait un terrain
injoignable. injoignable.
# L'INSEMINATION — le seul flux de ce role qui entre chez un tenant.
#
# Etroit par construction : il vise le groupe `serveur_ops_tenant`, qu'un ecosysteme ne
# pose que sur une machine. Declare au socle, il aurait ouvert le SSH du site vers TOUTE
# la flotte du tenant ; declare ici, sa portee est celle du groupe, et le groupe est
# ecrit au plan du tenant.
- sens: egress
port: 22
protocole: tcp
pair: serveur_ops_tenant
chiffrement: ssh
raison: >-
Insemination : amorcer le runner d'un tenant — socle, moteur, plan, plancher de
resolution — pour qu'il prenne ensuite le relais sur ses propres machines. Ne
transporte aucun secret : la voute est remise par un humain.

View file

@ -1,11 +1,40 @@
--- ---
# Flux réseau du runner de TENANT. Voir docs/flux-conception.md. # Flux réseau du runner de TENANT. Voir docs/flux-conception.md.
# #
# AUCUN FLUX PROPRE, ET C'EST LE POINT. Ce rôle ne fait que déposer un fichier ; ce qui # UN SEUL FLUX, ET C'EST LE LIEN DE FILIATION.
# consomme la voûte, c'est `serveur_ops` quand il configure les machines de l'écosystème,
# et ses flux sont déclarés chez lui. Ce rôle n'atteint NI la fabric NI la frontière —
# c'est exactement ce qui le sépare de `serveur_ops_site`.
# #
# Le dire explicitement plutôt que d'omettre le fichier : une absence de déclaration se # Ce rôle ne fait que déposer un fichier ; ce qui consomme la voûte, c'est `serveur_ops`
# lit comme un oubli, une déclaration vide se lit comme une décision. # quand il configure les machines de l'écosystème, et ses flux sont déclarés chez lui.
flux: [] # Ce rôle n'atteint NI la fabric NI la frontière — c'est exactement ce qui le sépare de
# `serveur_ops_site`.
#
# Mais il porte la marque de l'INSÉMINATION. Un écosystème neuf ne peut pas s'amorcer
# lui-même : quelqu'un doit poser sa première machine et lui donner de quoi continuer.
# Ce quelqu'un est le runner du SITE, et ce flux est le seul chemin par lequel il entre —
# vers cette machine-ci, et vers aucune autre du tenant.
#
# POURQUOI IL EST DÉCLARÉ ICI ET PAS AU SOCLE. Au socle, il ouvrirait le SSH du site vers
# TOUTES les machines du tenant, ce que l'isolation refuse. Déclaré sur ce rôle, il ne
# vaut que pour les hôtes qui le portent — un seul par écosystème. La portée du pouvoir
# est celle du groupe, et le groupe est écrit au plan.
#
# CE QU'IL NE TRANSPORTE JAMAIS : un secret. Le runner du SITE peut poser le socle, le
# moteur, le plan et le plancher de résolution — tout cela est public. Il ne détient pas
# la voûte du tenant (elle est hors dépôt) et n'en connaît pas le mot de passe. C'est
# l'humain qui arme ce runner, et c'est voulu : voir `docs/filiation-emancipation.md`.
flux:
- sens: ingress
port: 22
protocole: tcp
pair: runner_site
chiffrement: ssh
# CE ROLE N'OUVRE AUCUNE ECOUTE — il emprunte le sshd du socle, comme
# `serveur_backup`. Sans ce mot, P33 y voit deux roles qui se disputent le port 22
# sur la meme machine, et elle a raison de le demander : il n'y a que le registre
# pour distinguer « j'ouvre une ecoute » de « j'autorise une source de plus sur
# l'ecoute d'un autre ».
partage: true
raison: >-
Insémination : le runner du SITE amorce ce runner-ci — socle, moteur, plan,
plancher de résolution — jusqu'à ce qu'un humain lui remette sa voûte. Vers cette
machine seule, jamais vers le reste de l'écosystème.

View file

@ -234,6 +234,16 @@ def flux_frontiere(flux_par_role: dict[str, list[dict]]) -> list[tuple[str, dict
`voisins_site` — l'est-ouest INTER-TENANT. Deux tenants de la meme fabric vivent `voisins_site` — l'est-ouest INTER-TENANT. Deux tenants de la meme fabric vivent
sur des VLAN distincts, routes par la frontiere : leur trafic la sur des VLAN distincts, routes par la frontiere : leur trafic la
traverse, donc elle doit le porter. traverse, donc elle doit le porter.
ET UNE TROISIEME QUI N'EN EST PAS UNE : `runner_site`, l'INSEMINATION. Ce trafic
traverse bien la frontiere — le runner du SITE et celui du tenant vivent dans deux
VRF — mais le paquet y PENETRE PAR LA PATTE DU SITE, pas par le lien de transit. La
regle qui lui correspond est donc emise plus bas, avec les machines du site, depuis
la declaration `egress` de `serveur_ops_site`.
L'emettre ici AUSSI produirait une seconde regle attachee a la mauvaise interface :
jamais evaluee, impossible a distinguer d'une regle utile, et comptee comme telle par
tout ce qui audite ce devis. La declaration `ingress` du tenant n'est pas perdue pour
autant — c'est elle qui pose la regle nftables sur la machine visee, et elle seule.
OUBLIER LA SECONDE, C'ETAIT DECLARER UN FLUX QUE PERSONNE N'APPLIQUE (2026-08-24). Le OUBLIER LA SECONDE, C'ETAIT DECLARER UN FLUX QUE PERSONNE N'APPLIQUE (2026-08-24). Le
mot `voisins_site` etait accepte par la validation, le devis rendait zero regle, et mot `voisins_site` etait accepte par la validation, le devis rendait zero regle, et
@ -310,24 +320,14 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]:
return None, None return None, None
def _plan_site() -> Path | None: # CES TROIS LECTEURS ONT DEMENAGE DANS `underlay` (2026-08-28). `resoudre_flux` a eu
"""Le `plan/` du SITE, a cote de son `underlay.yml`.""" # besoin du plan du site a son tour, pour resoudre le pair `runner_site` ; une seconde
c = underlay_mod.chemin() # copie privee aurait fini par diverger de celle-ci sans que rien ne le signale. Lecon de
return (c.resolve().parent / "plan") if c else None # P41 appliquee au plan du site : une seule resolution, partagee. Les noms locaux restent
# pour ne pas reecrire trente appels.
_lire_plan_site = underlay_mod.lire_plan_site
def _lire_plan_site(nom: str) -> dict: _machines_du_plan_site = underlay_mod.machines_site
d = _plan_site() _services_du_plan_site = underlay_mod.services_site
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def _machines_du_plan_site() -> list[dict]:
srv = (_lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
def _ports_du_plan_site() -> dict[str, str]: def _ports_du_plan_site() -> dict[str, str]:
@ -346,15 +346,6 @@ def _ports_du_plan_site() -> dict[str, str]:
return out return out
def _services_du_plan_site() -> dict[str, list[str]]:
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
def construire(tenants: list[tuple[str, str, dict]]) -> dict: def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure — rendue ensuite en texte ou en JSON.""" """Le devis, en structure — rendue ensuite en texte ou en JSON."""
flux_par_role = charger_flux() flux_par_role = charger_flux()
@ -481,6 +472,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
regles: list[dict] = [] regles: list[dict] = []
tenants_sans_inventaire: list[str] = [] tenants_sans_inventaire: list[str] = []
tenants_sans_admin: list[str] = [] tenants_sans_admin: list[str] = []
# PAS D'ALIAS POUR LA SOURCE DE L'INSEMINATION ICI. La regle est emise du cote SITE
# (voir `flux_frontiere`), ou la source est deja l'alias de role du runner —
# `SETOPS_SITE_SERVEUR_OPS_SITE`. En poser un second ici aurait laisse sur la
# frontiere un alias qu'aucune regle ne reference : `appliquer_opnsense` l'aurait
# cree, et personne n'aurait su a quoi il sert.
for nom, pfx, n in tenants: for nom, pfx, n in tenants:
etiquette = f"{pfx}{n['index']}" etiquette = f"{pfx}{n['index']}"
alias_tenant = f"SETOPS_TENANT_{etiquette}" alias_tenant = f"SETOPS_TENANT_{etiquette}"
@ -691,6 +688,26 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"passerelle : le site n'a pas de resolveur a lui.", "passerelle : le site n'a pas de resolveur a lui.",
}) })
# LES RUNNERS DES TENANTS QUE CE SITE HEBERGE — destination de l'insemination.
#
# Un seul alias pour tous : ce que la regle dit, c'est « le runner du site peut
# amorcer le runner d'un tenant de CE site ». Le perimetre est celui du registre
# `underlay.tenants`, deja la source du reste du devis.
#
# Ce sont des adresses d'hotes, pas des reseaux : ouvrir le supernet d'un tenant
# au SSH du site rendrait l'insemination indiscernable d'un droit d'entree
# general — exactement ce que l'isolation refuse.
_runners_tenants = sorted({ip for _n2, _p2, _x2 in tenants
for ip in (cibles_par_role("serveur_ops_tenant",
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
if _runners_tenants:
alias["SETOPS_RUNNERS_TENANTS"] = {
"type": "host",
"contenu": _runners_tenants,
"description": "Runners des tenants heberges — cible de l'insemination",
}
_roles_site = sorted({g for gs in _services_site.values() for g in gs} _roles_site = sorted({g for gs in _services_site.values() for g in gs}
| {"serveur_debian"}) | {"serveur_debian"})
for _role in _roles_site: for _role in _roles_site:
@ -746,6 +763,10 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_portees = [(_a, z) for z in _zones] or [(_a, if_site)] _portees = [(_a, z) for z in _zones] or [(_a, if_site)]
# `derive` se resout par le port declare au plan ; sans lui, on n'emet # `derive` se resout par le port declare au plan ; sans lui, on n'emet
# rien plutot qu'une regle que le boitier refusera. # rien plutot qu'une regle que le boitier refusera.
# Une destination qui n'existe pas ne produit pas de regle : aucun
# tenant de ce site ne declare encore de runner.
if "serveur_ops_tenant" in _pairs_fl and not _runners_tenants:
continue
_p = _ports(_fl) _p = _ports(_fl)
if any(not str(x).isdigit() for x in _p): if any(not str(x).isdigit() for x in _p):
_reel = _ports_site.get(_role) _reel = _ports_site.get(_role)
@ -764,7 +785,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"protocole": _fl.get("protocole", "tcp"), "protocole": _fl.get("protocole", "tcp"),
"source": _src if _entrant else _a, "source": _src if _entrant else _a,
"destination": _a if _entrant else ( "destination": _a if _entrant else (
"SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"), "SETOPS_RUNNERS_TENANTS" if "serveur_ops_tenant" in _pairs_fl
else "SETOPS_FABRIC" if "fabric" in _pairs_fl
else "!SETOPS_INTERNES"),
"ports": _p, "ports": _p,
"chiffrement": _fl.get("chiffrement"), "chiffrement": _fl.get("chiffrement"),
"role": _role, "role": _role,

View file

@ -65,7 +65,7 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-
# tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul # tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul
# a s'y adresser est le runner du site, et c'est tout son objet — materialiser. # a s'y adresser est le runner du site, et c'est tout son objet — materialiser.
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin", MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
"voisins_site", "fabric"} "voisins_site", "fabric", "runner_site"}
GROUPE_EDGE = "serveur_nginx" GROUPE_EDGE = "serveur_nginx"
@ -229,10 +229,11 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
for p in (pair if isinstance(pair, list) else [pair]): for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"): if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
if p in ("admin", "voisins_site"): if p in ("admin", "voisins_site", "runner_site"):
# Ni l'un ni l'autre n'est un groupe d'hotes : ce sont des RESEAUX. `admin` # Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
# vient de l'intrant `nftables_admin_ssh` ; `voisins_site` des supernets des # sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
# autres tenants federes. Traites a part, plus bas. # `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
# federes ; `runner_site` du plan du SITE. Traites a part, plus bas.
continue continue
if p == "flotte": if p == "flotte":
noms |= actifs_noms noms |= actifs_noms
@ -245,9 +246,30 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
srcs |= set(_sources_admin_ssh()) srcs |= set(_sources_admin_ssh())
if "voisins_site" in demandes: if "voisins_site" in demandes:
srcs |= set(_supernets_voisins()) srcs |= set(_supernets_voisins())
if "runner_site" in demandes:
srcs |= set(_adresses_runner_site())
return sorted(srcs) return sorted(srcs)
def _adresses_runner_site() -> list[str]:
"""L'adresse du runner du SITE — la source du flux d'insemination.
DERIVEE DU PLAN DU SITE, jamais ecrite ici. Le site est le seul a savoir ou vit son
runner, et il l'a deja deplace une fois (decoupage en zones du 2026-08-25). Une
adresse recopiee ici aurait survecu a ce deplacement en silence, et le tenant se
serait retrouve avec une regle qui n'autorise personne — la panne la plus muette de
cette couche.
Rend [] quand le depot de l'hebergeur n'est pas monte : un tenant clone sans lui
degrade proprement, il ne casse pas.
"""
try:
import underlay as underlay_mod
return underlay_mod.adresses_site_portant("serveur_ops_site")
except Exception:
return []
def _supernets_voisins() -> list[str]: def _supernets_voisins() -> list[str]:
"""Les supernets des AUTRES tenants que CE SITE heberge. """Les supernets des AUTRES tenants que CE SITE heberge.

View file

@ -21,14 +21,25 @@ sys.path.insert(0, str(RACINE / "scripts"))
import underlay as u # noqa: E402 import underlay as u # noqa: E402
from inventory_rules import OCTET_ZONE # noqa: E402 from inventory_rules import OCTET_ZONE # noqa: E402
SUPERNETS = ["10.11.0.0/16", "10.17.0.0/16"] # deux sites : index 11 et 17 SUPERNETS = ["10.11.0.0/16", "10.17.0.0/16"] # deux tenants : index 11 et 17
MIEN = 11 MIEN = "OPS-Mien" # le tenant dont ce site occupe la bande basse
MIEN_INDEX = 11
# UN SITE N'A PAS D'INDEX (add94f2, 2026-08-25). Il ne derive pas : il DECLARE ses
# adresses, parce qu'il EST le terrain. Ce qui a remplace l'index, c'est `bande_basse_de`
# — porte par CHAQUE reseau, et non par le site : deux reseaux d'un meme site peuvent
# occuper la bande basse de deux tenants differents.
#
# CE TEST A SURVECU TROIS JOURS A CE CHANGEMENT EN ETANT ROUGE (2026-08-25 -> 08-28), et
# personne ne l'a vu : le geste quotidien est `make prouver`, qui ne joue pas les tests.
# Un test rouge que rien ne lit ne garde plus rien.
def _erreurs(sous: str, index: int | None = MIEN) -> list[str]: def _erreurs(sous: str, mien: str | None = MIEN) -> list[str]:
site = {"reseaux": [{"nom": "g", "vlan": 10, "sous_reseau": sous}]} reseau = {"nom": "g", "vlan": 10, "sous_reseau": sous}
if index is not None: if mien is not None:
site["index"] = index reseau["bande_basse_de"] = mien
site = {"reseaux": [reseau], "tenants": {MIEN: MIEN_INDEX}}
return [e for e in u.valider(site, tenants_vlans=set(), tenants_supernets=SUPERNETS) return [e for e in u.valider(site, tenants_vlans=set(), tenants_supernets=SUPERNETS)
if "deborde" in e or "chevauche le supernet" in e] if "deborde" in e or "chevauche le supernet" in e]
@ -60,19 +71,20 @@ def test_supernet_d_un_autre_site_refuse():
assert _erreurs("10.17.0.0/24"), "le supernet d'un autre site doit etre refuse" assert _erreurs("10.17.0.0/24"), "le supernet d'un autre site doit etre refuse"
def test_sans_bande_basse_declaree_tout_chevauchement_est_refuse():
"""Le defaut est le refus : un reseau qui ne DIT PAS de quel tenant il occupe la
bande basse n'a droit a aucun chevauchement, pas meme sur 10.11.x. Ne pas declarer
ne peut pas valoir permission."""
assert _erreurs("10.11.0.0/24", mien=None), \
"sans `bande_basse_de`, un chevauchement doit etre refuse"
def test_hors_de_tout_supernet_accepte(): def test_hors_de_tout_supernet_accepte():
"""Le stockage (192.168.x, identique partout) et l'heritage 10.0.x avant migration.""" """Le stockage (192.168.x, identique partout) et l'heritage 10.0.x avant migration."""
for sous in ("192.168.20.0/24", "192.168.31.0/24", "10.0.0.0/24"): for sous in ("192.168.20.0/24", "192.168.31.0/24", "10.0.0.0/24"):
assert _erreurs(sous) == [], f"{sous} est hors des supernets : conforme" assert _erreurs(sous) == [], f"{sous} est hors des supernets : conforme"
def test_sans_index_on_retombe_sur_la_regle_stricte():
"""Un underlay qui n'a pas encore migre ne declare pas d'index : aucun chevauchement
n'est alors tolere, ce qui reste le comportement sur."""
assert _erreurs("10.11.0.0/24", index=None), \
"sans index, tout chevauchement doit etre refuse"
# --- Bornes de l'index (2026-08-12) ----------------------------------------------- # --- Bornes de l'index (2026-08-12) -----------------------------------------------

View file

@ -97,6 +97,63 @@ def charger() -> dict | None:
return data.get("underlay") or None return data.get("underlay") or None
# --- LE PLAN DU SITE, a cote de sa carte ---------------------------------------
#
# `underlay.yml` decrit ce qui est MESURE (commutateurs, VLAN, MTU) ; `plan/` decrit ce
# qui est VOULU (machines, services, intrants). Les deux vivent dans le meme depot
# d'hebergeur, et c'est `chemin()` qui sait ou il est.
#
# CES LECTEURS VIVENT ICI, ET PAS DANS LEURS APPELANTS. `devis_opnsense` les portait en
# prive ; `resoudre_flux` en avait besoin a son tour, et une seconde copie aurait
# fini par diverger de la premiere sans que rien ne le dise. C'est la lecon de P41
# appliquee au plan du site : une seule resolution, partagee.
def plan_site() -> Path | None:
"""Le dossier `plan/` du SITE, a cote de son `underlay.yml`."""
c = chemin()
return (c.resolve().parent / "plan") if c else None
def lire_plan_site(nom: str) -> dict:
"""Un fichier du plan du site, ou {} s'il n'y en a pas."""
d = plan_site()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def machines_site() -> list[dict]:
"""Les machines ACTIVES du plan du site, chacune portant son `nom`."""
srv = (lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
def services_site() -> dict[str, list[str]]:
"""{machine du site: [groupes qu'elle porte]}, depuis `plan/applications.yml`."""
apps = (lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
def adresses_site_portant(groupe: str) -> list[str]:
"""Les adresses des machines du SITE qui portent ce groupe.
DERIVE, JAMAIS ECRIT. L'adresse du runner de site est la seule source du flux
d'insemination : l'ecrire en dur quelque part la ferait mentir le jour ou le site
redecoupe ses zones — ce qui est arrive le 2026-08-25, et a coute cinq machines
bloquees.
"""
svc = services_site()
return sorted({str(m["ip"]) for m in machines_site()
if m.get("ip") and groupe in svc.get(m["nom"], [])})
def reseaux(underlay: dict | None) -> list[dict]: def reseaux(underlay: dict | None) -> list[dict]:
return (underlay or {}).get("reseaux", []) or [] return (underlay or {}).get("reseaux", []) or []