From 712ad9f6308ae6d2e8d4e7b52627a294bcfca883 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 16 Sep 2026 22:09:21 -0400 Subject: [PATCH] DNSSEC : le locataire signe, avec la cle de sa voute 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_. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 37 ++ Makefile | 7 + docs/audit/preuve-2026-09-16.md | 10 +- docs/dns-interne.md | 40 +++ .../templates/sonde-zones-publiques.sh.j2 | 32 +- .../tasks/zones-publiques.yml | 66 ++++ .../templates/setops-dnssec-zone.sh.j2 | 109 ++++++ .../templates/zone-publique.db.j2 | 6 + scripts/dnssec.py | 320 ++++++++++++++++++ scripts/inventory_rules.py | 6 + scripts/site_inventaire.py | 6 +- scripts/voute.py | 31 ++ 12 files changed, 663 insertions(+), 7 deletions(-) create mode 100644 roles/serveur_powerdns/templates/setops-dnssec-zone.sh.j2 create mode 100644 scripts/dnssec.py 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"):