dns : client_unbound universel, et un resolveur d'amorcage

Une VM ne peut pas s'installer sans resoudre des noms. PowerDNS repond
UNIQUEMENT pour la zone souveraine et ne recurse pour personne : il manquait un
resolveur recursif. `client_unbound` est l'outil ecrit pour ca — il rejoint
`client_journal` et `client_metrique` parmi les integrations universelles.

`infra-dns-01` est exempte (PowerDNS occupe son port 53), et
`serveur_powerdns_listen_addresses` passe de 0.0.0.0 a l'adresse de l'hote pour
laisser 127.0.0.1:53 libre. Mais l'appartenance au groupe est AUSSI ce qui ouvre
le port 53 a la frontiere : en exemptant la machine, je lui retirais le droit de
resoudre. `serveur_powerdns` declare donc son propre flux sortant — il ne
recurse pour personne, mais doit resoudre pour lui-meme.

Nouvel intrant `dns_amorcage`, derive jusqu'a `make creer-vm`. Cloud-init
l'ecrit bien mais sans effet : `dns-nameservers` exige `resolvconf`, absent du
gabarit, et installer resolvconf demande apt, qui demande la resolution.
`serveur_debian` pose donc le resolveur en pre_tasks, avant le premier apt, avec
une garde qui respecte la bascule ulterieure de client_unbound.

Defaut corrige en chemin : `_intrants_communs()` lisait `instance/` en dur ; le
chemin derive maintenant de l'inventaire recu, et un test le prouve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 21:41:58 -04:00
parent 83c05b8320
commit e276e76f32
9 changed files with 241 additions and 6 deletions

View file

@ -2,6 +2,47 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### `client_unbound` devient universel — et l'amorçage DNS trouve sa place
Une VM ne peut pas s'installer sans résoudre des noms : `apt` en dépend. Or le DNS
autoritatif du tenant (PowerDNS) répond **uniquement** pour la zone souveraine et refuse
le reste — il ne récurse pour personne. Il manquait donc un résolveur récursif, et
`client_unbound` est exactement l'outil écrit pour ça : zone interne déléguée à
l'autoritatif, récursion depuis la racine, aucune dépendance au résolveur d'un fournisseur.
Il rejoint `client_journal` et `client_metrique` parmi les intégrations universelles :
13 hôtes sur 14, déclarés **une fois** dans `meta/integration.yml`, et 8 déclarations
redondantes retirées du plan.
**L'exemption, et son revers.** `infra-dns-01` est exempté : PowerDNS occupe déjà son port
53, y ajouter Unbound produirait un conflit de liaison. Au passage,
`serveur_powerdns_listen_addresses` passe de `0.0.0.0` à l'adresse de l'hôte — lier toutes
les interfaces occupait aussi `127.0.0.1:53`, là où un résolveur local voudrait s'installer.
Mais l'appartenance au groupe est **aussi** ce qui ouvre le port 53 à la frontière. En
exemptant la machine, je lui retirais le droit de résoudre : le serveur qui devait servir de
DNS au tenant était le seul à ne pas pouvoir s'installer. `serveur_powerdns` déclare donc son
propre flux sortant — il ne récurse pour personne, mais il doit résoudre **pour lui-même**.
### Le résolveur d'amorçage, et pourquoi cloud-init ne suffisait pas
Nouvel intrant `dns_amorcage`, dérivé jusqu'à `make creer-vm`. Cloud-init l'écrit bien —
mesuré : `dns-nameservers 9.9.9.9 149.112.112.112` dans `50-cloud-init`. Et il reste **sans
effet** : `dns-nameservers` d'ifupdown exige `resolvconf`, absent du gabarit doré, qui
transporte en outre un `/etc/resolv.conf` figé de l'ancien monde. Installer `resolvconf`
demanderait `apt`, qui demande la résolution : la boucle se referme.
`serveur_debian` pose donc le résolveur en `pre_tasks`, **avant** `common_packages` — donc
avant le premier `apt`. Une garde tient le passage de relais : si `127.0.0.1` est déjà là,
`client_unbound` a basculé et le fichier n'est pas touché. Sans elle, chaque déploiement
aurait défait la bascule, et deux rôles se seraient disputé le même fichier sans fin.
**Un défaut créé puis corrigé en chemin.** La première version de `_intrants_communs()` lisait
`instance/` en dur : un test sur inventaire synthétique serait allé chercher les intrants de
la production. Le chemin dérive maintenant de l'inventaire reçu, et un test le prouve dans les
deux sens — avec inventaire, et sans.
### La première VM tenant, et les quatre défauts qu'elle a révélés
`infra-pki-01` recréée pour éprouver la chaîne complète. Le CA est le bon premier service :

View file

@ -660,7 +660,7 @@ creer-vm: _instance-requise
VMID_MODELE="$(VMID_MODELE)" \
FORMAT_DISQUE="$(FORMAT_DISQUE)" \
DISQUE_PROXMOX="$(DISQUE_PROXMOX)" \
DNS="$(DNS)" \
DNS="$${SETOPS_DNS:-$(DNS)}" \
DHCP="$(DHCP)" \
CIUSER="$(CIUSER)" \
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \

View file

@ -0,0 +1,66 @@
# Preuve de conformite — Set-OPS — 2026-08-06
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ❌ NON CONFORME (29 OK · 1 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ❌ ECHEC | rc=1 : AssertionError: ["SETOPS_VMID='93101'", "SETOPS_IP='10.0.2.11'", "SETOPS_CIDR='24'", "SETOPS_PASSERELLE='10.0.2.1'", "SETOPS_VLAN='13'", "SETOPS_PONT=''" |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| 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 : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 70 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 | 14 hotes, 31 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 : 24 secret(s) exige(s), tous presents. Voute reelle : 27 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. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 27 regles, 2 routes, admin=10.0.0.0/24,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, 2 tenant(s), 36 groupe(s), 60 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (2 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 0 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 : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, 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, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-06._

View file

@ -11,6 +11,39 @@
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
# Résolveur d'AMORÇAGE, posé avant `common_packages` — donc avant le premier `apt`.
# Sans lui, le déploiement échoue avant d'avoir commencé : le gabarit doré transporte
# un `/etc/resolv.conf` figé, et le `dns-nameservers` écrit par cloud-init reste sans
# 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
# 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
ansible.builtin.slurp:
src: /etc/resolv.conf
register: serveur_debian_resolv_actuel
failed_when: false
changed_when: false
- name: Poser le résolveur d'amorçage (avant tout apt)
ansible.builtin.copy:
dest: /etc/resolv.conf
owner: root
group: root
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.
{% for serveur in dns_amorcage.split(',') if serveur | trim %}
nameserver {{ serveur | trim }}
{% endfor %}
when:
- dns_amorcage is defined
- dns_amorcage | string | length > 0
- "'127.0.0.1' not in (serveur_debian_resolv_actuel.content | default('') | b64decode | string)"
roles:
- common_packages
- hosts_statiques

View file

@ -0,0 +1,16 @@
---
# 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

@ -17,9 +17,12 @@ serveur_powerdns_soa_refresh: 3600
serveur_powerdns_soa_retry: 900
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.
# PowerDNS reste autoritatif pur : il repond pour la zone souveraine et REFUSE le reste.
serveur_powerdns_listen_addresses:
- "0.0.0.0"
- "::"
- "{{ ansible_host | default('0.0.0.0') }}"
serveur_powerdns_allow_axfr_ips: []
# Enregistrements additionnels geres explicitement.

View file

@ -13,3 +13,21 @@ flux:
pair: flotte
chiffrement: clair
raison: "Résolution DNS interne 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
# 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.
- sens: egress
port: 53
protocole: udp
pair: externe
chiffrement: clair
raison: "Résolution sortante du serveur autoritatif POUR SES PROPRES besoins (apt, NTP) — il ne récurse pour aucun autre hôte."
- sens: egress
port: 53
protocole: tcp
pair: externe
chiffrement: clair
raison: "Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP)."

View file

@ -165,10 +165,45 @@ 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`
# 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
# qui sort de la zone souveraine.
("SETOPS_DNS", "dns_amorcage", False),
)
def parametres_proxmox_hote(data: dict, host: str) -> list[str]:
def _intrants_communs(inventaire: Path | None) -> dict:
"""Intrants de `group_vars/all/` voisins de CET inventaire, visibles au CLONAGE.
L'inventaire genere ne porte que des variables d'HOTE ; un intrant commun comme
`dns_amorcage` y est donc invisible. Le clonage tourne sur localhost, avant qu'Ansible
ne resolve les group_vars de la cible : sans cette lecture, le parametre serait lu par
les roles mais jamais par la machine qui cree la VM.
Le chemin DERIVE de l'inventaire recu, jamais code en dur : lire `instance/` pendant
qu'on raisonne sur un autre inventaire ferait dependre le resultat de l'environnement
— et un test sur inventaire synthetique irait chercher les intrants de la production.
"""
if inventaire is None:
return {}
dossier = Path(inventaire).resolve().parent / "group_vars" / "all"
if not dossier.is_dir():
return {}
fusion: dict = {}
for f in sorted(dossier.glob("*.yml")):
try:
contenu = yaml.safe_load(f.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
continue # voute chiffree ou fichier illisible : sans objet ici
if isinstance(contenu, dict):
fusion.update(contenu)
return fusion
def parametres_proxmox_hote(data: dict, host: str,
inventaire: Path | None = None) -> list[str]:
"""Emet les parametres de clonage Proxmox d'un hote, lus depuis l'inventaire.
Sortie shell-evaluable (`SETOPS_*='valeur'`) consommee par `make creer-vm`.
@ -181,7 +216,7 @@ def parametres_proxmox_hote(data: dict, host: str) -> list[str]:
"`make instancier-appliquer`."
)
vars_for_host = variables_hote(data, host)
vars_for_host = _intrants_communs(inventaire) | variables_hote(data, host)
lignes: list[str] = []
manquants: list[str] = []
@ -450,7 +485,7 @@ def main() -> int:
elif args.command == "afficher":
afficher_hote(data, args.hote)
elif args.command == "parametres-proxmox":
for ligne in parametres_proxmox_hote(data, args.hote):
for ligne in parametres_proxmox_hote(data, args.hote, args.inventaire):
print(ligne)
elif args.command == "playbooks":
afficher_playbooks_hote(data, args.hote, args.dossier_playbooks)

View file

@ -95,6 +95,7 @@ def test_hote_complet_avec_noeud() -> None:
"SETOPS_NOEUD='noeud-a'",
"SETOPS_COEURS='4'",
"SETOPS_MEMOIRE='2048'",
"SETOPS_DNS=''",
}
assert set(lignes) == attendu, lignes
@ -105,6 +106,27 @@ def test_hote_sans_noeud_emet_noeud_vide() -> None:
assert "SETOPS_VMID='95201'" in lignes, lignes
def test_intrant_commun_lu_a_cote_de_l_inventaire_recu() -> None:
"""`dns_amorcage` vit dans group_vars/all, pas dans les vars d'hote.
Et il doit etre lu A COTE de l'inventaire passe en argument : lire `instance/` en
dur ferait dependre le resultat de l'environnement, et un test synthetique irait
chercher les intrants de la production.
"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
(base / "group_vars" / "all").mkdir(parents=True)
(base / "group_vars" / "all" / "10-intrants.yml").write_text(
"dns_amorcage: 9.9.9.9\n", encoding="utf-8")
inv = base / "hosts.yml"
inv.write_text("all: {children: {}}\n", encoding="utf-8")
lignes = parametres_proxmox_hote(_inventaire_factice(), "app-01", inv)
assert "SETOPS_DNS='9.9.9.9'" in lignes, lignes
# Sans inventaire, aucun intrant commun n'est devine.
assert "SETOPS_DNS=''" in parametres_proxmox_hote(_inventaire_factice(), "app-01")
def test_hote_absent_refuse() -> None:
try:
parametres_proxmox_hote(_inventaire_factice(), "inconnu-01")
@ -127,6 +149,7 @@ def main() -> int:
tests = [
test_hote_complet_avec_noeud,
test_hote_sans_noeud_emet_noeud_vide,
test_intrant_commun_lu_a_cote_de_l_inventaire_recu,
test_hote_absent_refuse,
test_champ_requis_manquant_refuse,
]