site : quatre zones d'autorite, et l'ordre inscrit dans les integrations

Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.

Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.

L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.

Autres defauts du meme soir :
  - le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
    boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
  - dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
  - l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.

ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.

44 preuves vertes, ansible-lint profil production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 17:31:07 -04:00
parent 158c3b314a
commit 1a7c042e5b
15 changed files with 391 additions and 40 deletions

View file

@ -1,5 +1,72 @@
# CHANGELOG — Set-OPS
## 2026-08-25 — Le SITE en quatre zones, et l'ordre inscrit dans les intégrations
**44 preuves.** Le site n'est plus un `/24` plat : une zone par **nature d'autorité**,
chacune son VLAN et sa patte sur la frontière.
```
opt3 vlan031 10.0.31.0/24 pilotage site-ops-01 voûte underlay, API Proxmox + OPNsense
opt4 vlan032 10.0.32.0/24 autorite site-pki-01 racine de confiance
opt5 vlan033 10.0.33.0/24 genome site-forge-01, site-cache-01
opt6 vlan034 10.0.34.0/24 service site-dns-01
```
L'inversion que ça corrige, mesurée : les cinq VM du site n'avaient **aucun** filtrage
est-ouest — `enable=None`, `policy_in=None`, zéro règle — contre `enable=1 policy_in=DROP`
sur une machine de tenant. **La machine la plus autoritaire du système était la moins
protégée.** Le filtrage est nord-sud, par choix de l'exploitant : un seul point de police,
un seul langage, un seul devis — plutôt que deux politiques à tenir d'accord.
Le devis passe de 90 à **117 règles**, et c'est tout le sens du découpage : *ce qui était
gratuit devient policé*. Chaque flux `flotte` produit une règle **par zone source**, avec
une destination nommée — le rôle visé, jamais la zone entière.
### L'ordre fait partie de l'intégration
`client_pki` s'exécutait sur tous les hôtes en parallèle. Sur celui qui porte l'autorité,
le rôle réémet le certificat de `step-ca` et **recharge le service** ; les quatre autres
l'interrogeaient dans cette fenêtre et échouaient ensemble sur `TLS handshake timeout` —
un message qui accuse le réseau alors que la cause est une course de dix secondes.
Ce n'est pas propre à la PKI. Chaque intégration déclare désormais sa dépendance dans
`meta/integration.yml`, les playbooks de groupe en sont le **miroir généré**, et **P44**
refuse l'écart. Quatre contrôles négatifs : play unique, second play qui n'exclut pas le
serveur, `any_errors_fatal` retiré, déclaration absente.
`serveur: ~` est une réponse, pas un oubli — `client_metrique` pose un exportateur que
Prometheus vient *lire*. Toutes les intégrations ne dépendent pas d'un service debout.
*Le cas dangereux n'est pas le certificat mais le **résolveur** : un hôte basculé sur un
résolveur pas encore prêt devient muet, et le runner qui devrait le réparer tombe avec lui.*
### Le plancher avant le premier `apt`
`hosts_statiques` venait après `common_packages`. Or apt vise le cache **par son nom** —
ce qui est voulu. Le jour où les adresses changent, la boucle se referme : apt échoue faute
de résoudre, donc le socle n'atteint jamais le plancher, donc le plancher reste périmé.
Cinq machines bloquées, `/etc/hosts` vide. Le plancher **ne s'installe pas, il rend
installable** — il passe donc avant.
### `dns_amorcage` se dérive
Écrit en dur, il a eu tort deux fois : la frontière d'abord, puis une adresse que le
découpage a déplacée. Il se lit maintenant sur l'hôte qui porte `serveur_resolveur`.
Et l'ACL du résolveur dérivait de `setops_supernet` — juste tant que le site était plat.
Découpé, il refusait trois zones sur quatre, *et la panne ressemble à un DNS mort alors
que c'est une autorisation*.
### Ce que je me suis trompé à croire
J'ai diagnostiqué un trou noir de MTU et déclaré 1450 sur les quatre zones. **C'était
faux.** Il n'y a pas de VXLAN sur ce chemin — il sert aux zones EVPN des tenants — et tout
est à 1500 de bout en bout, vérifié sur `igb1`, les `vlan03x`, `bond3`, `vmbr3` et les
cartes. Ma mesure (`1500 → échec`, `1300 → 200`) était réelle, mon interprétation ne
l'était pas : je venais de débrancher la carte à chaud en modifiant `net0`, et chaque
`ip link set mtu` **reconfigurait l'interface** au passage. C'est la reconfiguration qui
débloquait, pas la valeur. Les VM sont en `mtu=1`, héritant du pont, à 1500.
## 2026-08-25 — P43 : la frontière voit-elle les machines du site ?
**43 preuves.** Celle-ci couvre ce qui a failli coûter 36 objets ce soir : le devis de la

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml`
- **Verdict** : ✅ CONFORME (43 OK · 0 echec · 0 saute)
- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 0 saute)
## Preuves
@ -35,8 +35,8 @@
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| 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 : 8 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 86 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| 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). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 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. |
@ -55,7 +55,8 @@
| 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 : 45 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, 21 regle(s) du site. |
| 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. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
## Couverture des affirmations ✅ du registre

View file

@ -1,19 +1,38 @@
---
- name: Appliquer le groupe client_artefacts
hosts: client_artefacts
# client_artefacts — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_artefacts`, et sa metadonnee le declare
# (`roles/client_artefacts/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_artefacts — le serveur d'abord
hosts: client_artefacts:&serveur_artefacts
become: true
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_artefacts
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_artefacts
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_artefacts
- name: Intégration client_artefacts — puis les hôtes qui s'y adressent
hosts: client_artefacts:!serveur_artefacts
become: true
module_defaults: *defauts_artefacts
gather_facts: true
pre_tasks: *pre_artefacts
roles:
- client_artefacts

View file

@ -1,22 +1,38 @@
---
- name: Appliquer le groupe client_journal
hosts: client_journal
# client_journal — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_loki`, et sa metadonnee le declare
# (`roles/client_journal/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_journal — le serveur d'abord
hosts: client_journal:&serveur_loki
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_journal
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_journal
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_journal
- name: Intégration client_journal — puis les hôtes qui s'y adressent
hosts: client_journal:!serveur_loki
become: true
module_defaults: *defauts_journal
gather_facts: true
pre_tasks: *pre_journal
roles:
- client_journal

View file

@ -1,22 +1,38 @@
---
- name: Appliquer le groupe client_pki
hosts: client_pki
# client_pki — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_step_ca`, et sa metadonnee le declare
# (`roles/client_pki/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
- name: Intégration client_pki — le serveur d'abord
hosts: client_pki:&serveur_step_ca
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
any_errors_fatal: true
module_defaults: &defauts_pki
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
pre_tasks: &pre_pki
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_pki
- name: Intégration client_pki — puis les hôtes qui s'y adressent
hosts: client_pki:!serveur_step_ca
become: true
module_defaults: *defauts_pki
gather_facts: true
pre_tasks: *pre_pki
roles:
- client_pki

View file

@ -1,15 +1,38 @@
---
- name: Appliquer le groupe client_resolveur
hosts: client_resolveur
become: true
gather_facts: true
# client_resolveur — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
#
# Cette integration depend de `serveur_resolveur`, et sa metadonnee le declare
# (`roles/client_resolveur/meta/integration.yml`). L'hote qui PORTE ce serveur est donc
# configure AVANT ceux qui s'y adressent — sinon les clients s'enrolent aupres d'un
# service qui n'est pas encore la, ou qui redemarre au meme instant.
#
# Ce fichier est le miroir de la declaration ; la preuve P44 refuse tout ecart.
#
# `any_errors_fatal` sur le premier play : si le serveur n'a pas pu etre configure,
# y raccrocher des clients ne produit que des echecs qui accusent le reseau.
pre_tasks:
- name: Intégration client_resolveur — le serveur d'abord
hosts: client_resolveur:&serveur_resolveur
become: true
any_errors_fatal: true
module_defaults: &defauts_resolveur
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks: &pre_resolveur
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_resolveur
- name: Intégration client_resolveur — puis les hôtes qui s'y adressent
hosts: client_resolveur:!serveur_resolveur
become: true
module_defaults: *defauts_resolveur
gather_facts: true
pre_tasks: *pre_resolveur
roles:
- client_resolveur

View file

@ -78,8 +78,21 @@
| select('in', serveur_debian_resolv_texte) | list | length == 0
roles:
- common_packages
# LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25).
#
# `hosts_statiques` venait APRÈS `common_packages`, qui commence par un `apt update`.
# Or apt vise le cache PAR SON NOM — `site-cache-01.<domaine>` — ce qui est voulu :
# une configuration agnostique de l'adressage.
#
# Le jour où les adresses changent, la boucle se referme : apt échoue faute de
# résoudre, donc le socle n'atteint jamais `hosts_statiques`, donc le plancher reste
# périmé, donc apt échoue. Mesuré le 2026-08-25 après le découpage du site en zones :
# cinq machines bloquées, `/etc/hosts` vide de toute entrée du site.
#
# Le résolveur d'amorçage est déjà une `pre_task` pour exactement cette raison. Le
# plancher est de la même nature : il ne s'installe pas, **il rend installable**.
- hosts_statiques
- common_packages
- qemu_guest_agent
- cloud_init
- sudo_ansible

View file

@ -2,6 +2,14 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Braquer apt sur un cache absent casse toute installation ulterieure — y compris celle
# des roles qui suivent dans le meme deploiement.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_artefacts
universelle: true
raison: >-
Tout hote installe des paquets, et doit les prendre chez lui quand l'ecosysteme a

View file

@ -2,6 +2,13 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Un collecteur qui expedie vers un puits inexistant accumule en local, sans le dire.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_loki
universelle: true
raison: >-
Tout hote expedie ses journaux. Un incident se reconstitue depuis les traces

View file

@ -3,6 +3,15 @@
# serveur du plan : une ligne recopiee 14 fois n'existe que pour etre oubliee une
# quinzieme. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# AUCUNE. `client_metrique` pose un exportateur que Prometheus vient LIRE : il n'attend
# personne, c'est le serveur qui vient a lui. La distinction compte — toutes les
# integrations ne dependent pas d'un service debout.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: ~
universelle: true
raison: >-
Tout hote est mesure. Une machine hors supervision tombe sans que personne

View file

@ -2,6 +2,16 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# L'AC doit exister ET repondre avant qu'un hote lui demande un certificat. Sur l'hote
# qui la porte, le role recharge `step-ca` en reemettant son propre certificat :
# pendant cette fenetre, elle ne repond plus. Mesure du 2026-08-25 : quatre clients en
# echec simultane, message `TLS handshake timeout` — qui accuse le reseau, pas l'ordre.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_step_ca
universelle: true
raison: >-
Tout hote fait confiance a l'AC interne et porte un certificat : c'est le

View file

@ -2,6 +2,15 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
# DE QUI CETTE INTEGRATION DEPEND, ET POURQUOI L'ORDRE COMPTE.
#
# Basculer un hote sur un resolveur qui n'est pas encore pret le rend MUET, et avec lui
# tout ce qui resout un nom. C'est la dependance la plus dangereuse du lot : le runner
# qui devrait reparer tombe avec les autres.
#
# Le playbook de groupe applique l'hote qui porte ce serveur AVANT ceux qui s'y
# adressent, et la preuve P44 refuse tout ecart entre cette declaration et lui.
serveur: serveur_resolveur
universelle: true
raison: >-
Tout hote resout des noms, et doit le faire aupres du resolveur de son ecosysteme

View file

@ -330,6 +330,22 @@ def _machines_du_plan_site() -> list[dict]:
if str(s.get("etat", "actif")) == "actif"]
def _ports_du_plan_site() -> dict[str, str]:
"""Port declare au plan pour chaque role du site — ce que `port: derive` vaut ici.
UN PORT NON NUMERIQUE N'EST PAS UNE ECOUTE (voir `resoudre_flux`). `derive` dit que le
port depend du deploiement : la forge ecoute 3000 derriere un edge, 443 quand elle
sert son propre TLS. Le rendre tel quel produirait une regle que le boitier refuse —
on l'a deja paye sur le pare-feu est-ouest.
"""
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, str] = {}
for app in apps.values():
if app.get("groupe") and app.get("port"):
out[str(app["groupe"])] = str(app["port"])
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]] = {}
@ -348,6 +364,14 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
if_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT)
if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION)
if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE)
# UNE PATTE PAR ZONE DU SITE (2026-08-25). Le site n'est plus un `/24` plat : chaque
# nature d'autorite a son VLAN, et le trafic penetre le pare-feu par SA patte. Une
# regle posee sur la mauvaise interface ne correspond jamais.
if_zones = dict(_intr.get("opnsense_if_zones") or {})
def _if_de(reseau: str | None) -> str:
"""L'interface ou arrive le trafic de cette zone ; l'ancienne patte a defaut."""
return if_zones.get(str(reseau or ""), if_site)
nom_actif, nomenclature_active = _instance_active()
# L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet.
@ -596,6 +620,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# attrape, et rien d'autre ne l'aurait fait.
_machines_site = _machines_du_plan_site()
_services_site = _services_du_plan_site()
_ports_site = _ports_du_plan_site()
if _machines_site:
_res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)}
_sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"])
@ -687,6 +712,24 @@ 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 "flotte" in _pairs_fl:
# CE QUI ETAIT GRATUIT DEVIENT POLICE (2026-08-25).
#
# `flotte` veut dire « les machines de mon ecosysteme ». Dans un `/24`
# plat, ce trafic ne traversait rien : le cache, la forge, le
# resolveur et l'AC se joignaient en L2, sans qu'aucune regle
# n'existe — ni ne manque.
#
# Une zone par autorite change cela : une machine qui interroge le
# resolveur ou tire un paquet du cache passe desormais par la
# frontiere. Ce qui n'avait pas besoin d'etre dit doit l'etre.
#
# Une regle PAR ZONE SOURCE : le paquet penetre le pare-feu par la
# patte de la zone d'ou il part, et une regle posee ailleurs ne
# correspondrait jamais. La destination reste nommee — le role vise,
# jamais la zone entiere.
_portees = [("SETOPS_SITE", z) for z in
sorted({_if_de(m.get("reseau")) for m in _machines_site})]
elif _entrant and "22" in _ports(_fl) and _admin_site:
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
# frontiere, pas par celle du site : c'est la que le paquet penetre
@ -695,7 +738,25 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur
else:
_portees = [(_a, if_site)]
# Une portee par ZONE ou ce role est present : deux machines d'un meme
# role dans deux zones differentes ne partagent pas leur patte.
_zones = sorted({_if_de(m.get("reseau")) for m in _machines_site
if _role == "serveur_debian"
or _role in _services_site.get(m["nom"], [])})
_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.
_p = _ports(_fl)
if any(not str(x).isdigit() for x in _p):
_reel = _ports_site.get(_role)
if not _reel:
# SUR STDERR, JAMAIS STDOUT : ce script rend du JSON, et une
# note au milieu le rend illisible a `appliquer_opnsense`.
print(f"note : {_role} declare un port `{'/'.join(_p)}` que le "
f"plan du site ne resout pas — aucune regle emise.",
file=sys.stderr)
continue
_p = [_reel]
for _src, _itf in _portees:
regles.append({
"sens": "in" if _entrant else "out",
@ -704,7 +765,7 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"source": _src if _entrant else _a,
"destination": _a if _entrant else (
"SETOPS_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"),
"ports": _ports(_fl),
"ports": _p,
"chiffrement": _fl.get("chiffrement"),
"role": _role,
"tenant": "SITE",

View file

@ -1013,6 +1013,62 @@ def preuve_devis_frontiere_du_site() -> tuple[bool, str]:
f"{n_site} regle(s) du site.")
def preuve_ordre_des_integrations() -> tuple[bool, str]:
"""Une integration applique SON SERVEUR avant ceux qui s'y adressent.
POURQUOI (2026-08-25). `client_pki` s'executait sur tous les hotes en parallele. Sur
celui qui porte l'autorite, le role reemet le certificat de `step-ca` et RECHARGE le
service ; les quatre autres l'interrogeaient dans cette fenetre et echouaient ensemble
sur `net/http: TLS handshake timeout` — un message qui accuse le reseau alors que la
cause est une course de dix secondes.
Ce n'est pas propre a la PKI. Toute integration a la meme forme : un `client_*`
s'adresse a un `serveur_*`, et l'ordre compte. Le cas le plus dangereux n'est pas le
certificat mais le RESOLVEUR — un hote bascule sur un resolveur pas encore pret
devient muet, et le runner qui devrait le reparer tombe avec lui.
La dependance se declare dans `roles/<client>/meta/integration.yml` (`serveur:`), et
le playbook de groupe en est le miroir. Cette preuve refuse l'ecart entre les deux :
sans elle, la declaration deviendrait un commentaire, et l'ordre une coincidence.
`serveur: ~` est une reponse valable — `client_metrique` pose un exportateur que
Prometheus vient LIRE, il n'attend personne. Toutes les integrations ne dependent pas
d'un service debout, et le dire explicitement vaut mieux que l'omettre.
"""
manques: list[str] = []
vus = 0
for meta in sorted((RACINE / "roles").glob("client_*/meta/integration.yml")):
role = meta.parent.parent.name
d = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("integration") or {}
if "serveur" not in d:
manques.append(f"{role} : `meta/integration.yml` ne declare pas `serveur:` — "
f"on ne peut donc pas savoir si son ordre est correct "
f"(mettre `~` si l'integration n'attend aucun service)")
continue
srv = d.get("serveur")
pb = RACINE / "playbooks" / "groupes" / f"{role}.yml"
if not pb.is_file():
continue
texte = pb.read_text(encoding="utf-8")
if not srv:
continue # rien a ordonner, et c'est declare
vus += 1
if f"hosts: {role}:&{srv}" not in texte:
manques.append(f"{role} : declare dependre de `{srv}`, mais son playbook "
f"n'applique pas d'abord `{role}:&{srv}`")
if f"hosts: {role}:!{srv}" not in texte:
manques.append(f"{role} : son playbook n'exclut pas `{srv}` du second play — "
f"le serveur serait reconfigure deux fois, et l'ordre perdu")
if "any_errors_fatal: true" not in texte:
manques.append(f"{role} : sans `any_errors_fatal` sur le premier play, un "
f"serveur en echec laisse ses clients s'y raccrocher pour rien")
if manques:
return False, ("L'ordre des integrations n'est pas garanti :\n - "
+ "\n - ".join(manques))
return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.")
def preuve_glossaire_enseigne() -> tuple[bool, str]:
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
@ -1351,6 +1407,8 @@ PREUVES: list[dict] = [
"func": preuve_resolution_unique},
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
"func": preuve_edge_porte_ses_noms},
{"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [],
"func": preuve_ordre_des_integrations},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -93,9 +93,32 @@ def inventaire() -> dict:
return {"_meta": {"hostvars": {}}}
par_reseau = {r.get("nom"): r for r in U.reseaux(u)}
# Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de
# l'ecosysteme, telle que le plan la dessine.
_zones_du_site = sorted({str((par_reseau.get(s.get("reseau")) or {}).get("sous_reseau"))
for s in serveurs.values()
if str(s.get("etat", "actif")) == "actif"
and (par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")})
# LE RESOLVEUR D'AMORCAGE SE DERIVE, IL NE S'ECRIT PAS (2026-08-25).
#
# Il a ete ecrit en dur deux fois, et il a eu tort les deux fois : d'abord la frontiere
# (`10.0.3.1`) alors que le site avait son DNS, puis `10.0.3.51` — juste jusqu'a ce que
# le decoupage en zones deplace la machine en `10.0.34.11`.
#
# Une adresse ecrite a la main est une copie ; une copie se perime. Celle-ci se lit
# desormais la ou elle est vraie : l'hote qui porte `serveur_resolveur` dans ce plan.
# Le plan peut encore la surcharger — pour l'amorcage d'un site tout neuf, ou le
# resolveur n'existe pas encore — mais ce n'est plus le cas normal.
_resolveur = next((str(s.get("ip")) for nom, s in serveurs.items()
if any(a.get("hote") == nom and a.get("groupe") == "serveur_resolveur"
for a in applications.values())
and str(s.get("etat", "actif")) == "actif"), "")
# Ce que TOUTE machine du site recoit, des intrants du plan.
communes: dict = {}
if _resolveur:
communes["dns_amorcage"] = _resolveur
for cle in ("domaine_interne", "organisation", "dns_amorcage"):
if intrants.get(cle):
communes[cle] = str(intrants[cle])
@ -188,6 +211,17 @@ def inventaire() -> dict:
# le site le tient du reseau ou vivent ses machines. Meme sens, autre source.
# `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger.
"setops_supernet": str(r.get("sous_reseau") or ""),
# QUI A LE DROIT D'INTERROGER LE RESOLVEUR — TOUTES LES ZONES DU SITE.
#
# `serveur_resolveur` derive sa liste d'autorisation de `setops_supernet`, qui
# vaut le sous-reseau de la machine. C'etait juste tant que le site etait un
# `/24` plat. Decoupe en zones, le resolveur n'autorisait plus que la SIENNE :
# les trois autres se faisaient refuser, et la panne ressemble a un DNS mort
# alors que c'est une ACL.
#
# Un resolveur ouvert au monde est un relais d'amplification ; celui-ci est
# ouvert a son ecosysteme, et a rien d'autre.
"serveur_resolveur_reseaux_autorises": _zones_du_site,
# Un role partage peut avoir besoin de savoir de quel cote il tourne.
"setops_site": True,
# Le plan du site, pour les roles qui lisent des registres.