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 ## 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 ### 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 : `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)" \ VMID_MODELE="$(VMID_MODELE)" \
FORMAT_DISQUE="$(FORMAT_DISQUE)" \ FORMAT_DISQUE="$(FORMAT_DISQUE)" \
DISQUE_PROXMOX="$(DISQUE_PROXMOX)" \ DISQUE_PROXMOX="$(DISQUE_PROXMOX)" \
DNS="$(DNS)" \ DNS="$${SETOPS_DNS:-$(DNS)}" \
DHCP="$(DHCP)" \ DHCP="$(DHCP)" \
CIUSER="$(CIUSER)" \ CIUSER="$(CIUSER)" \
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \ 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" - ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour 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: roles:
- common_packages - common_packages
- hosts_statiques - 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_retry: 900
serveur_powerdns_soa_expire: 1209600 serveur_powerdns_soa_expire: 1209600
serveur_powerdns_soa_minimum: 3600 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: serveur_powerdns_listen_addresses:
- "0.0.0.0" - "{{ ansible_host | default('0.0.0.0') }}"
- "::"
serveur_powerdns_allow_axfr_ips: [] serveur_powerdns_allow_axfr_ips: []
# Enregistrements additionnels geres explicitement. # Enregistrements additionnels geres explicitement.

View file

@ -13,3 +13,21 @@ flux:
pair: flotte pair: flotte
chiffrement: clair chiffrement: clair
raison: "Résolution DNS interne en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips)." 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_NOEUD", "proxmox_noeud", False),
("SETOPS_COEURS", "proxmox_coeurs", False), ("SETOPS_COEURS", "proxmox_coeurs", False),
("SETOPS_MEMOIRE", "proxmox_memoire", 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. """Emet les parametres de clonage Proxmox d'un hote, lus depuis l'inventaire.
Sortie shell-evaluable (`SETOPS_*='valeur'`) consommee par `make creer-vm`. 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`." "`make instancier-appliquer`."
) )
vars_for_host = variables_hote(data, host) vars_for_host = _intrants_communs(inventaire) | variables_hote(data, host)
lignes: list[str] = [] lignes: list[str] = []
manquants: list[str] = [] manquants: list[str] = []
@ -450,7 +485,7 @@ def main() -> int:
elif args.command == "afficher": elif args.command == "afficher":
afficher_hote(data, args.hote) afficher_hote(data, args.hote)
elif args.command == "parametres-proxmox": 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) print(ligne)
elif args.command == "playbooks": elif args.command == "playbooks":
afficher_playbooks_hote(data, args.hote, args.dossier_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_NOEUD='noeud-a'",
"SETOPS_COEURS='4'", "SETOPS_COEURS='4'",
"SETOPS_MEMOIRE='2048'", "SETOPS_MEMOIRE='2048'",
"SETOPS_DNS=''",
} }
assert set(lignes) == attendu, lignes 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 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: def test_hote_absent_refuse() -> None:
try: try:
parametres_proxmox_hote(_inventaire_factice(), "inconnu-01") parametres_proxmox_hote(_inventaire_factice(), "inconnu-01")
@ -127,6 +149,7 @@ def main() -> int:
tests = [ tests = [
test_hote_complet_avec_noeud, test_hote_complet_avec_noeud,
test_hote_sans_noeud_emet_noeud_vide, test_hote_sans_noeud_emet_noeud_vide,
test_intrant_commun_lu_a_cote_de_l_inventaire_recu,
test_hote_absent_refuse, test_hote_absent_refuse,
test_champ_requis_manquant_refuse, test_champ_requis_manquant_refuse,
] ]