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:
parent
19bbadfb11
commit
63780645a7
12 changed files with 597 additions and 14 deletions
33
CHANGELOG.md
33
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.<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
|
||||
|
|
|
|||
4
Makefile
4
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,)
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
295
scripts/dns_bascule.py
Normal 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())
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue