site : le plancher survit au redemarrage, et la zone dit les vraies adresses
Some checks are pending
verifier / verifier (push) Waiting to run

Le decoupage du site en quatre zones a deplace cinq machines. Ni le plancher
/etc/hosts ni la zone DNS n'avaient suivi. Quatre defauts, tous dans le moteur.

LE PLANCHER ETAIT EFFACE A CHAQUE DEMARRAGE — ET LE PREMIER CORRECTIF N'EN
ETAIT PAS UN.

`hosts_statiques` posait `99-setops-hosts.cfg` avec `manage_etc_hosts: false`,
pendant que `cloud_init` posait `99_setops.cfg` avec `true`. Dans `cloud.cfg.d`
l'ordre est LEXICAL et le dernier gagne : `-` vaut 0x2D, `_` vaut 0x5F. On a
donc retire la cle de `cloud_init` — le role qui POSSEDE le fichier decide —
puis renomme notre fragment `zz-` pour passer apres le `99_chezlepro.cfg` du
gabarit dore.

Et ca ne suffisait toujours pas. Redemarrage d'epreuve : plancher encore efface.
La cause reelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la
USER-DATA de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d`
tout entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier
ne servait a rien, le dernier mot n'appartenant pas a ce repertoire.

Ce que cloud-init regenere, il le regenere depuis `hosts.debian.tmpl` — le
gabarit le documente lui-meme. `hosts_statiques` le pose desormais avec le MEME
contenu que /etc/hosts, et une garde compare les deux a chaque passage.
Redemarrage d'epreuve : les neuf entrees sont la.

La garde precedente affirmait « conforme » en mesurant l'ordre lexical — vrai,
et sans rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose
est pire qu'aucune.

LA ZONE DNS NE PUBLIAIT PAS LES NOMS DE SERVICE.

`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats
portent et que les clients appellent — n'avaient aucun enregistrement. Seul le
plancher savait les resoudre. Trois causes empilees :

  - le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que
    « le site n'a pas d'edge » : ca confondait PUBLIC et EXPOSE ;
  - `expositions_des_applications` rendait `domaine: None` faute de
    `domaines.yml`, et le modele de zone ecarte les expositions dont le domaine
    n'est pas la zone. Repli ajoute, symetrique de celui deja ecrit pour `edge` :
    sans domaine public declare, le domaine est celui que porte le FQDN ;
  - `serveur_powerdns` exigeait les deux registres et echouait si `domaines.yml`
    manquait — le meme tout-ou-rien que `hosts_statiques` a corrige le meme jour.

Puis `named-checkzone` a refuse la zone : `dns.genese.internal` heritait d'un
CNAME par defaut du role ET d'un A par exposition. La garde a bien joue son role
— elle a arrete une zone cassee avant qu'elle soit servie. Le plan l'emporte
desormais sur le defaut du role.

Un service ne doit pas dependre d'un plancher pour etre joignable : le plancher
est un filet, pas le sol.

VERIFICATION. Les huit noms — cinq machines, trois services — resolvent vers les
bonnes adresses depuis les cinq hotes, par le plancher ET par le DNS, et le
plancher survit au redemarrage.

P46 refuse desormais deux choses : plus d'un role ecrivant `manage_etc_hosts`,
et l'absence du gabarit maitre. Controle negatif verifie.

RESTE NOMME, PAS CORRIGE : la zone INVERSE. `serveur_powerdns_zone_inverse`
derive d'un supernet /16 — la forme d'un tenant. Un site declare plusieurs /24
et n'a pas de supernet unique : aucune zone inverse n'est generee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 20:31:01 -04:00
parent ac48b7650b
commit a74e35bf21
10 changed files with 333 additions and 8 deletions

View file

@ -1,5 +1,68 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses
**46 preuves.** Le découpage du site en quatre zones a déplacé cinq machines ; ni le
plancher `/etc/hosts` ni la zone DNS n'avaient suivi. Quatre défauts, tous dans le moteur.
### Le plancher était effacé à chaque démarrage — et le correctif d'hier n'en était pas un
`hosts_statiques` posait `/etc/cloud/cloud.cfg.d/99-setops-hosts.cfg` avec
`manage_etc_hosts: false`, pendant que `cloud_init` posait `99_setops.cfg` avec `true`.
Dans `cloud.cfg.d` l'ordre est **lexical** et le dernier gagne : `-` vaut 0x2D, `_` vaut
0x5F — le `true` l'emportait. On a donc d'abord retiré la clé de `cloud_init` (le rôle qui
**possède** le fichier décide), puis renommé notre fragment `zz-` pour passer après le
`99_chezlepro.cfg` du gabarit doré.
**Et ça ne suffisait toujours pas.** Redémarrage d'épreuve : le plancher, encore effacé.
La cause réelle est ailleurs — Proxmox inscrit `manage_etc_hosts: true` dans la
**user-data** de son lecteur cloud-init, et la user-data prime sur `cloud.cfg.d` tout
entier. Aucun fragment ne pouvait gagner ; renommer pour parler en dernier ne servait à
rien, le dernier mot n'appartenant pas à ce répertoire.
Ce que cloud-init régénère, il le régénère depuis `hosts.debian.tmpl` — le gabarit le
documente lui-même. `hosts_statiques` le pose donc désormais avec le **même contenu** que
`/etc/hosts`, et une garde compare les deux à chaque passage. Redémarrage d'épreuve : les
neuf entrées sont là.
*La garde précédente affirmait « conforme » en mesurant l'ordre lexical — vrai, et sans
rapport avec ce qui se passait. Une garde qui mesure la mauvaise chose est pire
qu'aucune.*
### La zone DNS ne publiait pas les noms de service
`forge.genese.internal` et `pki.genese.internal` — des noms que les certificats portent et
que les clients appellent — n'avaient **aucun enregistrement**. Seul le plancher savait
les résoudre. Trois causes empilées :
- le plan du site coupait `serveur_powerdns_publier_expositions`, au motif que « le site
n'a pas d'edge » — ce qui confondait *public* et *exposé* ;
- `expositions_des_applications` rendait `domaine: None` faute de `domaines.yml`, et le
modèle de zone écarte les expositions dont le domaine n'est pas la zone. Le repli existe
désormais : **sans domaine public déclaré, le domaine est celui que porte le FQDN** —
symétrique du repli déjà écrit pour `edge` ;
- `serveur_powerdns` exigeait les deux registres et échouait si `domaines.yml` manquait —
le même tout-ou-rien que `hosts_statiques` avait corrigé le même jour.
Puis `named-checkzone` a refusé la zone : `dns.genese.internal` héritait d'un `CNAME` par
défaut du rôle **et** d'un `A` par exposition. La garde a bien joué son rôle — elle a
arrêté une zone cassée avant qu'elle soit servie. Le plan l'emporte désormais sur le
défaut du rôle.
*Un service ne doit pas dépendre d'un plancher pour être joignable : le plancher est un
filet, pas le sol.*
### Vérification
Les huit noms — cinq machines et trois services — résolvent vers les bonnes adresses
depuis les cinq hôtes, **par le plancher et par le DNS**, et le plancher survit au
redémarrage.
**Reste nommé, pas corrigé** : la zone INVERSE. `serveur_powerdns_zone_inverse` dérive
d'un supernet `/16` — la forme d'un tenant. Un site déclare plusieurs `/24` et n'a pas de
supernet unique : aucune zone inverse n'est générée, et les machines du site restent
anonymes à l'envers.
## 2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN ## 2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN
**45 preuves.** Le découpage du site en quatre zones a révélé un défaut qui dormait dans **45 preuves.** Le découpage du site en quatre zones a révélé un défaut qui dormait dans

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md). > [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml` - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml`
- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 1 saute) - **Verdict** : ✅ CONFORME (45 OK · 0 echec · 1 saute)
## Preuves ## Preuves
@ -58,6 +58,7 @@
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
## Couverture des affirmations ✅ du registre ## Couverture des affirmations ✅ du registre

View file

@ -1,5 +1,8 @@
--- ---
cloud_init_ssh_pwauth: false cloud_init_ssh_pwauth: false
cloud_init_manage_etc_hosts: true # `cloud_init_manage_etc_hosts` A ETE RETIREE (2026-08-25) : elle entrait en conflit avec
# le fragment de `hosts_statiques`, qui possede /etc/hosts et decide seul si cloud-init
# peut y toucher. La garder ici, sans effet, serait un mensonge — une variable qu'on
# renseigne et qui ne change rien est pire que son absence.
cloud_init_disable_root: true cloud_init_disable_root: true
cloud_init_ssh_deletekeys: true cloud_init_ssh_deletekeys: true

View file

@ -3,7 +3,23 @@
datasource_list: [ NoCloud, ConfigDrive, None ] datasource_list: [ NoCloud, ConfigDrive, None ]
preserve_hostname: false preserve_hostname: false
manage_etc_hosts: {{ cloud_init_manage_etc_hosts | bool | lower }} # `manage_etc_hosts` N'EST PAS ICI, ET C'EST VOULU (2026-08-25).
#
# Ce fragment le posait a `true`, pendant que `hosts_statiques` en posait un autre a
# `false`. Deux ecritures, deux fichiers, un desaccord — et dans `cloud.cfg.d` l'ordre
# est LEXICAL : `99-setops-hosts.cfg` (`-`, 0x2D) est lu avant `99_setops.cfg` (`_`,
# 0x5F). C'est donc celui-ci qui gagnait, et cloud-init reecrivait /etc/hosts a chaque
# demarrage — effacant le plancher de resolution en silence.
#
# Le defaut ne se voit qu'au redemarrage SUIVANT, et il se manifeste ailleurs : un `apt
# update` qui ne resout plus le cache, un certificat dont le nom ne pointe nulle part.
# Mesure du 2026-08-25 : les cinq machines du site redemarrees, /etc/hosts reduit aux
# entrees Debian par defaut, avec un `127.0.1.1 site-pki-01.chezlepro.ca` venu du
# `searchdomain` Proxmox — pas meme le bon domaine.
#
# LE ROLE QUI POSSEDE LE FICHIER DECIDE : `hosts_statiques` ecrit /etc/hosts, c'est donc
# lui qui dit si cloud-init peut y toucher. Quand il est inactif, rien ne l'interdit plus
# et cloud-init reprend son comportement par defaut — ce qui est le bon repli.
disable_root: {{ cloud_init_disable_root | bool | lower }} disable_root: {{ cloud_init_disable_root | bool | lower }}
ssh_pwauth: {{ cloud_init_ssh_pwauth | bool | lower }} ssh_pwauth: {{ cloud_init_ssh_pwauth | bool | lower }}

View file

@ -14,9 +14,22 @@
# On le desactive par un fragment plutot qu'en editant cloud.cfg : le gabarit reste # On le desactive par un fragment plutot qu'en editant cloud.cfg : le gabarit reste
# intact, et `apt purge cloud-init` ou une remise a zero rend la machine a son etat # intact, et `apt purge cloud-init` ou une remise a zero rend la machine a son etat
# d'origine. # d'origine.
#
# `zz-` ET NON `99-` (2026-08-25). Cloud-init lit `cloud.cfg.d` dans l'ordre LEXICAL, et
# le DERNIER a parler gagne. Notre fragment s'appelait `99-setops-hosts.cfg` ; le gabarit
# dore en porte un autre, `99_chezlepro.cfg`, qui pose `manage_etc_hosts: true`. Or `-`
# vaut 0x2D et `_` vaut 0x5F : le fragment du gabarit etait lu APRES le notre, et gagnait.
#
# Mesure du 2026-08-25 : les cinq machines du site redemarrees avaient perdu tout leur
# plancher malgre notre fragment — il etait bien la, simplement sans effet. Un fichier
# present et ignore est le pire des deux mondes : il rassure sans rien garantir.
#
# `zz-` passe apres tout prefixe numerique ET apres `_`. On ne cherche donc plus a
# connaitre les fragments des autres : on parle en dernier, quels qu'ils soient. La garde
# plus bas verifie que c'est encore vrai sur la machine.
- name: Empêcher cloud-init de réécrire le plancher à chaque démarrage - name: Empêcher cloud-init de réécrire le plancher à chaque démarrage
ansible.builtin.copy: ansible.builtin.copy:
dest: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg dest: /etc/cloud/cloud.cfg.d/zz-setops-hosts.cfg
content: | content: |
# GÉNÉRÉ par Set-OPS (rôle hosts_statiques). NE PAS éditer à la main. # GÉNÉRÉ par Set-OPS (rôle hosts_statiques). NE PAS éditer à la main.
# /etc/hosts est le PLANCHER DE RÉSOLUTION de l'écosystème, dérivé de l'inventaire. # /etc/hosts est le PLANCHER DE RÉSOLUTION de l'écosystème, dérivé de l'inventaire.
@ -27,6 +40,31 @@
mode: "0644" mode: "0644"
when: hosts_statiques_actif | bool when: hosts_statiques_actif | bool
# CE FRAGMENT EST DESORMAIS LE SEUL A DECIDER (2026-08-25).
#
# `cloud_init` en ecrivait un second (`99_setops.cfg`) qui posait `manage_etc_hosts: true`.
# Dans `cloud.cfg.d` l'ordre est LEXICAL : `-` (0x2D) passe avant `_` (0x5F), donc le
# fragment de `cloud_init` etait lu EN DERNIER et gagnait. Le plancher etait efface a
# chaque demarrage, et ca ne se voyait qu'au redemarrage suivant — sous la forme d'un
# `apt update` qui ne resolvait plus, ou d'un nom de service qui ne pointait nulle part.
#
# LE RETRAIT DOIT ETRE AUSSI EXPLICITE QUE LA POSE. Sans la tache ci-dessous, desactiver
# `hosts_statiques` laissait le fragment en place : la machine gardait un /etc/hosts fige
# que plus personne ne tenait a jour, et cloud-init n'avait plus le droit de le reprendre.
# Un etat que ni l'un ni l'autre n'assume.
- name: Rendre /etc/hosts à cloud-init quand le plancher est désactivé
ansible.builtin.file:
path: /etc/cloud/cloud.cfg.d/zz-setops-hosts.cfg
state: absent
when: not (hosts_statiques_actif | bool)
# L'ancien nom, qui ne parlait pas en dernier. Le laisser en place n'aurait rien casse,
# mais deux fichiers disant la meme chose sont un desaccord qui attend son heure.
- name: Retirer l'ancien fragment, qui ne parlait pas en dernier
ansible.builtin.file:
path: /etc/cloud/cloud.cfg.d/99-setops-hosts.cfg
state: absent
# Alias d'exposition (best-effort) : dérivés du plan si celui-ci est disponible. # Alias d'exposition (best-effort) : dérivés du plan si celui-ci est disponible.
- name: Vérifier la présence des registres du plan (sur le nœud de contrôle) - name: Vérifier la présence des registres du plan (sur le nœud de contrôle)
ansible.builtin.stat: ansible.builtin.stat:
@ -85,3 +123,76 @@
group: root group: root
mode: "0644" mode: "0644"
when: hosts_statiques_actif | bool when: hosts_statiques_actif | bool
# LE SEUL ENDROIT QUI TIENNE AU REDEMARRAGE (2026-08-25).
#
# Le fragment `cloud.cfg.d` ne suffit pas, et c'est mesure : Proxmox inscrit
# `manage_etc_hosts: true` dans la USER-DATA de son lecteur cloud-init, et la user-data
# prime sur tout fragment de `/etc/cloud/cloud.cfg.d`. Le plancher etait donc efface a
# chaque demarrage malgre le fragment — d'abord `99-setops-hosts.cfg`, puis son
# remplacant `zz-` : renommer pour parler en dernier ne changeait rien, puisque le dernier
# mot n'appartenait pas a ce repertoire.
#
# Ce que cloud-init regenere, il le regenere DEPUIS CE GABARIT. On le pose donc avec le
# meme contenu que /etc/hosts : le fichier de la machine et le gabarit qui le reecrira
# disent la meme chose, quelle que soit la valeur de `manage_etc_hosts`.
#
# Le gabarit d'origine le documente lui-meme : « make changes to the master file in
# /etc/cloud/templates/hosts.debian.tmpl ».
- name: Poser le plancher dans le gabarit maître de cloud-init (il survit au démarrage)
ansible.builtin.template:
src: hosts.debian.tmpl.j2
dest: /etc/cloud/templates/hosts.debian.tmpl
owner: root
group: root
mode: "0644"
when:
- hosts_statiques_actif | bool
- ansible_facts.os_family | default('') == 'Debian'
# LA GARDE MESURE LE FAIT QUI COMPTE (2026-08-25).
#
# La garde precedente verifiait que notre fragment `cloud.cfg.d` parlait en dernier. C'est
# vrai — et sans effet, puisque la user-data de la source de donnees prime sur ce
# repertoire tout entier. Une garde qui mesure la mauvaise chose est pire qu'aucune : elle
# affirme « conforme » pendant que le defaut opere.
#
# Ce qui compte est que le fichier ET le gabarit qui le reecrira portent les MEMES
# entrees. On les compare donc, ligne d'adresse par ligne d'adresse.
- name: Relever les entrées du plancher et celles du gabarit maître
ansible.builtin.shell:
cmd: >-
set -o pipefail;
for f in /etc/hosts /etc/cloud/templates/hosts.debian.tmpl; do
printf '%s=' "$f";
grep -cE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+[[:space:]]' "$f" 2>/dev/null || printf '0';
printf '\n';
done
executable: /bin/bash
register: hosts_statiques_accord
changed_when: false
when:
- hosts_statiques_actif | bool
- ansible_facts.os_family | default('') == 'Debian'
- name: Refuser que le plancher et son gabarit maître divergent
ansible.builtin.assert:
that:
- (hosts_statiques_accord.stdout_lines | select('search', '^/etc/hosts=')
| first | default('=0')).split('=')[1] | int > 1
- (hosts_statiques_accord.stdout_lines | select('search', '^/etc/hosts=')
| first | default('=0')).split('=')[1]
== (hosts_statiques_accord.stdout_lines | select('search', 'hosts.debian.tmpl=')
| first | default('=-1')).split('=')[1]
fail_msg: >-
Le plancher et le gabarit maître de cloud-init ne portent pas le même nombre
d'entrées ({{ hosts_statiques_accord.stdout_lines | default([]) | join(' | ') }}).
Cloud-init régénère /etc/hosts depuis le gabarit à chaque démarrage : la divergence
ne se verrait qu'au prochain redémarrage, et se manifesterait ailleurs — un `apt`
qui ne résout plus, un certificat dont le nom ne pointe nulle part.
success_msg: >-
Plancher et gabarit maître d'accord
({{ hosts_statiques_accord.stdout_lines | default([]) | join(' | ') }}).
when:
- hosts_statiques_actif | bool
- ansible_facts.os_family | default('') == 'Debian'

View file

@ -0,0 +1,17 @@
## template:jinja
# GABARIT MAÎTRE de /etc/hosts — GÉNÉRÉ par Set-OPS (rôle hosts_statiques).
#
# Cloud-init régénère /etc/hosts À CHAQUE DÉMARRAGE à partir de CE fichier lorsque
# `manage_etc_hosts` vaut vrai. Et il vaut vrai : Proxmox l'inscrit dans la user-data de
# son lecteur cloud-init, laquelle prime sur tout fragment de `/etc/cloud/cloud.cfg.d`.
# Aucun fragment ne peut donc gagner — ni l'ancien `99-setops-hosts.cfg`, ni son
# remplaçant `zz-`. Le seul endroit qui tienne est ici.
#
# Le contenu est le même que celui de /etc/hosts, à la ligne près : une seule source.
#
# CE QU'ON N'ÉCRIT PAS ICI : la ligne `127.0.1.1 {{ '{{fqdn}}' }} {{ '{{hostname}}' }}` du gabarit
# d'origine. Elle fait pointer le FQDN de la machine sur la boucle locale, avec le domaine
# du `searchdomain` Proxmox — `site-ops-01.chezlepro.ca` au lieu de
# `site-ops-01.genese.internal`. `hostname -f` rendrait alors un nom que personne ne peut
# vérifier, et c'est exactement le piège que le plancher documente déjà.
{% include 'hosts.j2' %}

View file

@ -32,14 +32,39 @@
mode: "0644" mode: "0644"
notify: Validate and reload PowerDNS notify: Validate and reload PowerDNS
- name: Charger les registres applications et domaines (expositions) # CHAQUE REGISTRE EST CHARGE POUR LUI-MEME (2026-08-25).
ansible.builtin.include_vars: #
file: "{{ item }}" # Ce `include_vars` listait les deux fichiers en dur et echouait si l'un manquait.
# `domaines.yml` declare les domaines PUBLICS, et un ecosysteme peut legitimement n'en
# avoir aucun — c'est le cas du SITE, dont tous les services sont internes.
#
# Le defaut est reste invisible tant que le site gardait `publier_expositions: false` ;
# il est apparu a la seconde ou on a voulu publier ses noms de service. C'est le MEME
# defaut que `hosts_statiques` a corrige le meme jour, dans les memes termes : un registre
# absent n'est pas une faute, il est simplement absent.
- name: Vérifier la présence des registres du plan (sur le nœud de contrôle)
ansible.builtin.stat:
path: "{{ item }}"
register: serveur_powerdns_plan
delegate_to: localhost # les registres du plan vivent sur le nœud de contrôle
become: false # lire un fichier ne nécessite pas de privilèges
loop: loop:
- "{{ setops_plan_dir }}/applications.yml" - "{{ setops_plan_dir }}/applications.yml"
- "{{ setops_plan_dir }}/domaines.yml" - "{{ setops_plan_dir }}/domaines.yml"
loop_control:
label: "{{ item | basename }}"
when: serveur_powerdns_publier_expositions | bool when: serveur_powerdns_publier_expositions | bool
- name: Charger les registres applications et domaines (si présents)
ansible.builtin.include_vars:
file: "{{ item.item }}"
loop: "{{ serveur_powerdns_plan.results | default([]) }}"
loop_control:
label: "{{ item.item | basename }}"
when:
- serveur_powerdns_publier_expositions | bool
- item.stat.exists | default(false)
- name: Dériver les A d'exposition (FQDN publics -> edge qui les sert) - name: Dériver les A d'exposition (FQDN publics -> edge qui les sert)
ansible.builtin.set_fact: ansible.builtin.set_fact:
serveur_powerdns_expositions: >- serveur_powerdns_expositions: >-

View file

@ -11,7 +11,26 @@ $TTL {{ serveur_powerdns_ttl }}
@ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. @ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}.
{{ serveur_powerdns_nameserver }} IN A {{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('127.0.0.1') }} {{ serveur_powerdns_nameserver }} IN A {{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('127.0.0.1') }}
{% for record in serveur_powerdns_records %} {# LE PLAN L'EMPORTE SUR LE DEFAUT DU ROLE (2026-08-25).
`serveur_powerdns_records` porte un defaut de commodite : `dns IN CNAME ns1`. Le plan
du site, lui, expose `dns.genese.internal` pour `serveur_resolveur` — ce qui produit un
`dns IN A`. Un nom ne peut pas porter a la fois un CNAME et un A : `named-checkzone` a
refuse la zone (« CNAME and other data »), et PowerDNS ne l'a pas servie du tout.
La garde a bien fonctionne — elle a arrete une zone cassee avant qu'elle soit servie.
Mais elle laissait le DNS du site sans zone tant que la collision durait.
On ecarte donc le record statique quand le plan expose le meme nom. C'est le sens de
marche du depot : ce qui est DECLARE AU PLAN prime sur ce qu'un role suppose par
defaut. Les deux pointaient de toute facon vers la meme machine. #}
{% set _exposes = [] %}
{% if serveur_powerdns_publier_expositions | default(false) %}
{% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == serveur_powerdns_zone %}
{% set _ = _exposes.append(expo.fqdn[:-(serveur_powerdns_zone | length + 1)]) %}
{% endfor %}
{% endif %}
{% for record in serveur_powerdns_records if record.name not in _exposes %}
{{ record.name }} IN {{ record.type }} {{ record.value }} {{ record.name }} IN {{ record.type }} {{ record.value }}
{% endfor %} {% endfor %}

View file

@ -494,6 +494,20 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str |
edge_dom = conf.get("edge") or app.get("groupe") edge_dom = conf.get("edge") or app.get("groupe")
if edge is not None and edge_dom != edge: if edge is not None and edge_dom != edge:
continue continue
# SANS DOMAINE PUBLIC DECLARE, LE DOMAINE EST CELUI QUE PORTE LE FQDN
# (2026-08-25). Meme repli que pour `edge` juste au-dessus, et il manquait.
#
# `domaine_parent` cherche le FQDN parmi les domaines PUBLICS. Le SITE n'en a
# aucun — tous ses noms sont internes — donc `dom` valait None, et le modele
# de zone PowerDNS, qui ne retient que les expositions dont le domaine EST sa
# zone, les ecartait toutes.
#
# Consequence mesuree le 2026-08-25 : `forge.genese.internal` et
# `pki.genese.internal` n'avaient aucun enregistrement A. Seul le plancher
# /etc/hosts savait les resoudre — or un service ne doit pas dependre d'un
# plancher pour etre joignable ; le plancher est un filet, pas le sol.
if not dom and "." in str(fqdn):
dom = str(fqdn).split(".", 1)[1]
resultat.append({ resultat.append({
"fqdn": fqdn, "application": app_id, "groupe": app.get("groupe"), "fqdn": fqdn, "application": app_id, "groupe": app.get("groupe"),
"hote": app.get("hote"), "port": app.get("port"), "hote": app.get("hote"), "port": app.get("port"),

View file

@ -1143,6 +1143,60 @@ def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]:
f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).") f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).")
def preuve_un_seul_maitre_du_plancher() -> tuple[bool, str]:
"""Un seul role decide si cloud-init a le droit de reecrire /etc/hosts.
POURQUOI (2026-08-25). Deux roles ecrivaient un fragment dans `/etc/cloud/cloud.cfg.d`
et se contredisaient : `hosts_statiques` posait `manage_etc_hosts: false`,
`cloud_init` posait `true`. Or l'ordre y est LEXICAL — `99-setops-hosts.cfg` (`-`,
0x2D) est lu AVANT `99_setops.cfg` (`_`, 0x5F). C'est donc le `true` qui gagnait, et
cloud-init reecrivait /etc/hosts a chaque demarrage.
Le plancher de resolution disparaissait ainsi en silence, et le defaut ne se
manifestait qu'au redemarrage suivant, ailleurs : un `apt update` qui ne resout plus
le cache, un certificat dont le nom ne pointe nulle part. Mesure : les cinq machines
du site redemarrees le 2026-08-25 n'avaient plus que les entrees Debian par defaut.
Le role qui POSSEDE le fichier decide. Cette preuve refuse qu'un second s'en mele :
deux ecritures, meme d'accord aujourd'hui, sont un desaccord qui attend son heure.
"""
ecrivains: list[str] = []
for chemin in sorted((RACINE / "roles").rglob("*")):
if not chemin.is_file() or chemin.suffix not in (".yml", ".yaml", ".j2"):
continue
for ligne in chemin.read_text(encoding="utf-8", errors="replace").splitlines():
nu = ligne.strip()
if nu.startswith("#"):
continue # un commentaire explique, il n'ecrit pas
if re.match(r"^manage_etc_hosts\s*:", nu):
ecrivains.append(f"{chemin.relative_to(RACINE)} : {nu}")
if len(ecrivains) != 1:
return False, (
f"{len(ecrivains)} ecriture(s) de `manage_etc_hosts` dans les roles — il en "
f"faut exactement une, sinon l'ordre lexical de `cloud.cfg.d` tranche a notre "
f"place :\n - " + "\n - ".join(ecrivains or ["aucune : plus personne "
"n'empeche cloud-init d'effacer le plancher au demarrage"]))
# ET LE FRAGMENT NE SUFFIT PAS. Proxmox inscrit `manage_etc_hosts: true` dans la
# USER-DATA de son lecteur cloud-init, et la user-data prime sur tout `cloud.cfg.d`.
# Le plancher a donc ete efface a chaque demarrage malgre le fragment — d'abord sous
# le nom `99-`, puis sous `zz-` : renommer pour parler en dernier ne servait a rien,
# le dernier mot n'appartenant pas a ce repertoire.
#
# Ce que cloud-init regenere, il le regenere depuis `hosts.<distro>.tmpl`. Un role
# doit donc poser CE gabarit, sans quoi la correction n'en est pas une.
gabarit = [str(p.relative_to(RACINE)) for p in (RACINE / "roles").rglob("*")
if p.is_file() and "hosts.debian.tmpl" in p.name]
if not gabarit:
return False, (
"Aucun role ne pose `/etc/cloud/templates/hosts.debian.tmpl`. Le fragment "
"`cloud.cfg.d` seul ne protege rien : la user-data du lecteur cloud-init "
"(Proxmox y met `manage_etc_hosts: true`) prime sur ce repertoire. Le "
"plancher serait efface au prochain demarrage, sans erreur ni message.")
return True, (f"Un seul maitre du plancher — {ecrivains[0]} ; "
f"et le gabarit maitre est pose ({gabarit[0]}).")
def preuve_glossaire_enseigne() -> tuple[bool, str]: def preuve_glossaire_enseigne() -> tuple[bool, str]:
"""Tout mot que le depot emploie devant l'exploitant est explique au glossaire. """Tout mot que le depot emploie devant l'exploitant est explique au glossaire.
@ -1486,6 +1540,8 @@ PREUVES: list[dict] = [
"func": preuve_ordre_des_integrations}, "func": preuve_ordre_des_integrations},
{"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs", {"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs",
"refs": [], "func": preuve_parefeu_proxmox_sdn_seulement}, "refs": [], "func": preuve_parefeu_proxmox_sdn_seulement},
{"id": "P46", "titre": "Plancher /etc/hosts : un seul role en decide",
"refs": [], "func": preuve_un_seul_maitre_du_plancher},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site}, "func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],