Cle CSK ECDSA P-256 tiree dans la voute du locataire, importee par le role, DS calcule sans la machine. Refus de changer la cle ou de retirer la signature tant qu'un DS est publie. SOA-EDIT EPOCH : INCEPTION-EPOCH aurait laisse expirer les signatures du site. CAA sur chezlepro.ca, sonde des signatures au site, P18 deplie vault_dnssec_<zone>. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
15 KiB
YAML
366 lines
15 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([]))
|
|
- (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
|