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_<zone>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 22:09:21 -04:00
parent 63780645a7
commit 712ad9f630
12 changed files with 663 additions and 7 deletions

View file

@ -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_<zone>`), 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 :

View file

@ -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))

View file

@ -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 |

View file

@ -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_<zone>`). 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 <zone>` | 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.

View file

@ -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;"

View file

@ -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

View file

@ -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

View file

@ -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 }}

320
scripts/dnssec.py Normal file
View file

@ -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())

View file

@ -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.")

View file

@ -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()

View file

@ -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"):