Set-OPS-Public/docs/dns-interne.md
Daniel Allaire 712ad9f630 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>
2026-09-16 22:09:21 -04:00

241 lines
11 KiB
Markdown

# DNS interne
> **Pour qui :** le **mainteneur** du DNS interne.
Le service DNS interne est la premiere capacite de plateforme.
> **Plancher de resolution independant du DNS.** Le socle (`hosts_statiques`, dans
> `serveur_debian`) genere `/etc/hosts` sur **chaque** VM depuis l'inventaire : tout
> l'ecosysteme se resout par nom **meme serveur DNS eteint** (et au bootstrap, avant
> que PowerDNS ne soit la). PowerDNS devient une **commodite** (zone, externe,
> dynamique), plus un point de defaillance.
## Trois couches, et une seule est optionnelle
> **Ce paragraphe decrivait le modele d'avant le 2026-08-24** — un Unbound *sur chaque VM*,
> en opt-in. Ce n'est plus le cas : `client_resolveur` **n'installe plus rien**, et son
> integration est **universelle**, pas elective.
```text
1. hosts_statiques le PLANCHER : /etc/hosts genere sur chaque VM depuis l'inventaire
-> l'ecosysteme se resout DNS eteint. Jamais optionnel.
2. serveur_powerdns l'AUTORITATIF de la zone souveraine (un par ecosysteme)
3. serveur_resolveur le RECURSIF : UN seul Unbound pour tout le tenant, qui recurse
depuis la racine et delegue la zone souveraine a PowerDNS
client_resolveur l'integration : ecrit /etc/resolv.conf pour designer ce resolveur
```
**`client_resolveur` n'installe plus de demon** (2026-08-24). Il en posait un par VM — N
demons identiques de ~21 Mo pour quelques centaines de requetes. Il ne fait plus qu'une
chose : **ecrire `/etc/resolv.conf`**. Son integration est marquee `universelle: true`, et
elle n'a **aucune exemption, pas meme l'hote qui porte le resolveur** : il se sert
lui-meme. L'ancien nom (`client_unbound`) mentait des lors qu'il n'installait plus Unbound.
**L'integration suit l'existence du service, elle ne se declare pas.** Aucun
`serveur_resolveur` au plan rend `client_resolveur_actif` faux et le role ne touche a rien :
l'ecosysteme garde la resolution d'amorcage de cloud-init. C'est un choix valide, pas une
panne.
## Groupes
```text
serveur_powerdns -> autoritatif interne (PowerDNS Authoritative)
serveur_resolveur -> LE recursif du tenant (Unbound), un seul
client_resolveur -> integration universelle : designe ce resolveur dans /etc/resolv.conf
```
## Zone initiale
La zone initiale est :
```text
exemple.internal
```
Elle est definie dans :
```text
instance/inventories/<inventaire>/group_vars/serveur_powerdns.yml
```
## Enregistrement automatique
Le role `serveur_powerdns` genere la zone a partir de l'inventaire.
Les hotes qui remplissent ces conditions sont ajoutes automatiquement :
```text
hote dans hotes_actifs
ansible_host defini
```
Exemple :
```text
web-frontal-01 ansible_host=10.0.2.31
-> web-frontal-01.exemple.internal A 10.0.2.31
```
Les enregistrements additionnels explicites sont dans `serveur_powerdns_records`.
## Backend PowerDNS
Le premier jalon utilise le backend BIND de PowerDNS.
Raison :
- pas de dependance prematuree a PostgreSQL ;
- zone lisible et generee par Ansible ;
- idempotence simple ;
- bon point de depart pour la supervision.
Quand `serveur_postgresql` sera stable, il sera possible de migrer vers un backend SQL si le besoin operationnel le justifie.
## La bascule de `/etc/resolv.conf` est protegee
Le role `client_resolveur` ne bascule `/etc/resolv.conf` qu'apres avoir verifie que le
resolveur repond **deja** — pour l'interne *et* pour l'Internet :
```yaml
client_resolveur_apply: true
client_resolveur_confirm: true
```
Sans ces deux variables, le role prépare Unbound mais ne modifie pas `/etc/resolv.conf`.
Cette protection est volontaire : une mauvaise configuration DNS peut couper la resolution
de noms. C'est la dependance la plus dangereuse du lot — basculer un hote sur un resolveur
qui n'est pas encore pret le rend **muet**, et le runner qui devrait reparer tombe avec les
autres. Le playbook de groupe applique donc l'hote qui *porte* le resolveur avant ceux qui
s'y adressent, et la preuve **P44** refuse tout ecart entre cette declaration et lui.
## Le piege qui a coute deux jours : la racine signee nie notre TLD
`internal.` n'est **pas delegue dans la racine**, qui est signee : elle rend donc une preuve
NXDOMAIN *validee* pour ce TLD. Or `harden-below-nxdomain` — **actif par defaut** dans
Unbound — tient ce « non » pour prouve et repond NXDOMAIN pour **tout** nom sous
`internal.` depuis son cache, **sans jamais interroger la `stub-zone`** declaree plus bas.
La delegation etait correcte. L'autoritatif repondait juste. Pas une requete ne lui
parvenait.
Ce qui declenche l'empoisonnement : n'importe quelle question sur un nom inexistant sous
`internal.` — y compris la zone d'**un autre ecosysteme**, que ce resolveur ne sert pas et
va donc chercher a la racine. Sur un resolveur partage, ca arrive en permanence.
Et la panne parait **intermittente** : au redemarrage le cache est vide, tout fonctionne, on
conclut que c'est regle. Le remede mesure (2026-09-02) est `harden-below-nxdomain: no` dans
`roles/serveur_resolveur/templates/setops.conf.j2` — **pas** `aggressive-nsec`, qui traite
un autre symptome.
## Surveillance a prevoir
Les premiers checks utiles :
- service `pdns` actif ;
- port UDP/TCP 53 disponible ;
- SOA de `exemple.internal` resoluble ;
- enregistrement `ns1.exemple.internal` resoluble ;
- enregistrements des hotes actifs presents ;
- serial de zone attendu ;
- latence de resolution.
## Zones publiques
> Ajouté le 2026-09-16. Le mode `primaire-cache` était **validé par le schéma et consommé par
> rien** depuis sa création — *une autorité qu'on s'attribue sans l'exercer est une panne
> différée*.
`plan/domaines.yml` porte un champ `autorite` par domaine. Deux valeurs sont exercées :
| `autorite` | Ce que ça veut dire | Qui sert la zone |
|---|---|---|
| `auto-heberge` | zone **interne** (`.internal`), servie à l'écosystème seul | l'instance principale de `serveur_powerdns`, sur la boucle locale |
| `primaire-cache` | zone **publique** : le locataire l'écrit, le site la sert | `pdns@public` chez le locataire (primaire caché), `serveur_dns_public` au site (secondaire public) |
`delegue` est accepté par le validateur et **n'est consommé par aucun rôle**. Il ne faut pas
le déclarer en croyant qu'il fait quelque chose.
**Le locataire écrit, le site sert, un site pair réplique.** Le primaire reste chez le
locataire : quand il part, il emporte sa zone. Le détail — et ce que l'épreuve de PowerDNS a
appris — est dans `roles/serveur_dns_public/README.md`.
Trois règles, chacune payée ou mesurée :
- **TSIG seul ouvre le transfert.** `allow-axfr-ips` et TSIG sont *alternatifs* : une adresse
listée obtient la zone sans signature. L'instance publique n'autorise que la boucle locale.
- **Une instance à part pour le public.** Faire écouter l'instance principale sur l'adresse
de l'hôte aurait permis au serveur public du site d'interroger la zone `.internal`.
- **Le serial suit le contenu.** Un serial figé ferait garder au secondaire l'ancienne zone
pour toujours.
**Rien n'est exposé à Internet en phase 1.** P82 refuse d'exposer le serveur public tant
que toutes ses zones ne sont pas signées DNSSEC.
### Ce qu'une zone publique porte
> Ajouté le 2026-09-16, phase 2. Une zone qui ne portait que ses expositions web aurait coupé
> le courriel de production à la minute où le registraire l'aurait suivie.
Chaque zone `primaire-cache` déclare ses **enregistrements** au plan (`enregistrements:` dans
`plan/domaines.yml`) : A, AAAA, CNAME, MX, TXT, CAA. `valider_domaines` refuse une adresse
privée, une cible incomplète, un CNAME à l'apex ou à côté d'un autre type, un MX sans
priorité, un TXT avec guillemets ou hors ASCII, un nom écrit en absolu (`mx.chezlepro.ca`
sous `chezlepro.ca`), et tout enregistrement dans une zone que Set-OPS n'écrit pas.
Le **SOA et les NS** ne se déclarent pas : ils désignent le serveur de noms du site, sous le
nom que **le site** déclare (`dns_public_nom`, contrat du site, reçu par chaque locataire).
Seule la zone qui contient ce nom porte son A. Le premier choix, `ns1.<zone>`, aurait déplacé
en silence un `ns1.chezlepro.ca` qui existe en production vers une autre adresse.
**Avant de basculer : `make dns-bascule-devis`.** Il compare le plan au DNS en service, nom
par nom et type par type, sur les noms déclarés et une liste de sondes usuelles. Il rend ce
qui serait **perdu**, **changé**, **ajouté**, ou **abandonné en connaissance de cause** (les
marques `heritage=external-dns`), et les préalables : le serveur de noms doit déjà résoudre
publiquement, et il en faut **deux** (exigence du registre `.ca`). Une mesure qui échoue rend
« mesure impossible », jamais « absent ». Sa limite est écrite à chaque rapport : sans
transfert de zone, un export chez le fournisseur actuel reste la seule preuve d'exhaustivité.
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.