DNS public, phase 1 : le locataire ecrit, le site sert

Role serveur_dns_public (secondaire public, transfert signe TSIG, aucune zone interne) ;
serveur_powerdns exerce enfin autorite primaire-cache dans une instance pdns@public a part,
pour que le site ne puisse jamais interroger la zone .internal. Relations derivees des plans
des locataires, mots de flux dns_public_site et primaires_dns_locataires. Eprouve avant
d ecrire : allow-axfr-ips et TSIG sont alternatifs, le primaire notifie aussi ses NS, le
serial fige aurait gele le secondaire. P82 refuse l exposition sans DNSSEC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-16 18:08:20 -04:00
parent 8456d298bf
commit 788c5073dc
36 changed files with 1244 additions and 30 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 81 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 82 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,61 @@
# CHANGELOG — Set-OPS
## 2026-09-16 (5) — Le DNS public, phase 1 : le locataire ecrit, le site sert
Les noms publics des locataires etaient chez un tiers, et le mode `autorite: primaire-cache`
du plan etait valide par le schema et **consomme par rien**. Cette phase le fait exercer, sans
rien exposer a Internet.
LOCATAIRE infra-dns-01 pdns@public primaire cache de chezlepro.ca ─┐ NOTIFY
│ AXFR signe TSIG
SITE site-dnspub-01 secondaire public ◄──────────────┘
10.37.37.21 (zone de publication, pas sur l'edge)
### Eprouver l'outil avant le role
Deux instances PowerDNS 4.9.17 jetables, sur une machine du site, en repertoire temporaire.
Sept verifications, et trois enseignements que personne n'aurait devines :
- **`allow-axfr-ips` et TSIG sont ALTERNATIFS.** Une adresse listee obtient la zone *sans
signature* — le journal le dit : *« allowed: client IP is in allow-axfr-ips »*. La
configuration evidente rend TSIG decoratif, sans un message. Et la valeur par defaut
autorise la boucle locale : mon premier test negatif n'en etait pas un.
- **Le primaire notifie aussi les adresses de ses NS** — donc, en production, les IP
PUBLIQUES des serveurs de noms, depuis l'interieur du locataire. `only-notify` les borne.
- **`bind-dnssec-db` est pris en charge** alors que `ldd` du module ne montrait pas SQLite.
### Trois fautes evitees en concevant
1. **Une instance a part.** Faire ecouter l'instance principale sur l'adresse de l'hote
aurait permis au serveur public du site d'INTERROGER la zone `.internal` du locataire —
de lire la carte de ses machines. C'est ce que la charte des responsabilites refuse a
l'hebergeur. `pdns@public` est la seule a ecouter l'hote.
2. **Le serial suit le contenu.** Le role portait un serial fige : sans secondaire, sans
consequence ; avec un secondaire, il garde l'ancienne zone POUR TOUJOURS.
3. **Un mot de flux, pas un nom de role.** `serveur_powerdns` existe aussi au site : une
sortie « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme.
### Ce qui est pose
- le role `serveur_dns_public` (secondaire, aucun transfert sortant, notifications des seuls
primaires, `version-string=anonymous`, aucun appel vers les serveurs de l'editeur) ;
- `serveur_powerdns` exerce `primaire-cache` dans `pdns@public` ;
- les relations se DERIVENT des plans des locataires (`site_inventaire.py`), l'adresse du
primaire de leur nomenclature ; le site transmet `dns_public_site` et `ip_publique_site`
par son contrat ;
- deux mots de flux calques sur `runner_site` ; l'alias des primaires ne vise que les
locataires qui publient (Patient0, qui n'a aucune zone publique, en a ete retire) ;
- les cles TSIG dans les trois voutes, ecrites avec les gardes connues ;
- la relation de replication avec `SITE-Technolibre`, declaree des deux cotes, inactive.
### P82
Refuse une zone `.internal` publiee, une zone declaree et non servie (ou l'inverse), une
adresse publique privee, une replication declaree d'un seul cote, une instance publique qui
autoriserait autre chose que la boucle locale au transfert — et **l'exposition du serveur
public a Internet tant que toutes ses zones ne sont pas signees DNSSEC**. La condition de la
phase 2 est ecrite comme une garde, pas comme une promesse.
## 2026-09-16 (4) — La page cesse de proposer ce que le serveur refuse
La portee etait derivee et gardee au serveur ; la page, elle, proposait encore tout. On

View file

@ -7,21 +7,21 @@
> [`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 (80 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (81 OK · 0 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | } \| to_nice_json }}`. |
| P02 | Tests unitaires (inventaire, raser, ecritures du plan, rendu du GUI) | — | ✅ OK | OK |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 5 instance(s) verifiee(s) — instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 111 flux, schéma + matrice OK. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 42 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 40 rôles, 116 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -30,53 +30,53 @@
| 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). |
| 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 : 31 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| 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. |
| 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). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 14 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 4 tenant(s), 52 groupe(s), 96 regle(s). |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 4 tenant(s), 56 groupe(s), 104 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 13 hote(s) x 6 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 5 pool(s) Proxmox, 43 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| 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, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (144 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 36 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| 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). |
| 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). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 8 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| 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). |
| 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 : 8 machine(s) du plan retrouvees, 182 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 189 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 95 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (129 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 272 regles `pass`), tous non consignes et tous motives. |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 97 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (134 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 279 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (81 preuves, 68 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (82 preuves, 69 roles, 42 groupes). |
| 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). |
| 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 | 40 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| 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. |
| P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
@ -85,15 +85,16 @@
| 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 : 6 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 : 8 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 | 155 gabarits de role : tous se rendent. |
| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 161 gabarits de role : tous se rendent. |
| P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. |
| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). |
| P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 6 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0, SITE-Chezlepro) : pattes, edges, certificats, rechargemen |
| P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. |
| P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des symlinks, source d'inventaire `instance`, et 14 route(s) POST exigent toutes un pouvoir. |
| P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio |
## Couverture des affirmations ✅ du registre

View file

@ -98,7 +98,7 @@ Une directive qu'aucune garde ne vérifie finit par ne plus être vraie. Chaque
| `socle-identite` | **est** la chaîne d'identité, ne peut pas se déléguer à elle-même | keycloak, openldap |
| `ldap-direct` | protocole non-OIDC lié à LDAP | dovecot, postfix |
| `interne-sans-auth` | interface joignable **dans** le tenant sans authentification, non exposée | prometheus, loki |
| `sans-auth-humaine` | aucun point d'authentification humaine | 22 |
| `sans-auth-humaine` | aucun point d'authentification humaine | 23 |
La preuve refuse **l'oubli et le mensonge** : un rôle sans déclaration, une portée
inventée, un `web-sso` sans accès de secours ou sans posture de formulaire, un `web-sso

View file

@ -23,8 +23,8 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 68 | `roles/*/` |
| README de rôles | 68 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 69 | `roles/*/` |
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 44 | `docs/*.md` |
| pièces d'audit | 47 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
@ -75,6 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
| Exploitation de l'hébergeur | ses **opérations** n'appartiennent à aucun tenant et restent **hors overlay** | **à moitié construit** : les *VM* du site ont leur inventaire (`scripts/site_inventaire.py`), leur socle, leur durcissement, leurs sauvegardes et leur supervision (`site-mon-01`, 2026-09-02). Les *équipements* — hyperviseurs, commutateurs, frontière — n'ont toujours ni inventaire, ni sauvegarde de configuration, ni supervision | `hebergeur-exploitation.md` |
| Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `<rôle>_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` |
| Accès & habilitations | Set-OPS **amorce** un accès sysadmin puis se retire ; les appartenances aux groupes ne sont **jamais réconciliées** — c'est une personne qui gouverne | **construit et éprouvé** (2026-08-08) : rôle `amorcage_acces` (idempotence par existence, D-67), groupes projetés en rôles par `serveur_keycloak`, `meta/acces.yml` dans les 5 rôles web ; chaîne LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 | `autorisation.md` (§6 = runbook de reprise) |
| **DNS public** | le locataire **écrit** ses zones `autorite: primaire-cache`, le site les **sert** en secondaire, un site pair les réplique. Transfert ouvert **à la clé TSIG seule** (`allow-axfr-ips` et TSIG sont alternatifs dans PowerDNS) ; instance `pdns@public` à part pour que le site ne puisse jamais interroger la zone `.internal` | `roles/serveur_dns_public/`, `roles/serveur_powerdns/tasks/zones-publiques.yml` ; relations dérivées dans `scripts/site_inventaire.py` ; mots de flux `dns_public_site` / `primaires_dns_locataires` ; garde **P82** | `dns-interne.md` §Zones publiques |
| **Remise au client** | remettre un écosystème se fait en **deux temps** : l'*identité* le jour de la livraison (sa clé de voûte, sa voûte, sa racine d'AC), la *machine* à l'échéance (sa clé SSH entre, la nôtre sort, la voûte est re-clétée). Le registre déclare enfin le **responsable désigné** (D-18) | `scripts/remise.py` (`make remise-paquet`, `remise-inscrire`, `remise-recleer`) ; registre `remise.yml` chez le locataire ; garde **P80** | `remise-au-client.md` |
| SDN EVPN | ajouter un tenant implique **1 zone + 6 VNets + 6 sous-réseaux**, tous dérivés du seed | `scripts/devis_sdn.py` (`make devis-sdn`) ; nommage dérivé du seed (`t17`, `t17serv`), ≤ 8 caractères ; garde **P30** | `sdn-evpn.md` §2 |
| Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 |

View file

@ -65,6 +65,7 @@ Ce que la reconstruction couvre, par capacité :
| Forge du genome du site | `serveur_forge_site` | designe LA forge dont les ecosystemes de ce site se reproduisent (D-81), et l'ouvre a eux. Marqueur : `serveur_forgejo` installe. |
| Resolveur du site | `serveur_resolveur_site` | designe LE resolveur que les ecosystemes de ce site interrogent tant qu'ils n'ont pas le leur. Marqueur : `serveur_resolveur` installe. |
| Depot de sauvegarde du site | `serveur_backup_site` | designe LE depot ou les ecosystemes de ce site posent leur etat tant qu'ils n'ont pas le leur. Marqueur : `serveur_backup` installe. |
| DNS public du site | `serveur_dns_public` | **secondaire** public de toutes les zones `autorite: primaire-cache` des locataires du site : le locataire ecrit, le site sert. Transfert signe TSIG, aucune adresse de confiance, aucune zone `.internal`. Phase 1 : non expose a Internet |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp`, `client_sante` | collecte, relais et rapport de santé sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`
@ -200,7 +201,7 @@ dans le plan.
## Ordre de déploiement — le raisonnement
> **L'ordre exécutable n'est pas ici.** Il vit dans `docs/couches-deploiement.yml` et
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (41 groupes classés, aucun
> `docs/dependances-groupes.yml`, que P08 prouve cohérents (42 groupes classés, aucun
> cycle, aucune arête en arrière) et que `make reconstruire` suit. Ce qui suit en est le
> **raisonnement**, utile pour comprendre pourquoi cet ordre-là — et pour placer un
> service nouveau. Les phases sont franchies : les « intégrations à prévoir » ci-dessous

View file

@ -92,6 +92,9 @@ couches:
- serveur_resolveur_site
# Le depot de sauvegarde du site, meme nature : il recoit l'etat des locataires.
- serveur_backup_site
# Le DNS public du site vient APRES les services : il tire les zones que les
# autoritatifs des locataires ecrivent, et n'a rien a servir avant eux.
- serveur_dns_public
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
groupes:

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 81 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 82 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -139,3 +139,35 @@ Les premiers checks utiles :
- enregistrements des hotes actifs presents ;
- serial de zone attendu ;
- latence de resolution.
## Zones publiques
> Ajouté le 2026-09-16. Le mode `primaire-cache` était **validé par le schéma et consommé par
> rien** depuis sa création — *une autorité qu'on s'attribue sans l'exercer est une panne
> différée*.
`plan/domaines.yml` porte un champ `autorite` par domaine. Deux valeurs sont exercées :
| `autorite` | Ce que ça veut dire | Qui sert la zone |
|---|---|---|
| `auto-heberge` | zone **interne** (`.internal`), servie à l'écosystème seul | l'instance principale de `serveur_powerdns`, sur la boucle locale |
| `primaire-cache` | zone **publique** : le locataire l'écrit, le site la sert | `pdns@public` chez le locataire (primaire caché), `serveur_dns_public` au site (secondaire public) |
`delegue` est accepté par le validateur et **n'est consommé par aucun rôle**. Il ne faut pas
le déclarer en croyant qu'il fait quelque chose.
**Le locataire écrit, le site sert, un site pair réplique.** Le primaire reste chez le
locataire : quand il part, il emporte sa zone. Le détail — et ce que l'épreuve de PowerDNS a
appris — est dans `roles/serveur_dns_public/README.md`.
Trois règles, chacune payée ou mesurée :
- **TSIG seul ouvre le transfert.** `allow-axfr-ips` et TSIG sont *alternatifs* : une adresse
listée obtient la zone sans signature. L'instance publique n'autorise que la boucle locale.
- **Une instance à part pour le public.** Faire écouter l'instance principale sur l'adresse
de l'hôte aurait permis au serveur public du site d'interroger la zone `.internal`.
- **Le serial suit le contenu.** Un serial figé ferait garder au secondaire l'ancienne zone
pour toujours.
**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.

View file

@ -35,6 +35,9 @@
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. |
| `serveur_debian` | egress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » sortant : c'est ainsi que nos hôtes signalent l'overlay à 1450 aux correspondants distants. |
| `serveur_dns_public` | ingress | 53 | udp | voisins_site | clair | NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et c'est TSIG qui le garde, sur le transfert. |
| `serveur_dns_public` | ingress | 53 | tcp | voisins_site | clair | Repli TCP des notifications des primaires des locataires. |
| `serveur_dns_public` | egress | 5300 | tcp | primaires_dns_locataires | clair | AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne chiffre pas — et une zone publique n'a rien a cacher. |
| `serveur_dovecot` | ingress | 24 | tcp | serveur_postfix | tls-requis | Remise LMTP depuis Postfix (edge-mta -> mailstore), en TLS vérifié (lmtp_tls_security_level=verify). |
| `serveur_dovecot` | ingress | 993 | tcp | externe | tls-requis | Accès courriel des utilisateurs (IMAPS). Frontière publique gérée à l'OPNsense. |
| `serveur_dovecot` | ingress | 12345 | tcp | serveur_postfix | tls | Authentification SASL déléguée : Postfix valide les identifiants de soumission contre Dovecot. |
@ -97,6 +100,8 @@
| `serveur_postfix` | egress | 12345 | tcp | serveur_dovecot | tls | Validation SASL des identifiants de soumission contre Dovecot. |
| `serveur_postgresql` | ingress | 5432 | tcp | serveur_keycloak, serveur_forgejo, serveur_icinga, serveur_nextcloud | tls-requis | Connexions applicatives à PostgreSQL (verify-full ; pg_hba hostssl). |
| `serveur_postgresql` | ingress | 9187 | tcp | serveur_prometheus | clair | Metriques PostgreSQL lues par l'observatoire. Series, pas verdicts : ce qui derive lentement — cache qui decroche, connexions qui montent, bases qui grossissent — n'a que le graphe pour se faire voir. En clair pour l'instant, contrairement a `client_metrique` : dette inscrite, a fermer par `--web.config.file` + `client_pki`. |
| `serveur_powerdns` | ingress | 5300 | tcp | dns_public_site | clair | AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement. |
| `serveur_powerdns` | ingress | 5300 | udp | dns_public_site | clair | Interrogation du SOA par le secondaire du site avant chaque transfert. |
| `serveur_powerdns` | ingress | derive | udp | flotte | clair | Zone souveraine. 53 seul sur son hôte, 5300 sur la loopback derrière le résolveur. |
| `serveur_powerdns` | ingress | derive | tcp | flotte | clair | Idem en TCP (réponses volumineuses, AXFR restreint par allow_axfr_ips). |
| `serveur_powerdns` | egress | 53 | udp | externe | clair | Résolution sortante du serveur autoritatif POUR SES PROPRES besoins (apt, NTP) — il ne récurse pour aucun autre hôte. |
@ -120,7 +125,7 @@
## Synthèse chiffrement
- **clair** : 41 flux
- **clair** : 46 flux
- **n-a** : 8 flux
- **ssh** : 8 flux
- **starttls** : 6 flux

View file

@ -99,7 +99,7 @@ Nommer un point ouvert vaut mieux qu'une ligne rassurante sans mécanisme derri
Aucune de ces responsabilités n'est laissée à la parole :
```
make prouver le dépôt est-il cohérent avec lui-même (81 preuves, zéro réseau)
make prouver le dépôt est-il cohérent avec lui-même (82 preuves, zéro réseau)
make remise-verifier le second temps de la remise est-il fait, ou en retard ? (P80)
make certificats-plan ce que le disque porte contre ce que la mémoire sert
make expositions-plan chaque service publié répond-il, et depuis où

View file

@ -0,0 +1,19 @@
---
- name: Appliquer le groupe serveur_dns_public
hosts: serveur_dns_public
become: true
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_dns_public

View file

@ -0,0 +1,54 @@
# serveur_dns_public
> **Pour qui :** l'hébergeur qui publie les noms de ses locataires sur Internet.
**Le serveur DNS public du site.** Il est **secondaire** de toutes les zones publiques des
locataires qu'il héberge. Il ne porte aucune donnée à lui : le locataire écrit sa zone, le
site la sert.
## Le locataire écrit, le site sert
```
LOCATAIRE infra-dns-01 primaire caché de chezlepro.ca ─┐ NOTIFY
(autorite: primaire-cache) │ AXFR signé TSIG, tiré par le site
SITE site-dnspub-01 secondaire public ◄──────────────┘
```
- **Le primaire reste chez le locataire.** Quand il part, il emporte sa zone, et le site
n'a qu'à cesser d'être secondaire. Écrire les zones au site ferait de l'hébergeur la
source des données du client.
- **La liste des zones se dérive** de tous les locataires du site, dans
`scripts/site_inventaire.py`. Jamais une liste tenue à la main.
- **Uniquement `autorite: primaire-cache`.** Le rôle refuse une zone `.internal` : ce
serveur répond à Internet.
## Ce que l'épreuve de l'outil a appris (2026-09-16, PowerDNS 4.9.17)
Deux instances jetables, sept vérifications, avant d'écrire une ligne de ce rôle.
| Constat | Ce que le rôle en fait |
|---|---|
| **`allow-axfr-ips` et TSIG sont alternatifs, pas cumulatifs** : une adresse listée obtient la zone *sans signature* | le primaire n'autorise **aucune** adresse ; seule la clé ouvre le transfert |
| `allow-axfr-ips` autorise la boucle locale **par défaut** | un test négatif depuis la boucle locale n'en est pas un |
| le backend `bind` seul ne stocke **aucune** métadonnée | `bind-dnssec-db` porte les clés TSIG |
| la syntaxe exige `masters { ip:port; };` | sans accolades, **tout** le fichier est refusé |
| le primaire notifie **aussi les adresses de ses NS** | le primaire restreint ses notifications au seul serveur public du site |
## Durcissement
- **Secondaire seulement** : `secondary=yes`, `primary=no`.
- **Aucun transfert sortant** en phase 1 (`disable-axfr=yes`). La réplication vers le
site pair passera par le tunnel WireGuard, plus tard.
- **Notifications acceptées des seuls primaires déclarés.**
- **`version-string=anonymous`** : la version est la première chose qu'un balayage demande.
- **`security-poll-suffix=`** vide : PowerDNS interroge par défaut un domaine de l'éditeur
à chaque démarrage. Un serveur souverain n'appelle pas un tiers.
- **Pas de récursion** : PowerDNS Authoritative ne récurse pas — vérifié, il répond
`REFUSED`.
## Ce que la phase 1 n'expose pas
**Aucun flux depuis Internet.** Le port 53 public viendra en phase 2, par la redirection
de la frontière — et seulement après DNSSEC signé chez le locataire, CAA, et la limitation
de débit des réponses. Le déclarer aujourd'hui dans `meta/flux.yml` ferait émettre au
devis une règle sur le WAN que personne n'aurait décidée.

View file

@ -0,0 +1,39 @@
---
# LE SERVEUR DNS PUBLIC DU SITE — secondaire de toutes les zones publiques de ses locataires.
#
# Le locataire ECRIT sa zone (primaire cache, `autorite: primaire-cache`) ; le site la SERT.
# Ce role ne porte donc aucune donnee a lui : il recoit des repliques, signees TSIG.
serveur_dns_public_paquets:
- pdns-server
- pdns-backend-bind
- dnsutils
# LES RELATIONS AVEC LES LOCATAIRES — derivees, jamais ecrites.
#
# `scripts/site_inventaire.py` les calcule : il connait le plan du site ET le registre des
# locataires. Ce role tourne sur une machine qui n'a aucune raison de lire la carte de
# l'hebergeur. La derivation appartient a qui detient les deux sources.
#
# - ecosysteme: OPS-Chezlepro
# cle_tsig: tsig-chezlepro # le NOM de la cle, identique des deux cotes
# secret_var: vault_tsig_dns_public_chezlepro # la variable de voute qui porte le secret
# primaire: 10.17.19.11 # l'adresse DERIVEE de son autoritatif
# port: 5300
# zones: [chezlepro.ca]
serveur_dns_public_relations: []
serveur_dns_public_ecoute: "{{ ansible_host | default('0.0.0.0') }}"
serveur_dns_public_port: 53
serveur_dns_public_zones_dir: /var/lib/powerdns/zones-secondaires
serveur_dns_public_bind_config: /etc/powerdns/setops-dns-public-zones.conf
# LA BASE QUI PORTE LES CLES TSIG. Le backend `bind` seul ne sait stocker aucune metadonnee :
# sans elle, `AXFR-MASTER-TSIG` n'a nulle part ou vivre, et le transfert partirait NON
# SIGNE. Eprouve le 2026-09-16 sur PowerDNS 4.9.17 : `create-bind-db` est bien pris en
# charge par le paquet Debian, meme si `ldd` du module ne montre pas SQLite.
serveur_dns_public_dnssec_db: /var/lib/powerdns/dnssec.db
# SONDE : le nombre de zones que ce serveur doit servir. Mise en defaut par parametre (un
# seuil impossible), comme le veut le contrat des sondes.
serveur_dns_public_sonde_attendu: >-
{{ serveur_dns_public_relations | map(attribute='zones') | flatten | length }}

View file

@ -0,0 +1,6 @@
---
- name: Redemarrer le DNS public
ansible.builtin.systemd:
name: pdns
state: restarted
when: not ansible_check_mode

View file

@ -0,0 +1,9 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
raison: >-
Serveur DNS autoritatif public : il repond a qui le demande, et c'est son objet. Ce qui
s'authentifie ici, ce sont les SERVEURS entre eux (TSIG sur le transfert), jamais un
humain.

View file

@ -0,0 +1,7 @@
---
# Empreinte ressources du logiciel — dimensionnement VM derive (scripts/instancier.py).
# Un secondaire autoritatif sert des zones en memoire : peu de coeurs, peu de disque.
setops_empreinte:
coeurs: 1
memoire_mo: 512
disque_go: 2

View file

@ -0,0 +1,38 @@
---
# Flux reseau du serveur DNS public du site. Voir docs/flux-conception.md.
#
# PHASE 1 : AUCUN FLUX DEPUIS L'INTERNET. Le port 53 public viendra en phase 2, avec la
# redirection de la frontiere, APRES DNSSEC et la limitation de debit. Le declarer ici
# aujourd'hui ferait emettre au devis une regle sur le WAN — une exposition que personne
# n'aurait decidee.
flux:
# LES NOTIFICATIONS DES PRIMAIRES. Un locataire previent le site qu'une zone a change ;
# le site tire alors la nouvelle version. Sans ce flux, la replique attendrait le
# rafraichissement du SOA — une heure de retard sur chaque changement.
- sens: ingress
port: 53
protocole: udp
pair: voisins_site
chiffrement: clair
raison: >-
NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu
d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et
c'est TSIG qui le garde, sur le transfert.
- sens: ingress
port: 53
protocole: tcp
pair: voisins_site
chiffrement: clair
raison: "Repli TCP des notifications des primaires des locataires."
# LE TRANSFERT, TIRE PAR LE SITE. Le primaire d'un locataire n'accepte AUCUNE adresse :
# seule une requete signee par la cle de sa relation obtient la zone.
- sens: egress
port: 5300
protocole: tcp
pair: primaires_dns_locataires
chiffrement: clair
raison: >-
AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa
machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne
chiffre pas — et une zone publique n'a rien a cacher.

View file

@ -0,0 +1,8 @@
---
# Supervision derivee du role. Voir docs/supervision-conception.md.
sondes:
- nom: zones-publiques
ttl: 5400
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
service a l''air parfaitement vivant.'

View file

@ -0,0 +1,163 @@
---
# --- LES REFUS, AVANT DE TOUCHER A QUOI QUE CE SOIT -----------------------------------
# UNE ZONE INTERNE N'ARRIVE JAMAIS ICI. Ce serveur repond a Internet : une zone `.internal`
# servie ici serait la carte des machines d'un locataire offerte au monde. La derivation ne
# retient que `primaire-cache` ; ce refus garde le cas ou elle se tromperait.
- name: Refuser une zone interne sur le serveur public
ansible.builtin.assert:
that:
- >-
(serveur_dns_public_relations | map(attribute='zones') | flatten
| select('search', '\.internal\.?$') | list | length) == 0
fail_msg: >-
Une zone `.internal` est destinee au serveur DNS PUBLIC. Elle publierait la carte
des machines d'un locataire. Seules les zones `autorite: primaire-cache` y ont leur
place.
quiet: true
# UNE RELATION SANS SECRET N'EST PAS UNE RELATION NON SIGNEE : C'EST UN REFUS. Un
# secondaire qui tirerait sans cle ne recevrait rien (le primaire n'autorise aucune
# adresse) — ou pire, recevrait tout le jour ou quelqu'un ajouterait son adresse a
# `allow-axfr-ips`, et TSIG deviendrait decoratif sans le moindre message.
- name: Exiger un secret TSIG pour chaque relation
ansible.builtin.assert:
that:
- lookup('vars', item.secret_var, default='') | length > 0
fail_msg: >-
{{ item.ecosysteme }} : `{{ item.secret_var }}` est absent de la voute du site. Sans
lui, la zone de ce locataire ne se transfere pas — et aucun repli non signe n'est
prevu, par construction.
quiet: true
loop: "{{ serveur_dns_public_relations }}"
loop_control:
label: "{{ item.ecosysteme }}"
# --- L'INSTALLATION -------------------------------------------------------------------
- name: Installer PowerDNS Authoritative
ansible.builtin.apt:
name: "{{ serveur_dns_public_paquets }}"
state: present
update_cache: true
cache_valid_time: 3600
# Le secondaire ECRIT les zones qu'il recoit : le repertoire appartient a `pdns`.
- name: Créer le répertoire des zones secondaires
ansible.builtin.file:
path: "{{ serveur_dns_public_zones_dir }}"
state: directory
owner: pdns
group: pdns
mode: "0750"
- name: Déployer la configuration du serveur public
ansible.builtin.template:
src: setops-dns-public.conf.j2
dest: /etc/powerdns/pdns.d/setops-dns-public.conf
owner: root
group: pdns
mode: "0640"
notify: Redemarrer le DNS public
- name: Déployer l'index des zones secondaires
ansible.builtin.template:
src: bindbackend.conf.j2
dest: "{{ serveur_dns_public_bind_config }}"
owner: root
group: pdns
mode: "0640"
notify: Redemarrer le DNS public
- name: Créer la base des clés TSIG
ansible.builtin.command:
cmd: "pdnsutil create-bind-db {{ serveur_dns_public_dnssec_db }}"
creates: "{{ serveur_dns_public_dnssec_db }}"
when: not ansible_check_mode
- name: Confier la base des clés à pdns
ansible.builtin.file:
path: "{{ serveur_dns_public_dnssec_db }}"
owner: pdns
group: pdns
mode: "0640"
when: not ansible_check_mode
# --- LES CLES TSIG, UNE PAR RELATION --------------------------------------------------
#
# On COMPARE avant d'ecrire, et c'est ce qui rend la rotation possible : un secret change
# dans la voute doit remplacer l'ancien. Importer seulement quand le nom manque laisserait
# la vieille cle en place pour toujours, et le transfert echouerait sans rien dire.
- name: Relever les clés TSIG en place
ansible.builtin.command:
cmd: pdnsutil list-tsig-keys
register: serveur_dns_public_cles
changed_when: false
no_log: true
when: not ansible_check_mode
- name: Poser ou remplacer la clé TSIG de chaque relation
ansible.builtin.command:
argv:
- pdnsutil
- import-tsig-key
- "{{ item.cle_tsig }}"
- hmac-sha256
- "{{ lookup('vars', item.secret_var) }}"
loop: "{{ serveur_dns_public_relations }}"
loop_control:
label: "{{ item.ecosysteme }} → {{ item.cle_tsig }}"
when:
- not ansible_check_mode
- (item.cle_tsig ~ '. hmac-sha256. ' ~ lookup('vars', item.secret_var))
not in (serveur_dns_public_cles.stdout | default(''))
changed_when: true # n'arrive ici que si la cle manque ou differe
no_log: true
notify: Redemarrer le DNS public
- name: Relever les métadonnées de transfert
ansible.builtin.command:
argv: [pdnsutil, get-meta, "{{ item.1 }}", AXFR-MASTER-TSIG]
loop: "{{ serveur_dns_public_relations | subelements('zones') }}"
loop_control:
label: "{{ item.1 }}"
register: serveur_dns_public_meta
changed_when: false
failed_when: false
when: not ansible_check_mode
- name: Exiger la signature TSIG sur chaque transfert entrant
ansible.builtin.command:
argv: [pdnsutil, set-meta, "{{ item.item.1 }}", AXFR-MASTER-TSIG, "{{ item.item.0.cle_tsig }}"]
loop: "{{ serveur_dns_public_meta.results | default([]) }}"
loop_control:
label: "{{ item.item.1 }} ← {{ item.item.0.cle_tsig }}"
when:
- not ansible_check_mode
- ("= " ~ item.item.0.cle_tsig) not in (item.stdout | default(''))
changed_when: true # n'arrive ici que si la metadonnee manque ou differe
notify: Redemarrer le DNS public
- name: Activer et démarrer PowerDNS
ansible.builtin.systemd:
name: pdns
enabled: true
state: started
when: not ansible_check_mode
# --- SONDE (docs/supervision-conception.md) -------------------------------------------
- name: Assurer le répertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
- name: Déposer la sonde « zones-publiques »
ansible.builtin.template:
src: sonde-zones-publiques.sh.j2
dest: /usr/local/lib/setops/sondes/zones-publiques.sh
owner: root
group: root
mode: "0750"

View file

@ -0,0 +1,16 @@
{# GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
UNE DECLARATION PAR ZONE PUBLIQUE, CHACUNE AVEC SON PRIMAIRE.
La syntaxe exige des ACCOLADES autour des primaires : `masters { ip:port; };`. Sans
elles, PowerDNS refuse TOUT le fichier (« syntax error on line 1 ») et ne sert aucune
zone — mesure le 2026-09-16, pendant l'epreuve de l'outil. #}
{% for relation in serveur_dns_public_relations %}
{% for zone in relation.zones %}
zone "{{ zone }}" {
type slave;
masters { {{ relation.primaire }}:{{ relation.port }}; };
file "{{ serveur_dns_public_zones_dir }}/{{ zone }}.zone";
};
{% endfor %}
{% endfor %}

View file

@ -0,0 +1,34 @@
# GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
#
# LE SERVEUR DNS PUBLIC DU SITE — secondaire de toutes les zones publiques des locataires.
#
# `launch=bind` est deja declare par le paquet (pdns.d/bind.conf) : ne pas le redeclarer,
# PowerDNS refuserait « multiple backends 'bind' ». Ce fichier est lu APRES lui (ordre
# alphabetique), et son `bind-config` l'emporte.
bind-config={{ serveur_dns_public_bind_config }}
bind-dnssec-db={{ serveur_dns_public_dnssec_db }}
local-address={{ serveur_dns_public_ecoute }}
local-port={{ serveur_dns_public_port }}
# SECONDAIRE, ET RIEN D'AUTRE. Il recoit des repliques ; il n'est primaire de rien.
secondary=yes
primary=no
# IL NE REDONNE LES ZONES A PERSONNE. Le transfert sortant servira la replication vers le
# site pair, par le tunnel WireGuard — une phase ulterieure. D'ici la, fermé.
disable-axfr=yes
# LES NOTIFICATIONS NE VIENNENT QUE DES PRIMAIRES DECLARES. Un NOTIFY accepte de n'importe
# qui ferait tirer ce serveur vers le primaire a la demande de n'importe qui.
{% set _primaires = serveur_dns_public_relations | map(attribute='primaire') | unique | list %}
allow-notify-from={{ (_primaires | map('regex_replace', '$', '/32') | list | join(',')) if _primaires else '127.0.0.1/32' }}
# Ne rien dire de ce qui tourne ici. La version exacte est la premiere chose qu'un
# balayage demande.
version-string=anonymous
# AUCUN APPEL VERS LES SERVEURS DE L'EDITEUR. Par defaut, PowerDNS interroge un domaine de
# PowerDNS.COM pour savoir s'il a une mise a jour de securite : un serveur souverain qui
# appellerait un tiers a chaque demarrage. Les mises a jour viennent du cache du site.
security-poll-suffix=

View file

@ -0,0 +1,32 @@
#!/bin/bash
# GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
#
# SONDE « zones-publiques » — le serveur public sert-il TOUTES les zones qu'il doit servir ?
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `serveur_dns_public_sonde_attendu` (seuil impossible).
set -uo pipefail
ATTENDU={{ serveur_dns_public_sonde_attendu }}
ICI={{ serveur_dns_public_ecoute }}
systemctl is-active --quiet pdns || { echo "Le serveur DNS public (pdns) n'est pas actif."; exit 2; }
# ON INTERROGE CE QUE LE MONDE INTERROGERA : le SOA de chaque zone, sur l'adresse d'ecoute.
# Un secondaire qui n'a jamais recu sa zone repond REFUSED ou SERVFAIL — ce qui, vu d'un
# registraire, se lit « serveur de noms defaillant ».
servies=0
manquantes=()
{% for zone in serveur_dns_public_relations | map(attribute='zones') | flatten %}
if [[ -n "$(dig +short +time=3 +tries=1 @"${ICI}" -p {{ serveur_dns_public_port }} SOA {{ zone }} 2>/dev/null)" ]]; then
servies=$((servies + 1))
else
manquantes+=("{{ zone }}")
fi
{% endfor %}
if (( servies < ATTENDU )); then
echo "Le serveur public ne sert que ${servies} zone(s) sur ${ATTENDU} : ${manquantes[*]:-?} — ces domaines ne resolvent plus depuis Internet.|zones=${servies};${ATTENDU};;0;"
exit 2
fi
echo "Serveur public : ${servies} zone(s) servie(s) sur ${ATTENDU}.|zones=${servies};${ATTENDU};;0;"

View file

@ -84,3 +84,22 @@ serveur_powerdns_zones_inverses: >-
# --- Sonde de supervision -----------------------------------------------------------
# La zone souveraine et son inverse : deux au minimum.
serveur_powerdns_sonde_minimum: 2
# --- ZONES PUBLIQUES : `autorite: primaire-cache` (tasks/zones-publiques.yml) ---------
#
# Deux valeurs viennent DU SITE, par le contrat `site_intrants.py` : le serveur public qui
# tire les zones, et l'adresse publique vers laquelle elles pointent. Le locataire ne les
# 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 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`.
serveur_powerdns_tsig_cle: >-
tsig-{{ inventory_dir | realpath | dirname | dirname | basename | lower
| regex_replace('^ops-', '') }}
serveur_powerdns_public_port: 5300
serveur_powerdns_public_zones_dir: /etc/powerdns/zones-publiques
serveur_powerdns_public_bind_config: /etc/powerdns/setops-zones-publiques.conf
serveur_powerdns_public_dnssec_db: /var/lib/powerdns/public-dnssec.db
serveur_powerdns_zones_publiques: []

View file

@ -28,3 +28,37 @@
state: restarted
when: not ansible_check_mode # en dry-run le service n'existe pas encore
listen: Validate and reload PowerDNS
# L'INSTANCE PUBLIQUE : on valide chaque zone AVANT de redemarrer — une zone refusee est
# une zone que le secondaire du site cesserait de recevoir, sans erreur visible dehors.
- name: Valider les zones publiques
ansible.builtin.command:
cmd: >-
named-checkzone {{ zone_publique }}
{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.zone
changed_when: false
loop: "{{ serveur_powerdns_zones_publiques | default([]) }}"
loop_control:
loop_var: zone_publique
when: not ansible_check_mode
listen: Valider et redemarrer le DNS public du locataire
- name: Redemarrer l'instance publique
ansible.builtin.systemd:
name: pdns@public
state: restarted
when: not ansible_check_mode
listen: Valider et redemarrer le DNS public du locataire
# PREVENIR LE SITE TOUT DE SUITE : sans notification, le secondaire attendrait le
# rafraichissement du SOA — une heure de retard sur chaque changement.
- name: Notifier le serveur public du site
ansible.builtin.command:
argv: [pdns_control, --config-name=public, notify, "{{ zone_publique }}"]
changed_when: false
failed_when: false
loop: "{{ serveur_powerdns_zones_publiques | default([]) }}"
loop_control:
loop_var: zone_publique
when: not ansible_check_mode
listen: Valider et redemarrer le DNS public du locataire

View file

@ -41,3 +41,21 @@ flux:
pair: externe
chiffrement: clair
raison: "Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP)."
# LE SERVEUR PUBLIC DU SITE TIRE LES ZONES PUBLIQUES (2026-09-16).
#
# Sur l'instance `pdns@public` seulement, qui ne sert que les zones `primaire-cache`.
# UDP pour le SOA que le secondaire compare avant de tirer, TCP pour le transfert. La
# regle limite qui peut FRAPPER ; TSIG decide qui peut LIRE.
- sens: ingress
port: 5300
protocole: tcp
pair: dns_public_site
chiffrement: clair
raison: "AXFR signe TSIG par le serveur DNS public du site, vers l'instance publique uniquement."
- sens: ingress
port: 5300
protocole: udp
pair: dns_public_site
chiffrement: clair
raison: "Interrogation du SOA par le secondaire du site avant chaque transfert."

View file

@ -117,6 +117,12 @@
not in (serveur_powerdns_zones_inverses | default([]))
notify: Validate and reload PowerDNS
# LES ZONES PUBLIQUES, DANS LEUR PROPRE INSTANCE — seulement si le site a un serveur
# public pour les tirer. Sans lui, publier une zone n'aurait personne pour la servir.
- name: Publier les zones primaire-cache
ansible.builtin.include_tasks: zones-publiques.yml
when: serveur_powerdns_dns_public_site | length > 0
- name: Activer et démarrer PowerDNS
ansible.builtin.systemd:
name: "{{ serveur_powerdns_service_name }}"

View file

@ -0,0 +1,239 @@
---
# --- LES ZONES PUBLIQUES DU LOCATAIRE : `autorite: primaire-cache` ---------------------
#
# Le locataire ECRIT ses zones publiques ; le serveur DNS public de son site les SERT.
# Ce fichier fait exercer enfin un mode que le plan declarait depuis longtemps et que rien
# ne consommait — « une autorite qu'on s'attribue sans l'exercer est une panne differee ».
#
# UNE INSTANCE A PART, ET C'EST LE COEUR DE LA CONCEPTION (2026-09-16).
#
# Pour que le site tire la zone, l'autoritatif doit ecouter sur l'adresse de la machine.
# Or l'instance principale sert AUSSI la zone `.internal`. Lui faire ecouter l'adresse de
# l'hote aurait permis au serveur public du site d'INTERROGER la zone interne du locataire
# — de lire la carte de ses machines. C'est precisement ce que
# `docs/responsabilites-locataire-hebergeur.md` refuse a l'hebergeur.
#
# `pdns@public` ne sert que les zones publiques et est la seule a ecouter sur l'hote.
# L'instance interne reste sur la boucle locale, sans une ligne changee.
- name: Retenir les zones publiques du plan
ansible.builtin.set_fact:
serveur_powerdns_zones_publiques: >-
{{ (domaines_publics | default({})) | dict2items
| selectattr('value.autorite', 'defined')
| selectattr('value.autorite', 'equalto', 'primaire-cache')
| map(attribute='key') | sort | list }}
- name: Refuser une publication incohérente
ansible.builtin.assert:
that:
# UNE ZONE INTERNE N'EST JAMAIS PUBLIQUE, meme si quelqu'un la declare
# `primaire-cache` par erreur.
- >-
(serveur_powerdns_zones_publiques | select('search', '\.internal\.?$') | list
| length) == 0
# L'ADRESSE PUBLIQUE EST PUBLIQUE. Une adresse privee dans une zone servie a Internet
# ne resout pour personne dehors, et renseigne sur le reseau dedans.
- serveur_powerdns_ip_publique | length > 0
- not (serveur_powerdns_ip_publique is search('^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)'))
# TSIG N'EST PAS DECORATIF. PowerDNS donne la zone a toute adresse de
# `allow-axfr-ips` SANS signature — mesure le 2026-09-16. Le serveur public du site
# 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
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.
quiet: true
when: serveur_powerdns_zones_publiques | length > 0
- name: Créer le répertoire des zones publiques
ansible.builtin.file:
path: "{{ serveur_powerdns_public_zones_dir }}"
state: directory
owner: root
group: pdns
mode: "0750"
when: serveur_powerdns_zones_publiques | length > 0
# --- LE SERIAL SUIT LE CONTENU, PAS LE DEPLOIEMENT -----------------------------------
#
# LE PIEGE (2026-09-16). La zone interne porte un serial FIGE (`serveur_powerdns_serial`).
# Sans secondaire, c'est sans consequence. Avec un secondaire, c'est fatal en silence : il
# compare les serials, ne voit jamais de changement, et garde l'ancienne zone POUR
# TOUJOURS — pendant que le primaire sert la nouvelle.
#
# On rend donc d'abord le CORPS de la zone, sans serial. Si le corps change, le serial
# prend l'heure (secondes Unix : monotone, et tient dans 32 bits jusqu'en 2106). Sinon il
# garde sa valeur. Un deploiement sans changement ne notifie personne.
- name: Rendre le corps de chaque zone publique
ansible.builtin.template:
src: zone-publique.db.j2
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.corps"
owner: root
group: pdns
mode: "0640"
vars:
serveur_powerdns_serial_public: "SERIAL"
loop: "{{ serveur_powerdns_zones_publiques }}"
loop_control:
loop_var: zone_publique
register: serveur_powerdns_corps_publics
- name: Relever le serial en place de chaque zone publique
ansible.builtin.slurp:
src: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.serial"
loop: "{{ serveur_powerdns_zones_publiques }}"
loop_control:
loop_var: zone_publique
register: serveur_powerdns_serials_publics
failed_when: false
# PAS UN HANDLER, ET C'EST VOULU : le serial doit avancer AVANT que la zone soit rendue,
# dans la tache suivante. Un handler s'executerait a la fin du jeu — la zone serait deja
# ecrite avec l'ancien serial, et le secondaire ne verrait jamais le changement.
- name: Avancer le serial des zones dont le contenu a changé # noqa: no-handler
ansible.builtin.copy:
content: "{{ ansible_date_time.epoch }}\n"
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ item.zone_publique }}.serial"
owner: root
group: pdns
mode: "0640"
loop: "{{ serveur_powerdns_corps_publics.results | default([]) }}"
loop_control:
label: "{{ item.zone_publique }}"
when: item.changed | default(false)
register: serveur_powerdns_serials_avances
- name: Écrire chaque zone publique avec son serial
ansible.builtin.template:
src: zone-publique.db.j2
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.zone"
owner: root
group: pdns
mode: "0640"
vars:
_avance: >-
{{ (serveur_powerdns_serials_avances.results | default([])
| selectattr('item.zone_publique', 'equalto', zone_publique)
| selectattr('changed') | list | length) > 0 }}
_ancien: >-
{{ (serveur_powerdns_serials_publics.results | default([])
| selectattr('zone_publique', 'equalto', zone_publique)
| map(attribute='content') | first | default('') | b64decode | trim) }}
serveur_powerdns_serial_public: >-
{{ ansible_date_time.epoch if (_avance | bool) or not _ancien else _ancien }}
loop: "{{ serveur_powerdns_zones_publiques }}"
loop_control:
loop_var: zone_publique
notify: Valider et redemarrer le DNS public du locataire
# --- L'INSTANCE ---------------------------------------------------------------------
- name: Déployer la configuration de l'instance publique
ansible.builtin.template:
src: pdns-public.conf.j2
dest: /etc/powerdns/pdns-public.conf
owner: root
group: pdns
mode: "0640"
when: serveur_powerdns_zones_publiques | length > 0
notify: Valider et redemarrer le DNS public du locataire
- name: Déployer l'index des zones publiques
ansible.builtin.template:
src: bindbackend-public.conf.j2
dest: "{{ serveur_powerdns_public_bind_config }}"
owner: root
group: pdns
mode: "0640"
when: serveur_powerdns_zones_publiques | length > 0
notify: Valider et redemarrer le DNS public du locataire
- name: Créer la base des clés TSIG de l'instance publique
ansible.builtin.command:
cmd: >-
pdnsutil --config-name=public create-bind-db
{{ serveur_powerdns_public_dnssec_db }}
creates: "{{ serveur_powerdns_public_dnssec_db }}"
when:
- serveur_powerdns_zones_publiques | length > 0
- not ansible_check_mode
- name: Confier la base des clés à pdns
ansible.builtin.file:
path: "{{ serveur_powerdns_public_dnssec_db }}"
owner: pdns
group: pdns
mode: "0640"
when:
- serveur_powerdns_zones_publiques | length > 0
- not ansible_check_mode
# --- TSIG : SEULE LA CLE OUVRE LE TRANSFERT ------------------------------------------
- name: Relever les clés TSIG de l'instance publique
ansible.builtin.command:
cmd: pdnsutil --config-name=public list-tsig-keys
register: serveur_powerdns_cles_publiques
changed_when: false
no_log: true
when:
- serveur_powerdns_zones_publiques | length > 0
- not ansible_check_mode
# On COMPARE avant d'ecrire : un secret change dans la voute doit remplacer l'ancien.
- name: Poser ou remplacer la clé TSIG de la relation avec le site
ansible.builtin.command:
argv:
- pdnsutil
- --config-name=public
- import-tsig-key
- "{{ serveur_powerdns_tsig_cle }}"
- hmac-sha256
- "{{ vault_tsig_dns_public }}"
when:
- serveur_powerdns_zones_publiques | length > 0
- not ansible_check_mode
- vault_tsig_dns_public not in (serveur_powerdns_cles_publiques.stdout | default(''))
changed_when: true # n'arrive ici que si la cle manque ou differe
no_log: true
notify: Valider et redemarrer le DNS public du locataire
- name: Relever les autorisations de transfert
ansible.builtin.command:
argv: [pdnsutil, --config-name=public, get-meta, "{{ zone_publique }}", TSIG-ALLOW-AXFR]
loop: "{{ serveur_powerdns_zones_publiques }}"
loop_control:
loop_var: zone_publique
register: serveur_powerdns_meta_publiques
changed_when: false
failed_when: false
when: not ansible_check_mode
- name: N'autoriser le transfert qu'à la clé de la relation
ansible.builtin.command:
argv:
- pdnsutil
- --config-name=public
- set-meta
- "{{ item.zone_publique }}"
- TSIG-ALLOW-AXFR
- "{{ serveur_powerdns_tsig_cle }}"
loop: "{{ serveur_powerdns_meta_publiques.results | default([]) }}"
loop_control:
label: "{{ item.zone_publique }}"
when:
- not ansible_check_mode
- ("= " ~ serveur_powerdns_tsig_cle) not in (item.stdout | default(''))
changed_when: true # n'arrive ici que si la metadonnee manque ou differe
- name: Activer et démarrer l'instance publique
ansible.builtin.systemd:
name: pdns@public
enabled: true
state: started
when:
- serveur_powerdns_zones_publiques | length > 0
- not ansible_check_mode

View file

@ -0,0 +1,8 @@
{# GENERE par Set-OPS (role serveur_powerdns). Ne pas editer a la main.
Les zones publiques du locataire — et elles seules. #}
{% for zone_publique in serveur_powerdns_zones_publiques %}
zone "{{ zone_publique }}" {
type master;
file "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.zone";
};
{% endfor %}

View file

@ -0,0 +1,39 @@
# GENERE par Set-OPS (role serveur_powerdns). Ne pas editer a la main.
#
# L'INSTANCE PUBLIQUE DU LOCATAIRE — `pdns@public`, primaire cache de ses zones publiques.
#
# Elle ne sert QUE les zones `autorite: primaire-cache`. La zone `.internal` reste a
# l'instance principale, sur la boucle locale : le serveur public du site peut interroger
# celle-ci, jamais celle-la.
#
# Une instance nommee ne lit pas `pdns.d/` : tout ce dont elle a besoin est dit ici.
launch=bind
bind-config={{ serveur_powerdns_public_bind_config }}
bind-dnssec-db={{ serveur_powerdns_public_dnssec_db }}
# LA SEULE INSTANCE QUI ECOUTE L'ADRESSE DE L'HOTE. Le pare-feu de la machine ne laisse
# frapper que le serveur public du site (flux `dns_public_site`).
local-address={{ ansible_host }}
local-port={{ serveur_powerdns_public_port }}
primary=yes
secondary=no
# LE TRANSFERT S'OUVRE A LA CLE, PAS A L'ADRESSE (eprouve le 2026-09-16).
#
# `allow-axfr-ips` et TSIG sont ALTERNATIFS : une adresse listee obtient la zone sans
# signature. Seule la boucle locale y figure — le serveur public du site n'y est jamais,
# et ne recoit la zone que signee par `TSIG-ALLOW-AXFR`.
disable-axfr=no
allow-axfr-ips=127.0.0.1/32
# NOTIFIER LE SEUL SERVEUR PUBLIC DU SITE (decouvert le 2026-09-16, dans les journaux).
#
# Par defaut, un primaire notifie AUSSI les adresses de ses enregistrements NS. Ici, ce
# sont les adresses PUBLIQUES des serveurs de noms : le primaire enverrait des
# notifications vers Internet depuis l'interieur du locataire. `only-notify` les borne.
only-notify={{ serveur_powerdns_dns_public_site }}/32
also-notify={{ serveur_powerdns_dns_public_site }}
version-string=anonymous
security-poll-suffix=

View file

@ -0,0 +1,21 @@
$ORIGIN {{ zone_publique }}.
$TTL {{ serveur_powerdns_ttl }}
@ IN SOA ns1.{{ zone_publique }}. hostmaster.{{ zone_publique }}. (
{{ serveur_powerdns_serial_public }}
{{ serveur_powerdns_soa_refresh }}
{{ serveur_powerdns_soa_retry }}
{{ serveur_powerdns_soa_expire }}
{{ serveur_powerdns_soa_minimum }}
)
@ IN NS ns1.{{ zone_publique }}.
ns1 IN A {{ serveur_powerdns_ip_publique }}
{# UNE ZONE PUBLIQUE NE PORTE QUE DES ADRESSES PUBLIQUES.
Un nom expose sous ce domaine resout vers l'adresse publique du site — la frontiere le
relaie ensuite a l'edge. Jamais vers l'adresse interne de l'edge : elle ne resoudrait
pour personne dehors, et renseignerait sur le reseau dedans. #}
{% 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 %}

View file

@ -806,6 +806,37 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"description": "Runners des tenants heberges — cible de l'insemination",
}
# LES AUTORITATIFS DES LOCATAIRES — la source du DNS public du site (2026-09-16).
#
# Meme forme que les runners : des HOTES, pas des reseaux. Le serveur public tire la
# zone d'un locataire ; lui ouvrir le supernet entier ferait d'un transfert de zone
# un droit d'entree general. Et la regle ne suffit pas a lire : sans la cle TSIG de
# sa relation, le primaire refuse le transfert.
# SEULEMENT LES LOCATAIRES QUI PUBLIENT (resserre le 2026-09-16). La premiere
# version prenait l'autoritatif de TOUS les locataires du site : Patient0, qui n'a
# aucune zone publique, recevait une ouverture 5300 depuis le serveur public. Rien
# n'y ecoutait — mais un chemin ouvert vers une machine qui n'en a pas besoin est
# exactement ce que le moindre privilege refuse.
def _publie(_nom_tenant: str) -> bool:
try:
import devis_reseau as _dr
_f = _dr.DOSSIER_INSTANCES / _nom_tenant / "plan" / "domaines.yml"
_d = (yaml.safe_load(_f.read_text(encoding="utf-8")) or {}).get("domaines_publics") or {}
return any(isinstance(_c, dict) and str(_c.get("autorite")) == "primaire-cache"
for _c in _d.values())
except (OSError, ImportError):
return False
_primaires_dns = sorted({ip for _n2, _p2, _x2 in tenants if _publie(_n2)
for ip in (cibles_par_role("serveur_powerdns",
inventaire_de(_n2))
if inventaire_de(_n2) else [])})
if _primaires_dns:
alias["SETOPS_DNS_PRIMAIRES_TENANTS"] = {
"type": "host",
"contenu": _primaires_dns,
"description": "Autoritatifs des tenants heberges — source du DNS public",
}
_roles_site = sorted({g for gs in _services_site.values() for g in gs}
| {"serveur_debian"})
for _role in _roles_site:
@ -1045,6 +1076,18 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_dests = [_a]
elif "serveur_ops_tenant" in _pairs_fl:
_dests = ["SETOPS_RUNNERS_TENANTS"]
elif "primaires_dns_locataires" in _pairs_fl:
# AUCUN PRIMAIRE, AUCUNE REGLE — jamais le repli. Tomber dans le cas
# general rendrait `!SETOPS_INTERNES` : le serveur public du site
# pourrait alors ouvrir un transfert vers N'IMPORTE QUELLE machine
# de l'Internet. C'est « une source vide ouvre le port », cote
# destination, que P79 garde deja pour les roles nommes.
if "SETOPS_DNS_PRIMAIRES_TENANTS" not in alias:
print(f"note : {_role} declare une sortie vers les primaires DNS "
f"des locataires, et aucun locataire de ce site n'en a — "
f"aucune regle emise.", file=sys.stderr)
continue
_dests = ["SETOPS_DNS_PRIMAIRES_TENANTS"]
elif "fabric" in _pairs_fl:
_dests = ["SETOPS_FABRIC"]
else:

View file

@ -4397,6 +4397,150 @@ def preuve_console_dit_sa_portee() -> tuple[bool, str]:
f"un pouvoir.")
# --- LE DNS PUBLIC (P82) : chaque temoin prend ses donnees en parametre --------------
_PRIVEE = ("10.", "127.", "192.168.", "169.254.") + tuple(f"172.{i}." for i in range(16, 32))
def _dns_zones_declarees(plans: dict) -> tuple[dict, list[str]]:
"""{zone: ecosysteme} des zones `primaire-cache`, et les fautes de declaration."""
attendu, fautes = {}, []
for eco, doms in plans.items():
for zone, conf in (doms or {}).items():
if isinstance(conf, dict) and str(conf.get("autorite")) == "primaire-cache":
if zone.rstrip(".").endswith(".internal"):
fautes.append(f"{eco} : `{zone}` est declaree `primaire-cache` — une zone "
f"interne publiee serait la carte de ses machines offerte au monde")
attendu[zone] = eco
return attendu, fautes
def _dns_zones_servies(attendu: dict, servies: set, a_un_serveur: bool) -> list[str]:
fautes = []
if attendu and not a_un_serveur:
fautes.append(f"{len(attendu)} zone(s) `primaire-cache` declaree(s) et aucun serveur public "
f"au site : l'autorite est declaree, jamais exercee")
return fautes
for z in sorted(set(attendu) - servies):
fautes.append(f"`{z}` ({attendu[z]}) n'est servie par aucun serveur public du site")
for z in sorted(servies - set(attendu)):
fautes.append(f"`{z}` est servie par le serveur public sans etre declaree `primaire-cache`")
return fautes
def _dns_paires(declarations: dict) -> list[str]:
"""{site: [(pair, etat)]} -> fautes. Une relation vaut des deux cotes, ou pas du tout."""
fautes = []
for site, liens in declarations.items():
for pair, etat in liens:
retour = dict(declarations.get(pair, []))
if site not in retour:
fautes.append(f"{site} declare une replication avec {pair}, qui ne la declare pas — "
f"le second serveur de noms serait vide sans que personne le voie")
elif retour[site] != etat:
fautes.append(f"{site} -> {pair} est `{etat}`, {pair} -> {site} est `{retour[site]}`")
return fautes
def _dns_exposition(flux: list, dnssec_par_zone: dict) -> list[str]:
"""Exposer le serveur public a Internet exige DNSSEC sur TOUTES ses zones."""
expose = any(f.get("sens") == "ingress" and "externe" in
(f.get("pair") if isinstance(f.get("pair"), list) else [f.get("pair")])
for f in (flux or []))
if not expose:
return []
return [f"`{z}` : le serveur public est expose a Internet et la zone n'est pas signee — son "
f"hebergeur, ou le site pair, pourrait la falsifier sans que personne le detecte"
for z, signe in sorted(dnssec_par_zone.items()) if not signe]
def preuve_dns_public_tient() -> tuple[bool, str]:
"""Le DNS public du site sert exactement les zones que ses locataires publient.
POURQUOI (2026-09-16). Le locataire ecrit sa zone publique (`autorite: primaire-cache`),
le serveur public du site la sert, et un site pair la replique. Chaque maillon peut se
rompre EN SILENCE : une zone que personne ne sert se lit « serveur de noms defaillant »
chez le registraire, et une zone `.internal` servie publie la carte d'un locataire.
CE QU'ELLE REFUSE, chaque temoin lisant sa source directement :
1. une zone `.internal` declaree `primaire-cache` ;
2. une zone `primaire-cache` d'un locataire du site que le serveur public ne sert pas,
ou un serveur public qui sert une zone que personne ne publie — la liste servie
est lue dans l'inventaire du site, la liste attendue dans les plans des locataires ;
3. une adresse publique PRIVEE transmise a un locataire ;
4. une replication entre sites declaree d'un seul cote ;
5. la regle apprise en eprouvant PowerDNS : l'instance publique n'autorise que la boucle
locale au transfert (`allow-axfr-ips` et TSIG sont ALTERNATIFS — y lister le
secondaire rendrait la signature decorative) ;
6. LA CONDITION DE LA PHASE 2, ECRITE COMME UNE GARDE : exposer le serveur public a
Internet sans DNSSEC sur toutes ses zones. Sans signature, l'hebergeur ou le site
pair pourraient falsifier la zone d'un client sans que personne le detecte.
"""
lien = RACINE / "underlay.yml"
if not lien.exists():
return True, "Aucun site monte : aucun DNS public a tenir."
sys.path.insert(0, str(RACINE / "scripts"))
import devis_reseau
fautes: list[str] = []
plans, dnssec, ips = {}, {}, {}
for nom, _p, _n in devis_reseau.decouvrir_du_site():
base = devis_reseau.DOSSIER_INSTANCES / nom
doms = _yaml_ou_vide(base / "plan" / "domaines.yml").get("domaines_publics") or {}
plans[nom] = doms
for z, c in doms.items():
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache":
dnssec[z] = bool(c.get("dnssec"))
for inv in ("principal", "production"):
intr = _yaml_ou_vide(base / "inventories" / inv / "group_vars" / "all" / "10-intrants.yml")
if intr.get("ip_publique_site"):
ips[nom] = str(intr["ip_publique_site"])
attendu, f1 = _dns_zones_declarees(plans)
fautes += f1
inventaire = _inventaire_du_site()
if inventaire is None:
fautes.append("l'inventaire du site ne s'execute pas : impossible de savoir ce qui est servi")
else:
enfants, hv = inventaire["enfants"], inventaire["hv"]
serveurs = set(((enfants.get("serveur_dns_public") or {}).get("hosts")) or {})
servies = {z for h in serveurs
for r in ((hv.get(h) or {}).get("serveur_dns_public_relations") or [])
for z in (r.get("zones") or [])}
fautes += _dns_zones_servies(attendu, servies, bool(serveurs))
for eco, ip in sorted(ips.items()):
if ip.startswith(_PRIVEE):
fautes.append(f"{eco} : `ip_publique_site` vaut {ip}, une adresse privee")
declarations = {}
for d in sorted(RACINE.parent.glob("SITE-*")):
liens = _yaml_ou_vide(d / "plan" / "10-intrants.yml").get("dns_public_pairs") or []
if liens:
declarations[d.name] = [(str(x.get("site")), str(x.get("etat", "actif")))
for x in liens if isinstance(x, dict)]
fautes += _dns_paires(declarations)
gabarit = RACINE / "roles" / "serveur_powerdns" / "templates" / "pdns-public.conf.j2"
lignes = [l.strip() for l in gabarit.read_text(encoding="utf-8").splitlines()
if l.strip().startswith("allow-axfr-ips")] if gabarit.is_file() else []
if lignes != ["allow-axfr-ips=127.0.0.1/32"]:
fautes.append(f"l'instance publique autorise au transfert autre chose que la boucle locale "
f"({lignes or 'rien de declare : le defaut de PowerDNS s applique'}) — "
f"TSIG deviendrait decoratif")
flux_public = _yaml_ou_vide(RACINE / "roles" / "serveur_dns_public" / "meta" / "flux.yml").get("flux") or []
fautes += _dns_exposition(flux_public, dnssec)
if fautes:
return False, "DNS public :\n - " + "\n - ".join(fautes)
return True, (f"{len(attendu)} zone(s) publique(s) declaree(s), toutes servies par le site ; "
f"{sum(len(v) for v in declarations.values())} replication(s) declaree(s) des deux "
f"cotes ; transfert ouvert a la cle seule ; aucune exposition sans DNSSEC.")
PREUVES: list[dict] = [
{"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"],
"cmds": [["ansible-lint", "-q"]]},
@ -4579,6 +4723,8 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_remise_tenue},
{"id": "P81", "titre": "La console dit sa portee, et ne sert pas un inventaire vide en silence",
"refs": [], "func": preuve_console_dit_sa_portee},
{"id": "P82", "titre": "DNS public : les zones publiees sont servies, signees avant d'etre exposees",
"refs": [], "func": preuve_dns_public_tient},
]

View file

@ -64,8 +64,21 @@ CHIFFREMENT = {"tls-requis", "tls", "starttls", "ssh", "tls-cible", "clair", "n-
# Ce n'est pas non plus `flotte` (les machines d'un ecosysteme) ni `voisins_site` (les
# tenants d'a cote) : c'est le SOCLE sur lequel les uns et les autres reposent. Le seul
# a s'y adresser est le runner du site, et c'est tout son objet — materialiser.
# `dns_public_site` et `primaires_dns_locataires` — LA REPLICATION DU DNS PUBLIC (2026-09-16).
#
# Le locataire ecrit sa zone publique ; le serveur DNS public du SITE la tire, signee TSIG.
# Les deux mots sont les deux bouts du meme flux, calques sur `runner_site` :
#
# `dns_public_site` la SOURCE vue du locataire : le serveur public de son site,
# derive du plan du site — jamais une adresse recopiee ;
# `primaires_dns_locataires` la DESTINATION vue du site : les autoritatifs des locataires.
#
# POURQUOI PAS LE NOM DU ROLE. `serveur_powerdns` existe AUSSI au site (`site-dns-01`) : une
# sortie declaree « vers serveur_powerdns » aurait vise le PowerDNS du site lui-meme, et
# jamais ceux des locataires. Le mot ne peut designer que les bonnes machines.
MOTS_PAIR = {"edge", "flotte", "externe", "localhost", "expositions", "derive", "admin",
"voisins_site", "fabric", "frontiere", "runner_site"}
"voisins_site", "fabric", "frontiere", "runner_site",
"dns_public_site", "primaires_dns_locataires"}
GROUPE_EDGE = "serveur_nginx"
@ -277,7 +290,8 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
for p in (pair if isinstance(pair, list) else [pair]):
if p in ("localhost", "externe", "expositions", "derive"):
continue # lo (auto), frontiere OPNsense, ou hors perimetre noeud
if p in ("admin", "voisins_site", "runner_site"):
if p in ("admin", "voisins_site", "runner_site", "dns_public_site",
"primaires_dns_locataires"):
# Aucun des trois n'est un groupe d'hotes DE CET ECOSYSTEME : ce sont des
# sources qui lui sont EXTERIEURES. `admin` vient de l'intrant
# `nftables_admin_ssh` ; `voisins_site` des supernets des autres tenants
@ -350,6 +364,8 @@ def _resoudre_sources(data: dict, pair, actifs_noms: set[str], ip_par_hote: dict
srcs |= set(_supernets_voisins())
if "runner_site" in demandes:
srcs |= set(_adresses_runner_site())
if "dns_public_site" in demandes:
srcs |= set(_adresses_dns_public_site())
return sorted(srcs)
@ -372,6 +388,24 @@ def _adresses_runner_site() -> list[str]:
return []
def _adresses_dns_public_site() -> list[str]:
"""L'adresse du serveur DNS public du SITE — la seule source autorisee a tirer une zone.
Meme raison que `_adresses_runner_site` : derivee du plan du site, qui est le seul a
savoir ou vit ce serveur. Une adresse recopiee survivrait a un deplacement et laisserait
le primaire du locataire avec une regle qui n'autorise personne.
CE N'EST PAS LA GARDE DU TRANSFERT. PowerDNS ne donne la zone qu'a une requete signee
TSIG : cette regle limite qui peut FRAPPER, la cle decide qui peut LIRE. Rend [] sans
depot d'hebergeur monte.
"""
try:
import underlay as underlay_mod
return underlay_mod.adresses_site_portant("serveur_dns_public")
except Exception:
return []
def _supernets_voisins() -> list[str]:
"""Les supernets des AUTRES tenants que CE SITE heberge.

View file

@ -66,6 +66,19 @@ def _nom_expose(groupe: str, domaine: str) -> str:
return f"{hote}.{domaine}" if hote and domaine else ""
def _ip_publique_site() -> str:
"""L'adresse publique de la frontiere du site, lue dans sa carte (`opnsense.yml`)."""
try:
chemin = underlay_mod.chemin()
f = chemin.resolve().parent / "opnsense.yml" if chemin else None
if f and f.is_file():
return str((yaml.safe_load(f.read_text(encoding="utf-8")) or {})
.get("opnsense_wan_ip") or "")
except Exception:
pass
return ""
def contrat() -> dict:
u = underlay_mod.charger()
if not u:
@ -94,6 +107,12 @@ def contrat() -> dict:
/ "ac-racine-site.crt"),
# OU DEPOSER SON ETAT — par NOM : le depot sert plusieurs locataires et peut bouger
"client_backup_cible": _nom_expose("serveur_backup_site", domaine),
# QUI TIRE SES ZONES PUBLIQUES — le seul serveur a qui le primaire notifie, et le
# seul que son pare-feu laisse frapper. Une ADRESSE : c'est un pair de flux.
"dns_public_site": _une("serveur_dns_public"),
# 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(),
# QUI A LE DROIT D'ENTRER EN SSH
"nftables_admin_ssh": [gestion] if gestion else [],
# PAR OU SORTIR, ET A QUI DEMANDER L'HEURE
@ -109,6 +128,8 @@ OU_LE_LOCATAIRE_LE_DIT = {
"setops_depot_binaires": ("all/10-intrants.yml", "setops_depot_binaires"),
"serveur_ops_forge_amont": ("serveur_ops.yml", "serveur_ops_forge_amont"),
"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"),
# 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

View file

@ -228,6 +228,64 @@ def inventaire() -> dict:
_locataires_backup = _locataires_sauvegarde()
# LES RELATIONS DU DNS PUBLIC DU SITE (2026-09-16).
#
# Pour chaque locataire du site qui declare une zone `autorite: primaire-cache` : son
# autoritatif, et les zones que le serveur public du site doit en tirer. Meme decouverte
# que les sauvegardes — le site sert les locataires qu'il heberge, et cesse de les servir
# quand ils partent.
#
# L'ADRESSE DU PRIMAIRE SE DERIVE DE LA NOMENCLATURE DU LOCATAIRE, jamais d'une liste.
# Une table d'adresses ecrite a la main a deja fait publier huit adresses fausses le meme
# jour, dans un document remis a un client.
#
# SEULES LES ZONES `primaire-cache`. Les zones `.internal` vivent aussi dans
# `domaines_publics` (`autorite: auto-heberge`) : trier sur le nom du registre les
# aurait publiees au monde.
def _relations_dns_public() -> list:
out = []
try:
import devis_reseau
from inventory_rules import deriver_nomenclature
racine_instances = devis_reseau.DOSSIER_INSTANCES
decouverts = devis_reseau.decouvrir_du_site()
except Exception:
return out
for nom_depot, _prefixe, _n in decouverts:
plan = racine_instances / nom_depot / "plan"
try:
doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8"))
or {}).get("domaines_publics") or {}
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
or {}).get("applications") or {}
srvs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8"))
or {}).get("serveurs") or {}
nomencl = yaml.safe_load((plan / "nomenclature.yml").read_text(encoding="utf-8")) or {}
except OSError:
continue
zones = sorted(z for z, c in doms.items()
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache")
if not zones:
continue
hote = next((str(a.get("hote")) for a in apps.values()
if str(a.get("groupe")) == "serveur_powerdns"), "")
s = srvs.get(hote) or {}
d = deriver_nomenclature(s.get("fonction"), s.get("sequence", 1), nomencl) if s else None
if not d:
continue # un primaire sans adresse derivable : pas de relation, pas de trou
colocalise = any(str(a.get("groupe")) == "serveur_resolveur"
and str(a.get("hote")) == hote for a in apps.values())
slug = nom_depot.lower().removeprefix("ops-")
out.append({"ecosysteme": nom_depot,
"cle_tsig": f"tsig-{slug}",
"secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}",
"primaire": d["adresse_ip"],
"port": 5300 if colocalise else 53,
"zones": zones})
return out
_relations_dns = _relations_dns_public()
# OU LE SITE DEPOSE SON PROPRE ETAT.
#
# Le site a de l'etat a lui, et pas des moindres : la racine de son autorite de
@ -610,6 +668,10 @@ def inventaire() -> dict:
if U.chemin() else ""),
# Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ?
"serveur_resolveur_site_supernets_tenants": _supernets_tenants,
# Les zones que le serveur DNS public du site doit tirer, et de qui. Vide
# partout ailleurs : aucune autre machine du site ne sert Internet.
"serveur_dns_public_relations": (
_relations_dns if "serveur_dns_public" in _groupes_de(nom) else []),
# `MaxStartups` COMPTE LES CONNEXIONS NON AUTHENTIFIEES, et un depot de SITE
# en voit la somme de tous ses locataires — dont les minuteurs se reveillent
# a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere : le client