Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Mon livre « Cyberstructure »

Ève

Premiers essais avec Cascade, le logiciel pour gérer ses zones DNSSEC

Première rédaction de cet article le 13 octobre 2025


Annoncé officiellement le 7 octobre, Cascade est le successeur d'OpenDNSSEC. Ce programme sert à gérer automatiquement les opérations répétitives liées à DNSSEC comme la re-signature ou le remplacement d'une clé.

OpenDNSSEC est, depuis 15 ans, le logiciel utilisé par mes domaines personnels (comme bortzmeyer.org par lequel vous êtes passé pour voir cet article) mais aussi par plusieurs TLD comme .fr. Mais il est désormais en fin de vie (cela a été annoncé le 3 octobre), il n'y a plus que les mises à jour de sécurité. La même organisation, NLnet Labs, a développé un successeur, Cascade. Celui-ci est actuellement en alpha (ne l'utilisez pas pour la production !) et voici un premier essai.

DNSSEC, contrairement à ce qui se passe avec le DNS d'antan, nécessite des actions périodiques. La plus évidente est la re-signature, car les signatures DNSSEC ont une date d'expiration (pour éviter les attaques par rejeu). Ici, cette signature expire le 18 octobre 2025 (le « 20251018235702 ») :


% dig  +dnssec  cyberstructure.fr TXT 
…
;; ANSWER SECTION:
cyberstructure.fr.	86400 IN TXT "v=spf1 mx -all"
cyberstructure.fr.	86400 IN RRSIG TXT 13 2 86400 (
				20251018235702 20251004235301 34065 cyberstructure.fr.
				Hg/ModpW3pTLgUIcgp7yrVUxUyVAXdEIOtfONpN3pxG0
                                …

  

Mais il y a aussi la nécessité de pouvoir changer les clés cryptographiques utilisées, par exemple en cas de compromission, avérée ou suspectée. (La plupart des organisations changent les clés systématiquement, compromission ou pas, notamment pour être sûr de pouvoir le faire sans incident le jour où ça sera vraiment indispensable.) Cascade, comme OpenDNSSEC avant lui, automatise toutes ces tâches.

Cascade est développé en Rust, le langage à la mode, et qui pourrait remplacer une bonne partie du logiciel d'infrastructure de l'Internet, qui est quasi-uniquement en C (et C++). Il doit son nom au fait qu'une zone DNS qu'on va signer passe par plusieurs étapes successives, où on peut insérer différentes opérations, par exemple de validation.

Jouons donc un peu avec Cascade (j'ai bien dit que c'était en version alpha ; ne le faites pas en production). Notez que vous pouvez lire la documentation officielle au lieu de cet article. Mais j'ai bien dit que le logiciel était en version alpha, ne vous étonnez pas s'il manque beaucoup de choses dans la documentation (et dans le code, bien sûr). Il existe déjà des paquetages tout faits (mais évidemment pas encore distribués avec votre système d'exploitation) mais on va compiler, c'est plus amusant. Il faut donc une version de Rust qui marche. Rust est très pénible, avec ses changements permanents, qui font que certain·es conseillent même de récupérer et d'installer automatiquement une nouvelle version du compilateur chaque nuit. Mais, bon, la version qui est dans Debian stable (la version 13 de Debian) marche bien. Cascade est un logiciel libre, et il est hébergé sur GitHub :

% git clone https://github.com/NLnetLabs/cascade.git
…

# Installer les dépendances
% apt install cargo pkg-config libssl-dev
…

% rustc --version 
rustc 1.85.0 (4d91de4e4 2025-02-17) (built from a source tarball)

% cd cascade

% cargo build
…
  

Et vous n'avez plus qu'attendre que votre machine compile quelques centaines de crates Rust. Vous vous retrouvez alors avec deux exécutables, ./target/debug/cascade et ./target/debug/cascaded. Le second est le démon qui va tourner en permanence. (Si vous venez d'OpenDNSSEC, notez qu'il n'y a cette fois qu'un seul démon.) Notez que ce démon, bien qu'il inclue un serveur DNS minimal, ne doit pas être exposé sur l'Internet. La configuration recommandée est de l'utiliser comme serveur maitre caché, sur lequel s'alimenteront un ou plusieurs des serveurs faisant autorité pour vos domaines. (Si vous venez d'OpenDNSSEC, notez que Cascade ne sait actuellement pas écrire la zone sur le disque, il faut faire un transfert de zones, cf. RFC 5936.) Le premier exécutable, lui, est le programme que vous lancez pour accomplir telle ou telle tâche, et qui parle au démon, qui fera le boulot.

Sinon, une autre solution, pour compiler, est de suivre les instructions :

cargo install --locked --git https://github.com/nlnetlabs/cascade
cargo install --locked --branch keyset --git https://github.com/nlnetlabs/dnst

Et les exécutables atterriront dans votre ~/.cargo/bin.

Regardons les options des deux programmes :


% ./target/debug/cascade
Usage: cascade [OPTIONS] <COMMAND>

Commands:
  config    Manage Cascade's configuration
  zone      Manage zones
  policy    Manage policies
  keyset    Execute manual key roll or key removal commands
  hsm       Manage HSMs
  template  Print example config or policy files

Options:
  -s, --server <IP:PORT>   The cascade server instance to connect to [default: 127.0.0.1:4539]
      --log-level <LEVEL>  The minimum severity of messages to log [default: warning] [possible values: trace, debug, info,
                           warning, error, critical]
  -h, --help               Print help
  -V, --version            Print version



% ./target/debug/cascaded -h
Usage: cascaded [OPTIONS]

Options:
      --check-config
          Check the configuration and exit
      --state <PATH>
          The global state file to use
  -c, --config <PATH>
          The configuration file to load
      --log-level <LEVEL>
          The minimum severity of messages to log [possible values: trace, debug, info, warning, error, critical]
  -l, --log <TARGET>
          Where logs should be written to [possible values: stdout, stderr, file:<PATH>, syslog]
  -d, --daemonize
          Whether Cascade should fork on startup
  -h, --help
          Print help
  -V, --version
          Print version

Si vous avez procédé comme moi, un dernier truc à compiler, il nous faut aussi cascade-dnst, par la même organisation :

%   cargo install --locked --branch keyset --git https://github.com/nlnetlabs/dnst
 

Vérifiez bien que les exécutables sont dans votre PATH (par exemple, que ~/.cargo/bin y soit). Pour le démon, vous pouvez aussi utiliser la variable dnst-binary-path dans le config.toml.

Pour utiliser Cascade, vous devrez, comme avec OpenDNSSEC, définir une politique (quel algorithme de cryptographie, quelle durée de vie des signatures, quand remplacer les clés, etc), indiquer quelles zones Cascade doit gérer, et avoir un serveur faisant autorité qui va récupérer la zone sur Cascade.

Maintenant, configurons le logiciel, toujours en suivant la documentation. (Si vous oubliez cette étape, vous aurez un message « Cascade couldn't be configured: could not load the config file '/etc/cascade/config.toml': No such file or directory (os error 2) ».) Vous avez avec Cascade un fichier de configuration d'exemple, ./etc/config.template.toml, à la syntaxe TOML. Le fichier d'exemple peut être utilisé tel quel dans beaucoup de cas. J'ai juste modifié les paramètres de journalisation :

[daemon]
log-level = "debug"
log-target = { type = "file", path = "/dev/stdout"}
# Mais log-target = { type = "syslog" } est bien aussi.
 

Lançons maintenant le démon (voyez plus loin les mécanismes pour l'avoir en permanence). D'abord, on crée les répertoires configurés dans le config.toml :

% sudo mkdir /var/lib/cascade     
% sudo chown $USER /var/lib/cascade

% sudo mkdir /etc/cascade/policies 
  

Puis on y va :

% ./target/debug/cascaded         
[2025-10-09T15:56:07.480Z] INFO cascaded: State file not found; starting from scratch
…
[2025-10-09T15:56:09.182Z] DEBUG cascade::state: Saved the global state (to '/var/lib/cascade/state.db')
…

Maintenant que le démon tourne, testons le programme de contrôle :

% ./target/debug/cascade zone list
%
  

Pas de message d'erreur, mais pas de zones configurées, c'est normal. (Si le démon ne tournait pas, vous auriez eu «  [2025-10-09T15:59:37.183Z] ERROR cascade: Error: HTTP request failed: error sending request for url (http://127.0.0.1:4539/zone/) ».)

Maintenant, il faut créer une politique. La plus courte est :

version = "v1"

mais elle n'est pas très utile. Pour avoir un point de départ, on va demander à Cascade de nous générer un exemple :

%    ./target/debug/cascade template policy  > mypolicy.toml
  

(On peut aussi utiliser l'exemple en etc/policy.template.toml.) La politique contient l'algorithme de cryptographie utilisé (j'ai choisi Ed25519, cf. RFC 8080) :

algorithm = "ED25519"                                                    
  

Je n'ai rien changé d'autre. On copie le fichier en /etc/cascade/policies/default.toml (on peut évidemment avoir plusieurs politiques, je vous laisse choisir les noms). Et on indique à Cascade de la charger (ce n'est à faire qu'une fois, ce sera mémorisé) :


% ./target/debug/cascade policy reload      
Policies reloaded:
- default added

% ./target/debug/cascade  policy list  
default

% ./target/debug/cascade policy show default
default:
  zones: <none>
…
  signer:
    serial policy: date counter
    signature inception offset: 86400 seconds
    signature validity offset: 1209600 seconds
    denial: NSEC
…
    
  

Bon, on peut maintenant ajouter une zone à gérer. On crée un fichier de zone (je l'ai mis dans /etc/cascade/zones mais vous faites comme vous voulez). Et on dit à Cascade d'y aller, en indiquant la politique à suivre :

% ./target/debug/cascade zone add --source /etc/cascade/zones/rutaba.ga --policy default rutaba.ga
Added zone rutaba.ga
   

Et voilà :

% ./target/debug/cascade zone list
rutaba.ga

% ./target/debug/cascade zone status rutaba.ga
Status report for zone 'rutaba.ga' using policy 'default'
✔ Waited for a new version of the rutaba.ga zone
✔ Loaded version 2025100901
  Loaded at 2025-10-09T16:40:36+00:00 (17s ago)
  Loaded 246 B and 5 records from the filesystem in 0 seconds
✔ Auto approving signing of version 2025100901, no checks enabled in policy.
✔ Approval received to sign version 2025100901, signing requested
✔ Signed version 2025100901 as version 2025100902
  Signing requested at 2025-10-09T16:40:36+00:00 (17s ago)
  Signing started at 2025-10-09T16:40:36+00:00 (17s ago)
  Signing finished at 2025-10-09T16:40:36+00:00 (17s ago)
  Collected 5 records in 0s, sorted in 0s
  Generated 4 NSEC(3) records in 0s
  Generated 5 signatures in 0s (5 sig/s)
  Inserted signatures in 0s (5 sig/s)
  Took 0s in total, using 1 threads
  Current action: Finished
✔ Auto approving publication of version 2025100902, no checks enabled in policy.
✔ Published version 2025100902
  Published zone available on 127.0.0.1:4543
   

(L'option --detailed après status vous donnera… des détails.)

Vous pouvez maintenant récupérer la zone signée :

    
%  dig @localhost -p 4543 rutaba.ga AXFR
…
rutaba.ga.		600 IN SOA ns4.bortzmeyer.org. hostmaster.bortzmeyer.org. (
				2025100902 ; serial
…
rutaba.ga.		3600 IN	DNSKEY 257 3 15 (
				oQjxu7aDwhDyqZbqPfx0e3I4Y+UowV0eYlssuXgiMw0=
				) ; KSK; alg = ED25519 ; key id = 57782
…
rutaba.ga.		600 IN NS ns4.bortzmeyer.org.
rutaba.ga.		600 IN NS ns1.bortzmeyer.org.
…
rutaba.ga.		600 IN RRSIG SOA 15 2 600 (
				20251023164036 20251008164036 64849 rutaba.ga.
				xn3KC4qzitoUz5ABlJDTtMd9VGPU2l8dYpsd4bYjeCqo
				vpjBdWcBbwwk1HENj5ESCLEIobpjNS3/rA4qidUvCg== )
rutaba.ga.		600 IN RRSIG NS 15 2 600 (
				20251023164036 20251008164036 64849 rutaba.ga.
				gWrzM1jYO7GJ234bH8JDejrxMhwtHrGWjxRM30gz37Fb
				dxnSv2/DiAgOt5OhdTPKYc7+IX4gLR1DPAUlJgXqDA== )
rutaba.ga.		600 IN RRSIG DNSKEY 15 2 3600 (
				20251023163739 20251008163739 57782 rutaba.ga.
				t3wZ31rY7TgXY+QZXd/Yy7QtTmYdJy6v7Kuj6IN8G+m4
				GycTLLn9W3lXErMS7f93RfkvXfERrwzbHqrTp1/oDg== )
…
;; Query time: 1 msec
;; SERVER: ::1#4543(localhost) (TCP)
;; WHEN: Thu Oct 09 16:43:24 UTC 2025
;; XFR size: 15 records (messages 1, bytes 1201)

  

(On verra plus loin comment configurer le « vrai » serveur de noms pour utiliser cette zone.)

Comme OpenDNSSEC, Cascade regarde sa montre pour savoir si un délai suffisant s'est écoulé (rappelez-vous que les résolveurs DNS ont une mémoire, et que cela a une grande importance opérationnelle ; dans le DNS, rien n'est jamais instantané). Mais, contrairement à OpenDNSSEC, il demande au résolveur local des tests (à l'heure actuelle, la machine qui fait tourner Cascade doit donc avoir accès à un résolveur qui marche). En attendant, cascade zone status --detailed vous affichera des choses comme :

      Roll AlgorithmRoll, state Propagation1:
    	Wait until the new DNSKEY RRset has propagated to all nameservers.
    	Try again after 2025-10-09T16:47:42Z

Voyons plutôt une fonction essentielle de Cascade : les étapes de validation. Pour l'instant, je n'en ai configuré aucune. Mais, en production, on veut éviter à tout prix ces ennuyeuses erreurs, qui plantent DSNSSEC et toute la zone. On va donc forcément valider la zone signée avant de la publier. Le principe est d'écrire un programme (typiquement un script shell) qui va lancer un programme de validation comme validns ou le dnssec-verify de BIND. On peut avoir cette étape de validation avant la signature (pour tester le fichier de zone original) ou après (ce que je fais ici). Essayons pour commencer avec un script de validation méchant qui refuse tout :

% cat /etc/cascade/reviews/always-reject.sh
#!/bin/sh

logger "Rejecting ${CASCADE_ZONE} of serial ${CASCADE_SERIAL} from ${CASCADE_SERVER}"

♯ Cascade va utiliser le code de retour pou savoir si la zone est
# validée ou pas.
exit 1

On configure la politique (pas la configuration générale : valider ou pas fait partie de la politique) :

% cat /etc/cascade/policies/default.toml
…
# How signed zones are reviewed.
[signer.review]

required = true

cmd-hook = "/etc/cascade/reviews/always-reject.sh"
  

On recharge la politique (cascade policy reload) et la validation est configurée (Cascade étant en version alpha, pour l'instant, il faut redémarrer le démon mais ce n'est pas normal). Le résultat est l'attendu :

% ./target/debug/cascade zone status rutaba.ga     
…
• Waiting for approval to publish version 2025101004
  Configured to invoke /etc/cascade/reviews/always-reject.sh
x An error occurred that prevents further processing of this zone version:
x Signed zone was rejected at the review stage.

Et dans le journal du démon :

[2025-10-10T06:32:49.335Z] INFO cascade::units::zone_server: [RS2]: Executed hook '/etc/cascade/reviews/always-reject.sh' for signed zone 'rutaba.ga' at serial 2025101004
[2025-10-10T06:32:49.339Z] DEBUG cascade::units::zone_server: [RS2]: Hook '/etc/cascade/reviews/always-reject.sh' exited with status exit status: 1
[2025-10-10T06:32:49.339Z] DEBUG cascade::targets::central_command: [CC]: Event received: ReviewZone { name: Name(rutaba.ga.), stage: Signed, serial: Serial(2025101004), decision: Reject }
…
[2025-10-10T06:32:49.339Z] DEBUG cascade::targets::central_command: [CC]: Event received: SignedZoneRejectedEvent { zone_name: Name(rutaba.ga.), zone_serial: Serial(2025101004) }

Écrivons maintenant un script plus sérieux. Il va obtenir la zone à valider via un transfert de zones. Notez que le port utilisé est obtenu via une variable d'environnement ; Cascade sert la zone sur des ports différents selon qu'elle a été validée ou pas (cf. les paramètres servers dans config.toml). Donc, le « vrai » serveur, présenté plus loin, n'utilisera pas le même port. Voici le script de validation utilisable en production :

% cat /etc/cascade/reviews/validate-signed.bash 
#!/bin/bash

# Yes, bash is required, to parse CASCADE_SERVER

set -e

logger -p daemon.notice -t cascade "Validating ${CASCADE_ZONE} of serial ${CASCADE_SERIAL} from ${CASCADE_SERVER}"

# Parsing magic by mozzieongit
SERVER=${CASCADE_SERVER%:*}
SERVER_IP="${SERVER//[\[\]]/}" # remove brackets from IPv6
SERVER_PORT=${CASCADE_SERVER##*:} # Using double '##' in case its an IPv6

tmp_zone=$(mktemp /tmp/.cascade_zone.XXXXXXXXXX)
# Clean when leaving
trap  "rm -f ${tmp_zone}; exit 1" 1 2 3 15
trap  "rm -f ${tmp_zone}" EXIT

# Too bad dig logs some errors on standard output :-(
dig @${SERVER_IP} -p ${SERVER_PORT} ${CASCADE_ZONE} AXFR > ${tmp_zone}

# validns does not handle Ed25519
# validns -z ${CASCADE_ZONE} -p all ${tmp_zone}
# or:
dnssec-verify -q -o ${CASCADE_ZONE} ${tmp_zone}

# Le code de retour du programme de vérification va être celui du script.
  

Et la zone est acceptée :

[2025-10-10T07:01:01.462Z] DEBUG cascade::units::zone_server: [RS2] Received command: SeekApprovalForSignedZone { zone_name: Name(rutaba.ga.), zone_serial: Serial(2025101001) }
[2025-10-10T07:01:01.462Z] INFO cascade::units::zone_server: [RS2]: Seeking approval for signed zone 'rutaba.ga' at serial 2025101001.
[2025-10-10T07:01:01.463Z] INFO cascade::units::zone_server: [RS2]: Executed hook '/etc/cascade/reviews/validns.sh' for signed zone 'rutaba.ga' at serial 2025101001
[2025-10-10T07:01:01.485Z] INFO domain::net::server::middleware::cookies: preprocess; request_ip=127.0.0.1
[2025-10-10T07:01:01.485Z] INFO domain::net::server::middleware::xfr::service: AXFR for rutaba.ga from 127.0.0.1:35159
[2025-10-10T07:01:01.498Z] DEBUG cascade::units::zone_server: [RS2]: Hook '/etc/cascade/reviews/validns.sh' exited with status exit status: 0
[2025-10-10T07:01:01.498Z] DEBUG cascade::targets::central_command: [CC]: Event received: ReviewZone { name: Name(rutaba.ga.), stage: Signed, serial: Serial(2025101001), decision: Approve }
[2025-10-10T07:01:01.498Z] INFO cascade::targets::central_command: [CC]: Passing back zone review

Bon, maintenant, configurons un « vrai » serveur de noms pour charger la zone et la servir à tous les résolveurs de l'Internet. J'utilise nsd. Dans le nsd.conf, je mets :

zone:
      name: "internautique.fr"
      zonefile: "/var/lib/nsd/%s.zone"
      request-xfr: AXFR 127.0.0.1@8053 NOKEY
      allow-notify: 127.0.0.1 NOKEY
      # Mettre ici les autres serveurs faisant autorité, qu'il faut notifier et autoriser. 

On va autoriser 127.0.0.1 (la machine locale, où tourne Cascade) à nous notifier les changements de la zone signée, et on demandera les transferts de zone à cette même machine, sur le port où écoute Cascade (ici 8053). nsd va alors charger la zone, éventuellement notifier les autres serveurs faisant autorité et tout roule.

Si vous connaissez DNSSEC, vous avez vu qu'il manque une étape : ajouter l'enregistrement DS (Delegation Signer) dans la zone parente. Cascade n'affiche pas ce DS tout de suite, il sait que les résolveurs ont une mémoire et il attend d'avoir vu la zone signée publiée et que la durée configurée soit écoulée. En version alpha, cette étape ne marche pas toujours donc on va le faire à la main (ne le faites pas en production !) :

    
% dig @127.0.0.1 -p 8053 AXFR internautique.fr > /tmp/internautique.fr

% dnssec-dsfromkey -f /tmp/internautique.fr internautique.fr
internautique.fr. IN DS 30790 15 2 BB8987B69144BFB6863D9F86983EE1FB465629CDB3EE8DC43EE6B7C11056598F

Et c'est ce DS qu'on va transmettre à la zone parente (peut-être via son BE). Les programmes de test disponibles en ligne montrent une configuration (presque) correcte : Zonemaster et DNSviz.

Avant-dernière chose à faire : faire tourner le démon en permanence sur la machine. Cascade vient avec un fichier de configuration pour systemd mais une bogue subtile l'empêche actuellement de fonctionner. J'ai donc utilisé un plus classique script rc.local :

% cat /etc/boot.d/cascade
#!/bin/sh

# You may change this one:
DEBUG=1

set -e 

# Don't touch
export PATH=${PATH}:/home/cascade/.cargo/bin

if [ ${DEBUG} -ge 1 ]; then
   DAEMON_OPTS="--log-level debug"
   export RUST_BACKTRACE=full
else
    DAEMON_OPTS="--log-level info"
fi
   
# Does not work with su (for unknown reasons): "Permission denied"
sudo -E -u cascade /home/cascade/.cargo/bin/cascaded --daemonize ${DAEMON_OPTS} --state=/var/lib/cascade/state.db --config=/etc/cascade/config.toml
# su -p cascade -c '/home/cascade/.cargo/bin/cascaded --state=/var/lib/cascade/state.db --config=/etc/cascade/config.toml'

Enfin, quand on sera en production, il faudra évidemment superviser Cascade. J'utilise un petit script qui suit l'API Nagios :

% cat /usr/local/lib/nagios/plugins/check_cascade
#!/bin/sh

# Simple Nagios/Icinga plugin for Cascade. 

USAGE="Usage: $0 -e string_to_expect"

TEMP=$(getopt -o "e:h" -- "$@")
if [ $? != 0 ]; then
    echo $USAGE
    exit 1
fi

eval set -- "$TEMP"

while true ; do
    case "$1" in
	-e) EXPECTED=$2; shift 2;;
	-h) echo $USAGE; exit 3;;
	--) shift ; break ;;
	*) echo "Internal error!"; exit 3 ;;
    esac
done

logger "Test Cascade, expect \"${EXPECTED}\""

E_OUT=$(/home/cascade/.cargo/bin/cascade zone list 2>/dev/null)

if [ $? -ne 0 ];
then
    echo "Cascade did not respond"
    exit 2
fi

if [ ! -z "${EXPECTED}" ]; then
    L_CNT=$(echo ${E_OUT} | grep "${EXPECTED}" 2>/dev/null | wc -l)
    if [ ${L_CNT} -eq 0 ]; then
	echo "Cascade zone list did not return a match on ${EXPECTED}"
	exit 2
    fi
fi

echo "Cascade OK ${S_OUT}"
exit 0

Et qui se configure comme cela dans Icinga :

object CheckCommand "cascade" {          
  command = [ PluginContribDir + "/check_cascade" ]       
  arguments = {                 
      "-e" = "$cascade_expect$", 
    } 
}
                                           
object Host "ayla-cascade" {  
  import "generic-host"   
  check_command = "remote_always_true"  
  vars.cascade = true   
  vars.cascade_expect="internautique\\.fr" 
}        

Et en conclusion, un peu d'opéra, avec l'air « Cascader la vertu » de La Belle Hélène, interprété par Gaëlle Arquez.

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)