From 63780645a70f3741ae08dc523689b2d84ab97ea1 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 16 Sep 2026 21:54:49 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 33 ++ Makefile | 4 + docs/audit/preuve-2026-09-16.md | 20 +- docs/audit/schema-plan.json | 57 ++++ docs/dns-interne.md | 28 ++ roles/serveur_powerdns/defaults/main.yml | 2 + .../tasks/zones-publiques.yml | 26 +- .../templates/zone-publique.db.j2 | 28 +- scripts/dns_bascule.py | 295 ++++++++++++++++++ scripts/inventory_rules.py | 93 ++++++ scripts/schema_plan.py | 22 ++ scripts/site_intrants.py | 3 + 12 files changed, 597 insertions(+), 14 deletions(-) create mode 100644 scripts/dns_bascule.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a288f58..2a99448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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.` 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 diff --git a/Makefile b/Makefile index 3e53d4a..4d11249 100644 --- a/Makefile +++ b/Makefile @@ -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,) diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index 88a130e..5b42a41 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -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. | diff --git a/docs/audit/schema-plan.json b/docs/audit/schema-plan.json index 287d973..d693a8e 100644 --- a/docs/audit/schema-plan.json +++ b/docs/audit/schema-plan.json @@ -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, diff --git a/docs/dns-interne.md b/docs/dns-interne.md index 7a9717a..f3e3e2f 100644 --- a/docs/dns-interne.md +++ b/docs/dns-interne.md @@ -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.`, 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é. diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 272e57e..0156b8c 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -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-`). `realpath`, parce # que sur le poste l'inventaire passe par le lien `instance`. diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml index 7a5c774..b571911 100644 --- a/roles/serveur_powerdns/tasks/zones-publiques.yml +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -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 diff --git a/roles/serveur_powerdns/templates/zone-publique.db.j2 b/roles/serveur_powerdns/templates/zone-publique.db.j2 index 44330e0..da87be0 100644 --- a/roles/serveur_powerdns/templates/zone-publique.db.j2 +++ b/roles/serveur_powerdns/templates/zone-publique.db.j2 @@ -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.` 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 %} diff --git a/scripts/dns_bascule.py b/scripts/dns_bascule.py new file mode 100644 index 0000000..286a87c --- /dev/null +++ b/scripts/dns_bascule.py @@ -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()) diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 1043eb1..c3f25e4 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -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]: diff --git a/scripts/schema_plan.py b/scripts/schema_plan.py index 6bcce87..7c59669 100644 --- a/scripts/schema_plan.py +++ b/scripts/schema_plan.py @@ -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 diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index 8d6cd5b..ae63514 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -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