From 19bbadfb11b2bb47ced2bb56dabe1074a79c29ee Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 16 Sep 2026 18:35:36 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 66 +++++++++++++++++++ docs/audit/preuve-2026-09-16.md | 10 +-- docs/registre-flux.md | 3 +- playbooks/site.yml | 2 + roles/serveur_dns_public/defaults/main.yml | 2 +- roles/serveur_dns_public/meta/flux.yml | 16 +++++ roles/serveur_dns_public/tasks/main.yml | 29 ++++++++ roles/serveur_powerdns/defaults/main.yml | 2 +- roles/serveur_powerdns/handlers/main.yml | 5 +- .../tasks/zones-publiques.yml | 37 +++++++++++ .../templates/pdns-public.conf.j2 | 7 ++ scripts/appliquer_opnsense.py | 32 ++++++++- scripts/orchestrer.py | 35 +++++++++- scripts/prouver.py | 18 +++++ 14 files changed, 251 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661b79b..a288f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index c25cebf..88a130e 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -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 | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 635df93..1c606d3 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 diff --git a/playbooks/site.yml b/playbooks/site.yml index 93ecd47..97204a3 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -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 diff --git a/roles/serveur_dns_public/defaults/main.yml b/roles/serveur_dns_public/defaults/main.yml index d3e5eb5..0d72425 100644 --- a/roles/serveur_dns_public/defaults/main.yml +++ b/roles/serveur_dns_public/defaults/main.yml @@ -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. diff --git a/roles/serveur_dns_public/meta/flux.yml b/roles/serveur_dns_public/meta/flux.yml index eff18d8..0030c73 100644 --- a/roles/serveur_dns_public/meta/flux.yml +++ b/roles/serveur_dns_public/meta/flux.yml @@ -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. diff --git a/roles/serveur_dns_public/tasks/main.yml b/roles/serveur_dns_public/tasks/main.yml index 85b6528..87bbadb 100644 --- a/roles/serveur_dns_public/tasks/main.yml +++ b/roles/serveur_dns_public/tasks/main.yml @@ -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([]) }}" diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 1d3b14c..272e57e 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -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: [] diff --git a/roles/serveur_powerdns/handlers/main.yml b/roles/serveur_powerdns/handlers/main.yml index 52eab17..8cd0a9e 100644 --- a/roles/serveur_powerdns/handlers/main.yml +++ b/roles/serveur_powerdns/handlers/main.yml @@ -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 diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml index 353f8ef..7a5c774 100644 --- a/roles/serveur_powerdns/tasks/zones-publiques.yml +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -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 diff --git a/roles/serveur_powerdns/templates/pdns-public.conf.j2 b/roles/serveur_powerdns/templates/pdns-public.conf.j2 index a07c35d..85763ec 100644 --- a/roles/serveur_powerdns/templates/pdns-public.conf.j2 +++ b/roles/serveur_powerdns/templates/pdns-public.conf.j2 @@ -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 }} diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index e702bf0..3198f56 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -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.") diff --git a/scripts/orchestrer.py b/scripts/orchestrer.py index 3107d9e..8bfb8b1 100644 --- a/scripts/orchestrer.py +++ b/scripts/orchestrer.py @@ -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": diff --git a/scripts/prouver.py b/scripts/prouver.py index 1d35801..aa9d788 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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)