diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f5871..9a3a683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `: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 : diff --git a/roles/serveur_dns_public/README.md b/roles/serveur_dns_public/README.md index a64cac6..9f07aed 100644 --- a/roles/serveur_dns_public/README.md +++ b/roles/serveur_dns_public/README.md @@ -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 +`: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. diff --git a/roles/serveur_dns_public/defaults/main.yml b/roles/serveur_dns_public/defaults/main.yml index 0d72425..446ccb7 100644 --- a/roles/serveur_dns_public/defaults/main.yml +++ b/roles/serveur_dns_public/defaults/main.yml @@ -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 : diff --git a/roles/serveur_dns_public/handlers/main.yml b/roles/serveur_dns_public/handlers/main.yml index 7cf29e1..aa78cd8 100644 --- a/roles/serveur_dns_public/handlers/main.yml +++ b/roles/serveur_dns_public/handlers/main.yml @@ -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 diff --git a/roles/serveur_dns_public/tasks/main.yml b/roles/serveur_dns_public/tasks/main.yml index 87bbadb..4170621 100644 --- a/roles/serveur_dns_public/tasks/main.yml +++ b/roles/serveur_dns_public/tasks/main.yml @@ -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: diff --git a/roles/serveur_dns_public/templates/dnsdist.conf.j2 b/roles/serveur_dns_public/templates/dnsdist.conf.j2 new file mode 100644 index 0000000..bbde8a1 --- /dev/null +++ b/roles/serveur_dns_public/templates/dnsdist.conf.j2 @@ -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()) 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 0f6f5b9..ce0c0fe 100644 --- a/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 +++ b/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 @@ -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 }}")