resolveur : un par tenant, et non plus un par machine

Mesure avant de decider : ~21 Mo de RSS par VM pour 28 a 189 requetes servies.
Le gain de cache est negligeable ; ce qu'on recupere, c'est un demon au lieu de
cinq et un endroit a regarder au lieu de cinq.

Pas sur la frontiere : un resolveur de SITE devrait connaitre la zone interne de
chaque tenant, et un tenant dont le resolveur vit chez l'hebergeur ne peut plus
s'emanciper avec. La recursion est generique, la zone interne ne l'est pas.

L'autoritatif se replie sur 127.0.0.1:5300 -- derive de la colocalisation, pas
declare a la main. Son port devient `derive` dans meta/flux.yml : les deux lient
53 mais sur des adresses differentes.

Deux pieges. Unbound refuse d'interroger une loopback par defaut : sans lever
do-not-query-localhost, toute la zone rendait SERVFAIL. Et le plancher
/etc/hosts MASQUAIT la panne -- getent repondait, dig disait SERVFAIL. La tache
de validation du role avait raison contre moi.

client_unbound n'installant plus Unbound, son nom mentait : client_resolveur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-24 16:59:34 -04:00
parent 70e57076e8
commit db2d6f6bf0
44 changed files with 571 additions and 270 deletions

View file

@ -1,5 +1,70 @@
# CHANGELOG — Set-OPS
## 2026-08-24 — Un résolveur par tenant, et non plus un par machine
`client_unbound` posait un Unbound sur **chaque VM**. C'était étanche, et c'était N démons
identiques pour un service unique.
**Mesure prise avant de décider** : ~21 Mo de RSS par VM, et **28 à 189 requêtes** servies
depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère,
c'est un démon au lieu de cinq — et **un endroit à regarder** au lieu de cinq.
### Pourquoi pas sur la frontière
C'était la proposition, et elle aurait été la plus économe. Mais un résolveur partagé par
tout le SITE devrait connaître la zone interne de **chaque tenant** : sans vues par réseau
soigneusement réglées, le tenant A résoudrait les noms du tenant B. Et un tenant dont le
résolveur vit chez l'hébergeur **ne peut plus s'émanciper avec**.
La récursion est générique ; **la zone interne ne l'est pas**. C'est ce qui fait du
résolveur un service de tenant, et non de fabric.
### La cohabitation avec l'autoritatif
Les deux vivent sur `infra-dns-01` et voudraient le port 53. Le partage est **dérivé, pas
déclaré** — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
rôle :
```
résolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
```
Le port de PowerDNS est déclaré `derive` dans son `meta/flux.yml` — `verifier_ports` traite
un port non numérique comme « pas une écoute fixe », ce qui est exactement le cas : les
deux lient le port 53, mais sur des **adresses** différentes.
### Deux pièges, dont un que le harnais seul a vu
**Unbound refuse d'interroger une loopback.** `do-not-query-localhost` vaut `yes` d'origine
— une protection contre les boucles. Or l'autoritatif vit désormais sur `127.0.0.1:5300`,
derrière le résolveur. Sans la lever, **toute la zone souveraine rendait SERVFAIL**.
**Et la panne était masquée par le plancher.** `getent hosts forge.genese.internal`
répondait `10.29.16.11` sur les cinq machines — c'était `/etc/hosts`, pas le DNS. J'ai
d'abord conclu que la résolution fonctionnait. Seul un `dig` explicite a montré le
SERVFAIL. Le plancher fait son travail — mais il rend une panne DNS invisible à qui
mesure avec le mauvais instrument.
C'est la tâche de validation du rôle qui a refusé de se dire satisfaite, et elle avait
raison contre moi.
### Le client sait se retirer
`client_resolveur` **désinstalle** l'Unbound local après avoir basculé — jamais avant,
sinon l'hôte perdrait toute résolution au milieu du play. L'hôte qui porte le résolveur est
épargné : c'est le même paquet.
Sans cette tâche, le rôle aurait changé le `resolv.conf` sans rien économiser, et la raison
même du changement aurait été perdue.
### Le renommage
`client_unbound` n'installant plus Unbound, son nom mentait. Il devient `client_resolveur`
— une vingtaine de fichiers, quatre plans d'instance et six modèles. Le harnais a rattrapé
chaque oubli : playbook homonyme manquant, inventaires en écart, plan de recette périmé,
README absent.
## 2026-08-24 — Collabora en natif : la dernière exception conteneurisée tombe
`serveur_collabora` lançait `collabora/code` dans Docker — **seule exception** de la

View file

@ -68,7 +68,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c
| 1 | Le plancher, sans DNS | Sur un nœud : « commande » | 👁 observe | — |
| 2 | Interroge l'autoritatif | Demande à PowerDNS directement : « commande » | 👁 observe | — |
| 3 | Vois les couches | Compare getent hosts (plancher) et dig (DNS) : deux chemins, même IP. | 👁 observe | — |
| 4 | Casse & répare | Sur un nœud sans client_unbound, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le planc… | 🔨 casse-répare | — |
| 4 | Casse & répare | Sur un nœud sans client_resolveur, vide /etc/hosts de ses entrées chezlepro (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne échoue. Restaure /etc/hosts : ça remarche sans DNS. Tu viens de *sentir* pourquoi le pla… | 🔨 casse-répare | — |
*Source : [DNS & résolution de noms](DNS-et-résolution) · § À toi de jouer.*

View file

@ -20,15 +20,15 @@
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 34 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 32 rôles, 83 flux, schéma + matrice OK. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 35 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 33 rôles, 84 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| 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 | 15 hotes, 32 groupes (inventaire dechiffre et parse). |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 15 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 : 23 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
@ -36,21 +36,21 @@
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 7 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 55 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 48 groupe(s), 77 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 52 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 83 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 26 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 15, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 27 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 16, 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 | 47 scripts expliques et atteignables, 98 cibles make documentees, 58 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 34 exigence(s) de role, toutes satisfaites (121 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (34 groupes). |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 47 scripts expliques et atteignables, 98 cibles make documentees, 59 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (121 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (22 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 33 role(s) serveur/client tous nommes, 34 groupe(s) cite(s) en table existent tous. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 34 role(s) serveur/client tous nommes, 35 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, 27 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 : 43 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |

View file

@ -75,7 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
- ✅ **soldé** — README de rôles : **tous les rôles en ont un** (les 12 manquants écrits le
2026-07-29 : `serveur_debian`, `hosts_statiques`, `resoudre_base`, `resoudre_annuaire`,
`serveur_dovecot`, `serveur_postfix`, `serveur_rspamd`, `client_backup`, `serveur_backup`,
`client_unbound`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
`client_resolveur`, `serveur_oauth2_proxy`, `serveur_icingaweb2`).
- ✅ **soldé** — `expose` **est** consommé au déploiement : `plan/applications.yml` →
filtre `expositions_des_applications` → vhosts nginx dérivés
(`roles/serveur_nginx/tasks/main.yml`, template `expositions.conf.j2`, drapeau

View file

@ -37,7 +37,7 @@ Ce que la reconstruction couvre, par capacité :
|---|---|---|
| Socle et durcissement | `serveur_debian`, `serveur_durci` | clone du gabarit doré → machine conforme |
| Confiance | `serveur_step_ca`, `client_pki` | mTLS avec SAN dérivés du plan, renouvellement |
| Noms | `serveur_powerdns`, `client_unbound` | autoritaire interne + résolveur local |
| Noms | `serveur_powerdns`, `client_resolveur` | autoritaire interne + résolveur local |
| Identité | `serveur_openldap`, `serveur_keycloak` | LDAPS, SSO OIDC, **fédération LDAP automatisée** (`tasks/federation-ldap.yml`), exposé par le plan (`auth.<domaine>`) |
| Passerelle SSO | `serveur_oauth2_proxy` | SSO devant une app sans OIDC natif (éprouvé sur Icinga Web 2) |
| Données | `serveur_postgresql`, `serveur_redis` | bases et comptes dérivés du registre, TLS `verify-full` |
@ -52,10 +52,11 @@ Ce que la reconstruction couvre, par capacité :
| Exploitation | `serveur_ops` | le poste depuis lequel l'ecosysteme se reconstruit : Ansible epingle, genome clone depuis **sa propre forge**, cle SSH propre — **sans** la voute ni son mot de passe |
| Source d'artefacts | `serveur_artefacts`, `client_artefacts` | cache apt de l'ecosysteme (apt-cacher-ng) : les paquets viennent de chez soi, pas de six serveurs etrangers — **mode hors ligne** pour prouver ce que le cache detient vraiment |
| Runner de site | `serveur_ops_site` | le pouvoir de **materialiser** : creer et detruire des VM sur la fabric. Detient la voute du SITE, chiffree, et n'entre JAMAIS chez un tenant — reserve a l'ecosysteme de l'hebergeur |
| Resolution | `serveur_resolveur`, `client_resolveur` | UN resolveur recursif par tenant (Unbound), qui recurse depuis la racine et delegue la zone souveraine a PowerDNS. Remplace les N demons locaux d'avant le 2026-08-24 |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_unbound`), `client_ldap`
(→ Postfix), `client_dns` (→ plancher `/etc/hosts` + `client_resolveur`), `client_ldap`
(login LDAP au niveau OS, hors design).
> **Ce que « éprouvé » ne dit pas.** La reconstruction prouve que le moteur mène une
@ -154,7 +155,7 @@ boîtes (Dovecot). La coupure suit l'exposition, pas le logiciel.
| Confiance PKI / ACME | `client_pki` | **tout hôte** (universelle) |
| Métriques Prometheus | `client_metrique` | **tout hôte** (universelle) |
| Journaux vers Loki | `client_journal` | **tout hôte** (universelle) |
| Résolution locale (Unbound) | `client_unbound` | **tout hôte** (universelle) |
| Résolution locale (Unbound) | `client_resolveur` | **tout hôte** (universelle) |
| Relais SMTP | `client_smtp` | déclaré par hôte, dans le plan |
| Sauvegarde restic | `client_backup` | déclaré par hôte — **obligatoire pour tout détenteur d'état** (P36) |
@ -271,7 +272,7 @@ L'ordre ci-dessous privilégie les dépendances structurantes avant les applicat
- Tout service exposé en HTTP(S) doit prévoir son intégration avec `serveur_nginx`.
- Tout service avec authentification humaine doit prévoir son intégration avec `serveur_keycloak`, sauf justification contraire.
- Tout service générant des alertes ou notifications doit prévoir `client_smtp`.
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_unbound` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
- Toute VM de service rejoint `client_pki`, `client_metrique`, `client_journal` et `client_resolveur` — **par dérivation, sans rien écrire** (intégrations universelles, P26). La supervision, elle, ne pose rien sur l'hôte.
- Tout hôte qui **détient de l'état** doit porter `client_backup` (P36 le refuse sinon).
- Tout service utilisant un certificat interne doit dépendre de `client_pki`.
- Tout rôle serveur doit documenter ses ports, secrets, sauvegardes, dépendances et groupes clients associés.

View file

@ -39,6 +39,9 @@ couches:
- serveur_postgresql
- serveur_openldap
- serveur_powerdns
# Le resolveur du tenant vient APRES son autoritatif : il le prend en stub-zone,
# et sa validation exige que la zone souveraine reponde deja.
- serveur_resolveur
- serveur_redis
- serveur_prometheus
- serveur_loki
@ -78,5 +81,5 @@ couches:
- client_journal
- client_smtp
- client_backup
- client_unbound
- client_resolveur
- client_artefacts

View file

@ -127,6 +127,14 @@ groupes:
raison: "Le runner de site n'ajoute qu'un pouvoir a un poste d'exploitation existant : sans lui, rien a quoi l'attacher."
surveillance: "Verifier que la voute du site est presente ET CHIFFREE, et que la carte de la fabric est lisible."
serveur_resolveur:
requiert_groupes_actifs:
- serveur_powerdns
# Le resolveur prend la zone souveraine en STUB-ZONE : sans autoritatif, il ne saurait
# resoudre aucun nom de l'ecosysteme, et sa propre validation echouerait.
raison: "Le resolveur du tenant delegue la zone souveraine a l'autoritatif ; sans lui, il ne sait rien de l'ecosysteme."
surveillance: "Verifier qu'il repond pour la zone interne ET pour un nom de l'Internet."
serveur_nextcloud:
requiert_groupes_actifs:
- serveur_postgresql

View file

@ -8,17 +8,17 @@ Le service DNS interne est la premiere capacite de plateforme.
> `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. Le resolveur local `client_unbound` est
> dynamique), plus un point de defaillance. Le resolveur local `client_resolveur` est
> **optionnel** (opt-in, avec bascule validee) : sans lui, le plancher `/etc/hosts` suffit.
## Groupes
```text
serveur_powerdns -> service DNS central PowerDNS Authoritative
client_unbound -> resolveur local optionnel (opt-in, bascule validee)
client_resolveur -> resolveur local optionnel (opt-in, bascule validee)
```
`client_unbound` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
`client_resolveur` (résolveur local optionnel) peut viser PowerDNS en stub-zone + récursion.
## Zone initiale
@ -69,15 +69,15 @@ Quand `serveur_postgresql` sera stable, il sera possible de migrer vers un backe
## Résolveur local (optionnel)
Le role `client_unbound` (opt-in) installe un résolveur récursif local qui, en stub-zone,
Le role `client_resolveur` (opt-in) installe un résolveur récursif local qui, en stub-zone,
délègue les noms internes à PowerDNS et récurse le reste.
La bascule du resolver local est **protegee** (le role valide qu'Unbound répond AVANT de
basculer `/etc/resolv.conf`) :
```yaml
client_unbound_apply: true
client_unbound_confirm: true
client_resolveur_apply: true
client_resolveur_confirm: true
```
Sans ces deux variables, le role prépare Unbound mais ne modifie pas `/etc/resolv.conf`.

View file

@ -420,7 +420,7 @@ Reste à trancher sur une interface **physique** : `ip ?`, `access-group ?`, `sp
et `<PORT-TRUNK>` ; rien dans le modèle ne peut les deviner.
- ~~**La sortie générale** n'est pas déclarée~~ — **réglé le 2026-08-02.** Elle est
déclarée dans le registre, donc dérivée comme le reste : `serveur_debian` (le socle, porté
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_unbound`
par tous les hôtes) déclare 443, 80 et 123/udp — dépôts apt et horloge ; `client_resolveur`
déclare 53 en UDP et TCP, la récursion depuis la racine que le choix souverain implique.
Le `block out` de la section 5 est donc un vrai default-deny **assumé**, et le devis
l'énonce désormais au lieu de le poser en silence.

View file

@ -18,7 +18,7 @@ integration:
sauf_role: serveur_step_ca # facultatif — voir « exemptions »
```
**Facultative** — `client_backup`, `client_smtp`, `client_unbound`. Là il y a un vrai choix,
**Facultative** — `client_backup`, `client_smtp`, `client_resolveur`. Là il y a un vrai choix,
et il se déclare par serveur, dans `plan/serveurs.yml : integrations`.
**Pourquoi cette inversion.** Le plan portait 57 lignes d'intégration écrites à la main. 28

View file

@ -40,7 +40,7 @@ nftables baseline · fail2ban SSH · auditd · AppArmor · sysctl · unattended-
journald (rétention) · core_dumps · systemd_ssh_auto.
### F. Endpoints des services centraux (les rôles `client_*` en dérivent)
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_unbound` (opt-in).
- DNS interne : plancher `/etc/hosts` (`hosts_statiques`) + PowerDNS + `client_resolveur` (opt-in).
- AC/PKI (`client_pki_ca_url` → infra-pki, provisioner).
- IdM/LDAP : annuaire résolu par `resoudre_annuaire` (hôte + base DN dérivés du domaine).
- Relais courriel (`client_smtp_relais` → edge-mta, MTA Postfix, port 25).
@ -90,7 +90,7 @@ domaines publics, `edge`, autorité DNS, FQDN exposés.
| Secrets Vault | **Constante** 🔒 | — (gérés à part, jamais en clair) |
| `fuseau_horaire` | Défaut | par hôte (rare) |
| `proxmox_clone_noeud` / `stockage` / `pont` | Défaut | par hôte (`serveurs.yml`) |
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_unbound`) | Défaut | par hôte / groupe |
| DNS internes (plancher `/etc/hosts` + PowerDNS + `client_resolveur`) | Défaut | par hôte / groupe |
| Politiques durcissement (SSH, nftables, fail2ban, journald…) | Défaut | par hôte / groupe |
| Relais SMTP, TLS internes | Défaut | par hôte / groupe |
| `ciuser`, compte `ansible` | Défaut | rarement surchargé |

View file

@ -94,7 +94,7 @@ prouvés.
- **Forge logicielle** : `serveur_forgejo`.
- **Observabilité** : `serveur_prometheus`, `serveur_grafana`, `serveur_loki`, `serveur_icinga`,
avec les clients `client_metrique`, `client_journal`, `client_supervision`.
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_unbound`.
- **Intégrations transverses** : `client_pki`, `client_backup`, `client_smtp`, `client_metrique`, `client_journal`, `client_resolveur`.
- **Méta-rôles d'agrégation** : `identity`, `applications`, `database`, `web`, `monitoring`,
`backup`, `storage`.

View file

@ -12,10 +12,10 @@
| `client_metrique` | ingress | 9100 | tcp | serveur_prometheus | tls | Scrape des métriques par Prometheus (node_exporter en HTTPS). |
| `client_pki` | egress | 8443 | tcp | serveur_step_ca | tls-requis | Émission/renouvellement des certificats par ACME et récupération de la racine auprès de l'AC interne. |
| `client_smtp` | egress | 25 | tcp | serveur_postfix | starttls | Relais des notifications locales vers le MTA central (Postfix), STARTTLS. |
| `client_unbound` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
| `client_unbound` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
| `client_unbound` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
| `client_unbound` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
| `client_resolveur` | ingress | 53 | udp | localhost | clair | Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1). |
| `client_resolveur` | egress | 53 | tcp | serveur_powerdns | clair | Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS). |
| `client_resolveur` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
| `client_resolveur` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
| `serveur_backup` | egress | 5665 | tcp | serveur_icinga | tls-requis | Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe client_resolveur
hosts: client_resolveur
become: true
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_resolveur

View file

@ -24,7 +24,7 @@
# effet faute de `resolvconf` (qu'on ne peut pas installer sans résolution — la boucle
# se referme). Mesuré le 2026-08-06 sur deux VM neuves.
#
# PASSAGE DE RELAIS : `client_unbound` reprend ce fichier plus tard pour le basculer
# PASSAGE DE RELAIS : `client_resolveur` reprend ce fichier plus tard pour le basculer
# vers `127.0.0.1`. La garde ci-dessous le respecte — sans elle, chaque déploiement
# défairait la bascule, et deux rôles se disputeraient le même fichier sans fin.
- name: Lire le résolveur en place
@ -42,7 +42,7 @@
mode: "0644"
content: |
# Géré par Set-OPS — résolveur d'amorçage (intrant `dns_amorcage`).
# Remplacé par `client_unbound` lorsqu'il bascule vers le résolveur local.
# Remplacé par `client_resolveur` lorsqu'il bascule vers le résolveur local.
{% for serveur in dns_amorcage.split(',') if serveur | trim %}
nameserver {{ serveur | trim }}
{% endfor %}

View file

@ -1,10 +1,7 @@
---
- name: Appliquer le groupe client_unbound
hosts: client_unbound
- name: Appliquer le groupe serveur_resolveur
hosts: serveur_resolveur
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
@ -19,4 +16,4 @@
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- client_unbound
- serveur_resolveur

View file

@ -74,5 +74,5 @@
import_playbook: groupes/client_smtp.yml
- name: Couche agents — groupe client_backup
import_playbook: groupes/client_backup.yml
- name: Couche agents — groupe client_unbound
import_playbook: groupes/client_unbound.yml
- name: Couche agents — groupe client_resolveur
import_playbook: groupes/client_resolveur.yml

View file

@ -1,4 +1,4 @@
# client_unbound
# client_resolveur
**Résolveur local** par nœud (Unbound) : cache + récursion, avec une **stub-zone** vers
l'autoritatif interne (PowerDNS). Rend la résolution *dynamique* sans casser Internet.
@ -21,8 +21,8 @@ Changer le résolveur d'un nœud à distance, c'est risquer de le rendre muet. L
donc **deux** interrupteurs, et vérifie avant d'agir :
```yaml
client_unbound_apply: true
client_unbound_confirm: true
client_resolveur_apply: true
client_resolveur_confirm: true
```
Sans les deux, le rôle installe et démarre Unbound mais **ne touche pas** à `/etc/resolv.conf`
@ -33,18 +33,18 @@ qu'ensuite. Même doctrine que la règle 4 d'`AGENTS.md` : action risquée = con
## Variables
| Variable | Défaut | Rôle |
| --- | --- | --- |
| `client_unbound_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
| `client_unbound_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
| `client_unbound_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
| `client_unbound_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
| `client_unbound_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
| `client_resolveur_ecoute` | `127.0.0.1` | Écoute (boucle locale) |
| `client_resolveur_zone_interne` | `{{ domaine_interne }}` | Zone en stub |
| `client_resolveur_dns_autoritatif` | dérivé du groupe `serveur_powerdns` | Cible de la stub-zone |
| `client_resolveur_transitaires` | `[]` | Vide = récursion propre (racine + DNSSEC) |
| `client_resolveur_apply` / `_confirm` | `false` / `false` | Bascule de `/etc/resolv.conf` |
## Flux (`meta/flux.yml`)
`53/udp` entrant en **localhost** · `53/tcp` sortant vers `serveur_powerdns`.
## Notes / limites
- Transitaires vides = Unbound **récurse lui-même** (racine + DNSSEC). Si la récursion
sortante est bloquée par le réseau, renseigner `client_unbound_transitaires`.
sortante est bloquée par le réseau, renseigner `client_resolveur_transitaires`.
- Le rôle exige l'IP de l'autoritatif interne (assertion) : `serveur_powerdns` doit être
dans l'inventaire avec un `ansible_host`.
- DoT (DNS chiffré) n'est pas encore branché — c'est un des flux restants du zéro-confiance.

View file

@ -0,0 +1,34 @@
---
# INTÉGRATION CLIENTE : désigner le résolveur de l'écosystème.
#
# CE RÔLE N'INSTALLE PLUS RIEN (2026-08-24). Il posait un Unbound sur CHAQUE VM — N démons
# identiques, ~21 Mo chacun, pour quelques centaines de requêtes. Le résolveur est
# désormais un service du tenant (`serveur_resolveur`), et ce rôle ne fait plus qu'une
# chose : écrire `/etc/resolv.conf`.
#
# L'ancien nom mentait dès lors qu'il n'installait plus Unbound — d'où `client_resolveur`.
# DÉRIVÉ de l'inventaire : l'hôte qui porte le résolveur. Aucun résolveur au plan rend une
# valeur vide, et le rôle ne touche à rien (voir `client_resolveur_actif`).
client_resolveur_hote: >-
{{ (groups['serveur_resolveur'] | default([]) | first | default('')) }}
client_resolveur_adresse: >-
{{ hostvars[client_resolveur_hote].ansible_host | default('')
if client_resolveur_hote else '' }}
# L'INTÉGRATION SUIT L'EXISTENCE DU SERVICE — elle ne se déclare pas. Un écosystème sans
# résolveur garde la résolution d'amorçage posée par cloud-init : c'est un choix valide,
# pas une panne.
client_resolveur_actif: "{{ (groups['serveur_resolveur'] | default([])) | length > 0 }}"
client_resolveur_zone_interne: "{{ domaine_interne }}"
client_resolveur_resolv_conf: "/etc/resolv.conf"
# --- BASCULE PROTÉGÉE ---------------------------------------------------------
#
# Réécrire /etc/resolv.conf peut couper toute la flotte si la cible est mauvaise. Le rôle
# exige donc une confirmation ET vérifie que le résolveur répond DÉJÀ — pour l'interne et
# pour l'Internet — avant de basculer quoi que ce soit.
client_resolveur_apply: false
client_resolveur_confirm: false
client_resolveur_attente_reconnexion: 120

View file

@ -0,0 +1,18 @@
---
# Flux réseau du client de résolution. Voir docs/flux-conception.md.
#
# Il n'écoute plus rien : depuis 2026-08-24, le résolveur est un service du tenant et
# non un démon local. L'hôte ne fait qu'interroger.
flux:
- sens: egress
port: 53
protocole: udp
pair: serveur_resolveur
chiffrement: clair
raison: "Résoudre auprès du résolveur de l'écosystème, et de personne d'autre."
- sens: egress
port: 53
protocole: tcp
pair: serveur_resolveur
chiffrement: clair
raison: "Réponses longues et bascule TCP, obligatoires en DNS."

View file

@ -0,0 +1,12 @@
---
# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le
# raisonnement, et docs/decisions-architecture.md (D-33).
integration:
universelle: true
raison: >-
Tout hote resout des noms, et doit le faire aupres du resolveur de son ecosysteme
plutot que d'un tiers. Une machine restee sur la resolution d'amorcage envoie
chacune de ses questions dehors, sans que rien ne le signale.
# AUCUNE exemption, pas meme l'hote qui PORTE le resolveur : il se sert lui-meme, et
# c'est le cas le plus simple. L'exempter reviendrait a dire que le resolveur ne se
# fait pas confiance.

View file

@ -0,0 +1,75 @@
---
- name: Exiger un résolveur joignable quand l'intégration est active
ansible.builtin.assert:
that:
- client_resolveur_adresse | length > 0
fail_msg: >-
client_resolveur est actif mais aucun hôte ne porte `serveur_resolveur`, ou son
adresse est introuvable. Déclarer le service au plan, ou forcer
client_resolveur_actif=false.
when: client_resolveur_actif | bool
- name: Refuser la bascule du résolveur sans confirmation explicite
ansible.builtin.assert:
that:
- client_resolveur_confirm | bool
fail_msg: "Bascule refusée : définir client_resolveur_confirm=true (avec client_resolveur_apply=true)."
when: client_resolveur_apply | bool
# ON VÉRIFIE AVANT DE BASCULER, PAS APRÈS. Un resolv.conf qui pointe vers un service muet
# coupe l'hôte de tout — et le diagnostic, lui, exige de résoudre des noms.
- name: Valider que le résolveur répond AVANT de basculer (interne + Internet)
ansible.builtin.command: "dig @{{ client_resolveur_adresse }} {{ item.nom }} {{ item.type }} +short"
register: client_resolveur_validation
changed_when: false
failed_when: client_resolveur_validation.stdout | trim == ''
loop:
- { nom: "{{ client_resolveur_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- not ansible_check_mode
- name: Désigner le résolveur de l'écosystème
ansible.builtin.copy:
dest: "{{ client_resolveur_resolv_conf }}"
content: |
# Généré par Set-OPS (rôle client_resolveur). Résolveur de l'écosystème.
search {{ client_resolveur_zone_interne }}
nameserver {{ client_resolveur_adresse }}
owner: root
group: root
mode: "0644"
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- not ansible_check_mode
# RETIRER L'ANCIEN DÉMON LOCAL — APRÈS la bascule, jamais avant.
#
# Sans cette tâche, les N Unbound qu'on voulait supprimer resteraient installés et actifs :
# le rôle aurait changé le `resolv.conf` sans rien économiser, et la raison même du
# changement serait perdue. Une intégration qui ne sait pas se retirer est un piège
# différé.
#
# L'ORDRE COMPTE. Retirer le paquet avant d'avoir écrit le nouveau `resolv.conf` couperait
# l'hôte de toute résolution au milieu du play — et le diagnostic, lui, a besoin de
# résoudre des noms.
#
# L'HÔTE QUI PORTE LE RÉSOLVEUR EST ÉPARGNÉ, évidemment : c'est le même paquet.
- name: Retirer le résolveur local devenu inutile
ansible.builtin.apt:
name: unbound
state: absent
purge: false
when:
- client_resolveur_actif | bool
- client_resolveur_apply | bool
- client_resolveur_confirm | bool
- inventory_hostname not in (groups['serveur_resolveur'] | default([]))
- not ansible_check_mode

View file

@ -1,28 +0,0 @@
---
# Résolveur local Unbound par nœud : cache + récursion (ou forward), avec une
# stub-zone vers l'autoritatif interne (PowerDNS). Rend la résolution dynamique
# (au lieu du plancher /etc/hosts statique) sans casser Internet. La bascule de
# /etc/resolv.conf est PROTÉGÉE (apply + confirm) — bascule du résolveur validée avant application.
client_unbound_paquets:
- unbound
- bind9-dnsutils # dig, pour valider avant de basculer le resolver
client_unbound_service: "unbound"
client_unbound_ecoute: "127.0.0.1"
# Zone interne autoritative (PowerDNS) — résolue via une stub-zone.
client_unbound_zone_interne: "{{ domaine_interne }}"
# IP du serveur autoritatif interne. Dérivée du groupe serveur_powerdns.
client_unbound_dns_autoritatif: "{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if groups.get('serveur_powerdns') else '' }}"
# Transitaires pour '.' (Internet). Vide => Unbound recurse lui-même (racine + DNSSEC).
# À renseigner si la récursion sortante est bloquée (forward vers l'upstream réseau).
client_unbound_transitaires: []
# Bascule de /etc/resolv.conf vers 127.0.0.1 : les DEUX requis (garde-fou anti-coupure).
client_unbound_apply: false
client_unbound_confirm: false
client_unbound_resolv_conf: "/etc/resolv.conf"
# Delai maximal pour que l'hote reponde apres le redemarrage d'Unbound. Depasser reste
# un ECHEC : on attend une machine qui revient, pas une panne qu'on tait.
client_unbound_attente_reconnexion: 180

View file

@ -1,18 +0,0 @@
---
# `ignore_unreachable` : le redemarrage a deja fait perdre la connexion en cours de
# deploiement — « Connection timed out during banner exchange », le 2026-08-09 sur
# web-dorsal-01. La CAUSE n'est pas etablie : `usedns no` ecarte la resolution inverse
# de sshd, et le journal du deploiement rate a ete ecrase avant d'etre lu. Ce que le
# depot sait deja de ce symptome (Makefile, `_attendre-hote`) : a travers la frontiere,
# le TCP s'etablit par proxy SYN et l'echec se lit ainsi meme quand l'hote n'est
# simplement pas la.
#
# On ne masque donc pas une panne : une perte ICI n'abandonne plus les couches
# suivantes, et `wait_for_connection` exige que l'hote revienne pour de bon. S'il ne
# revient pas, la tache suivante echoue — franchement.
- name: Redémarrer unbound
ansible.builtin.systemd:
name: "{{ client_unbound_service }}"
state: restarted
when: not ansible_check_mode
ignore_unreachable: true

View file

@ -1,32 +0,0 @@
---
# Flux réseau du résolveur local Unbound (opt-in). Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: localhost
chiffrement: clair
raison: "Résolveur local sur boucle locale (les processus du nœud interrogent 127.0.0.1)."
- sens: egress
port: 53
protocole: tcp
pair: serveur_powerdns
chiffrement: clair
raison: "Transfert des requêtes de la zone souveraine vers le DNS autoritatif interne (PowerDNS)."
# Récursion sortante : `client_unbound_transitaires` est vide par défaut, donc Unbound
# interroge lui-même la racine puis les autoritatifs — c'est le choix souverain, sans
# dépendre du résolveur d'un fournisseur. Renseigner des transitaires rendrait ces deux
# flux inutiles, mais les laisser déclarés ne coûte rien et évite une panne muette.
- sens: egress
port: 53
protocole: udp
pair: externe
chiffrement: clair
raison: "Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est."
- sens: egress
port: 53
protocole: tcp
pair: externe
chiffrement: clair
raison: "Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC)."

View file

@ -1,16 +0,0 @@
---
# Politique d'integration. Declaree ICI, une fois, plutot que recopiee sur chaque
# serveur du plan. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33).
integration:
universelle: true
raison: >-
Tout hote doit resoudre des noms — ne serait-ce que pour `apt`. Le DNS
autoritatif du tenant (PowerDNS) ne repond QUE pour la zone souveraine et
refuse le reste : sans resolveur recursif, une machine ne peut meme pas
s'installer. `client_unbound` delegue la zone interne a l'autoritatif et
recurse depuis la racine, sans dependre du resolveur d'un fournisseur.
# Le serveur autoritatif est exempte : PowerDNS occupe deja le port 53 sur cet
# hote. Y ajouter Unbound produirait un conflit de liaison, pas un resolveur.
# Il resout par le resolveur d'amorcage (`proxmox_clone_dns`), qui lui suffit :
# ses seuls besoins externes sont apt et NTP.
sauf_role: serveur_powerdns

View file

@ -1,83 +0,0 @@
---
- name: Exiger l'IP de l'autoritatif interne (PowerDNS)
ansible.builtin.assert:
that:
- client_unbound_dns_autoritatif | length > 0
fail_msg: "client_unbound_dns_autoritatif requis (aucun hôte serveur_powerdns actif ?)."
- name: Installer Unbound
ansible.builtin.apt:
name: "{{ client_unbound_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Déployer la configuration Unbound (stub-zone interne + récursion)
ansible.builtin.template:
src: setops.conf.j2
dest: /etc/unbound/unbound.conf.d/setops.conf
owner: root
group: root
mode: "0644"
notify: Redémarrer unbound
- name: Valider la configuration Unbound
ansible.builtin.command: unbound-checkconf
changed_when: false
when: not ansible_check_mode
- name: Activer et démarrer Unbound
ansible.builtin.systemd:
name: "{{ client_unbound_service }}"
enabled: true
state: started
when: not ansible_check_mode
- name: Appliquer les redémarrages Unbound avant toute validation/bascule
ansible.builtin.meta: flush_handlers
# Attendre une CONDITION — que l'hote reponde — et non une duree. Si la connexion a
# saute pendant le redemarrage, on la retablit ici ; sinon la tache passe aussitot.
- name: Exiger que l hote reponde avant de poursuivre
ansible.builtin.wait_for_connection:
timeout: "{{ client_unbound_attente_reconnexion }}"
sleep: 5
when: not ansible_check_mode
# --- Bascule du resolver : PROTÉGÉE + validée (ne casse jamais un nœud) ---
- name: Refuser la bascule du resolver sans confirmation explicite
ansible.builtin.assert:
that:
- client_unbound_confirm | bool
fail_msg: "Bascule refusée : définir client_unbound_confirm=true (avec client_unbound_apply=true)."
when: client_unbound_apply | bool
- name: Valider qu'Unbound résout AVANT de basculer (interne + Internet)
ansible.builtin.command: "dig @127.0.0.1 {{ item.nom }} {{ item.type }} +short"
register: client_unbound_validation
changed_when: false
failed_when: client_unbound_validation.stdout | trim == ''
loop:
- { nom: "{{ client_unbound_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when:
- client_unbound_apply | bool
- client_unbound_confirm | bool
- not ansible_check_mode
- name: Basculer /etc/resolv.conf vers Unbound local (127.0.0.1)
ansible.builtin.copy:
dest: "{{ client_unbound_resolv_conf }}"
content: |
# Généré par Set-OPS (rôle client_unbound). Résolveur local Unbound.
search {{ client_unbound_zone_interne }}
nameserver 127.0.0.1
owner: root
group: root
mode: "0644"
when:
- client_unbound_apply | bool
- client_unbound_confirm | bool
- not ansible_check_mode

View file

@ -1,25 +0,0 @@
# Géré par Set-OPS (rôle client_unbound). Ne pas éditer à la main.
# Résolveur local : cache + récursion (ou forward), stub-zone vers l'autoritatif interne.
server:
interface: {{ client_unbound_ecoute }}
access-control: 127.0.0.0/8 allow
hide-identity: yes
hide-version: yes
prefetch: yes
do-ip6: no
# La zone interne n'est pas signée DNSSEC (PowerDNS autoritatif, dnssec off).
domain-insecure: "{{ client_unbound_zone_interne }}"
# Zone interne : déléguée à l'autoritatif (PowerDNS).
stub-zone:
name: "{{ client_unbound_zone_interne }}"
stub-addr: {{ client_unbound_dns_autoritatif }}
{% if client_unbound_transitaires | length > 0 %}
# Internet : forward vers l'upstream réseau (au lieu de la récursion directe).
forward-zone:
name: "."
{% for t in client_unbound_transitaires %}
forward-addr: {{ t }}
{% endfor %}
{% endif %}

View file

@ -18,7 +18,7 @@ C'est ce qui rend l'**ordre de reconstruction** possible : appliqué dans la cou
déploiement d'une instance neuve.
Les trois couches de résolution : ce plancher → PowerDNS autoritatif → Unbound local
(`client_unbound`, opt-in). Cf. `docs/dns-interne.md`.
(`client_resolveur`, opt-in). Cf. `docs/dns-interne.md`.
## Variables
| Variable | Défaut | Rôle |

View file

@ -19,10 +19,20 @@ serveur_powerdns_soa_expire: 1209600
serveur_powerdns_soa_minimum: 3600
# L'adresse du tenant SEULEMENT, pas `0.0.0.0` : lier toutes les interfaces occupe aussi
# `127.0.0.1:53`, ou un resolveur recursif local voudrait s'installer. Ce n'est pas un
# probleme theorique — c'est la raison pour laquelle `client_unbound` exempte cet hote.
# probleme theorique — c'est la raison pour laquelle `client_resolveur` exempte cet hote.
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
serveur_powerdns_listen_addresses:
- "{{ ansible_host | default('0.0.0.0') }}"
# LE RESOLVEUR DU TENANT PARTAGE CET HOTE (2026-08-24). Quand c'est le cas, l'autoritatif
# lui laisse le port 53 de l'adresse LAN et se replie sur la loopback, sur un autre port :
# le resolveur devient la SEULE porte, et personne n'interroge l'autoritatif directement.
#
# Derive de l'inventaire, pas declare a la main : l'exploitant ne doit pas avoir a se
# souvenir de deplacer un port parce qu'il a ajoute un role.
serveur_powerdns_colocalise_resolveur: >-
{{ inventory_hostname in (groups['serveur_resolveur'] | default([])) }}
serveur_powerdns_listen_port: "{{ 5300 if (serveur_powerdns_colocalise_resolveur | bool) else 53 }}"
serveur_powerdns_listen_addresses: >-
{{ ['127.0.0.1'] if (serveur_powerdns_colocalise_resolveur | bool)
else [ansible_host | default('0.0.0.0')] }}
serveur_powerdns_allow_axfr_ips: []
# Enregistrements additionnels geres explicitement.

View file

@ -1,21 +1,31 @@
---
# Flux réseau du DNS autoritatif interne (PowerDNS). Voir docs/flux-conception.md.
# LE PORT DE L'AUTORITATIF EST DÉRIVÉ DEPUIS LE 2026-08-24, et c'est pour ça qu'il est
# déclaré `derive` plutôt que `53` :
#
# seul sur son hôte -> 53 sur l'adresse du tenant
# partageant l'hôte avec le résolveur -> 5300 sur la loopback, derrière lui
#
# Le résolveur devient alors la seule porte, et personne n'interroge l'autoritatif
# directement. `verifier_ports` traite un port non numérique comme « pas une écoute fixe »
# — ce qui est exactement le cas : les deux services lient le port 53 mais sur des
# ADRESSES différentes, ce qu'une comparaison de ports seuls ne peut pas distinguer.
flux:
- sens: ingress
port: 53
port: derive
protocole: udp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne (zone souveraine). DoT/DoH = feuille de route (chiffrement DNS)."
raison: "Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur."
- sens: ingress
port: 53
port: derive
protocole: tcp
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
raison: "Idem en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)."
# L'autoritatif ne RÉCURSE pour personne — mais il doit résoudre POUR LUI-MÊME : `apt`,
# NTP, les certificats. Il est le seul hôte exempté de `client_unbound` (PowerDNS occupe
# NTP, les certificats. Il est le seul hôte exempté de `client_resolveur` (PowerDNS occupe
# déjà son port 53), donc rien d'autre ne lui ouvre ce flux. Sans cette déclaration, la
# frontière jette ses requêtes et la machine ne peut même pas s'installer — mesuré le
# 2026-08-06, sur la VM qui devait justement servir de DNS au tenant.

View file

@ -2,6 +2,9 @@
Ne PAS le redeclarer ici : sinon PowerDNS refuse « multiple backends 'bind' ». #}
bind-config={{ serveur_powerdns_bind_config }}
local-address={{ serveur_powerdns_listen_addresses | join(',') }}
# Le port suit l'adresse : 53 quand l'autoritatif est seul sur son hote, 5300 quand le
# resolveur du tenant partage la machine et prend le port 53 de l'adresse LAN.
local-port={{ serveur_powerdns_listen_port }}
{% if serveur_powerdns_allow_axfr_ips | length > 0 %}
allow-axfr-ips={{ serveur_powerdns_allow_axfr_ips | join(',') }}
disable-axfr=no

View file

@ -0,0 +1,66 @@
# serveur_resolveur
**Le résolveur de l'écosystème** : un seul Unbound pour tout le tenant, récursif depuis
les serveurs racine, qui délègue la zone souveraine à PowerDNS.
## Pourquoi un seul, et pourquoi ici
Avant le 2026-08-24, `client_unbound` posait un Unbound sur **chaque VM**. Ça marchait et
c'était parfaitement étanche — mais c'était N démons identiques pour un service unique.
Mesure prise avant de décider : **~21 Mo de RSS par VM**, et **28 à 189 requêtes** servies
depuis le démarrage. Le gain de cache mutualisé est donc négligeable ; ce qu'on récupère,
c'est un démon au lieu de quatorze, et **un endroit à regarder** au lieu de quatorze.
## Pourquoi pas sur la frontière
Un résolveur partagé par tout le SITE — l'OPNsense — aurait été le geste le plus économe.
Il devrait alors connaître la zone interne de **chaque tenant** : sans vues par réseau
soigneusement réglées, le tenant A résoudrait les noms du tenant B. C'est l'étanchéité
qu'on protège partout ailleurs.
Et un tenant dont le résolveur vit chez l'hébergeur **ne peut plus s'émanciper avec**
(voir `docs/filiation-emancipation.md`).
La récursion est générique ; **la zone interne ne l'est pas**. C'est ce qui fait du
résolveur un service de tenant, et non de fabric.
## La cohabitation avec l'autoritatif
Les deux vivent sur `infra-dns-01` et voudraient le port 53. Le partage est **dérivé, pas
déclaré** — l'exploitant n'a pas à se souvenir de déplacer un port parce qu'il a ajouté un
rôle :
```
resolveur (Unbound) 10.29.19.11:53 + 127.0.0.1:53 la seule porte
autoritatif (PowerDNS) 127.0.0.1:5300 derrière lui
```
Personne n'interroge l'autoritatif directement. Le résolveur le prend en `stub-zone`.
## Aucun transitaire
`forward-addr` reste vide : Unbound récurse depuis la racine. Poser un transitaire
reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
2026-08-23 avait justement supprimé.
## Ce qu'il vérifie avant de se déclarer bon
Il interroge sa propre adresse pour la **zone interne** *et* pour un nom de l'**Internet**.
Un résolveur « active » qui ne répond pas est le pire des cas : toute la flotte pointe vers
lui, et découvre la panne en essayant de résoudre — c'est-à-dire au moment où le
diagnostic lui-même devient impossible.
## Variables principales
| Variable | Défaut | Rôle |
|---|---|---|
| `serveur_resolveur_ecoutes` | loopback + IP du tenant | où il répond |
| `serveur_resolveur_reseaux_autorises` | le supernet du tenant | qui a le droit de demander |
| `serveur_resolveur_autoritatif` | `127.0.0.1:5300` | la zone souveraine |
| `serveur_resolveur_transitaires` | `[]` | vide = récursion depuis la racine |
## Ce que ce rôle ne fait pas
Il ne bascule aucun `/etc/resolv.conf` — c'est le travail de `client_resolveur`, qui
valide que ce service répond **avant** de désigner qui que ce soit.

View file

@ -0,0 +1,44 @@
---
# LE RÉSOLVEUR DE L'ÉCOSYSTÈME — un seul, pour tout le tenant.
#
# Avant : un Unbound sur CHAQUE VM, écoutant sur 127.0.0.1. Ça marchait, et c'était
# parfaitement étanche — mais c'était N démons identiques pour un service unique.
#
# MESURE DU 2026-08-24, avant de décider : ~21 Mo de RSS par VM, et entre 28 et 189
# requêtes servies depuis le démarrage. Le gain de cache mutualisé est donc négligeable ;
# ce qu'on récupère, c'est un démon au lieu de quatorze, et un endroit à regarder au lieu
# de quatorze.
#
# POURQUOI PAS SUR LA FRONTIÈRE (OPNsense), qui aurait été le geste le plus économe : un
# résolveur partagé par tout le SITE devrait connaître la zone interne de CHAQUE tenant.
# Sans vues par réseau soigneusement réglées, le tenant A résoudrait les noms du tenant B
# — l'étanchéité qu'on protège partout ailleurs. Et un tenant dont le résolveur vit chez
# l'hébergeur ne peut plus s'émanciper avec (docs/filiation-emancipation.md).
#
# La récursion est générique ; la ZONE INTERNE ne l'est pas. C'est ce qui fait du
# résolveur un service de TENANT, et non de fabric.
serveur_resolveur_paquets:
- unbound
serveur_resolveur_service: "unbound"
# Il écoute pour toute la flotte du tenant — plus seulement pour lui-même.
serveur_resolveur_ecoutes:
- "127.0.0.1"
- "{{ ansible_host }}"
# QUI A LE DROIT DE L'INTERROGER : le supernet du tenant, dérivé du seed comme tout le
# reste. Un résolveur ouvert au monde est un relais d'amplification.
serveur_resolveur_reseaux_autorises:
- "{{ setops_supernet | default('127.0.0.0/8') }}"
# La zone souveraine et son autoritatif. PowerDNS se replie sur la loopback quand il
# partage cet hôte (voir serveur_powerdns/defaults), pour lui laisser le port 53.
serveur_resolveur_zone_interne: "{{ domaine_interne }}"
serveur_resolveur_autoritatif: "127.0.0.1"
serveur_resolveur_autoritatif_port: 5300
# AUCUN TRANSITAIRE : Unbound récurse depuis les serveurs racine. Poser un transitaire ici
# reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du
# 2026-08-23 avait justement supprimé.
serveur_resolveur_transitaires: []

View file

@ -0,0 +1,6 @@
---
- name: Redémarrer le résolveur
when: not ansible_check_mode
ansible.builtin.systemd:
name: "{{ serveur_resolveur_service }}"
state: restarted

View file

@ -0,0 +1,13 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
mecanisme: aucun
formulaire_local: sans-objet
secours: "Acces SSH a l'hote"
raison: >-
Un resolveur DNS ne sert que des machines et n'expose aucune interface humaine.
Ce qui le protege n'est pas une authentification mais sa LISTE D'ACCES : il ne
repond qu'au supernet du tenant. Un resolveur ouvert au monde serait un relais
d'amplification, pas un service mal authentifie.

View file

@ -0,0 +1,8 @@
---
# Empreinte ressources — mesure du 2026-08-24 : ~21 Mo de RSS pour un Unbound servant
# une VM. Servir la flotte entiere n'y change presque rien : le cout est le cache, et le
# trafic mesure etait de quelques centaines de requetes.
setops_empreinte:
coeurs: 0
memoire_mo: 64
disque_go: 0

View file

@ -0,0 +1,23 @@
---
# Flux réseau du résolveur du tenant. Voir docs/flux-conception.md.
flux:
- sens: ingress
port: 53
protocole: udp
pair: flotte
chiffrement: clair
raison: "Toute la flotte du tenant résout ici — et nulle part ailleurs."
- sens: ingress
port: 53
protocole: tcp
pair: flotte
chiffrement: clair
raison: "Réponses longues et bascule TCP, obligatoires en DNS."
- sens: egress
port: 53
protocole: udp
pair: externe
chiffrement: clair
raison: >-
Récursion depuis les serveurs racine. Aucun transitaire : l'écosystème ne confie
ses questions à personne.

View file

@ -0,0 +1,55 @@
---
- name: Exiger la zone souveraine et un réseau autorisé
ansible.builtin.assert:
that:
- serveur_resolveur_zone_interne | length > 0
- serveur_resolveur_reseaux_autorises | length > 0
fail_msg: >-
serveur_resolveur exige `domaine_interne` et au moins un réseau dans
`serveur_resolveur_reseaux_autorises`. Un résolveur sans liste d'accès serait
soit muet, soit un relais ouvert.
- name: Installer le résolveur
ansible.builtin.apt:
name: "{{ serveur_resolveur_paquets }}"
state: present
update_cache: true
when: not ansible_check_mode
- name: Déployer la configuration Set-OPS
ansible.builtin.template:
src: setops.conf.j2
dest: /etc/unbound/unbound.conf.d/setops.conf
owner: root
group: root
mode: "0644"
notify: Redémarrer le résolveur
- name: Valider la configuration avant de la servir
ansible.builtin.command: unbound-checkconf
changed_when: false
when: not ansible_check_mode
- name: Activer et démarrer le résolveur
ansible.builtin.systemd:
name: "{{ serveur_resolveur_service }}"
enabled: true
state: started
when: not ansible_check_mode
- name: Appliquer les redémarrages avant de vérifier
ansible.builtin.meta: flush_handlers
# ÉCRIRE, PUIS RELIRE (D-68). Un résolveur « active » qui ne répond pas est le pire des
# cas : toute la flotte pointe vers lui, et découvre la panne en essayant de résoudre.
- name: Répond-il vraiment, pour l'interne ET pour l'Internet ?
ansible.builtin.command: "dig @{{ ansible_host }} {{ item.nom }} {{ item.type }} +short"
register: serveur_resolveur_controle
changed_when: false
failed_when: serveur_resolveur_controle.stdout | trim == ''
loop:
- { nom: "{{ serveur_resolveur_zone_interne }}", type: "SOA" }
- { nom: "deb.debian.org", type: "A" }
loop_control:
label: "{{ item.nom }} {{ item.type }}"
when: not ansible_check_mode

View file

@ -0,0 +1,37 @@
# GÉNÉRÉ par Set-OPS (rôle serveur_resolveur). NE PAS éditer à la main.
server:
{% for a in serveur_resolveur_ecoutes %}
interface: {{ a }}
{% endfor %}
{% for r in serveur_resolveur_reseaux_autorises %}
access-control: {{ r }} allow
{% endfor %}
access-control: 127.0.0.0/8 allow
hide-identity: yes
hide-version: yes
prefetch: yes
do-ip6: no
# La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC.
domain-insecure: "{{ serveur_resolveur_zone_interne }}"
# UNBOUND REFUSE D'INTERROGER UNE LOOPBACK PAR DÉFAUT (2026-08-24).
#
# `do-not-query-localhost` vaut `yes` d'origine — une protection contre les boucles.
# Or l'autoritatif de l'écosystème vit désormais SUR 127.0.0.1:5300, derrière ce
# résolveur. Sans cette ligne, toute la zone souveraine rend SERVFAIL.
#
# Et la panne était MASQUÉE : le plancher /etc/hosts répondait pour les noms de la
# flotte, si bien qu'un `getent hosts forge.genese.internal` semblait prouver que la
# résolution marchait. Seul un `dig` explicite l'a montrée.
do-not-query-localhost: no
stub-zone:
name: "{{ serveur_resolveur_zone_interne }}"
stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }}
{% if serveur_resolveur_transitaires %}
forward-zone:
name: "."
{% for f in serveur_resolveur_transitaires %}
forward-addr: {{ f }}
{% endfor %}
{% endif %}

View file

@ -166,7 +166,7 @@ CHAMPS_PROXMOX: tuple[tuple, ...] = (
("SETOPS_NOEUD", "proxmox_noeud", False),
("SETOPS_COEURS", "proxmox_coeurs", False),
("SETOPS_MEMOIRE", "proxmox_memoire", False),
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound`
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_resolveur`
# bascule ensuite `/etc/resolv.conf` vers 127.0.0.1. Mais sans lui, la VM nait sans
# resolution de noms et `apt` ne peut meme pas installer Unbound — l'amorcage
# n'aboutit jamais. Le DNS autoritatif du tenant ne convient PAS : il refuse tout ce

View file

@ -181,7 +181,7 @@ def integrations_universelles(racine: Path | None = None) -> dict[str, dict]:
Une integration universelle n'est PAS recopiee sur chaque serveur du plan : le
role la declare une fois dans roles/<role>/meta/integration.yml, et tout hote
la recoit. Le plan ne porte plus que les integrations reellement facultatives
(client_backup, client_smtp, client_unbound) et les exemptions.
(client_backup, client_smtp, client_resolveur) et les exemptions.
Pourquoi : recopiee 14 fois, la meme ligne finit par manquer une quinzieme —
et un hote non supervise, non journalise ou sans certificat ne proteste pas.

View file

@ -37,7 +37,7 @@ SOURCE = """# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE.
---
domaine_interne: chezlepro.internal
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound`
# Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_resolveur`
# bascule ensuite /etc/resolv.conf vers 127.0.0.1. Sans lui, la VM nait sans resolution.
dns_amorcage: 9.9.9.9,149.112.112.112
fuseau_horaire: America/Toronto

View file

@ -28,7 +28,7 @@ chercher* la réponse pour toi »).
|---|---|---|
| **1. Le plancher** | `hosts_statiques` (socle) → `/etc/hosts` sur **chaque** nœud | résout **même DNS éteint**, dès le bootstrap. Le filet en dessous de tout. |
| **2. Autoritatif** | `serveur_powerdns` (PowerDNS) | la zone interne, les enregistrements **A**. |
| **3. Récursif local** | `client_unbound` (**opt-in**) | résolveur local : stub-zone → PowerDNS pour l'interne, récursion pour le reste. |
| **3. Récursif local** | `client_resolveur` (**opt-in**) | résolveur local : stub-zone → PowerDNS pour l'interne, récursion pour le reste. |
Le **plancher** est le cœur pédagogique : parce que chaque nœud connaît *tout l'écosystème* par
`/etc/hosts`, **rien ne dépend du DNS pour démarrer** — PowerDNS devient une *commodité*, pas un
@ -65,7 +65,7 @@ Tu as appris **la hiérarchie de résolution** (hosts → récursif → autorita
dig @infra-dns-01.lab.chezlepro.internal lab.chezlepro.internal SOA +short
```
3. **Vois les couches.** Compare `getent hosts` (plancher) et `dig` (DNS) : **deux chemins**, même IP.
4. **Casse & répare.** Sur un nœud **sans** `client_unbound`, vide `/etc/hosts` de ses entrées
4. **Casse & répare.** Sur un nœud **sans** `client_resolveur`, vide `/etc/hosts` de ses entrées
`chezlepro` (garde une sauvegarde !) et coupe l'accès au DNS : la résolution interne **échoue**.
Restaure `/etc/hosts` : ça remarche **sans DNS**. Tu viens de *sentir* pourquoi le plancher est le
filet de sécurité.
@ -73,6 +73,6 @@ Tu as appris **la hiérarchie de résolution** (hosts → récursif → autorita
---
## Pour aller plus loin *(dépôt)*
- Rôles : `roles/hosts_statiques` (le plancher), `roles/serveur_powerdns`, `roles/client_unbound`.
- Rôles : `roles/hosts_statiques` (le plancher), `roles/serveur_powerdns`, `roles/client_resolveur`.
- Conception des 3 couches + frontière publique : `docs/dns-interne.md`.
- Note : `client_unbound` est une **liaison optionnelle** (opt-in) — voir l'unité *Liaisons*.
- Note : `client_resolveur` est une **liaison optionnelle** (opt-in) — voir l'unité *Liaisons*.