DNS public en production : cinq fautes que seule la production montrait

Applicateur de frontiere : ecrire n est pas charger, le chemin rien a faire charge
desormais. site-verifier compare enfin playbooks/site.yml, regenere avec le groupe.
Base TSIG dans un repertoire a pdns et pdnsutil en pdns (journal SQLite). Flux UDP 5300 :
le secondaire demande le SOA avant de transferer, garde dans P82. socket-dir de
pdns@public, et le failed_when qui taisait la notification est retire. Eprouve sur les
machines : 2 zones sur 2 tirees automatiquement, NOTIFY compte, zone interne refusee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 18:35:36 -04:00
parent 788c5073dc
commit 19bbadfb11
14 changed files with 251 additions and 13 deletions

View file

@ -1,5 +1,71 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (6) — Le DNS public en production, et cinq fautes que seule la production montrait
La phase 1 tourne. Chaque ligne ci-dessous a ete MESUREE sur les machines, pas deduite d'un
recapitulatif Ansible :
primaires (Chezlepro, TechnoLibre) pdns@public actif ; zone .internal demandee a
l'instance publique : REFUSED ; AXFR sans cle : refuse
site-dnspub-01 2 zones sur 2, serials IDENTIQUES aux primaires ;
tirage AUTOMATIQUE en 80 s, jamais force
recursion, AXFR sortant, zone interne : REFUSED
version annoncee : aucune
AXFR non signe vers un primaire refuse PAR LE PRIMAIRE (pas un delai : le chemin
existe, seule la cle manque)
NOTIFY compteur du secondaire 3 -> 5 pour deux envois
frontiere 8 regles CHARGEES, verifiees dans pf
L'epreuve de l'outil avait tourne dans un /tmp, sous l'utilisateur qui la lancait, sans
unite systemd, sur la boucle locale. Elle ne pouvait voir aucune des cinq fautes suivantes.
### 1. Ecrire n'est pas charger — l'applicateur de la frontiere
Deux coupures reseau pendant `frontiere-appliquer` : les objets ont ete ECRITS dans la
configuration d'OPNsense, l'applicateur s'est arrete avant `filter/apply`. Au passage
suivant, le plan comparait la configuration au devis, les trouvait d'accord, et rendait
« Rien a faire » — sans jamais charger. `pfctl` montrait zero regle du DNS public pendant que
le plan disait « a creer : 0 ». Relancer n'aurait JAMAIS repare : le code connaissait ce
defaut pour les routes seules. Desormais, `CONFIRMER=true` charge meme quand il n'y a rien a
creer, et l'echec dit que la relance terminera le chargement.
### 2. `site-verifier` promettait une verification qu'il ne faisait pas
Son aide : « verifie que playbooks/site.yml correspond aux couches declarees ». Son code :
les couches et le graphe, jamais le fichier. `serveur_dns_public` etait classe, coherent,
et ABSENT du playbook qui deploie le site. `verifier` rend maintenant le fichier en memoire
et le compare ; eprouve contre la version committee, il nomme le groupe manquant. P08 appelle
cette commande : la garde vient sans nouvelle preuve.
### 3. SQLite ecrit son journal a cote du fichier
`pdns@public` refusait de demarrer : « attempt to write a readonly database ». Le fichier
appartenait a pdns — le REPERTOIRE, `/var/lib/powerdns`, a root. Reproduit en `pdns` hors
systemd : ce n'etait pas le confinement, c'etaient les droits. La base vit dans un
sous-repertoire a pdns, toutes les commandes `pdnsutil` s'executent en pdns, et l'ancienne
base — qui portait une cle TSIG — est retiree.
### 4. La question precede le transfert
Le secondaire n'a tire AUCUNE zone. Avant un transfert (TCP), il demande le SOA — EN UDP.
Seul le TCP etait declare : la frontiere laissait passer l'AXFR et jetait la question.
Mesure : SOA en UDP, delai ; en TCP, reponse. Un tirage force a la main passait, et aurait
fait croire que tout marchait. P82 exige desormais les deux protocoles.
### 5. `failed_when: false` avalait un socket introuvable
`pdns_control --config-name=public` cherchait son socket a l'emplacement par defaut, alors
que l'unite le cree dans `/run/pdns-public`. La notification echouait a CHAQUE deploiement,
et le handler le taisait. `socket-dir` est declare, et le `failed_when: false` est retire :
un canal de controle casse est un defaut, pas un bruit.
### Ce qu'elles ont en commun
Toutes rendaient un SUCCES : un plan conforme, un orchestrateur coherent, un deploiement
vert, un tirage force reussi, un handler sans erreur. C'est la famille de P79, et la raison
pour laquelle chaque verification de cette entree a ete faite sur la machine qui devait
utiliser le resultat.
## 2026-09-16 (5) — Le DNS public, phase 1 : le locataire ecrit, le site sert
Les noms publics des locataires etaient chez un tiers, et le mode `autorite: primaire-cache`

View file

@ -20,8 +20,8 @@
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 116 flux, schéma + matrice OK. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere ; playbooks/site.yml a jour. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 117 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -55,14 +55,14 @@
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 64 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 189 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 190 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 97 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (134 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 279 regles `pass`), tous non consignes et tous motives. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (135 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 280 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |

View file

@ -38,6 +38,7 @@
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
| `serveur_dns_public` | egress | 5300 | tcp | primaires_dns_locataires | clair | AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne chiffre pas — et une zone publique n'a rien a cacher. |
| `serveur_dns_public` | egress | 5300 | udp | primaires_dns_locataires | clair | SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change. Sans lui, le secondaire garde sa premiere copie pour toujours. |
| `serveur_dovecot` | ingress | 24 | tcp | serveur_postfix | tls-requis | Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify). |
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
@ -125,7 +126,7 @@
## Synthèse chiffrement
- **clair** : 46 flux
- **clair** : 47 flux
- **n-a** : 8 flux
- **ssh** : 8 flux
- **starttls** : 6 flux

View file

@ -62,6 +62,8 @@
import_playbook: groupes/serveur_resolveur_site.yml
- name: Couche services — groupe serveur_backup_site
import_playbook: groupes/serveur_backup_site.yml
- name: Couche services — groupe serveur_dns_public
import_playbook: groupes/serveur_dns_public.yml
- name: Couche services — groupe serveur_resolveur
import_playbook: groupes/serveur_resolveur.yml
- name: Couche services — groupe serveur_dovecot

View file

@ -31,7 +31,7 @@ serveur_dns_public_bind_config: /etc/powerdns/setops-dns-public-zones.conf
# sans elle, `AXFR-MASTER-TSIG` n'a nulle part ou vivre, et le transfert partirait NON
# SIGNE. Eprouve le 2026-09-16 sur PowerDNS 4.9.17 : `create-bind-db` est bien pris en
# charge par le paquet Debian, meme si `ldd` du module ne montre pas SQLite.
serveur_dns_public_dnssec_db: /var/lib/powerdns/dnssec.db
serveur_dns_public_dnssec_db: /var/lib/powerdns/setops-public/dnssec.db
# SONDE : le nombre de zones que ce serveur doit servir. Mise en defaut par parametre (un
# seuil impossible), comme le veut le contrat des sondes.

View file

@ -36,3 +36,19 @@ flux:
AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa
machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne
chiffre pas — et une zone publique n'a rien a cacher.
# LA QUESTION QUI PRECEDE LE TRANSFERT (mesure le 2026-09-16, en production).
#
# Avant de tirer une zone, un secondaire compare les serials : il demande le SOA au
# primaire, EN UDP. La premiere version ne declarait que le TCP du transfert. La frontiere
# laissait donc passer l'AXFR et jetait la question qui le declenche : aucune zone n'a
# jamais ete tiree toute seule. Un tirage force a la main passait, et aurait fait croire
# que tout marchait — le secondaire n'aurait vu aucun changement, jamais.
- sens: egress
port: 5300
protocole: udp
pair: primaires_dns_locataires
chiffrement: clair
raison: >-
SOA demande au primaire avant chaque transfert : c'est lui qui dit si la zone a change.
Sans lui, le secondaire garde sa premiere copie pour toujours.

View file

@ -69,7 +69,28 @@
mode: "0640"
notify: Redemarrer le DNS public
# LA BASE APPARTIENT A PDNS, ET SON REPERTOIRE AUSSI (mesure le 2026-09-16, en production).
#
# La premiere version posait la base dans `/var/lib/powerdns`, qui appartient a root. Le
# fichier etait bien a pdns — mais SQLite ecrit son JOURNAL a cote du fichier, dans le
# repertoire. `pdns@public` refusait donc de demarrer : « attempt to write a readonly
# database ». L'epreuve de l'outil ne pouvait pas le voir : elle tournait dans un /tmp qui
# appartenait a l'utilisateur qui la lancait.
#
# Toutes les commandes `pdnsutil` s'executent donc en `pdns`. Lancees en root, elles
# laisseraient un journal appartenant a root au premier incident — et la meme panne
# reviendrait, plus tard, sans cause visible.
- name: Créer le répertoire de la base, à pdns
ansible.builtin.file:
path: "{{ serveur_dns_public_dnssec_db | dirname }}"
state: directory
owner: pdns
group: pdns
mode: "0750"
- name: Créer la base des clés TSIG
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
cmd: "pdnsutil create-bind-db {{ serveur_dns_public_dnssec_db }}"
creates: "{{ serveur_dns_public_dnssec_db }}"
@ -89,6 +110,8 @@
# dans la voute doit remplacer l'ancien. Importer seulement quand le nom manque laisserait
# la vieille cle en place pour toujours, et le transfert echouerait sans rien dire.
- name: Relever les clés TSIG en place
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
cmd: pdnsutil list-tsig-keys
register: serveur_dns_public_cles
@ -97,6 +120,8 @@
when: not ansible_check_mode
- name: Poser ou remplacer la clé TSIG de chaque relation
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv:
- pdnsutil
@ -116,6 +141,8 @@
notify: Redemarrer le DNS public
- name: Relever les métadonnées de transfert
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv: [pdnsutil, get-meta, "{{ item.1 }}", AXFR-MASTER-TSIG]
loop: "{{ serveur_dns_public_relations | subelements('zones') }}"
@ -127,6 +154,8 @@
when: not ansible_check_mode
- name: Exiger la signature TSIG sur chaque transfert entrant
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv: [pdnsutil, set-meta, "{{ item.item.1 }}", AXFR-MASTER-TSIG, "{{ item.item.0.cle_tsig }}"]
loop: "{{ serveur_dns_public_meta.results | default([]) }}"

View file

@ -101,5 +101,5 @@ serveur_powerdns_tsig_cle: >-
serveur_powerdns_public_port: 5300
serveur_powerdns_public_zones_dir: /etc/powerdns/zones-publiques
serveur_powerdns_public_bind_config: /etc/powerdns/setops-zones-publiques.conf
serveur_powerdns_public_dnssec_db: /var/lib/powerdns/public-dnssec.db
serveur_powerdns_public_dnssec_db: /var/lib/powerdns/setops-public/dnssec.db
serveur_powerdns_zones_publiques: []

View file

@ -56,7 +56,10 @@
ansible.builtin.command:
argv: [pdns_control, --config-name=public, notify, "{{ zone_publique }}"]
changed_when: false
failed_when: false
# PAS DE `failed_when: false` (retire le 2026-09-16). Il cachait un socket de controle
# introuvable : la notification echouait a chaque deploiement, sans un mot, et un
# changement de zone aurait mis jusqu'a l'expiration du SOA a se propager. Un canal de
# controle casse est un defaut, pas un bruit.
loop: "{{ serveur_powerdns_zones_publiques | default([]) }}"
loop_control:
loop_var: zone_publique

View file

@ -152,7 +152,36 @@
when: serveur_powerdns_zones_publiques | length > 0
notify: Valider et redemarrer le DNS public du locataire
# LA BASE APPARTIENT A PDNS, ET SON REPERTOIRE AUSSI (mesure le 2026-09-16, en production).
#
# La premiere version posait la base dans `/var/lib/powerdns`, qui appartient a root. Le
# fichier etait bien a pdns — mais SQLite ecrit son JOURNAL a cote du fichier, dans le
# repertoire. `pdns@public` refusait donc de demarrer : « attempt to write a readonly
# database ». L'epreuve de l'outil ne pouvait pas le voir : elle tournait dans un /tmp qui
# appartenait a l'utilisateur qui la lancait.
#
# Toutes les commandes `pdnsutil` s'executent donc en `pdns`. Lancees en root, elles
# laisseraient un journal appartenant a root au premier incident — et la meme panne
# reviendrait, plus tard, sans cause visible.
- name: Créer le répertoire de la base, à pdns
ansible.builtin.file:
path: "{{ serveur_powerdns_public_dnssec_db | dirname }}"
state: directory
owner: pdns
group: pdns
mode: "0750"
when: serveur_powerdns_zones_publiques | length > 0
# L'ANCIEN EMPLACEMENT PORTE UNE CLE TSIG. Le laisser serait garder un secret a un endroit
# que plus rien ne lit ni ne surveille.
- name: Retirer la base de l'ancien emplacement
ansible.builtin.file:
path: /var/lib/powerdns/public-dnssec.db
state: absent
- name: Créer la base des clés TSIG de l'instance publique
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
cmd: >-
pdnsutil --config-name=public create-bind-db
@ -174,6 +203,8 @@
# --- TSIG : SEULE LA CLE OUVRE LE TRANSFERT ------------------------------------------
- name: Relever les clés TSIG de l'instance publique
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
cmd: pdnsutil --config-name=public list-tsig-keys
register: serveur_powerdns_cles_publiques
@ -185,6 +216,8 @@
# On COMPARE avant d'ecrire : un secret change dans la voute doit remplacer l'ancien.
- name: Poser ou remplacer la clé TSIG de la relation avec le site
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv:
- pdnsutil
@ -202,6 +235,8 @@
notify: Valider et redemarrer le DNS public du locataire
- name: Relever les autorisations de transfert
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv: [pdnsutil, --config-name=public, get-meta, "{{ zone_publique }}", TSIG-ALLOW-AXFR]
loop: "{{ serveur_powerdns_zones_publiques }}"
@ -213,6 +248,8 @@
when: not ansible_check_mode
- name: N'autoriser le transfert qu'à la clé de la relation
become: true
become_user: pdns # voir « la base appartient a pdns »
ansible.builtin.command:
argv:
- pdnsutil

View file

@ -11,6 +11,13 @@ launch=bind
bind-config={{ serveur_powerdns_public_bind_config }}
bind-dnssec-db={{ serveur_powerdns_public_dnssec_db }}
# LE SOCKET DE CONTROLE VIT OU L'UNITE LE MET (mesure le 2026-09-16, en production).
#
# `pdns@.service` cree `RuntimeDirectory=pdns-%i`, soit `/run/pdns-public`. Sans ce reglage,
# `pdns_control --config-name=public` cherchait `/var/run/pdns/pdns-public.controlsocket`,
# ne le trouvait pas — et la notification du site echouait. Le handler l'avalait en silence.
socket-dir=/run/pdns-public
# LA SEULE INSTANCE QUI ECOUTE L'ADRESSE DE L'HOTE. Le pare-feu de la machine ne laisse
# frapper que le serveur public du site (flux `dns_public_site`).
local-address={{ ansible_host }}

View file

@ -497,8 +497,22 @@ def appliquer(api: Frontiere, p: dict) -> int:
_fait(api(f"/api/firewall/alias/delItem/{x['uuid']}", {}), f"retrait alias {n}")
if echecs:
print(f"\n {echecs} echec(s) — RIEN N'EST APPLIQUE, la config reste en attente.")
print(f"\n {echecs} echec(s) — RIEN N'EST CHARGE : les objets deja ecrits restent dans la")
print(" configuration, INACTIFS. Rejouer avec CONFIRMER=true terminera le chargement —")
print(" meme si le plan dit alors qu'il n'y a plus rien a creer.")
return 1
return _charger(api, p)
def _charger(api: Frontiere, p: dict) -> int:
"""Charge dans le pare-feu ce que la configuration porte. Idempotent.
ECRIRE N'EST PAS CHARGER (mesure du 2026-09-16). OPNsense garde les regles et les alias
ecrits par l'API dans sa CONFIGURATION ; ils ne s'appliquent qu'a `filter/apply` et
`alias/reconfigure`. Une application interrompue par deux coupures reseau avait ecrit
ses neuf objets et n'en avait charge aucun : `pfctl` montrait zero regle du DNS public,
pendant que le plan, qui lit la configuration, rendait « a creer : 0 ».
"""
# La reconfiguration ne se declenchait QUE sur un changement du modele. Le jour ou
# le noyau perd des routes que le modele a toujours, il n'y a aucun changement —
# donc aucune reparation, et `appliquer` rendait « OK » sur un routage casse.
@ -555,8 +569,20 @@ def main() -> int:
# routage : le message disait vrai du modele et faux du service.
if not afficher(p) and not p.get("routes_absentes_du_noyau") \
and not p.get("routes_eteintes"):
print("\n La frontiere dit deja ce que le devis dit. Rien a faire.")
return 0
# LE MODELE A JOUR N'EST PAS UNE CONFIGURATION CHARGEE (2026-09-16).
#
# Ce chemin rendait « Rien a faire » sans jamais charger. Or une application
# interrompue laisse EXACTEMENT cet etat : les objets ecrits, le modele d'accord avec
# le devis — et rien d'actif. Relancer ne l'aurait jamais repare : le plan disait vrai
# de la configuration, et faux du pare-feu. Meme defaut que les routes absentes du
# noyau, corrige plus haut pour elles seules.
if os.environ.get("CONFIRMER") != "true":
print("\n La frontiere dit deja ce que le devis dit — dans sa CONFIGURATION.")
print(" Ce qui est CHARGE n'est pas verifie ici : une application interrompue laisse")
print(" des objets ecrits et inactifs. CONFIRMER=true recharge sans rien creer.")
return 0
print("\n Rien a creer ni a retirer. Chargement de la configuration en place :")
return _charger(api, p)
if os.environ.get("CONFIRMER") != "true":
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")

View file

@ -186,6 +186,31 @@ def ecrire(path: Path = SITE) -> None:
print(f"{path.relative_to(RACINE)} (re)genere. git est le filet : git diff / git checkout.")
def ecart_site(path: Path = SITE) -> list[str]:
"""Ce par quoi `playbooks/site.yml` ne suit plus les couches. [] = le fichier est a jour.
LA PROMESSE QUE `make site-verifier` NE TENAIT PAS (2026-09-16). Son aide disait « verifie
que playbooks/site.yml correspond aux couches declarees » ; son code ne validait que les
couches et le graphe. Un groupe neuf — `serveur_dns_public` — a donc ete classe, verifie
coherent, et ABSENT du playbook qui deploie le site : `site-deployer-tout` l'aurait saute
sans un mot. P08 appelle cette commande et restait verte.
Meme patron que la garde du registre des flux : on rend en memoire, on compare, et l'on
n'ecrit rien — une verification qui repare ce qu'elle mesure ne mesure plus rien.
"""
attendu = rendre_site(ordre_deploiement())
try:
actuel = path.read_text(encoding="utf-8")
except OSError:
return [f"{path.name} est absent : `make site` le genere"]
if actuel == attendu:
return []
a, b = set(actuel.splitlines()), set(attendu.splitlines())
ecarts = ([f"absent du fichier : {l[len('- name: '):]}" for l in sorted(b - a) if l.startswith("- name:")]
+ [f"en trop dans le fichier : {l[len('- name: '):]}" for l in sorted(a - b) if l.startswith("- name:")])
return ecarts or ["le fichier differe du rendu (ordre des groupes ou en-tete)"]
def imprimer_ordre() -> None:
for i, (nom, groupes) in enumerate(ordre_deploiement()):
print(f"{i}. couche {nom}")
@ -199,7 +224,15 @@ def main(argv: list[str]) -> int:
if commande == "verifier":
ordre_deploiement() # leve si incoherent
total = sum(len(g) for _, g in ordre_deploiement())
print(f"Orchestration coherente : {total} groupes classes, aucun cycle, aucune arete en arriere.")
ecarts = ecart_site()
if ecarts:
print("ERREUR : playbooks/site.yml ne suit plus les couches declarees — "
"`make site` le regenere :", file=sys.stderr)
for e in ecarts[:10]:
print(f" - {e}", file=sys.stderr)
return 1
print(f"Orchestration coherente : {total} groupes classes, aucun cycle, aucune arete "
f"en arriere ; playbooks/site.yml a jour.")
elif commande == "ordre":
imprimer_ordre()
elif commande == "ecrire":

View file

@ -4454,6 +4454,20 @@ def _dns_exposition(flux: list, dnssec_par_zone: dict) -> list[str]:
for z, signe in sorted(dnssec_par_zone.items()) if not signe]
def _dns_question_avant_transfert(flux: list) -> list[str]:
"""La sortie vers les primaires porte l'UDP (le SOA) ET le TCP (le transfert)."""
protos = {str(f.get("protocole")) for f in (flux or [])
if f.get("sens") == "egress" and "primaires_dns_locataires" in
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])}
if not protos:
return []
manque = sorted({"udp", "tcp"} - protos)
return [f"la sortie vers les primaires ne declare pas `{m}` — "
+ ("le secondaire ne peut jamais demander le SOA, donc jamais savoir qu'une zone a change"
if m == "udp" else "le transfert lui-meme ne peut pas passer")
for m in manque]
def preuve_dns_public_tient() -> tuple[bool, str]:
"""Le DNS public du site sert exactement les zones que ses locataires publient.
@ -4473,6 +4487,9 @@ def preuve_dns_public_tient() -> tuple[bool, str]:
5. la regle apprise en eprouvant PowerDNS : l'instance publique n'autorise que la boucle
locale au transfert (`allow-axfr-ips` et TSIG sont ALTERNATIFS — y lister le
secondaire rendrait la signature decorative) ;
7. une sortie vers les primaires sans UDP : le secondaire demande le SOA en UDP avant
de transferer en TCP. Mesure en production le 2026-09-16 — le TCP seul laissait
passer un tirage force et empechait tous les tirages normaux ;
6. LA CONDITION DE LA PHASE 2, ECRITE COMME UNE GARDE : exposer le serveur public a
Internet sans DNSSEC sur toutes ses zones. Sans signature, l'hebergeur ou le site
pair pourraient falsifier la zone d'un client sans que personne le detecte.
@ -4533,6 +4550,7 @@ def preuve_dns_public_tient() -> tuple[bool, str]:
flux_public = _yaml_ou_vide(RACINE / "roles" / "serveur_dns_public" / "meta" / "flux.yml").get("flux") or []
fautes += _dns_exposition(flux_public, dnssec)
fautes += _dns_question_avant_transfert(flux_public)
if fautes:
return False, "DNS public :\n - " + "\n - ".join(fautes)