--- # --- LES ZONES PUBLIQUES DU LOCATAIRE : `autorite: primaire-cache` --------------------- # # Le locataire ECRIT ses zones publiques ; le serveur DNS public de son site les SERT. # Ce fichier fait exercer enfin un mode que le plan declarait depuis longtemps et que rien # ne consommait — « une autorite qu'on s'attribue sans l'exercer est une panne differee ». # # UNE INSTANCE A PART, ET C'EST LE COEUR DE LA CONCEPTION (2026-09-16). # # Pour que le site tire la zone, l'autoritatif doit ecouter sur l'adresse de la machine. # Or l'instance principale sert AUSSI la zone `.internal`. Lui faire ecouter l'adresse de # l'hote aurait permis au serveur public du site d'INTERROGER la zone interne du locataire # — de lire la carte de ses machines. C'est precisement ce que # `docs/responsabilites-locataire-hebergeur.md` refuse a l'hebergeur. # # `pdns@public` ne sert que les zones publiques et est la seule a ecouter sur l'hote. # L'instance interne reste sur la boucle locale, sans une ligne changee. - name: Retenir les zones publiques du plan ansible.builtin.set_fact: serveur_powerdns_zones_publiques: >- {{ (domaines_publics | default({})) | dict2items | selectattr('value.autorite', 'defined') | selectattr('value.autorite', 'equalto', 'primaire-cache') | map(attribute='key') | sort | list }} # Les FQDN que les zones publiques declarent. Un enregistrement au nom du serveur de noms # entrerait en conflit avec le A que le gabarit lui rend. # # EN JSON, ET C'EST VOULU (eprouve le 2026-09-16). Une boucle Jinja rend une CHAINE, pas une # liste : `in` y cherchait alors une sous-chaine, et `lepro.ca` s'y trouvait. La garde # aurait refuse des noms innocents — ou laisse passer un vrai conflit selon le texte. - name: Relever les noms déclarés dans les zones publiques ansible.builtin.set_fact: serveur_powerdns_fqdn_declares: >- {% set _r = [] %} {% for z in serveur_powerdns_zones_publiques %} {% for e in (domaines_publics[z].enregistrements | default([], true)) %} {% set _ = _r.append(z if e.nom in ['@', z] else e.nom ~ '.' ~ z) %} {% endfor %} {% endfor %} {{ _r | unique | list | to_json }} # UNE ZONE SIGNEE SANS CLE N'EST PAS « NON SIGNEE » : c'est un plan que la voute ne suit # pas. On le dit avant de toucher a quoi que ce soit. - name: Exiger la clé DNSSEC de chaque zone signée ansible.builtin.assert: that: - >- lookup('vars', 'vault_dnssec_' ~ (zone_publique | lower | regex_replace('[^a-z0-9]', '_')), default='') is match('^[A-Za-z0-9+/]{43}=$') fail_msg: >- {{ zone_publique }} est `dnssec: true` au plan, et la voute du locataire n'a pas sa cle (vault_dnssec_{{ zone_publique | lower | regex_replace('[^a-z0-9]', '_') }}). La creer : `python3 scripts/dnssec.py generer {{ zone_publique }}`. quiet: true loop: "{{ serveur_powerdns_zones_publiques }}" loop_control: loop_var: zone_publique when: domaines_publics[zone_publique].dnssec | default(false) | bool - name: Refuser une publication incohérente ansible.builtin.assert: that: # UNE ZONE INTERNE N'EST JAMAIS PUBLIQUE, meme si quelqu'un la declare # `primaire-cache` par erreur. - >- (serveur_powerdns_zones_publiques | select('search', '\.internal\.?$') | list | length) == 0 # L'ADRESSE PUBLIQUE EST PUBLIQUE. Une adresse privee dans une zone servie a Internet # ne resout pour personne dehors, et renseigne sur le reseau dedans. - serveur_powerdns_ip_publique | length > 0 - not (serveur_powerdns_ip_publique is search('^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)')) # TSIG N'EST PAS DECORATIF. PowerDNS donne la zone a toute adresse de # `allow-axfr-ips` SANS signature — mesure le 2026-09-16. Le serveur public du site # ne doit donc jamais y figurer. - serveur_powerdns_dns_public_site not in (serveur_powerdns_allow_axfr_ips | default([])) - (vault_tsig_dns_public | default('')) | length > 0 # LE NOM DU SERVEUR DE NOMS VIENT DU SITE. Sans lui, NS et SOA seraient vides. - serveur_powerdns_dns_public_nom is search('^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.?$') # PERSONNE NE LE REDECLARE : un enregistrement a ce nom lui donnerait une seconde # adresse, ou un CNAME a cote de son A. - (serveur_powerdns_dns_public_nom | trim | regex_replace('\\.$', '')) not in (serveur_powerdns_fqdn_declares | from_json) fail_msg: >- Zones publiques refusees : zone `.internal` declaree publique, adresse publique absente ou privee ({{ serveur_powerdns_ip_publique | default('?') }}), serveur public du site liste dans `allow-axfr-ips` (TSIG deviendrait decoratif), ou `vault_tsig_dns_public` absent de la voute, `dns_public_nom` absent ou malforme ({{ serveur_powerdns_dns_public_nom | default('?') }}), ou redeclare dans les enregistrements d'une zone. quiet: true when: serveur_powerdns_zones_publiques | length > 0 - name: Créer le répertoire des zones publiques ansible.builtin.file: path: "{{ serveur_powerdns_public_zones_dir }}" state: directory owner: root group: pdns mode: "0750" when: serveur_powerdns_zones_publiques | length > 0 # --- LE SERIAL SUIT LE CONTENU, PAS LE DEPLOIEMENT ----------------------------------- # # LE PIEGE (2026-09-16). La zone interne porte un serial FIGE (`serveur_powerdns_serial`). # Sans secondaire, c'est sans consequence. Avec un secondaire, c'est fatal en silence : il # compare les serials, ne voit jamais de changement, et garde l'ancienne zone POUR # TOUJOURS — pendant que le primaire sert la nouvelle. # # On rend donc d'abord le CORPS de la zone, sans serial. Si le corps change, le serial # prend l'heure (secondes Unix : monotone, et tient dans 32 bits jusqu'en 2106). Sinon il # garde sa valeur. Un deploiement sans changement ne notifie personne. - name: Rendre le corps de chaque zone publique ansible.builtin.template: src: zone-publique.db.j2 dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.corps" owner: root group: pdns mode: "0640" vars: serveur_powerdns_serial_public: "SERIAL" loop: "{{ serveur_powerdns_zones_publiques }}" loop_control: loop_var: zone_publique register: serveur_powerdns_corps_publics - name: Relever le serial en place de chaque zone publique ansible.builtin.slurp: src: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.serial" loop: "{{ serveur_powerdns_zones_publiques }}" loop_control: loop_var: zone_publique register: serveur_powerdns_serials_publics failed_when: false # PAS UN HANDLER, ET C'EST VOULU : le serial doit avancer AVANT que la zone soit rendue, # dans la tache suivante. Un handler s'executerait a la fin du jeu — la zone serait deja # ecrite avec l'ancien serial, et le secondaire ne verrait jamais le changement. - name: Avancer le serial des zones dont le contenu a changé # noqa: no-handler ansible.builtin.copy: content: "{{ ansible_date_time.epoch }}\n" dest: "{{ serveur_powerdns_public_zones_dir }}/{{ item.zone_publique }}.serial" owner: root group: pdns mode: "0640" loop: "{{ serveur_powerdns_corps_publics.results | default([]) }}" loop_control: label: "{{ item.zone_publique }}" when: item.changed | default(false) register: serveur_powerdns_serials_avances - name: Écrire chaque zone publique avec son serial ansible.builtin.template: src: zone-publique.db.j2 dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.zone" owner: root group: pdns mode: "0640" vars: _avance: >- {{ (serveur_powerdns_serials_avances.results | default([]) | selectattr('item.zone_publique', 'equalto', zone_publique) | selectattr('changed') | list | length) > 0 }} _ancien: >- {{ (serveur_powerdns_serials_publics.results | default([]) | selectattr('zone_publique', 'equalto', zone_publique) | map(attribute='content') | first | default('') | b64decode | trim) }} serveur_powerdns_serial_public: >- {{ ansible_date_time.epoch if (_avance | bool) or not _ancien else _ancien }} loop: "{{ serveur_powerdns_zones_publiques }}" loop_control: loop_var: zone_publique notify: Valider et redemarrer le DNS public du locataire # --- L'INSTANCE --------------------------------------------------------------------- - name: Déployer la configuration de l'instance publique ansible.builtin.template: src: pdns-public.conf.j2 dest: /etc/powerdns/pdns-public.conf owner: root group: pdns mode: "0640" when: serveur_powerdns_zones_publiques | length > 0 notify: Valider et redemarrer le DNS public du locataire - name: Déployer l'index des zones publiques ansible.builtin.template: src: bindbackend-public.conf.j2 dest: "{{ serveur_powerdns_public_bind_config }}" owner: root group: pdns mode: "0640" 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 {{ serveur_powerdns_public_dnssec_db }} creates: "{{ serveur_powerdns_public_dnssec_db }}" when: - serveur_powerdns_zones_publiques | length > 0 - not ansible_check_mode - name: Confier la base des clés à pdns ansible.builtin.file: path: "{{ serveur_powerdns_public_dnssec_db }}" owner: pdns group: pdns mode: "0640" when: - serveur_powerdns_zones_publiques | length > 0 - not ansible_check_mode # --- 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 changed_when: false no_log: true when: - serveur_powerdns_zones_publiques | length > 0 - not ansible_check_mode # 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 - --config-name=public - import-tsig-key - "{{ serveur_powerdns_tsig_cle }}" - hmac-sha256 - "{{ vault_tsig_dns_public }}" when: - serveur_powerdns_zones_publiques | length > 0 - not ansible_check_mode - vault_tsig_dns_public not in (serveur_powerdns_cles_publiques.stdout | default('')) changed_when: true # n'arrive ici que si la cle manque ou differe no_log: true 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 }}" loop_control: loop_var: zone_publique register: serveur_powerdns_meta_publiques changed_when: false failed_when: false 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 - --config-name=public - set-meta - "{{ item.zone_publique }}" - TSIG-ALLOW-AXFR - "{{ serveur_powerdns_tsig_cle }}" loop: "{{ serveur_powerdns_meta_publiques.results | default([]) }}" loop_control: label: "{{ item.zone_publique }}" when: - not ansible_check_mode - ("= " ~ serveur_powerdns_tsig_cle) not in (item.stdout | default('')) changed_when: true # n'arrive ici que si la metadonnee manque ou differe # --- DNSSEC : LE LOCATAIRE SIGNE, AVEC SA CLE --------------------------------------- # # La cle vit dans la voute du locataire (`scripts/dnssec.py`), pas sur la machine : une # reconstruction depuis zero doit signer avec la MEME cle, sinon le DS du registraire ne # correspond plus et le domaine devient BOGUS. Le site ne detient aucune cle : il recoit la # zone deja signee et la sert telle quelle (PRESIGNED, pose par PowerDNS au transfert — # eprouve le 2026-09-16, `delv` : « fully validated », reponses negatives comprises). - name: Installer l'outil de signature des zones publiques ansible.builtin.template: src: setops-dnssec-zone.sh.j2 dest: /usr/local/sbin/setops-dnssec-zone owner: root group: root mode: "0750" when: serveur_powerdns_zones_publiques | length > 0 - name: Mettre chaque zone publique dans l'état DNSSEC du plan ansible.builtin.command: argv: - /usr/local/sbin/setops-dnssec-zone - "{{ zone_publique }}" - "{{ 'signer' if (domaines_publics[zone_publique].dnssec | default(false) | bool) else 'nu' }}" stdin: >- {{ lookup('vars', 'vault_dnssec_' ~ (zone_publique | lower | regex_replace('[^a-z0-9]', '_')), default='') }} loop: "{{ serveur_powerdns_zones_publiques }}" loop_control: loop_var: zone_publique register: serveur_powerdns_dnssec changed_when: serveur_powerdns_dnssec.rc == 10 # L'ECHEC EST RENDU PAR LA TACHE SUIVANTE : `no_log` masquerait la raison du refus, et la # cle passe par l'entree standard de celle-ci. failed_when: false no_log: true when: not ansible_check_mode notify: Valider et redemarrer le DNS public du locataire - name: Dire pourquoi une zone n'a pas pu prendre son état DNSSEC ansible.builtin.fail: msg: "{{ item.zone_publique }} (code {{ item.rc }}) : {{ item.stdout | default('') }} {{ item.stderr | default('') }}" loop: "{{ serveur_powerdns_dnssec.results | default([]) }}" loop_control: label: "{{ item.zone_publique }}" when: - not ansible_check_mode - item.rc is defined - item.rc not in [0, 10] - name: Activer et démarrer l'instance publique ansible.builtin.systemd: name: pdns@public enabled: true state: started when: - serveur_powerdns_zones_publiques | length > 0 - not ansible_check_mode