diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a99448..b8f5871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # CHANGELOG — Set-OPS +## 2026-09-16 (8) — DNSSEC : le locataire signe avec la cle de sa voute + +Phase 2 du DNS public, deuxieme volet. Eprouve sur les vraies machines AVANT d'etre code : +cle importee depuis un fichier, transfert signe vers le site, `PRESIGNED` pose tout seul, +`delv` « fully validated » (reponses negatives comprises). + +- **La cle vit dans la voute du locataire** (`vault_dnssec_`), tiree par + `scripts/dnssec.py generer`. Une cle nee sur la machine mourrait a la reconstruction, et le + DS publie rendrait le domaine BOGUS. Le DS se calcule depuis la voute seule + (`make dnssec-ds`) ; le calcul local a ete confronte a `pdnsutil` : identique a l'octet. +- **`setops-dnssec-zone`** met chaque zone dans l'etat du plan (cle unique, conforme a la + voute), idempotent (second passage : 0 changement), et REFUSE de changer la cle ou de retirer + la signature tant qu'un DS est publie — une mesure qui echoue vaut refus. +- **CAA** `issue letsencrypt.org` sur `chezlepro.ca` (tous ses certificats publics mesures + viennent de Let's Encrypt). Pas sur `technolibre.ca` : c'est a son responsable de le dire. +- **Sonde du site** : zone signee servie nue = critique ; signatures sous 6 jours = + avertissement, sous 3 = critique. Mise en defaut eprouvee. +- **`valider_domaines`** refuse `dnssec: true` hors `primaire-cache`. +- **P18 deplie les familles de secrets derivees du plan** : `'vault_dnssec_' ~ zone` exige une + cle par zone signee, au lieu d'exiger un nom `vault_dnssec_` que personne ne peut fournir. + Au passage : `vault_pg_exportateur` manquait au gabarit de Chezlepro (P18 ne lit que + l'instance montee). + +UNE FAUTE DE CONCEPTION, TROUVEE PAR LE CALCUL APRES LE DEPLOIEMENT. La premiere version +posait `SOA-EDIT INCEPTION-EPOCH` : serial servi = max(serial, inception). PowerDNS date +l'inception au debut de la semaine PRECEDENTE ; notre serial suit l'heure du dernier +changement et la depasse donc pendant deux semaines. Le serial n'aurait bouge qu'a +l'expiration meme des signatures du site — une fenetre BOGUS a chaque cycle. Passe a +`SOA-EDIT EPOCH` : serial servi = l'heure, le site retire la zone a chaque rafraichissement. + +Mesure en production : serial servi par le primaire = l'heure exacte ; le site a retire les +deux zones par NOTIFY sans intervention ; ancres = DS calcules depuis les voutes, `delv` valide +MX, CAA, TXT, A et NXDOMAIN sur les deux zones. `make prouver` : CONFORME, 82 OK, 0 echec. + +LIMITE : le retirage horaire (rafraichissement du SOA) n'est pas encore observe ; le journal +du site le dira dans l'heure. + ## 2026-09-16 (7) — Les zones publiques portent le courriel : reproduire avant de basculer Phase 2 du DNS public, premier volet. Une zone publique ne portait que des A d'exposition : diff --git a/Makefile b/Makefile index 4d11249..b3a024f 100644 --- a/Makefile +++ b/Makefile @@ -823,6 +823,13 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture) python3 scripts/site_intrants.py --verifier +.PHONY: dnssec-ds dnssec-verifier +dnssec-ds: ## DS a remettre au registraire, calcules depuis la voute du locataire (aucune ecriture) + python3 scripts/dnssec.py ds $(ZONE) + +dnssec-verifier: ## Cle en voute pour chaque zone signee, et DS du registre conforme ? (aucune ecriture) + python3 scripts/dnssec.py verifier + .PHONY: dns-bascule-devis dns-bascule-devis: ## Basculer nos serveurs de noms changerait-il quelque chose ? Plan contre DNS en service (aucune ecriture) python3 scripts/dns_bascule.py $(if $(SERVEUR),--serveur $(SERVEUR)) $(if $(ZONE),--zone $(ZONE)) diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index 5b42a41..c9cfd3e 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -30,7 +30,7 @@ | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 hotes, 33 groupes (inventaire dechiffre et parse). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 32 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 33 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 6 instance(s) federee(s), aucun index en collision. | @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 44 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 69 scripts expliques et atteignables, 129 cibles make documentees, 69 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 70 scripts expliques et atteignables, 131 cibles make documentees, 69 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 40 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (42 genere(s) exempte(s)). | @@ -53,7 +53,7 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 96 page(s) de wiki toutes atteignables. | | 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 : 65 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 66 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, 190 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | @@ -74,7 +74,7 @@ | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). | | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 52 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | -| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:7, serveurs:3 champ(s) lus par validateur). | +| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:8, serveurs:3 champ(s) lus par validateur). | | P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s | | P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 41 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | | P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | @@ -88,7 +88,7 @@ | P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 9 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). | | P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. | | P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | -| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 161 gabarits de role : tous se rendent. | +| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 162 gabarits de role : tous se rendent. | | P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | | P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | | P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen | diff --git a/docs/dns-interne.md b/docs/dns-interne.md index f3e3e2f..4fd0190 100644 --- a/docs/dns-interne.md +++ b/docs/dns-interne.md @@ -199,3 +199,43 @@ transfert de zone, un export chez le fournisseur actuel reste la seule preuve d' Au 2026-09-16 : `chezlepro.ca` reproduit la production (rien ne serait perdu) ; `technolibre.ca` est **préparée, pas basculée** — la décision revient au responsable désigné de TechnoLibre, et elle attend que `dns1.chezlepro.ca` soit publié. + +### Signature DNSSEC : le locataire signe, avec la clé de sa voûte + +> Ajouté le 2026-09-16, phase 2. Éprouvé sur les machines réelles avant d'être codé. + +`dnssec: true` sur une zone `primaire-cache` la fait signer **par le primaire du locataire**, +avec **une** clé CSK ECDSA P-256 tenue dans **sa** voûte (`vault_dnssec_`). Le site ne +détient aucune clé : il reçoit la zone déjà signée et la sert telle quelle (PowerDNS pose +`PRESIGNED` au transfert). + +| Geste | Commande | Écrit ? | +|---|---|---| +| Créer la clé d'une zone | `python3 scripts/dnssec.py generer ` | la voûte du locataire (copie de sûreté chiffrée, relue avant et après) | +| Lire le DS à remettre au registraire | `make dnssec-ds` | rien — calculé depuis la voûte, sans la machine | +| Voûte, plan et registre concordent-ils ? | `make dnssec-verifier` | rien | + +Pourquoi la clé ne naît pas sur la machine : une reconstruction depuis zéro signerait avec +une **autre** clé, le DS du registraire ne correspondrait plus, et le domaine deviendrait +**BOGUS** pour tout résolveur validant, sans qu'aucune machine soit en panne. + +Trois règles, chacune mesurée : + +- **Changer la clé ou retirer la signature pendant qu'un DS est publié est refusé** par l'outil + de la machine (`setops-dnssec-zone`) comme par `dnssec.py`. Une mesure du DS qui échoue vaut + refus. +- **Le serial servi est l'heure (`SOA-EDIT EPOCH`).** PowerDNS renouvelle ses signatures chaque + semaine (inception au début de la semaine précédente, expiration deux semaines après le + début de la semaine courante). `INCEPTION-EPOCH`, essayé d'abord, ne dépassait notre serial + qu'au moment où les signatures du site expiraient. Avec l'heure, le site retire la zone à + chaque rafraîchissement ; les signatures servies gardent 7 à 14 jours. +- **L'état DNSSEC fait partie du corps de la zone.** Signer ne change aucun enregistrement : + sans la ligne `; DNSSEC : …`, le serial n'aurait pas bougé et le site aurait gardé la version + non signée. + +La sonde `zones-publiques` du site alerte sur une zone signée servie **nue**, avertit sous +6 jours de signature restante, et alerte sous 3. + +**Ordre de la mise en service** : basculer les serveurs de noms (zone signée, sans DS : le +domaine reste « non sécurisé », pas cassé), vérifier, **puis** remettre le DS au registraire. +Jamais l'inverse. diff --git a/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 b/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 index 3370a6e..0f6f5b9 100644 --- a/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 +++ b/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 @@ -25,8 +25,38 @@ else fi {% endfor %} +# LES SIGNATURES DES ZONES SIGNEES (2026-09-16). Le primaire du locataire renouvelle ses +# signatures chaque semaine ; son serial servi est l'heure (SOA-EDIT EPOCH), et le site +# retire la zone a chaque rafraichissement. Si ce relais casse, rien ne tombe tout de suite : +# les signatures servies EXPIRENT, en 7 a 14 jours, et le domaine devient BOGUS d'un coup. +# Normalement il reste entre 7 et 14 jours ; on avertit sous 6, on alerte sous 3. +maintenant=$(date -u +%s) +alerte=() +avert=() +{% for zone in serveur_dns_public_relations | map(attribute='signees') | map('default', []) | flatten %} +exp=$(dig +dnssec +noall +answer +time=3 +tries=1 @"${ICI}" -p {{ serveur_dns_public_port }} SOA {{ zone }} 2>/dev/null | awk '$4 == "RRSIG" { print $9; exit }') +if [[ -z "$exp" ]]; then + alerte+=("{{ zone }} (non signee)") +else + jours=$(( ( $(date -u -d "${exp:0:8} ${exp:8:2}:${exp:10:2}:${exp:12:2}" +%s) - maintenant ) / 86400 )) + if (( jours < 3 )); then alerte+=("{{ zone }} (${jours} j)") + elif (( jours < 6 )); then avert+=("{{ zone }} (${jours} j)"); fi +fi +{% endfor %} + if (( servies < ATTENDU )); then echo "Le serveur public ne sert que ${servies} zone(s) sur ${ATTENDU} : ${manquantes[*]:-?} — ces domaines ne resolvent plus depuis Internet.|zones=${servies};${ATTENDU};;0;" exit 2 fi -echo "Serveur public : ${servies} zone(s) servie(s) sur ${ATTENDU}.|zones=${servies};${ATTENDU};;0;" +{# `${#tableau[@]}` ouvrirait un commentaire Jinja : ces lignes sont rendues telles quelles. #} +{% raw %} +if (( ${#alerte[@]} > 0 )); then + echo "Signatures DNSSEC absentes ou sur le point d'expirer : ${alerte[*]} — ces domaines deviennent BOGUS pour les resolveurs validants.|zones=${servies};${ATTENDU};;0;" + exit 2 +fi +if (( ${#avert[@]} > 0 )); then + echo "Signatures DNSSEC qui ne se renouvellent plus : ${avert[*]} — le primaire du locataire relaie-t-il encore ?|zones=${servies};${ATTENDU};;0;" + exit 1 +fi +{% endraw %} +echo "Serveur public : ${servies} zone(s) servie(s) sur ${ATTENDU}, signatures a jour.|zones=${servies};${ATTENDU};;0;" diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml index b571911..bd44276 100644 --- a/roles/serveur_powerdns/tasks/zones-publiques.yml +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -41,6 +41,24 @@ {% 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: @@ -290,6 +308,54 @@ - ("= " ~ 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 diff --git a/roles/serveur_powerdns/templates/setops-dnssec-zone.sh.j2 b/roles/serveur_powerdns/templates/setops-dnssec-zone.sh.j2 new file mode 100644 index 0000000..9eb2b13 --- /dev/null +++ b/roles/serveur_powerdns/templates/setops-dnssec-zone.sh.j2 @@ -0,0 +1,109 @@ +#!/bin/bash +# GENERE par Set-OPS (role serveur_powerdns). Ne pas editer a la main. +# +# setops-dnssec-zone ZONE signer|nu — met une zone publique dans l'etat DNSSEC du plan. +# +# signer : la cle arrive sur l'ENTREE STANDARD (la cle CSK de la voute du locataire, en +# base64). La zone doit porter CETTE cle, et elle seule. +# nu : la zone ne doit porter aucune cle. +# +# Codes : 0 deja conforme, 10 change, 3 refus (la raison sur la sortie), autre = erreur. +# LA CLE N'EST JAMAIS ECRITE SUR LA SORTIE : Ansible la journaliserait. +# +# LE REFUS QUI COMPTE. Changer la cle — ou retirer la signature — pendant que le registre +# publie un DS rend le domaine BOGUS pour tout resolveur validant : site et courriel +# injoignables, et aucune machine en panne. On mesure donc le DS publie avant, et une mesure +# qui echoue vaut refus : l'absence de preuve n'est pas une preuve d'absence. +set -euo pipefail + +ZONE="${1:?zone}" +MODE="${2:?signer|nu}" +P=(runuser -u pdns -- pdnsutil --config-name=public) + +ids() { + "${P[@]}" list-keys "$ZONE" 2>/dev/null | awk -v z="$ZONE" '$1 == z { print $7 }' +} + +ds_publie() { + # Rend 0 si le registre ne publie AUCUN DS, 1 s'il en publie, 2 si on ne sait pas. + local sortie statut + sortie=$(dig +time=4 +tries=2 +noall +comments +answer "$ZONE" DS 2>/dev/null) || return 2 + statut=$(sed -n 's/.*status: \([A-Z]*\),.*/\1/p' <<<"$sortie" | head -1) + [[ "$statut" == "NOERROR" || "$statut" == "NXDOMAIN" ]] || return 2 + grep -qE "^[^;].*[[:space:]]DS[[:space:]]" <<<"$sortie" && return 1 + return 0 +} + +refuser_si_ds() { + local code=0 + ds_publie || code=$? + case "$code" in + 0) return 0 ;; + 1) echo "REFUS : le registre publie un DS pour $ZONE. $1 rendrait le domaine BOGUS. Retirer d'abord le DS chez le registraire, attendre son TTL, puis relancer." ;; + *) echo "REFUS : impossible de lire le DS publie pour $ZONE (resolveur injoignable ?). $1 sans cette mesure pourrait rendre le domaine BOGUS." ;; + esac + exit 3 +} + +actuelles=$(ids) + +if [[ "$MODE" == "nu" ]]; then + [[ -z "$actuelles" ]] && exit 0 + refuser_si_ds "Retirer la signature" + "${P[@]}" disable-dnssec "$ZONE" >/dev/null + for id in $(ids); do "${P[@]}" remove-zone-key "$ZONE" "$id" >/dev/null; done + # LIMITE CONNUE. Sans SOA-EDIT, le serial servi redescend a celui du contenu — plus petit + # que l'heure que le site a vue en dernier. Le site garde donc la version signee jusqu'au + # prochain changement de contenu ; sans DS publie (verifie plus haut), c'est sans effet + # pour les resolveurs. Pour l'aligner tout de suite : `pdns_control retrieve ZONE` au site. + "${P[@]}" set-meta "$ZONE" SOA-EDIT >/dev/null + echo "$ZONE : signature retiree." + exit 10 +fi + +[[ "$MODE" == "signer" ]] || { echo "mode inconnu : $MODE"; exit 2; } +IFS= read -r CLE || true +[[ "$CLE" =~ ^[A-Za-z0-9+/]{43}=$ ]] || { echo "REFUS : cle absente ou malformee sur l'entree standard."; exit 3; } + +# SOA-EDIT EPOCH : LE SERIAL SERVI EST L'HEURE (calcule le 2026-09-16, apres une premiere +# version fausse). +# +# PowerDNS signe avec une inception au debut de la semaine PRECEDENTE et une expiration deux +# semaines apres le debut de la semaine COURANTE (mesure : inception 2026-09-10, expiration +# 2026-10-01, un 2026-09-17). Le secondaire du site garde les signatures qu'il a tirees : il +# faut qu'il retire la zone au moins chaque semaine. +# +# La premiere version posait INCEPTION-EPOCH : serial = max(serial, inception). Or notre +# serial suit deja l'heure du dernier changement — il DEPASSE l'inception pendant deux +# semaines. Le serial servi ne bougeait donc qu'au moment ou les signatures du site +# expiraient : une heure de BOGUS, au mieux, a chaque cycle. +# +# EPOCH rend l'heure : le site voit un serial neuf a chaque rafraichissement (une heure) et +# retire la zone. Deux zones de quelques Ko par heure — le prix d'une signature jamais +# perimee de plus d'une heure. Les signatures servies gardent entre 7 et 14 jours. +soa_edit=$("${P[@]}" get-meta "$ZONE" SOA-EDIT 2>/dev/null | grep -cx "SOA-EDIT = EPOCH" || true) + +if [[ $(wc -w <<<"$actuelles") -eq 1 ]] \ + && "${P[@]}" export-zone-key "$ZONE" "$actuelles" 2>/dev/null | grep -qxF "PrivateKey: $CLE" \ + && [[ "$soa_edit" -ge 1 ]]; then + exit 0 +fi + +# UNE AUTRE CLE EST EN PLACE : la remplacer n'est sur que si aucun DS n'est publie. +if [[ -n "$actuelles" ]] && ! { [[ $(wc -w <<<"$actuelles") -eq 1 ]] \ + && "${P[@]}" export-zone-key "$ZONE" "$actuelles" 2>/dev/null | grep -qxF "PrivateKey: $CLE"; }; then + refuser_si_ds "Remplacer la cle" + for id in $actuelles; do "${P[@]}" remove-zone-key "$ZONE" "$id" >/dev/null; done +fi + +if [[ -z "$(ids)" ]]; then + f=$(mktemp /run/setops-dnssec.XXXXXX) + trap 'shred -u -z "$f" 2>/dev/null || rm -f "$f"' EXIT + chown pdns "$f" + chmod 0600 "$f" + printf 'Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\nPrivateKey: %s\n' "$CLE" > "$f" + "${P[@]}" import-zone-key "$ZONE" "$f" ksk active published >/dev/null +fi +"${P[@]}" set-meta "$ZONE" SOA-EDIT EPOCH >/dev/null +echo "$ZONE : signee." +exit 10 diff --git a/roles/serveur_powerdns/templates/zone-publique.db.j2 b/roles/serveur_powerdns/templates/zone-publique.db.j2 index da87be0..a53da73 100644 --- a/roles/serveur_powerdns/templates/zone-publique.db.j2 +++ b/roles/serveur_powerdns/templates/zone-publique.db.j2 @@ -15,6 +15,12 @@ $TTL {{ serveur_powerdns_ttl }} {{ serveur_powerdns_soa_minimum }} ) +{# L'ETAT DNSSEC FAIT PARTIE DU CORPS. Signer, changer de cle ou retirer la signature ne + change aucun enregistrement : sans cette ligne, le serial ne bougerait pas, et le site + garderait la version non signee (eprouve le 2026-09-16). L'empreinte d'une cle aleatoire + de 256 bits ne revele rien d'elle. #} +{% set _cle = lookup('vars', 'vault_dnssec_' ~ (zone_publique | lower | regex_replace('[^a-z0-9]', '_')), default='') %} +; DNSSEC : {{ ('signee, cle ' ~ (_cle | hash('sha256'))[:12]) if ((domaines_publics[zone_publique].dnssec | default(false) | bool) and _cle) else 'non signee' }} @ IN NS {{ _ns }}. {% if _ns.endswith('.' ~ zone_publique) %} {{ _ns[:-(zone_publique | length + 1)] }} IN A {{ serveur_powerdns_ip_publique }} diff --git a/scripts/dnssec.py b/scripts/dnssec.py new file mode 100644 index 0000000..f4ab6aa --- /dev/null +++ b/scripts/dnssec.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Les cles DNSSEC d'un locataire : les creer dans SA voute, en calculer les DS, les verifier. + +POURQUOI LA CLE VIT DANS LA VOUTE, ET PAS SUR LA MACHINE (2026-09-16). PowerDNS range ses cles +dans une base SQLite sur le primaire. Or ce depot reconstruit ses machines depuis zero, et +c'est prouve. Une cle nee sur la machine mourrait avec elle : la nouvelle zone serait signee +par une AUTRE cle, le DS publie chez le registraire ne correspondrait plus, et tout resolveur +validant — la majorite de l'Internet — declarerait le domaine BOGUS. Le site web ne +repondrait plus, le courriel ne partirait plus, et rien sur nos machines ne serait en panne. + +La cle est donc tiree ici, une fois, rangee dans la voute DU LOCATAIRE (c'est lui qui signe ; +l'hebergeur ne la detient pas), et le role `serveur_powerdns` l'importe a chaque passage. + +LE DS SE CALCULE SANS LA MACHINE. Il se deduit de la cle publique ; ce script le rend depuis +la voute seule. Le locataire peut donc le remettre a son registraire avant meme que la zone +soit deployee — et le rapprocher de ce que le registre publie (`verifier`). + +UNE CLE, CSK, ECDSA P-256 (algorithme 13). Un seul type de cle : pas de roulement ZSK a +orchestrer. P-256 : ce que tous les resolveurs valident, et des reponses courtes. + +Usage : + python3 scripts/dnssec.py ds # DS de chaque zone `dnssec: true` + python3 scripts/dnssec.py verifier # voute, plan et registre concordent-ils ? + python3 scripts/dnssec.py generer chezlepro.ca # cree la cle si elle manque + CONFIRMER=true python3 scripts/dnssec.py generer chezlepro.ca --remplacer + +Remplacer une cle dont le DS est publie rend le domaine BOGUS : `--remplacer` le refuse tant +que le registre publie un DS pour la zone. +""" +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import hashlib +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from inventory_rules import instance_courante # noqa: E402 +from voutes import cle_de # noqa: E402 + +ALGORITHME = 13 # ECDSAP256SHA256 +DRAPEAUX_CSK = 257 # SEP : la cle que le parent designe +RESOLVEUR = "9.9.9.9" + + +def nom_variable(zone: str) -> str: + """`chezlepro.ca` -> `vault_dnssec_chezlepro_ca`. Le role derive le meme nom.""" + return "vault_dnssec_" + re.sub(r"[^a-z0-9]", "_", zone.lower().rstrip(".")) + + +# --- LA CLE ET SES DERIVES ------------------------------------------------------------- + +def _privee(scalaire_b64: str): + from cryptography.hazmat.primitives.asymmetric import ec + d = int.from_bytes(base64.b64decode(scalaire_b64), "big") + return ec.derive_private_key(d, ec.SECP256R1()) + + +def nouvelle_cle() -> str: + from cryptography.hazmat.primitives.asymmetric import ec + k = ec.generate_private_key(ec.SECP256R1()) + return base64.b64encode(k.private_numbers().private_value.to_bytes(32, "big")).decode() + + +def dnskey_rdata(scalaire_b64: str) -> bytes: + pub = _privee(scalaire_b64).public_key().public_numbers() + cle = pub.x.to_bytes(32, "big") + pub.y.to_bytes(32, "big") + return DRAPEAUX_CSK.to_bytes(2, "big") + bytes([3, ALGORITHME]) + cle + + +def etiquette(rdata: bytes) -> int: + """Key tag, RFC 4034 annexe B.""" + acc = 0 + for i, octet in enumerate(rdata): + acc += octet << 8 if i % 2 == 0 else octet + acc = (acc & 0xFFFF) + (acc >> 16) + return acc & 0xFFFF + + +def _nom_filaire(zone: str) -> bytes: + return b"".join(bytes([len(p)]) + p.encode() for p in zone.lower().rstrip(".").split(".")) + b"\0" + + +def ds(zone: str, scalaire_b64: str) -> str: + """Le DS (SHA-256, type 2) a remettre au registraire.""" + rdata = dnskey_rdata(scalaire_b64) + empreinte = hashlib.sha256(_nom_filaire(zone) + rdata).hexdigest() + return f"{etiquette(rdata)} {ALGORITHME} 2 {empreinte}" + + +def fichier_prive(scalaire_b64: str) -> str: + """Le format que `pdnsutil import-zone-key` lit (celui de BIND, v1.2).""" + return ("Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\n" + f"PrivateKey: {scalaire_b64}\n") + + +# --- LA VOUTE DU LOCATAIRE -------------------------------------------------------------- + +ENV = dict(os.environ, PAGER="cat", ANSIBLE_PAGER="cat") +for _k in ("ANSIBLE_VAULT_IDENTITY_LIST", "ANSIBLE_VAULT_PASSWORD_FILE"): + ENV.pop(_k, None) # une voute, UNE cle : on ne laisse rien d'autre essayer + + +def _instance() -> Path: + base = instance_courante() + if not base.exists(): + raise SystemExit("REFUS : aucune instance montee (symlink `instance/` ou SETOPS_INSTANCE). " + "Une cle DNSSEC appartient a UN locataire.") + return base.resolve() + + +def _voute(base: Path) -> tuple[Path, Path]: + f = base / "inventories" / "principal" / "group_vars" / "all" / "vault.yml" + cle = cle_de(base.name) + if not f.is_file() or not cle.is_file(): + raise SystemExit(f"REFUS : voute ({f}) ou cle ({cle}) absente.") + return f, cle + + +def _dechiffrer(fichier: Path, cle: Path) -> str: + # PAR UN TUBE : ansible-vault ecrit sur une sortie non bloquante et echoue en silence + # sans lui (docs : « ansible-vault exige un tube »). + r = subprocess.run(["ansible-vault", "view", "--vault-password-file", str(cle), str(fichier)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=ENV) + if r.returncode != 0: + raise SystemExit(f"ECHEC du dechiffrement de {fichier} : {r.stderr.strip()[-160:]}") + return r.stdout + + +def lire_voute(base: Path) -> dict: + return yaml.safe_load(_dechiffrer(*_voute(base))) or {} + + +def _empreinte(d: dict) -> str: + return hashlib.sha256(yaml.safe_dump(d, sort_keys=True, allow_unicode=True).encode()).hexdigest() + + +def ecrire_variable(base: Path, nom: str, valeur: str, commentaire: str) -> Path: + """Pose ou remplace UNE variable, voute relue avant et apres. Rend la copie de surete.""" + f, cle = _voute(base) + clair = _dechiffrer(f, cle) + data = yaml.safe_load(clair) or {} + motif = re.compile(rf"^{re.escape(nom)}:.*$", re.MULTILINE) + if nom in data: + if len(motif.findall(clair)) != 1: + raise SystemExit(f"REFUS : `{nom}` n'est pas sur une seule ligne dans la voute ; " + "a remplacer a la main.") + nouveau = motif.sub(f'{nom}: "{valeur}"', clair) + else: + nouveau = clair.rstrip("\n") + f"\n\n# --- {commentaire} ---\n{nom}: \"{valeur}\"\n" + attendu = {**data, nom: valeur} + if yaml.safe_load(nouveau) != attendu: + raise SystemExit("REFUS : le YAML modifie ne reproduit pas l'attendu ; rien n'est ecrit.") + + sauve = Path.home() / ".config" / f"setops-voutes-avant-dnssec-{dt.date.today().isoformat()}" + sauve.mkdir(mode=0o700, exist_ok=True) + copie = sauve / f"{base.name}--{dt.datetime.now():%H%M%S}--{f.name}" + shutil.copy2(f, copie) + + atelier = Path(tempfile.mkdtemp(dir=sauve)) + atelier.chmod(0o700) + f_clair, f_chiffre = atelier / "clair.yml", atelier / "chiffre.yml" + try: + f_clair.write_text(nouveau, encoding="utf-8") + f_clair.chmod(0o600) + r = subprocess.run(["ansible-vault", "encrypt", "--vault-password-file", str(cle), + "--output", str(f_chiffre), str(f_clair)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=ENV) + if r.returncode != 0: + raise SystemExit(f"ECHEC du chiffrement : {r.stderr.strip()[-160:]}") + if not f_chiffre.read_text(encoding="utf-8").startswith("$ANSIBLE_VAULT;1.1;AES256"): + raise SystemExit("ECHEC : en-tete de voute inattendu ; rien n'est remplace.") + if _empreinte(yaml.safe_load(_dechiffrer(f_chiffre, cle)) or {}) != _empreinte(attendu): + raise SystemExit("ECHEC : la relecture differe de l'attendu ; rien n'est remplace.") + mode = f.stat().st_mode & 0o777 + os.replace(f_chiffre, f) + f.chmod(mode) + finally: + if f_clair.exists(): + subprocess.run(["shred", "-u", "-z", str(f_clair)], check=False) + shutil.rmtree(atelier, ignore_errors=True) + if _empreinte(yaml.safe_load(_dechiffrer(f, cle)) or {}) != _empreinte(attendu): + raise SystemExit(f"ECHEC : la voute en place differe apres remplacement. Copie : {copie}") + return copie + + +# --- LE PLAN ET LE REGISTRE ------------------------------------------------------------- + +def zones_signees(base: Path) -> list[str]: + reg = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {} + return sorted(z for z, c in (reg.get("domaines_publics") or {}).items() + if c.get("autorite") == "primaire-cache" and c.get("dnssec") is True) + + +def ds_publies(zone: str) -> set[str] | None: + """Les DS que le parent publie, ou None si la mesure echoue (jamais « aucun »).""" + r = subprocess.run(["dig", "+noall", "+answer", "+comments", "+time=4", "+tries=2", + f"@{RESOLVEUR}", zone, "DS"], capture_output=True, text=True, timeout=30) + statut = next((ligne.split("status:")[1].split(",")[0].strip() + for ligne in r.stdout.splitlines() if "status:" in ligne), None) + if r.returncode != 0 or statut != "NOERROR": + return None + vus = set() + for ligne in r.stdout.splitlines(): + champs = ligne.split() + if len(champs) >= 8 and not ligne.startswith(";") and champs[3] == "DS": + vus.add(f"{champs[4]} {champs[5]} {champs[6]} {''.join(champs[7:]).lower()}") + return vus + + +# --- COMMANDES ------------------------------------------------------------------------ + +def cmd_ds(args) -> int: + base = _instance() + voute = lire_voute(base) + zones = [args.zone] if args.zone else zones_signees(base) + if not zones: + print(f"Aucune zone `dnssec: true` dans {base.name}.") + return 0 + manque = 0 + for z in zones: + s = voute.get(nom_variable(z)) + if not s: + print(f"{z} : AUCUNE CLE dans la voute ({nom_variable(z)}) — `dnssec.py generer {z}`.") + manque += 1 + continue + print(f"{z}. IN DS {ds(z, s)}") + print(f" DNSKEY 257 3 13 {base64.b64encode(dnskey_rdata(s)[4:]).decode()}") + print("\nA remettre au registraire (type de condensat 2, SHA-256), APRES que les serveurs de " + "noms servent la zone signee — jamais avant.") + return 1 if manque else 0 + + +def cmd_verifier(args) -> int: + base = _instance() + voute = lire_voute(base) + code = 0 + zones = zones_signees(base) + orphelines = sorted(k for k in voute if k.startswith("vault_dnssec_") + and k not in {nom_variable(z) for z in zones}) + for z in zones: + s = voute.get(nom_variable(z)) + if not s: + print(f"{z} : ECHEC — `dnssec: true` au plan, aucune cle dans la voute.") + code = 1 + continue + attendu = ds(z, s).lower() + publies = ds_publies(z) + if publies is None: + print(f"{z} : MESURE IMPOSSIBLE — le DS publie n'a pu etre lu via {RESOLVEUR}.") + code = max(code, 2) + elif not publies: + print(f"{z} : cle presente ; le registre ne publie aucun DS (zone signee, pas encore ancree).") + elif attendu in publies: + print(f"{z} : CONFORME — le registre publie le DS de la cle de la voute.") + else: + print(f"{z} : ECHEC — le registre publie {sorted(publies)}, la voute donne {attendu}. " + "Tout resolveur validant tient ce domaine pour BOGUS.") + code = 1 + for k in orphelines: + print(f"note : {k} est dans la voute, mais aucune zone `dnssec: true` ne la lit.") + return code + + +def cmd_generer(args) -> int: + base = _instance() + z = args.zone.lower().rstrip(".") + reg = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {} + conf = (reg.get("domaines_publics") or {}).get(z) + if not conf or conf.get("autorite") != "primaire-cache": + raise SystemExit(f"REFUS : {z} n'est pas une zone `primaire-cache` de {base.name}.") + voute = lire_voute(base) + var = nom_variable(z) + if voute.get(var): + if not args.remplacer: + print(f"{z} : une cle existe deja ({var}). Rien n'est fait. DS : {ds(z, voute[var])}") + return 0 + if os.environ.get("CONFIRMER") != "true": + raise SystemExit("REFUS : remplacer une cle DNSSEC exige CONFIRMER=true.") + publies = ds_publies(z) + if publies is None or publies: + raise SystemExit( + f"REFUS : le registre publie un DS pour {z} (ou la mesure a echoue). Remplacer la " + "cle maintenant rendrait le domaine BOGUS. Un roulement se fait en publiant " + "d'abord le nouveau DS a cote de l'ancien — hors de portee de cette commande.") + s = nouvelle_cle() + copie = ecrire_variable(base, var, s, f"DNSSEC : cle CSK ECDSA P-256 de {z} ({dt.date.today()})") + print(f"{z} : cle posee dans la voute de {base.name} ({var}), relue en place.") + print(f" DS : {ds(z, s)}") + print(f" copie de surete chiffree de l'ancienne voute : {copie}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sous = ap.add_subparsers(dest="commande", required=True) + p = sous.add_parser("ds", help="DS a remettre au registraire, depuis la voute") + p.add_argument("zone", nargs="?") + p.set_defaults(fn=cmd_ds) + p = sous.add_parser("verifier", help="voute, plan et registre concordent-ils ? (aucune ecriture)") + p.set_defaults(fn=cmd_verifier) + p = sous.add_parser("generer", help="cree la cle d'une zone dans la voute du locataire") + p.add_argument("zone") + p.add_argument("--remplacer", action="store_true") + p.set_defaults(fn=cmd_generer) + args = ap.parse_args() + return args.fn(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index c3f25e4..13383c2 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -761,6 +761,12 @@ def valider_domaines(registre: dict) -> None: if fqdn in fqdns: raise ValueError(f"Exposition en double: {fqdn}") fqdns.add(fqdn) + # DNSSEC NE SIGNE QUE CE QUE SET-OPS ECRIT. Ailleurs, `dnssec: true` ne ferait rien — + # et laisserait croire a une zone signee. + if conf.get("dnssec") is True and autorite != "primaire-cache": + raise ValueError( + f"Domaine '{nom_domaine}': `dnssec: true` n'est applique qu'a une zone " + f"`primaire-cache` (autorite actuelle: {autorite}).") enregistrements = conf.get("enregistrements") or [] if not isinstance(enregistrements, list): raise ValueError(f"Domaine '{nom_domaine}': 'enregistrements' doit etre une liste.") diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 5fe6c6a..82b889f 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -281,7 +281,11 @@ def inventaire() -> dict: "secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}", "primaire": d["adresse_ip"], "port": 5300 if colocalise else 53, - "zones": zones}) + "zones": zones, + # LES ZONES QUE LE LOCATAIRE SIGNE. Le site ne detient aucune cle ; il + # doit pourtant savoir qu'une zone signee lui arrive NUE, ou avec des + # signatures proches d'expirer — c'est lui que le monde interroge. + "signees": sorted(z for z in zones if doms[z].get("dnssec") is True)}) return out _relations_dns = _relations_dns_public() diff --git a/scripts/voute.py b/scripts/voute.py index 204f94c..be89236 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -91,6 +91,23 @@ def _refs_vault(chemin: Path) -> set[str]: return set(MOTIF_VAULT.findall(utile)) +def _cles_dnssec() -> list[str]: + """Une cle par zone `primaire-cache` signee du plan. Meme nom que `dnssec.py` et le role.""" + import re as _re + try: + doms = (yaml.safe_load((INSTANCE / "plan" / "domaines.yml").read_text(encoding="utf-8")) + or {}).get("domaines_publics") or {} + except OSError: + return [] + return sorted("vault_dnssec_" + _re.sub(r"[^a-z0-9]", "_", str(z).lower().rstrip(".")) + for z, c in doms.items() + if isinstance(c, dict) and c.get("autorite") == "primaire-cache" + and c.get("dnssec") is True) + + +FAMILLES_DERIVEES = {"vault_dnssec_": _cles_dnssec} + + def groupes_du_plan() -> set[str]: apps = charger_applications(INSTANCE / "plan/applications.yml") srv = charger_serveurs(INSTANCE / "plan/serveurs.yml") @@ -146,6 +163,20 @@ def secrets_exiges() -> dict[str, set[str]]: for nom in _refs_vault(f): noter(nom, f"role {nom_role} (playbook {pb.name})") + # LES FAMILLES DE SECRETS DONT LE NOM SE DERIVE DU PLAN (2026-09-16). Un role qui compose + # `'vault_dnssec_' ~ zone` laisse ici un PREFIXE : le lire comme un nom exigeait une clef + # `vault_dnssec_` que personne ne peut fournir. Le taire aurait ete pire — la voute d'un + # locataire aurait pu manquer la cle d'une zone signee sans qu'aucune preuve le dise. + # Chaque famille connue est donc DEPLIEE depuis le plan ; une famille inconnue reste + # exigee telle quelle, et l'echec qui en resulte la nomme. + for prefixe, derive in FAMILLES_DERIVEES.items(): + origines = exiges.pop(prefixe, None) + if origines is None: + continue + for nom in derive(): + for origine in origines: + noter(nom, f"{origine}, derive du plan") + d = _inventaire_dir() if d and (d / "group_vars").is_dir(): for f in (d / "group_vars").rglob("*.yml"):