zones publiques : le courriel dans la zone, et un devis avant de basculer

Une zone publique ne portait que des A d'exposition ; basculer chezlepro.ca aurait coupe
son MX, son SPF et son DMARC. Les enregistrements se declarent au plan, valides et rendus ;
le serveur de noms porte le nom que le site declare (dns1.chezlepro.ca), parce que
ns1.chezlepro.ca existe deja en production. make dns-bascule-devis compare le plan au DNS
en service : 0 perdu sur les deux zones, deux prealables restants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 21:54:49 -04:00
parent 19bbadfb11
commit 63780645a7
12 changed files with 597 additions and 14 deletions

View file

@ -1,5 +1,38 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (7) — Les zones publiques portent le courriel : reproduire avant de basculer
Phase 2 du DNS public, premier volet. Une zone publique ne portait que des A d'exposition :
basculer `chezlepro.ca` aurait fait disparaitre son MX, son SPF et son DMARC — le courriel de
production — a la minute ou le registraire aurait suivi.
- **Enregistrements declares au plan** (`domaines_publics.<zone>.enregistrements`) : A, AAAA,
CNAME, MX, TXT, CAA. `valider_domaines` les passe au crible ; le schema les decrit (et
porte desormais les `enum` d'une liste d'entrees) ; `zone-publique.db.j2` les rend, TXT
coupes en tranches de 255.
- **Le serveur de noms porte le nom que le site declare** (`dns_public_nom:
dns1.chezlepro.ca`), transmis par le contrat du site. `ns1.<zone>` aurait deplace
`ns1.chezlepro.ca`, qui existe en production vers .53. Le role refuse un enregistrement
redeclare a ce nom.
- **`chezlepro.ca` reproduit la production** (releve par 9.9.9.9, Namespro refusant le
transfert) ; **`technolibre.ca` est preparee** (courriel chez Koumbit), sans les marques
`heritage=external-dns`.
- **`make dns-bascule-devis`** (`scripts/dns_bascule.py`) : plan contre DNS en service, aucune
ecriture. Verdict mesure : `chezlepro.ca` 12 identiques, 0 perdu ; `technolibre.ca` 8
identiques, 0 perdu. Bloquent encore : un seul serveur de noms (le `.ca` en exige deux) et
`dns1.chezlepro.ca` non publie.
Deux fautes trouvees par l'epreuve, avant la production :
- une boucle Jinja dans `set_fact` rend une **chaine** : `in` y cherchait une sous-chaine, et
`lepro.ca` « appartenait » a la liste. La liste passe en JSON.
- `shlex.split` sur une valeur du plan (non citee) effacait les espaces : `v=spf1 mx` devenait
`v=spf1mx`, et le devis voyait deux ecarts qui n'existaient pas.
Mesure en production : deploiement des deux primaires 0 failed ; le site a tire les nouveaux
serials par NOTIFY sans intervention ; les 20 couples (nom, type) du plan sont servis a
l'identique par `site-dnspub-01`. `make prouver` : CONFORME, 82 OK, 0 echec.
## 2026-09-16 (6) — Le DNS public en production, et cinq fautes que seule la production montrait
La phase 1 tourne. Chaque ligne ci-dessous a ete MESUREE sur les machines, pas deduite d'un

View file

@ -823,6 +823,10 @@ site-intrants: ## Les intrants que le SITE monte expose a ses locataires (derive
site-intrants-verifier: ## Le locataire monte suit-il encore son site ? (aucune ecriture)
python3 scripts/site_intrants.py --verifier
.PHONY: dns-bascule-devis
dns-bascule-devis: ## Basculer nos serveurs de noms changerait-il quelque chose ? Plan contre DNS en service (aucune ecriture)
python3 scripts/dns_bascule.py $(if $(SERVEUR),--serveur $(SERVEUR)) $(if $(ZONE),--zone $(ZONE))
.PHONY: devis-proxmox-pools devis-proxmox-pools-verifier
devis-proxmox-pools: ansible-runtime ## Devis des pools Proxmox (un par tenant), derive du plan
python3 scripts/devis_proxmox_pools.py $(if $(JSON),--json,)

View file

@ -6,8 +6,8 @@
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`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/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (81 OK · 0 echec · 1 saute)
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (82 OK · 0 echec · 0 saute)
## Preuves
@ -28,10 +28,10 @@
| 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 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 13 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 : 32 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 29 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 30 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 : 6 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). |
@ -43,8 +43,8 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 44 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, 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 | 68 scripts expliques et atteignables, 128 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (146 cle(s) declaree(s) par l'instance). |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 69 scripts expliques et atteignables, 129 cibles make documentees, 69 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (147 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 40 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 48 document(s) declarent leur lecteur (42 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). |
@ -53,7 +53,7 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 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, 96 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 : 64 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 65 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 5 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 190 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
@ -73,8 +73,8 @@
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `392da4d` (publie le 2026-09-14). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 52 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:7, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 41 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. |
@ -85,7 +85,7 @@
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |
| P71 | Pool du site : le genome ne nait pas chez un tenant | — | ✅ OK | `site-creer` nomme `--pool-site` ; le pool du genome ne peut plus etre celui d'un tenant. |
| P72 | Annuaire : aucun service ne se lie avec le compte du maitre | — | ✅ OK | 5 role(s) consultent l'annuaire, chacun avec SON compte de service ; seul `amorcage_acces` garde celui d'administration, et il provisionne au lieu de consommer. |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 8 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 9 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). |
| P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. |
| P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 161 gabarits de role : tous se rendent. |

View file

@ -330,6 +330,63 @@
"nom"
]
}
},
"enregistrements": {
"type": "array",
"title": "Enregistrements publics",
"description": "MX, SPF, DMARC, DKIM, CAA et noms historiques de la zone.",
"items": {
"type": "object",
"properties": {
"nom": {
"type": "string",
"title": "Nom",
"description": "Relatif a la zone : `@`, `mx`, `_dmarc`."
},
"type": {
"type": "string",
"title": "Type",
"enum": [
"A",
"AAAA",
"CAA",
"CNAME",
"MX",
"TXT"
]
},
"valeur": {
"type": "string",
"title": "Valeur",
"description": "Adresse publique, nom d'hote complet ou texte (sans guillemets)."
},
"priorite": {
"type": "integer",
"title": "Priorite",
"description": "Requise pour un MX."
},
"etiquette": {
"type": "string",
"title": "Etiquette CAA",
"enum": [
"iodef",
"issue",
"issuewild"
]
},
"ttl": {
"type": "integer",
"title": "TTL",
"description": "Secondes (60 a 604800) ; defaut de la zone sinon."
}
},
"additionalProperties": false,
"required": [
"nom",
"type",
"valeur"
]
}
}
},
"additionalProperties": false,

View file

@ -171,3 +171,31 @@ Trois règles, chacune payée ou mesurée :
**Rien n'est exposé à Internet en phase 1.** P82 refuse d'exposer le serveur public tant
que toutes ses zones ne sont pas signées DNSSEC.
### Ce qu'une zone publique porte
> Ajouté le 2026-09-16, phase 2. Une zone qui ne portait que ses expositions web aurait coupé
> le courriel de production à la minute où le registraire l'aurait suivie.
Chaque zone `primaire-cache` déclare ses **enregistrements** au plan (`enregistrements:` dans
`plan/domaines.yml`) : A, AAAA, CNAME, MX, TXT, CAA. `valider_domaines` refuse une adresse
privée, une cible incomplète, un CNAME à l'apex ou à côté d'un autre type, un MX sans
priorité, un TXT avec guillemets ou hors ASCII, un nom écrit en absolu (`mx.chezlepro.ca`
sous `chezlepro.ca`), et tout enregistrement dans une zone que Set-OPS n'écrit pas.
Le **SOA et les NS** ne se déclarent pas : ils désignent le serveur de noms du site, sous le
nom que **le site** déclare (`dns_public_nom`, contrat du site, reçu par chaque locataire).
Seule la zone qui contient ce nom porte son A. Le premier choix, `ns1.<zone>`, aurait déplacé
en silence un `ns1.chezlepro.ca` qui existe en production vers une autre adresse.
**Avant de basculer : `make dns-bascule-devis`.** Il compare le plan au DNS en service, nom
par nom et type par type, sur les noms déclarés et une liste de sondes usuelles. Il rend ce
qui serait **perdu**, **changé**, **ajouté**, ou **abandonné en connaissance de cause** (les
marques `heritage=external-dns`), et les préalables : le serveur de noms doit déjà résoudre
publiquement, et il en faut **deux** (exigence du registre `.ca`). Une mesure qui échoue rend
« mesure impossible », jamais « absent ». Sa limite est écrite à chaque rapport : sans
transfert de zone, un export chez le fournisseur actuel reste la seule preuve d'exhaustivité.
Au 2026-09-16 : `chezlepro.ca` reproduit la production (rien ne serait perdu) ;
`technolibre.ca` est **préparée, pas basculée** — la décision revient au responsable désigné
de TechnoLibre, et elle attend que `dns1.chezlepro.ca` soit publié.

View file

@ -92,6 +92,8 @@ serveur_powerdns_sonde_minimum: 2
# recopie pas — il les recoit, comme ses adresses d'amorcage.
serveur_powerdns_dns_public_site: "{{ dns_public_site | default('') }}"
serveur_powerdns_ip_publique: "{{ ip_publique_site | default('') }}"
# Le nom du serveur de noms du site (contrat du site) : NS et SOA de chaque zone publique.
serveur_powerdns_dns_public_nom: "{{ dns_public_nom | default('') }}"
# Le NOM de la cle TSIG, identique des deux cotes — derive du nom du depot du locataire,
# par la meme regle que `site_inventaire.py` (`tsig-<depot sans ops->`). `realpath`, parce
# que sur le poste l'inventaire passe par le lien `instance`.

View file

@ -24,6 +24,23 @@
| selectattr('value.autorite', 'equalto', 'primaire-cache')
| map(attribute='key') | sort | list }}
# Les FQDN que les zones publiques declarent. Un enregistrement au nom du serveur de noms
# entrerait en conflit avec le A que le gabarit lui rend.
#
# EN JSON, ET C'EST VOULU (eprouve le 2026-09-16). Une boucle Jinja rend une CHAINE, pas une
# liste : `in` y cherchait alors une sous-chaine, et `lepro.ca` s'y trouvait. La garde
# aurait refuse des noms innocents — ou laisse passer un vrai conflit selon le texte.
- name: Relever les noms déclarés dans les zones publiques
ansible.builtin.set_fact:
serveur_powerdns_fqdn_declares: >-
{% set _r = [] %}
{% for z in serveur_powerdns_zones_publiques %}
{% for e in (domaines_publics[z].enregistrements | default([], true)) %}
{% set _ = _r.append(z if e.nom in ['@', z] else e.nom ~ '.' ~ z) %}
{% endfor %}
{% endfor %}
{{ _r | unique | list | to_json }}
- name: Refuser une publication incohérente
ansible.builtin.assert:
that:
@ -41,11 +58,18 @@
# ne doit donc jamais y figurer.
- serveur_powerdns_dns_public_site not in (serveur_powerdns_allow_axfr_ips | default([]))
- (vault_tsig_dns_public | default('')) | length > 0
# LE NOM DU SERVEUR DE NOMS VIENT DU SITE. Sans lui, NS et SOA seraient vides.
- serveur_powerdns_dns_public_nom is search('^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+\.?$')
# PERSONNE NE LE REDECLARE : un enregistrement a ce nom lui donnerait une seconde
# adresse, ou un CNAME a cote de son A.
- (serveur_powerdns_dns_public_nom | trim | regex_replace('\\.$', '')) not in (serveur_powerdns_fqdn_declares | from_json)
fail_msg: >-
Zones publiques refusees : zone `.internal` declaree publique, adresse publique
absente ou privee ({{ serveur_powerdns_ip_publique | default('?') }}), serveur public
du site liste dans `allow-axfr-ips` (TSIG deviendrait decoratif), ou
`vault_tsig_dns_public` absent de la voute.
`vault_tsig_dns_public` absent de la voute, `dns_public_nom` absent ou malforme
({{ serveur_powerdns_dns_public_nom | default('?') }}), ou redeclare dans les
enregistrements d'une zone.
quiet: true
when: serveur_powerdns_zones_publiques | length > 0

View file

@ -1,6 +1,13 @@
{# LE SERVEUR DE NOMS EST CELUI DU SITE, SOUS LE NOM QUE LE SITE DECLARE (2026-09-16).
La premiere version ecrivait `ns1.<zone>` dans chaque zone. Or `ns1.chezlepro.ca`
existe en production, vers une autre adresse : basculer l'aurait deplace en silence.
Toutes les zones du site designent desormais un seul nom, `dns_public_nom`, et seule la
zone qui le contient porte son A. #}
{% set _ns = serveur_powerdns_dns_public_nom | trim | regex_replace('\\.$', '') %}
$ORIGIN {{ zone_publique }}.
$TTL {{ serveur_powerdns_ttl }}
@ IN SOA ns1.{{ zone_publique }}. hostmaster.{{ zone_publique }}. (
@ IN SOA {{ _ns }}. hostmaster.{{ zone_publique }}. (
{{ serveur_powerdns_serial_public }}
{{ serveur_powerdns_soa_refresh }}
{{ serveur_powerdns_soa_retry }}
@ -8,8 +15,10 @@ $TTL {{ serveur_powerdns_ttl }}
{{ serveur_powerdns_soa_minimum }}
)
@ IN NS ns1.{{ zone_publique }}.
ns1 IN A {{ serveur_powerdns_ip_publique }}
@ IN NS {{ _ns }}.
{% if _ns.endswith('.' ~ zone_publique) %}
{{ _ns[:-(zone_publique | length + 1)] }} IN A {{ serveur_powerdns_ip_publique }}
{% endif %}
{# UNE ZONE PUBLIQUE NE PORTE QUE DES ADRESSES PUBLIQUES.
@ -19,3 +28,16 @@ ns1 IN A {{ serveur_powerdns_ip_publique }}
{% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == zone_publique %}
{{ expo.fqdn[:-(zone_publique | length + 1)] if expo.fqdn != zone_publique else '@' }} IN A {{ serveur_powerdns_ip_publique }}
{% endfor %}
{# LES ENREGISTREMENTS DECLARES AU PLAN : courriel, verifications, noms historiques.
`valider_domaines` les a deja passes au crible (adresses publiques, cibles completes,
CNAME seul a son nom, texte sans guillemet). Le gabarit ne fait que les ecrire. Un TXT
plus long que 255 octets — une cle DKIM — est coupe en tranches : un resolveur les
recolle, et une seule chaine trop longue rendrait la zone illisible. #}
{% for enr in (domaines_publics | default({}))[zone_publique].enregistrements | default([]) %}
{% set _t = enr.type | upper %}
{% set _v = enr.valeur | string | trim %}
{{ enr.nom }} {{ enr.ttl | default('') }} IN {{ _t }} {% if _t in ['A', 'AAAA'] %}{{ _v }}{% elif _t == 'CNAME' %}{{ _v.rstrip('.') }}.{% elif _t == 'MX' %}{{ enr.priorite }} {{ _v.rstrip('.') }}.{% elif _t == 'CAA' %}0 {{ enr.etiquette }} "{{ _v }}"{% elif _t == 'TXT' %}{% for i in range(0, _v | length, 255) %}"{{ _v[i:i + 255] }}"{{ ' ' if not loop.last }}{% endfor %}{% endif %}
{% endfor %}

295
scripts/dns_bascule.py Normal file
View file

@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Devis de bascule DNS — la zone du plan contre le DNS public en service. Aucune ecriture.
POURQUOI CE SCRIPT EXISTE (2026-09-16). Basculer les serveurs de noms d'un domaine chez le
registraire remplace, d'un coup, TOUT ce que le monde lit de ce domaine. Si la zone du plan
oublie un MX, le courriel s'arrete ; si elle oublie un nom, un service disparait. Rien ne
le signale a l'avance : le premier temoin est un client dont le message revient.
Ce devis repond a une seule question, avant le geste : « si le registraire suivait nos
serveurs de noms maintenant, qu'est-ce qui changerait ? » La reponse attendue est « rien »,
ou une liste d'ajouts voulus.
CE QU'IL COMPARE. Pour chaque zone `autorite: primaire-cache` de l'instance visee, chaque
nom declare au plan ET chaque nom d'une liste de sondes usuelles, type par type (A, AAAA,
CNAME, MX, TXT, CAA). D'un cote le plan, de l'autre un resolveur public (9.9.9.9 par
defaut) — et, avec `--serveur`, ce que notre propre serveur sert.
CE QU'IL NE PEUT PAS VOIR. Sans transfert de zone (Namespro et Cloudflare le refusent), un
nom que personne ne sonde reste invisible. Le devis le dit a chaque rapport : un export de
la zone chez le fournisseur actuel est la seule preuve d'exhaustivite.
UNE MESURE QUI ECHOUE N'EST PAS UNE ABSENCE. Un resolveur qui ne repond pas, ou repond
SERVFAIL, rend « mesure impossible » — jamais « absent en ligne ». La meme confusion a deja
fabrique trois faux diagnostics dans ce depot.
Usage :
python3 scripts/dns_bascule.py # l'instance visee, via 9.9.9.9
python3 scripts/dns_bascule.py --serveur 10.37.37.21
python3 scripts/dns_bascule.py --zone chezlepro.ca
Code de sortie : 0 si aucune zone ne perdrait rien ; 1 sinon ; 2 si une mesure a echoue.
"""
from __future__ import annotations
import argparse
import shlex
import subprocess
import sys
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from inventory_rules import instance_courante, valider_domaines # noqa: E402
TYPES = ("A", "AAAA", "CNAME", "MX", "TXT", "CAA")
# Les noms qu'on interroge meme quand le plan ne les declare pas : c'est ainsi qu'on trouve
# ce que le plan a oublie. Un releve, pas une garantie (voir « ce qu'il ne peut pas voir »).
NOMS_SONDES = (
"@", "www", "mail", "mx", "smtp", "imap", "pop", "webmail", "autoconfig", "autodiscover",
"_dmarc", "_mta-sts", "mta-sts", "_smtp._tls", "default._domainkey", "google._domainkey",
"selector1._domainkey", "selector2._domainkey", "setops._domainkey", "ns1", "ns2", "ns",
"dns", "dns1", "dns2", "vpn", "git", "forge", "cloud", "nextcloud", "collabora", "bureau",
"auth", "sso", "api", "blog", "docs", "wiki", "status", "ftp", "lists", "matrix", "chat",
)
# CE QU'ON ABANDONNE EN CONNAISSANCE DE CAUSE. Les marques de propriete qu'external-dns pose
# chez Cloudflare : hors de Cloudflare, rien ne les lit. Elles sont rapportees, pas comptees.
ABANDONS_CONNUS = (
("TXT", "heritage=external-dns,", "marque de propriete d'external-dns, lue par lui seul"),
)
def _rel_en_fqdn(zone: str, nom: str) -> str:
return zone if nom in ("@", "", zone) else f"{nom}.{zone}"
def _normaliser(rtype: str, rdata: str) -> str:
"""Une forme comparable d'une donnee, qu'elle vienne du plan ou de `dig`."""
rdata = rdata.strip()
if rtype in ("CNAME", "NS"):
return rdata.rstrip(".").lower()
if rtype == "MX":
prio, _, cible = rdata.partition(" ")
return f"{int(prio)} {cible.strip().rstrip('.').lower()}"
if rtype == "TXT":
# `dig` rend les tranches entre guillemets ; un resolveur les recolle. Le plan, lui,
# ecrit la valeur nue (le validateur y refuse les guillemets) : la decouper aux
# espaces les effacerait — eprouve, `v=spf1 mx` devenait `v=spf1mx`.
if not rdata.startswith('"'):
return rdata
try:
return "".join(shlex.split(rdata))
except ValueError:
return rdata
if rtype == "CAA":
drapeau, etiquette, valeur = (rdata.split(None, 2) + ["", "", ""])[:3]
return f"{int(drapeau)} {etiquette.lower()} {valeur.strip().strip(chr(34))}"
if rtype == "AAAA":
import ipaddress
return str(ipaddress.ip_address(rdata))
return rdata
def _du_plan(zone: str, conf: dict, ns: str, ip_publique: str) -> dict[tuple[str, str], set[str]]:
"""Ce que la zone rendue portera, par (FQDN, type). SOA et NS exceptes."""
attendu: dict[tuple[str, str], set[str]] = {}
for e in conf.get("enregistrements") or []:
t = str(e["type"]).upper()
v = str(e["valeur"]).strip()
if t == "MX":
v = f"{e['priorite']} {v}"
elif t == "CAA":
v = f"0 {e['etiquette']} {v}"
attendu.setdefault((_rel_en_fqdn(zone, e["nom"]), t), set()).add(_normaliser(t, v))
if ns.endswith("." + zone):
attendu.setdefault((ns, "A"), set()).add(ip_publique)
return attendu
class MesureImpossible(Exception):
pass
def _interroger(serveur: str, fqdn: str, rtype: str) -> set[str]:
hote, _, port = serveur.partition(":")
cmd = ["dig", "+noall", "+answer", "+comments", "+time=4", "+tries=2",
f"@{hote}", fqdn, rtype]
if port:
cmd[1:1] = ["-p", port]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired) as exc:
raise MesureImpossible(f"{fqdn} {rtype} @{serveur} : {exc}") from exc
statut = next((ligne.split("status:")[1].split(",")[0].strip()
for ligne in r.stdout.splitlines() if "status:" in ligne), None)
if r.returncode != 0 or statut not in ("NOERROR", "NXDOMAIN"):
detail = statut or (r.stdout.strip().splitlines() or ["sans reponse"])[-1]
raise MesureImpossible(f"{fqdn} {rtype} @{serveur} : {detail}")
vus = set()
for ligne in r.stdout.splitlines():
if not ligne or ligne.startswith(";"):
continue
champs = ligne.split(None, 4)
# SEULEMENT CE QUI EST AU NOM ET AU TYPE DEMANDES : un A demande sur un CNAME rend
# aussi la chaine suivie, qui appartient a un autre nom.
if len(champs) == 5 and champs[0].rstrip(".").lower() == fqdn.lower() and champs[3] == rtype:
vus.add(_normaliser(rtype, champs[4]))
return vus
def _abandon(rtype: str, valeur: str) -> str | None:
for t, prefixe, raison in ABANDONS_CONNUS:
if t == rtype and valeur.startswith(prefixe):
return raison
return None
def _intrants(base: Path) -> dict:
f = base / "inventories" / "principal" / "group_vars" / "all" / "10-intrants.yml"
return (yaml.safe_load(f.read_text(encoding="utf-8")) or {}) if f.is_file() else {}
def devis_zone(zone: str, conf: dict, ns: str, ip_publique: str, resolveur: str,
serveur: str | None) -> tuple[int, int, list[str]]:
"""Rend (pertes, mesures_impossibles, lignes)."""
lignes, echecs = [], 0
attendu = _du_plan(zone, conf, ns, ip_publique)
noms = sorted({fq for fq, _ in attendu} | {_rel_en_fqdn(zone, n) for n in NOMS_SONDES})
try:
ns_actuels = sorted(v.rstrip(".") for v in _interroger(resolveur, zone, "NS"))
lignes.append(f" delegation actuelle : {', '.join(ns_actuels) or '(aucune)'}")
if ns in ns_actuels:
lignes.append(" deja basculee : le devis compare alors la zone a elle-meme.")
except MesureImpossible as exc:
echecs += 1
lignes.append(f" delegation : MESURE IMPOSSIBLE — {exc}")
identiques, ajouts, differences, en_ligne_seul, abandons = [], [], [], [], []
for fq in noms:
for t in TYPES:
try:
en_ligne = _interroger(resolveur, fq, t)
except MesureImpossible as exc:
echecs += 1
lignes.append(f" MESURE IMPOSSIBLE : {exc}")
continue
plan = attendu.get((fq, t), set())
if not plan and not en_ligne:
continue
gardes = {v for v in en_ligne if not _abandon(t, v)}
for v in en_ligne - gardes:
abandons.append(f"{fq} {t} {v[:60]} — {_abandon(t, v)}")
if plan == gardes:
identiques.append(f"{fq} {t}")
elif not gardes:
ajouts.append(f"{fq} {t} {', '.join(sorted(plan))}")
elif not plan:
en_ligne_seul.append(f"{fq} {t} {', '.join(sorted(gardes))}")
else:
differences.append(f"{fq} {t} : plan {sorted(plan)} / en ligne {sorted(gardes)}")
pertes = len(differences) + len(en_ligne_seul)
lignes.append(f" identiques : {len(identiques)}")
for titre, liste in (("PERDU A LA BASCULE (en ligne, absent du plan)", en_ligne_seul),
("CHANGE A LA BASCULE", differences),
("ajoute par la bascule (voulu ?)", ajouts),
("abandonne en connaissance de cause", abandons)):
if liste:
lignes.append(f" {titre} : {len(liste)}")
lignes += [f" - {x}" for x in liste]
# DEUX SERVEURS DE NOMS, OU PAS DE BASCULE. Le registre des `.ca` (CIRA) en exige deux,
# et un seul serveur fait de la moindre panne du site une disparition du domaine. La zone
# n'en designe qu'un tant que le serveur du site pair n'est pas actif.
lignes.append(f" PREALABLE : la zone ne designe qu'un serveur de noms ({ns}) ; il en faut deux "
f"— celui du site pair, quand `dns_public_pairs` sera actif.")
pertes += 1
# LA COLLE. Le serveur de noms doit resoudre AVANT qu'un registraire le designe : sinon
# la zone basculee pointe vers un nom que personne ne trouve.
try:
a_ns = _interroger(resolveur, ns, "A")
if a_ns == {ip_publique}:
lignes.append(f" {ns} resout deja vers {ip_publique} : le registraire peut le designer.")
else:
lignes.append(f" PREALABLE : {ns} ne resout pas publiquement vers {ip_publique} "
f"(en ligne : {sorted(a_ns) or 'rien'}). La zone qui le contient doit "
f"le publier d'abord, et son registraire en tenir la colle.")
# Pour la zone qui CONTIENT le nom, c'est la bascule elle-meme qui le publie.
pertes += 0 if ns.endswith("." + zone) else 1
except MesureImpossible as exc:
echecs += 1
lignes.append(f" colle : MESURE IMPOSSIBLE — {exc}")
if serveur:
manquants = []
for (fq, t), valeurs in sorted(attendu.items()):
try:
servi = _interroger(serveur, fq, t)
except MesureImpossible as exc:
echecs += 1
manquants.append(f"MESURE IMPOSSIBLE : {exc}")
continue
if servi != valeurs:
manquants.append(f"{fq} {t} : plan {sorted(valeurs)} / servi {sorted(servi)}")
if manquants:
pertes += len([m for m in manquants if not m.startswith("MESURE")])
lignes.append(f" NOTRE SERVEUR ({serveur}) NE SERT PAS LE PLAN : {len(manquants)}")
lignes += [f" - {m}" for m in manquants]
else:
lignes.append(f" notre serveur ({serveur}) sert exactement le plan.")
return pertes, echecs, lignes
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--resolveur", default="9.9.9.9", help="resolveur public de reference")
ap.add_argument("--serveur", help="notre serveur, pour verifier qu'il sert le plan (IP[:port])")
ap.add_argument("--zone", help="une seule zone")
args = ap.parse_args()
base = instance_courante()
if not base.exists():
print("REFUS : aucune instance montee (symlink `instance/` ou SETOPS_INSTANCE).")
return 2
base = base.resolve()
registre = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {}
valider_domaines(registre)
intrants = _intrants(base)
ns = str(intrants.get("dns_public_nom") or "").rstrip(".")
ip_publique = str(intrants.get("ip_publique_site") or "")
if not ns or not ip_publique:
print("REFUS : `dns_public_nom` ou `ip_publique_site` absent des intrants du locataire "
"(make site-intrants). Sans eux, on ne sait pas ce que la zone rendra.")
return 2
zones = {z: c for z, c in (registre.get("domaines_publics") or {}).items()
if c.get("autorite") == "primaire-cache" and (not args.zone or z == args.zone)}
if not zones:
print(f"Aucune zone `primaire-cache` dans {base.name}"
+ (f" nommee {args.zone}" if args.zone else "") + " : rien a basculer.")
return 2 if args.zone else 0
total_pertes = total_echecs = 0
print(f"DEVIS DE BASCULE DNS — {base.name}, reference {args.resolveur}, serveur de noms {ns}")
for zone, conf in sorted(zones.items()):
pertes, echecs, lignes = devis_zone(zone, conf, ns, ip_publique, args.resolveur, args.serveur)
verdict = ("MESURE INCOMPLETE" if echecs else
"rien ne serait perdu" if not pertes else f"{pertes} ecart(s) a regler")
print(f"\n{zone} — {verdict}")
print("\n".join(lignes))
total_pertes += pertes
total_echecs += echecs
print("\nLIMITE : seuls les noms declares et sondes sont compares. Un export de la zone chez "
"le fournisseur actuel reste la seule preuve d'exhaustivite.")
if total_echecs:
return 2
return 1 if total_pertes else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import ipaddress
import os
import re
import tempfile
@ -637,6 +638,77 @@ def chaine_connexion(entree: dict, serveur: dict, secret: str = "***") -> str:
AUTORITES_DNS = {"primaire-cache", "auto-heberge", "delegue"}
# LES ENREGISTREMENTS QU'UNE ZONE PUBLIQUE PEUT DECLARER (2026-09-16).
#
# Avant, une zone publique ne portait que ses expositions web : des A vers l'adresse
# publique du site. Or la zone d'un locataire porte aussi son COURRIEL (MX, SPF, DMARC),
# ses verifications et ses noms historiques. Basculer les serveurs de noms sur une zone
# qui les ignore couperait le courriel de production a la minute ou le registraire suit.
#
# La liste est courte a dessein : chaque type ajoute est un type rendu, et donc un type a
# eprouver. SRV, NS et SOA n'y sont pas — le SOA et les NS sont ceux du site.
TYPES_ENREGISTREMENT = {"A", "AAAA", "CNAME", "MX", "TXT", "CAA"}
ETIQUETTES_CAA = {"issue", "issuewild", "iodef"}
_NOM_HOTE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9])?)(\.[A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9])?)*$")
def _valider_enregistrement(nom_domaine: str, entree) -> str:
"""Valide un enregistrement declare ; rend son FQDN. Leve ValueError sinon."""
if not isinstance(entree, dict):
raise ValueError(f"Domaine '{nom_domaine}': enregistrement invalide (table attendue).")
nom = str(entree.get("nom", "")).strip()
rtype = str(entree.get("type", "")).strip().upper()
valeur = entree.get("valeur")
lieu = f"Domaine '{nom_domaine}', enregistrement {nom or '?'} {rtype or '?'}"
if not nom:
raise ValueError(f"{lieu}: champ 'nom' requis (`@` pour la zone elle-meme).")
if nom != "@" and (nom.endswith(".") or not _NOM_HOTE.match(nom)):
raise ValueError(f"{lieu}: nom relatif attendu, sans point final (ex. `mx`, `_dmarc`).")
# `mx.chezlepro.ca` ecrit sous `chezlepro.ca` deviendrait `mx.chezlepro.ca.chezlepro.ca`.
if nom.lower() == nom_domaine.lower() or nom.lower().endswith("." + nom_domaine.lower()):
raise ValueError(f"{lieu}: nom relatif a la zone attendu (`mx`, pas `mx.{nom_domaine}`).")
if rtype not in TYPES_ENREGISTREMENT:
raise ValueError(f"{lieu}: type inconnu (attendu: {', '.join(sorted(TYPES_ENREGISTREMENT))}).")
if valeur is None or not str(valeur).strip():
raise ValueError(f"{lieu}: champ 'valeur' requis.")
valeur = str(valeur).strip()
ttl = entree.get("ttl")
if ttl is not None and (not isinstance(ttl, int) or isinstance(ttl, bool) or not 60 <= ttl <= 604800):
raise ValueError(f"{lieu}: 'ttl' doit etre un entier entre 60 et 604800.")
if rtype in ("A", "AAAA"):
# UNE ZONE PUBLIQUE NE PORTE QUE DES ADRESSES PUBLIQUES : une adresse privee ne
# resout pour personne dehors, et renseigne sur le reseau dedans.
try:
ip = ipaddress.ip_address(valeur)
except ValueError:
raise ValueError(f"{lieu}: '{valeur}' n'est pas une adresse IP.") from None
if (rtype == "A") != (ip.version == 4):
raise ValueError(f"{lieu}: '{valeur}' n'est pas une adresse IPv{4 if rtype == 'A' else 6}.")
if not ip.is_global:
raise ValueError(f"{lieu}: '{valeur}' n'est pas une adresse publique.")
elif rtype in ("CNAME", "MX"):
# UNE CIBLE EST TOUJOURS ABSOLUE. Dans un fichier de zone, `mx.chezlepro.ca` sans
# point final devient `mx.chezlepro.ca.chezlepro.ca.` ; le gabarit ajoute le point.
if not _NOM_HOTE.match(valeur.rstrip(".")):
raise ValueError(f"{lieu}: '{valeur}' n'est pas un nom d'hote complet.")
if rtype == "CNAME" and nom == "@":
raise ValueError(f"{lieu}: un CNAME a l'apex est interdit (le SOA et les NS y vivent).")
if rtype == "MX":
prio = entree.get("priorite")
if not isinstance(prio, int) or isinstance(prio, bool) or not 0 <= prio <= 65535:
raise ValueError(f"{lieu}: 'priorite' entiere (0-65535) requise pour un MX.")
elif rtype in ("TXT", "CAA"):
# Le gabarit met la valeur entre guillemets et la coupe en tranches de 255 : un
# guillemet ou une barre oblique inverse la casserait sans que rien ne le dise.
# Et en ASCII : les tranches se comptent en octets sur le fil, en caracteres ici.
if not valeur.isascii():
raise ValueError(f"{lieu}: 'valeur' doit etre en ASCII.")
if '"' in valeur or "\\" in valeur:
raise ValueError(f"{lieu}: guillemet ou barre oblique inverse refuses dans 'valeur'.")
if rtype == "CAA" and str(entree.get("etiquette", "")).strip() not in ETIQUETTES_CAA:
raise ValueError(f"{lieu}: 'etiquette' requise pour un CAA ({', '.join(sorted(ETIQUETTES_CAA))}).")
return fqdn_exposition(nom_domaine, nom)
def charger_domaines(path: Path | None) -> dict:
"""Charge le registre des domaines publics (domaines_publics)."""
@ -689,6 +761,27 @@ def valider_domaines(registre: dict) -> None:
if fqdn in fqdns:
raise ValueError(f"Exposition en double: {fqdn}")
fqdns.add(fqdn)
enregistrements = conf.get("enregistrements") or []
if not isinstance(enregistrements, list):
raise ValueError(f"Domaine '{nom_domaine}': 'enregistrements' doit etre une liste.")
# UN ENREGISTREMENT QUE RIEN NE REND EST UNE PROMESSE SILENCIEUSE. Seules les zones
# `primaire-cache` sont ecrites par Set-OPS ; ailleurs, la declaration n'aurait aucun
# effet et laisserait croire le contraire.
if enregistrements and autorite != "primaire-cache":
raise ValueError(
f"Domaine '{nom_domaine}': 'enregistrements' n'est rendu que pour une zone "
f"`primaire-cache` (autorite actuelle: {autorite}).")
par_nom: dict[str, list] = {}
for entree in enregistrements:
fqdn = _valider_enregistrement(nom_domaine, entree)
par_nom.setdefault(fqdn, []).append(str(entree.get("type")).strip().upper())
for fqdn, liste in sorted(par_nom.items()):
types = set(liste)
# UN CNAME EST SEUL A SON NOM (RFC 1034) : un resolveur qui le suit ignore le reste.
if "CNAME" in types and (len(liste) > 1 or fqdn in fqdns):
raise ValueError(f"Domaine '{nom_domaine}': {fqdn} porte un CNAME et autre chose.")
if fqdn in fqdns and types & {"A", "AAAA"}:
raise ValueError(f"Domaine '{nom_domaine}': {fqdn} est deja une exposition (A rendu par Set-OPS).")
def expositions_du_groupe(registre: dict, groupe: str) -> list[dict]:

View file

@ -213,6 +213,23 @@ REGISTRES: dict = {
"source_valeurs": "groupes_operationnels",
"aide": "Le groupe interne qui sert ce nom."},
"type": {"type": "string", "libelle": "Type", "defaut": "web"}}},
# LE COURRIEL ET LE RESTE DE LA ZONE (2026-09-16). Rendus seulement pour une zone
# `primaire-cache` ; `valider_domaines` refuse ailleurs.
"enregistrements": {"type": "array", "libelle": "Enregistrements publics",
"aide": "MX, SPF, DMARC, DKIM, CAA et noms historiques de la zone.",
"entrees": {
"nom": {"type": "string", "requis": True, "libelle": "Nom",
"aide": "Relatif a la zone : `@`, `mx`, `_dmarc`."},
"type": {"type": "string", "requis": True, "libelle": "Type",
"enum": ["A", "AAAA", "CAA", "CNAME", "MX", "TXT"]},
"valeur": {"type": "string", "requis": True, "libelle": "Valeur",
"aide": "Adresse publique, nom d'hote complet ou texte (sans guillemets)."},
"priorite": {"type": "integer", "libelle": "Priorite",
"aide": "Requise pour un MX."},
"etiquette": {"type": "string", "libelle": "Etiquette CAA",
"enum": ["iodef", "issue", "issuewild"]},
"ttl": {"type": "integer", "libelle": "TTL",
"aide": "Secondes (60 a 604800) ; defaut de la zone sinon."}}},
},
},
"nomenclature": {
@ -311,6 +328,11 @@ def construire() -> dict:
q["description"] = sd["aide"]
if "defaut" in sd:
q["default"] = sd["defaut"]
# Une liste fermee dans une liste : `type` d'un enregistrement public.
# Sans elle, le formulaire offrirait une case libre que le validateur
# refuserait ensuite.
if "enum" in sd:
q["enum"] = sd["enum"]
if "source_valeurs" in sd:
q["x-source-valeurs"] = sd["source_valeurs"]
sous[sc] = q

View file

@ -113,6 +113,8 @@ def contrat() -> dict:
# VERS OU POINTENT SES NOMS PUBLICS — l'adresse publique de la frontiere. Un fait
# du site : le locataire la recoit, il ne la recopie pas.
"ip_publique_site": _ip_publique_site(),
# SOUS QUEL NOM LE SITE FAIT AUTORITE — les NS et le SOA de chaque zone publique.
"dns_public_nom": str(intrants.get("dns_public_nom") or ""),
# QUI A LE DROIT D'ENTRER EN SSH
"nftables_admin_ssh": [gestion] if gestion else [],
# PAR OU SORTIR, ET A QUI DEMANDER L'HEURE
@ -130,6 +132,7 @@ OU_LE_LOCATAIRE_LE_DIT = {
"client_backup_cible": ("client_backup.yml", "client_backup_cible"),
"dns_public_site": ("all/10-intrants.yml", "dns_public_site"),
"ip_publique_site": ("all/10-intrants.yml", "ip_publique_site"),
"dns_public_nom": ("all/10-intrants.yml", "dns_public_nom"),
# CES DEUX-LA MANQUAIENT, ET LE SILENCE A COUTE (2026-09-13). Le site en expose SEPT ;
# cette table n'en comparait que CINQ. Le controle rapportait donc « CONFORME » avec
# un aplomb complet, sur un locataire dont le plan d'administration pointait encore