diff --git a/CHANGELOG.md b/CHANGELOG.md index 03201c6..edf4c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index c115fda..f305796 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -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 diff --git a/playbooks/groupes/client_artefacts.yml b/playbooks/groupes/client_artefacts.yml index 534bbcc..6a5b4c8 100644 --- a/playbooks/groupes/client_artefacts.yml +++ b/playbooks/groupes/client_artefacts.yml @@ -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 diff --git a/playbooks/groupes/client_journal.yml b/playbooks/groupes/client_journal.yml index a23b254..4b46024 100644 --- a/playbooks/groupes/client_journal.yml +++ b/playbooks/groupes/client_journal.yml @@ -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 diff --git a/playbooks/groupes/client_pki.yml b/playbooks/groupes/client_pki.yml index 62994b2..366a11a 100644 --- a/playbooks/groupes/client_pki.yml +++ b/playbooks/groupes/client_pki.yml @@ -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 diff --git a/playbooks/groupes/client_resolveur.yml b/playbooks/groupes/client_resolveur.yml index e40ad93..37f64b7 100644 --- a/playbooks/groupes/client_resolveur.yml +++ b/playbooks/groupes/client_resolveur.yml @@ -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 diff --git a/playbooks/groupes/serveur_debian.yml b/playbooks/groupes/serveur_debian.yml index 84d5e77..fa067a5 100644 --- a/playbooks/groupes/serveur_debian.yml +++ b/playbooks/groupes/serveur_debian.yml @@ -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.` — 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 diff --git a/roles/client_artefacts/meta/integration.yml b/roles/client_artefacts/meta/integration.yml index 10c1d5e..10fa5bd 100644 --- a/roles/client_artefacts/meta/integration.yml +++ b/roles/client_artefacts/meta/integration.yml @@ -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 diff --git a/roles/client_journal/meta/integration.yml b/roles/client_journal/meta/integration.yml index 21e13c5..99cef6e 100644 --- a/roles/client_journal/meta/integration.yml +++ b/roles/client_journal/meta/integration.yml @@ -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 diff --git a/roles/client_metrique/meta/integration.yml b/roles/client_metrique/meta/integration.yml index ab5e1b7..e530ae1 100644 --- a/roles/client_metrique/meta/integration.yml +++ b/roles/client_metrique/meta/integration.yml @@ -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 diff --git a/roles/client_pki/meta/integration.yml b/roles/client_pki/meta/integration.yml index 45e0a92..599be67 100644 --- a/roles/client_pki/meta/integration.yml +++ b/roles/client_pki/meta/integration.yml @@ -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 diff --git a/roles/client_resolveur/meta/integration.yml b/roles/client_resolveur/meta/integration.yml index 7c28a5e..1aafe43 100644 --- a/roles/client_resolveur/meta/integration.yml +++ b/roles/client_resolveur/meta/integration.yml @@ -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 diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index e240c13..48416f6 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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", diff --git a/scripts/prouver.py b/scripts/prouver.py index ee4913d..2bde8ca 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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//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": [], diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index dde4f2b..23cd891 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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.