Le contrat du site porte dns_public_noms_pairs : nom et adresse publique du serveur de chaque pair actif, lus dans son plan et sa carte de frontiere par les memes fonctions que pour le site lui-meme. Le gabarit de zone ecrit un NS par serveur ; la zone qui contient un de ces noms en porte le A, a l'adresse de son site. Sans pair, le rendu est identique a l'ancien. Garde au role (nom, adresse publique, jamais redeclare) ; la preuve du DNS public refuse un pair actif sans dns_public_nom ; dns_bascule compte les serveurs designes et sonde la colle de chacun. Deploye chez les deux locataires : les deux serveurs publics servent chezlepro.ca et technolibre.ca au meme serial, deux NS signes. CHANGELOG 151. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
17 KiB
YAML
380 lines
17 KiB
YAML
---
|
|
# --- 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([]))
|
|
- serveur_powerdns_dns_public_pairs | intersect(serveur_powerdns_allow_axfr_ips | default([])) | length == 0
|
|
- (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)
|
|
# LES SERVEURS DES SITES PAIRS, SOUS LES MEMES REGLES : un nom bien forme, une adresse
|
|
# publique, distinct du notre, et que personne ne redeclare.
|
|
- serveur_powerdns_dns_public_noms_pairs is mapping
|
|
- (serveur_powerdns_dns_public_noms_pairs | dict2items
|
|
| rejectattr('key', 'search', '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.?$') | list | length) == 0
|
|
- (serveur_powerdns_dns_public_noms_pairs.values()
|
|
| select('search', '^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)') | list | length) == 0
|
|
- (serveur_powerdns_dns_public_noms_pairs.values()
|
|
| reject('search', '^\d{1,3}(\.\d{1,3}){3}$') | list | length) == 0
|
|
- (serveur_powerdns_dns_public_noms_pairs.keys() | map('trim') | map('regex_replace', '\.$', '')
|
|
| intersect([serveur_powerdns_dns_public_nom | trim | regex_replace('\.$', '')]
|
|
+ (serveur_powerdns_fqdn_declares | from_json)) | list | length) == 0
|
|
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 ; ou un serveur de site pair malforme, a une adresse non
|
|
publique, ou redeclare ({{ serveur_powerdns_dns_public_noms_pairs | default({}) }}).
|
|
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
|