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:
parent
788c5073dc
commit
19bbadfb11
14 changed files with 251 additions and 13 deletions
66
CHANGELOG.md
66
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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([]) }}"
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue