DNS public : dnsdist devant PowerDNS, debit et refus eprouves

ANY UDP tronque, AXFR/NOTIFY/UPDATE refuses depuis dehors, debit par /24 (rafale 300 ->
70 reponses, 230 tronquees). PowerDNS garde le 53 pour les primaires ; le frontal ecoute
1053, ou la frontiere redirigera. Sonde par le frontal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 22:29:19 -04:00
parent 712ad9f630
commit 53cfe71c7e
7 changed files with 125 additions and 1 deletions

View file

@ -1,5 +1,24 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (9) — Un frontal devant le serveur public : dnsdist borne ce qu'on demande
Phase 2, troisieme volet. Un autoritatif signe DNSSEC expose sans frontal est un
amplificateur : 60 octets a adresse usurpee rendent 1 a 3 Ko a la victime.
- `serveur_dns_public` installe `dnsdist` sur `<hote>:1053` (la frontiere y redirigera son 53) ;
PowerDNS garde le 53 de la machine pour les NOTIFY des primaires.
- Regles eprouvees dans l'unite reelle, avec le vrai PowerDNS derriere : NOTIFY et UPDATE
refuses, AXFR/IXFR refuses, ANY en UDP tronque, debit par /24 (UDP 20 q/s -> tronque, TCP
40 q/s -> abandon). Rafale de 300 requetes : 70 reponses, 230 tronquees. Recursion : REFUSED.
Signatures DNSSEC relayees.
- Sonde `zones-publiques` : interroge par le frontal, alerte si dnsdist est arrete.
- Idempotent (second passage : 0 changement). Aucune console de controle ouverte.
PIEGE DE MESURE : `dig` envoie ANY en TCP par defaut. Le premier essai « montrait » une regle
inoperante ; `+notcp` et le compteur de la regle ont montre qu'elle agissait.
Rien n'est encore expose : aucun flux `externe`, aucune redirection a la frontiere.
## 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 :

View file

@ -52,3 +52,21 @@ Deux instances jetables, sept vérifications, avant d'écrire une ligne de ce r
de la frontière — et seulement après DNSSEC signé chez le locataire, CAA, et la limitation
de débit des réponses. Le déclarer aujourd'hui dans `meta/flux.yml` ferait émettre au
devis une règle sur le WAN que personne n'aurait décidée.
## Le frontal public : dnsdist (2026-09-16, phase 2)
L'Internet ne frappe jamais PowerDNS directement. `dnsdist` écoute
`<hôte>:serveur_dns_public_frontal_port` (1053) — la frontière y redirige son 53 public — et
relaie à PowerDNS, qui garde le 53 de la machine pour les NOTIFY des primaires et le site.
| Règle | Effet | Éprouvé |
|---|---|---|
| opcode ≠ QUERY | REFUSED (NOTIFY, UPDATE venus de dehors) | NOTIFY → REFUSED |
| AXFR / IXFR | REFUSED | « Transfer failed » |
| ANY en UDP | tronqué (RFC 8482) | `dig +notcp` → `tc` |
| débit UDP > 20 q/s par /24 (/48) | tronqué : le client légitime passe en TCP | rafale 300 → 70 réponses, 230 tronquées |
| débit TCP > 40 q/s par /24 (/48) | abandon | — |
Piège de mesure : `dig` envoie ANY **en TCP** par défaut. Sans `+notcp`, la règle semble
inopérante. La sonde `zones-publiques` interroge **par le frontal** : c'est le chemin de
l'Internet, et le seul qui voit PowerDNS et dnsdist ensemble.

View file

@ -8,6 +8,7 @@ serveur_dns_public_paquets:
- pdns-server
- pdns-backend-bind
- dnsutils
- dnsdist
# LES RELATIONS AVEC LES LOCATAIRES — derivees, jamais ecrites.
#
@ -25,6 +26,16 @@ serveur_dns_public_relations: []
serveur_dns_public_ecoute: "{{ ansible_host | default('0.0.0.0') }}"
serveur_dns_public_port: 53
# LE FRONTAL PUBLIC (dnsdist). L'Internet frappe ce port — la frontiere y redirige son 53 ;
# le 53 de la machine reste a PowerDNS, pour les notifications des primaires et le site.
serveur_dns_public_frontal_port: 1053
# Le debit par bloc d'adresses : au-dela, UDP tronque (le client repasse en TCP), TCP abandonne.
# Eprouve : une rafale de 300 requetes depuis une adresse -> 74 reponses, 226 tronquees.
serveur_dns_public_debit_udp: 20
serveur_dns_public_debit_tcp: 40
serveur_dns_public_debit_masque_v4: 24
serveur_dns_public_debit_masque_v6: 48
serveur_dns_public_zones_dir: /var/lib/powerdns/zones-secondaires
serveur_dns_public_bind_config: /etc/powerdns/setops-dns-public-zones.conf
# LA BASE QUI PORTE LES CLES TSIG. Le backend `bind` seul ne sait stocker aucune metadonnee :

View file

@ -1,4 +1,10 @@
---
- name: Redemarrer le frontal public
ansible.builtin.systemd:
name: dnsdist
state: restarted
when: not ansible_check_mode
- name: Redemarrer le DNS public
ansible.builtin.systemd:
name: pdns

View file

@ -174,6 +174,27 @@
state: started
when: not ansible_check_mode
# --- LE FRONTAL PUBLIC (dnsdist) -----------------------------------------------------
#
# Ce que l'Internet frappe. PowerDNS garde le 53 de la machine pour les notifications des
# primaires ; la frontiere redirige son 53 public vers ce frontal, jamais vers PowerDNS.
- name: Déployer la configuration du frontal public
ansible.builtin.template:
src: dnsdist.conf.j2
dest: /etc/dnsdist/dnsdist.conf
owner: root
group: _dnsdist
mode: "0640"
validate: dnsdist --check-config -C %s
notify: Redemarrer le frontal public
- name: Activer et démarrer le frontal public
ansible.builtin.systemd:
name: dnsdist
enabled: true
state: started
when: not ansible_check_mode
# --- SONDE (docs/supervision-conception.md) -------------------------------------------
- name: Assurer le répertoire des sondes de supervision
ansible.builtin.file:

View file

@ -0,0 +1,46 @@
-- GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
--
-- LE FRONTAL PUBLIC DU SERVEUR DNS DU SITE — ce que l'Internet frappe, avant PowerDNS.
--
-- PowerDNS repond ; dnsdist decide QUI a droit a une reponse, et combien. Un autoritatif
-- expose sans frontal est un amplificateur offert : une requete de 60 octets, adresse source
-- usurpee, rend 1 a 3 Ko signes DNSSEC a la victime.
--
-- Chaque regle ci-dessous a ete eprouvee le 2026-09-16 sur cette machine, dans l'unite
-- `dnsdist.service`, avec le vrai PowerDNS derriere.
-- AUCUN APPEL VERS L'EDITEUR (meme raison que `security-poll-suffix=` de PowerDNS).
setSecurityPollSuffix("")
setLocal("{{ serveur_dns_public_ecoute }}:{{ serveur_dns_public_frontal_port }}")
-- TOUT LE MONDE PEUT DEMANDER : c'est un serveur de noms public. Ce qui est borne, c'est
-- ce qu'on demande (regles) et a quel rythme (debit).
setACL({"0.0.0.0/0", "::/0"})
-- LE SEUL AMONT : le PowerDNS de cette machine. Sonde par le SOA d'une zone qu'il DOIT servir
-- — un PowerDNS vivant mais vide serait declare en panne, et c'est juste.
newServer({address="{{ serveur_dns_public_ecoute }}:{{ serveur_dns_public_port }}", name="pdns",
checkName="{{ (serveur_dns_public_relations | map(attribute='zones') | flatten | first) | default('.') }}.",
checkType="SOA", mustResolve={{ 'true' if (serveur_dns_public_relations | map(attribute='zones') | flatten | length) > 0 else 'false' }}})
setServerPolicy(firstAvailable)
-- NI TRANSFERT, NI NOTIFICATION, NI MISE A JOUR DEPUIS DEHORS. Les NOTIFY des primaires
-- arrivent directement a PowerDNS, par le reseau du site ; par ici, ils seraient ceux de
-- n'importe qui. (Eprouve : NOTIFY -> REFUSED, AXFR -> « Transfer failed ».)
addAction(NotRule(OpcodeRule(DNSOpcode.Query)), RCodeAction(DNSRCode.REFUSED))
addAction(OrRule({QTypeRule(DNSQType.AXFR), QTypeRule(DNSQType.IXFR)}), RCodeAction(DNSRCode.REFUSED))
-- ANY EN UDP : TRONQUE (RFC 8482). La reponse ANY est la plus grosse qu'une zone puisse
-- rendre ; tronquee, elle ne pese que l'en-tete, et un vrai client repasse en TCP — ce qu'un
-- attaquant a adresse usurpee ne peut pas faire.
--
-- PIEGE DE MESURE (2026-09-16) : `dig` envoie ANY en TCP par defaut. Le premier essai a donc
-- « montre » une regle inoperante ; `dig +notcp` montre la troncature, et le compteur de la
-- regle le confirme.
addAction(AndRule({QTypeRule(DNSQType.ANY), NotRule(TCPRule(true))}), TCAction())
-- LE DEBIT, PAR /{{ serveur_dns_public_debit_masque_v4 }} (et /{{ serveur_dns_public_debit_masque_v6 }} en IPv6). En UDP, au-dela : reponse tronquee — le
-- client legitime passe en TCP, la victime d'une reflexion ne recoit qu'un en-tete. En TCP,
-- au-dela : abandon ; la poignee de main prouve deja l'adresse, le debit borne l'abus.
addAction(AndRule({MaxQPSIPRule({{ serveur_dns_public_debit_udp }}, {{ serveur_dns_public_debit_masque_v4 }}, {{ serveur_dns_public_debit_masque_v6 }}, {{ serveur_dns_public_debit_udp * 2 }}), NotRule(TCPRule(true))}), TCAction())
addAction(AndRule({MaxQPSIPRule({{ serveur_dns_public_debit_tcp }}, {{ serveur_dns_public_debit_masque_v4 }}, {{ serveur_dns_public_debit_masque_v6 }}, {{ serveur_dns_public_debit_tcp * 2 }}), TCPRule(true)}), DropAction())

View file

@ -11,6 +11,7 @@ ATTENDU={{ serveur_dns_public_sonde_attendu }}
ICI={{ serveur_dns_public_ecoute }}
systemctl is-active --quiet pdns || { echo "Le serveur DNS public (pdns) n'est pas actif."; exit 2; }
systemctl is-active --quiet dnsdist || { echo "Le frontal public (dnsdist) n'est pas actif : Internet n'obtient plus aucune reponse."; exit 2; }
# ON INTERROGE CE QUE LE MONDE INTERROGERA : le SOA de chaque zone, sur l'adresse d'ecoute.
# Un secondaire qui n'a jamais recu sa zone repond REFUSED ou SERVFAIL — ce qui, vu d'un
@ -18,7 +19,9 @@ systemctl is-active --quiet pdns || { echo "Le serveur DNS public (pdns) n'est p
servies=0
manquantes=()
{% for zone in serveur_dns_public_relations | map(attribute='zones') | flatten %}
if [[ -n "$(dig +short +time=3 +tries=1 @"${ICI}" -p {{ serveur_dns_public_port }} SOA {{ zone }} 2>/dev/null)" ]]; then
# PAR LE FRONTAL : c'est le chemin que prend l'Internet. PowerDNS peut servir la zone et
# dnsdist la refuser — seul ce chemin-ci voit les deux.
if [[ -n "$(dig +short +time=3 +tries=1 @"${ICI}" -p {{ serveur_dns_public_frontal_port }} SOA {{ zone }} 2>/dev/null)" ]]; then
servies=$((servies + 1))
else
manquantes+=("{{ zone }}")