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 # 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 ? ## 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 **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). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` - **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 ## 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. | | 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. | | 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). | | 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. | | 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 | 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. | | 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), 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). | | 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. | | 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. | | 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). | | 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 | | 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 ## Couverture des affirmations ✅ du registre

View file

@ -1,19 +1,38 @@
--- ---
- name: Appliquer le groupe client_artefacts # client_artefacts — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
hosts: client_artefacts #
# 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 become: true
module_defaults: any_errors_fatal: true
module_defaults: &defauts_artefacts
ansible.builtin.apt: ansible.builtin.apt:
lock_timeout: 300 lock_timeout: 300
gather_facts: true gather_facts: true
pre_tasks: &pre_artefacts
pre_tasks:
- name: Vérifier que la cible est Debian - name: Vérifier que la cible est Debian
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- ansible_facts.distribution == "Debian" - ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour 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: roles:
- client_artefacts - client_artefacts

View file

@ -1,22 +1,38 @@
--- ---
- name: Appliquer le groupe client_journal # client_journal — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
hosts: client_journal #
# 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 become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs any_errors_fatal: true
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute module_defaults: &defauts_journal
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
ansible.builtin.apt: ansible.builtin.apt:
lock_timeout: 300 lock_timeout: 300
gather_facts: true gather_facts: true
pre_tasks: &pre_journal
pre_tasks:
- name: Vérifier que la cible est Debian - name: Vérifier que la cible est Debian
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- ansible_facts.distribution == "Debian" - ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour 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: roles:
- client_journal - client_journal

View file

@ -1,22 +1,38 @@
--- ---
- name: Appliquer le groupe client_pki # client_pki — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
hosts: client_pki #
# 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 become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs any_errors_fatal: true
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute module_defaults: &defauts_pki
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
ansible.builtin.apt: ansible.builtin.apt:
lock_timeout: 300 lock_timeout: 300
gather_facts: true gather_facts: true
pre_tasks: &pre_pki
pre_tasks:
- name: Vérifier que la cible est Debian - name: Vérifier que la cible est Debian
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- ansible_facts.distribution == "Debian" - ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour 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: roles:
- client_pki - client_pki

View file

@ -1,15 +1,38 @@
--- ---
- name: Appliquer le groupe client_resolveur # client_resolveur — L'ORDRE FAIT PARTIE DE L'INTEGRATION (2026-08-25).
hosts: client_resolveur #
become: true # Cette integration depend de `serveur_resolveur`, et sa metadonnee le declare
gather_facts: true # (`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 - name: Vérifier que la cible est Debian
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- ansible_facts.distribution == "Debian" - ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour 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: roles:
- client_resolveur - client_resolveur

View file

@ -78,8 +78,21 @@
| select('in', serveur_debian_resolv_texte) | list | length == 0 | select('in', serveur_debian_resolv_texte) | list | length == 0
roles: 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 - hosts_statiques
- common_packages
- qemu_guest_agent - qemu_guest_agent
- cloud_init - cloud_init
- sudo_ansible - sudo_ansible

View file

@ -2,6 +2,14 @@
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le # Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33). # raisonnement, et docs/decisions-architecture.md (D-33).
integration: 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 universelle: true
raison: >- raison: >-
Tout hote installe des paquets, et doit les prendre chez lui quand l'ecosysteme a 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 # Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33). # raisonnement, et docs/decisions-architecture.md (D-33).
integration: 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 universelle: true
raison: >- raison: >-
Tout hote expedie ses journaux. Un incident se reconstitue depuis les traces 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 # 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). # quinzieme. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
integration: 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 universelle: true
raison: >- raison: >-
Tout hote est mesure. Une machine hors supervision tombe sans que personne 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 # Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33). # raisonnement, et docs/decisions-architecture.md (D-33).
integration: 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 universelle: true
raison: >- raison: >-
Tout hote fait confiance a l'AC interne et porte un certificat : c'est le 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 # Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33). # raisonnement, et docs/decisions-architecture.md (D-33).
integration: 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 universelle: true
raison: >- raison: >-
Tout hote resout des noms, et doit le faire aupres du resolveur de son ecosysteme 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"] 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]]: def _services_du_plan_site() -> dict[str, list[str]]:
apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {} apps = (_lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {} 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_transit = _ou_marqueur(_intr, "opnsense_if_transit", IF_TRANSIT)
if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION) if_gestion = _ou_marqueur(_intr, "opnsense_if_gestion", IF_GESTION)
if_site = _ou_marqueur(_intr, "opnsense_if_site", IF_SITE) 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() nom_actif, nomenclature_active = _instance_active()
# L'alias d'un tenant vaut ses SOUS-RESEAUX ATTRIBUES, pas son supernet. # 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. # attrape, et rien d'autre ne l'aurait fait.
_machines_site = _machines_du_plan_site() _machines_site = _machines_du_plan_site()
_services_site = _services_du_plan_site() _services_site = _services_du_plan_site()
_ports_site = _ports_du_plan_site()
if _machines_site: if _machines_site:
_res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)} _res_site = {r.get("nom"): r for r in underlay_mod.reseaux(_u)}
_sous_site = sorted({str(_res_site[m["reseau"]]["sous_reseau"]) _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. # parce qu'une regle porte une source.
_portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit) _portees = [(f"SETOPS_TENANT_{p2}{n2['index']}", if_transit)
for _n2, p2, n2 in tenants] 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: elif _entrant and "22" in _ports(_fl) and _admin_site:
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la # 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 # 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: elif _entrant:
continue # rien d'autre n'entre chez le site depuis l'exterieur continue # rien d'autre n'entre chez le site depuis l'exterieur
else: 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: for _src, _itf in _portees:
regles.append({ regles.append({
"sens": "in" if _entrant else "out", "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, "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_FABRIC" if "fabric" in _pairs_fl else "!SETOPS_INTERNES"),
"ports": _ports(_fl), "ports": _p,
"chiffrement": _fl.get("chiffrement"), "chiffrement": _fl.get("chiffrement"),
"role": _role, "role": _role,
"tenant": "SITE", "tenant": "SITE",

View file

@ -1013,6 +1013,62 @@ def preuve_devis_frontiere_du_site() -> tuple[bool, str]:
f"{n_site} regle(s) du site.") 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]: def preuve_glossaire_enseigne() -> tuple[bool, str]:
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire. """Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
@ -1351,6 +1407,8 @@ PREUVES: list[dict] = [
"func": preuve_resolution_unique}, "func": preuve_resolution_unique},
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [], {"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
"func": preuve_edge_porte_ses_noms}, "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": [], {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site}, "func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -93,9 +93,32 @@ def inventaire() -> dict:
return {"_meta": {"hostvars": {}}} return {"_meta": {"hostvars": {}}}
par_reseau = {r.get("nom"): r for r in U.reseaux(u)} 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. # Ce que TOUTE machine du site recoit, des intrants du plan.
communes: dict = {} communes: dict = {}
if _resolveur:
communes["dns_amorcage"] = _resolveur
for cle in ("domaine_interne", "organisation", "dns_amorcage"): for cle in ("domaine_interne", "organisation", "dns_amorcage"):
if intrants.get(cle): if intrants.get(cle):
communes[cle] = str(intrants[cle]) communes[cle] = str(intrants[cle])
@ -188,6 +211,17 @@ def inventaire() -> dict:
# le site le tient du reseau ou vivent ses machines. Meme sens, autre source. # 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. # `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger.
"setops_supernet": str(r.get("sous_reseau") or ""), "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. # Un role partage peut avoir besoin de savoir de quel cote il tourne.
"setops_site": True, "setops_site": True,
# Le plan du site, pour les roles qui lisent des registres. # Le plan du site, pour les roles qui lisent des registres.