diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2753e..9a15fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,82 @@ # 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 **52 preuves.** Entre tenants, la frontière refuse tout ce qui n'est pas déclaré. Mais un diff --git a/docs/audit/preuve-2026-08-28.md b/docs/audit/preuve-2026-08-28.md index 0ff7531..a3f7d9e 100644 --- a/docs/audit/preuve-2026-08-28.md +++ b/docs/audit/preuve-2026-08-28.md @@ -6,8 +6,8 @@ > [`docs/audit/README.md`](README.md), et le registre trace : > [`docs/audit/affirmations.md`](affirmations.md). -- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute) +- **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** : ❌ NON CONFORME (51 OK · 1 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). | | 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). | | 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. | @@ -37,7 +37,7 @@ | 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. | | 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). | | 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. | @@ -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. | | 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). | -| 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)). | | 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). | @@ -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. | | 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 | -| 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. | | 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 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). | -| 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. | +| 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 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 | | 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. | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 026a7f2..1b32c20 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 | 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 | 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 | 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 | 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é. | @@ -103,7 +105,7 @@ - **clair** : 35 flux - **n-a** : 3 flux -- **ssh** : 5 flux +- **ssh** : 7 flux - **starttls** : 6 flux - **tls** : 9 flux - **tls-cible** : 2 flux diff --git a/roles/serveur_ops_site/meta/flux.yml b/roles/serveur_ops_site/meta/flux.yml index 206938b..6c17b14 100644 --- a/roles/serveur_ops_site/meta/flux.yml +++ b/roles/serveur_ops_site/meta/flux.yml @@ -2,9 +2,25 @@ # 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 -# la frontiere. Chacun est un pouvoir distinct, donc declare a part. Ce rôle n'entre JAMAIS -# chez un tenant — c'est `serveur_ops` qui configure les machines, une fois qu'elles -# existent. +# la frontiere. Chacun est un pouvoir distinct, donc declare a part. +# +# 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: - sens: egress port: 8006 @@ -38,3 +54,19 @@ 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 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. diff --git a/roles/serveur_ops_tenant/meta/flux.yml b/roles/serveur_ops_tenant/meta/flux.yml index 1fde4bd..318cd11 100644 --- a/roles/serveur_ops_tenant/meta/flux.yml +++ b/roles/serveur_ops_tenant/meta/flux.yml @@ -1,11 +1,40 @@ --- # 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 -# 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`. +# UN SEUL FLUX, ET C'EST LE LIEN DE FILIATION. # -# Le dire explicitement plutôt que d'omettre le fichier : une absence de déclaration se -# lit comme un oubli, une déclaration vide se lit comme une décision. -flux: [] +# Ce rôle ne fait que déposer un fichier ; ce qui 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`. +# +# 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. diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 4bf42fd..cc76e67 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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 sur des VLAN distincts, routes par la frontiere : leur trafic la 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 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 -def _plan_site() -> Path | None: - """Le `plan/` du SITE, a cote de son `underlay.yml`.""" - c = underlay_mod.chemin() - return (c.resolve().parent / "plan") if c else None - - -def _lire_plan_site(nom: str) -> dict: - 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_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"] +# CES TROIS LECTEURS ONT DEMENAGE DANS `underlay` (2026-08-28). `resoudre_flux` a eu +# besoin du plan du site a son tour, pour resoudre le pair `runner_site` ; une seconde +# copie privee aurait fini par diverger de celle-ci sans que rien ne le signale. Lecon de +# 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 +_machines_du_plan_site = underlay_mod.machines_site +_services_du_plan_site = underlay_mod.services_site def _ports_du_plan_site() -> dict[str, str]: @@ -346,15 +346,6 @@ def _ports_du_plan_site() -> dict[str, str]: 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: """Le devis, en structure — rendue ensuite en texte ou en JSON.""" flux_par_role = charger_flux() @@ -481,6 +472,12 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: regles: list[dict] = [] tenants_sans_inventaire: 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: etiquette = f"{pfx}{n['index']}" 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.", }) + # 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} | {"serveur_debian"}) 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)] # `derive` se resout par le port declare au plan ; sans lui, on n'emet # 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) if any(not str(x).isdigit() for x in _p): _reel = _ports_site.get(_role) @@ -764,7 +785,9 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "protocole": _fl.get("protocole", "tcp"), "source": _src if _entrant else _a, "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, "chiffrement": _fl.get("chiffrement"), "role": _role, diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 2305d1d..7fc267e 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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 # 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", - "voisins_site", "fabric"} + "voisins_site", "fabric", "runner_site"} 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]): if p in ("localhost", "externe", "expositions", "derive"): continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud - if p in ("admin", "voisins_site"): - # Ni l'un ni l'autre n'est un groupe d'hotes : ce sont des RESEAUX. `admin` - # vient de l'intrant `nftables_admin_ssh` ; `voisins_site` des supernets des - # autres tenants federes. Traites a part, plus bas. + if p in ("admin", "voisins_site", "runner_site"): + # 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 + # federes ; `runner_site` du plan du SITE. Traites a part, plus bas. continue if p == "flotte": 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()) if "voisins_site" in demandes: srcs |= set(_supernets_voisins()) + if "runner_site" in demandes: + srcs |= set(_adresses_runner_site()) 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]: """Les supernets des AUTRES tenants que CE SITE heberge. diff --git a/scripts/tests/test_adressage_derive.py b/scripts/tests/test_adressage_derive.py index f09e0ba..e557df0 100644 --- a/scripts/tests/test_adressage_derive.py +++ b/scripts/tests/test_adressage_derive.py @@ -21,14 +21,25 @@ sys.path.insert(0, str(RACINE / "scripts")) import underlay as u # 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 -MIEN = 11 +SUPERNETS = ["10.11.0.0/16", "10.17.0.0/16"] # deux tenants : index 11 et 17 +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]: - site = {"reseaux": [{"nom": "g", "vlan": 10, "sous_reseau": sous}]} - if index is not None: - site["index"] = index +def _erreurs(sous: str, mien: str | None = MIEN) -> list[str]: + reseau = {"nom": "g", "vlan": 10, "sous_reseau": sous} + if mien is not None: + 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) 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" +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(): """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"): 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) ----------------------------------------------- diff --git a/scripts/underlay.py b/scripts/underlay.py index e822d39..ddc3adf 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -97,6 +97,63 @@ def charger() -> dict | 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]: return (underlay or {}).get("reseaux", []) or []