From 788c5073dc1fe562fbd5bb795ed6016e2015cd0b Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 16 Sep 2026 18:08:20 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 +- CHANGELOG.md | 56 ++++ docs/audit/preuve-2026-09-16.md | 41 +-- docs/authentification.md | 2 +- docs/carte-set-ops.md | 5 +- docs/catalogue-services.md | 3 +- docs/couches-deploiement.yml | 3 + docs/devis-services.md | 2 +- docs/dns-interne.md | 32 +++ docs/registre-flux.md | 7 +- docs/responsabilites-locataire-hebergeur.md | 2 +- playbooks/groupes/serveur_dns_public.yml | 19 ++ roles/serveur_dns_public/README.md | 54 ++++ roles/serveur_dns_public/defaults/main.yml | 39 +++ roles/serveur_dns_public/handlers/main.yml | 6 + .../meta/authentification.yml | 9 + roles/serveur_dns_public/meta/empreinte.yml | 7 + roles/serveur_dns_public/meta/flux.yml | 38 +++ roles/serveur_dns_public/meta/supervision.yml | 8 + roles/serveur_dns_public/tasks/main.yml | 163 ++++++++++++ .../templates/bindbackend.conf.j2 | 16 ++ .../templates/setops-dns-public.conf.j2 | 34 +++ .../templates/sonde-zones-publiques.sh.j2 | 32 +++ roles/serveur_powerdns/defaults/main.yml | 19 ++ roles/serveur_powerdns/handlers/main.yml | 34 +++ roles/serveur_powerdns/meta/flux.yml | 18 ++ roles/serveur_powerdns/tasks/main.yml | 6 + .../tasks/zones-publiques.yml | 239 ++++++++++++++++++ .../templates/bindbackend-public.conf.j2 | 8 + .../templates/pdns-public.conf.j2 | 39 +++ .../templates/zone-publique.db.j2 | 21 ++ scripts/devis_opnsense.py | 43 ++++ scripts/prouver.py | 146 +++++++++++ scripts/resoudre_flux.py | 38 ++- scripts/site_intrants.py | 21 ++ scripts/site_inventaire.py | 62 +++++ 36 files changed, 1244 insertions(+), 30 deletions(-) create mode 100644 playbooks/groupes/serveur_dns_public.yml create mode 100644 roles/serveur_dns_public/README.md create mode 100644 roles/serveur_dns_public/defaults/main.yml create mode 100644 roles/serveur_dns_public/handlers/main.yml create mode 100644 roles/serveur_dns_public/meta/authentification.yml create mode 100644 roles/serveur_dns_public/meta/empreinte.yml create mode 100644 roles/serveur_dns_public/meta/flux.yml create mode 100644 roles/serveur_dns_public/meta/supervision.yml create mode 100644 roles/serveur_dns_public/tasks/main.yml create mode 100644 roles/serveur_dns_public/templates/bindbackend.conf.j2 create mode 100644 roles/serveur_dns_public/templates/setops-dns-public.conf.j2 create mode 100644 roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 create mode 100644 roles/serveur_powerdns/tasks/zones-publiques.yml create mode 100644 roles/serveur_powerdns/templates/bindbackend-public.conf.j2 create mode 100644 roles/serveur_powerdns/templates/pdns-public.conf.j2 create mode 100644 roles/serveur_powerdns/templates/zone-publique.db.j2 diff --git a/AGENTS.md b/AGENTS.md index 7a7d6d9..e8b0e3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index f61bbb5..661b79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/audit/preuve-2026-09-16.md b/docs/audit/preuve-2026-09-16.md index b59e8f6..c25cebf 100644 --- a/docs/audit/preuve-2026-09-16.md +++ b/docs/audit/preuve-2026-09-16.md @@ -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 diff --git a/docs/authentification.md b/docs/authentification.md index 851f4af..66bc17b 100644 --- a/docs/authentification.md +++ b/docs/authentification.md @@ -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 diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 33e7e6b..8dcfab8 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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é | `_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 | diff --git a/docs/catalogue-services.md b/docs/catalogue-services.md index 0d297d2..1aef697 100644 --- a/docs/catalogue-services.md +++ b/docs/catalogue-services.md @@ -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 diff --git a/docs/couches-deploiement.yml b/docs/couches-deploiement.yml index 26ac1b0..f6bef52 100644 --- a/docs/couches-deploiement.yml +++ b/docs/couches-deploiement.yml @@ -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: diff --git a/docs/devis-services.md b/docs/devis-services.md index d9903a4..c59d674 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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. diff --git a/docs/dns-interne.md b/docs/dns-interne.md index 50d17be..7a9717a 100644 --- a/docs/dns-interne.md +++ b/docs/dns-interne.md @@ -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. diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 55ddd6f..635df93 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 diff --git a/docs/responsabilites-locataire-hebergeur.md b/docs/responsabilites-locataire-hebergeur.md index bb45c8d..2997207 100644 --- a/docs/responsabilites-locataire-hebergeur.md +++ b/docs/responsabilites-locataire-hebergeur.md @@ -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ù diff --git a/playbooks/groupes/serveur_dns_public.yml b/playbooks/groupes/serveur_dns_public.yml new file mode 100644 index 0000000..133a358 --- /dev/null +++ b/playbooks/groupes/serveur_dns_public.yml @@ -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 diff --git a/roles/serveur_dns_public/README.md b/roles/serveur_dns_public/README.md new file mode 100644 index 0000000..a64cac6 --- /dev/null +++ b/roles/serveur_dns_public/README.md @@ -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. diff --git a/roles/serveur_dns_public/defaults/main.yml b/roles/serveur_dns_public/defaults/main.yml new file mode 100644 index 0000000..d3e5eb5 --- /dev/null +++ b/roles/serveur_dns_public/defaults/main.yml @@ -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 }} diff --git a/roles/serveur_dns_public/handlers/main.yml b/roles/serveur_dns_public/handlers/main.yml new file mode 100644 index 0000000..7cf29e1 --- /dev/null +++ b/roles/serveur_dns_public/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Redemarrer le DNS public + ansible.builtin.systemd: + name: pdns + state: restarted + when: not ansible_check_mode diff --git a/roles/serveur_dns_public/meta/authentification.yml b/roles/serveur_dns_public/meta/authentification.yml new file mode 100644 index 0000000..6ef2ebb --- /dev/null +++ b/roles/serveur_dns_public/meta/authentification.yml @@ -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. diff --git a/roles/serveur_dns_public/meta/empreinte.yml b/roles/serveur_dns_public/meta/empreinte.yml new file mode 100644 index 0000000..6f8d7e2 --- /dev/null +++ b/roles/serveur_dns_public/meta/empreinte.yml @@ -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 diff --git a/roles/serveur_dns_public/meta/flux.yml b/roles/serveur_dns_public/meta/flux.yml new file mode 100644 index 0000000..eff18d8 --- /dev/null +++ b/roles/serveur_dns_public/meta/flux.yml @@ -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. diff --git a/roles/serveur_dns_public/meta/supervision.yml b/roles/serveur_dns_public/meta/supervision.yml new file mode 100644 index 0000000..e72b6d0 --- /dev/null +++ b/roles/serveur_dns_public/meta/supervision.yml @@ -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.' diff --git a/roles/serveur_dns_public/tasks/main.yml b/roles/serveur_dns_public/tasks/main.yml new file mode 100644 index 0000000..85b6528 --- /dev/null +++ b/roles/serveur_dns_public/tasks/main.yml @@ -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" diff --git a/roles/serveur_dns_public/templates/bindbackend.conf.j2 b/roles/serveur_dns_public/templates/bindbackend.conf.j2 new file mode 100644 index 0000000..eaa6df9 --- /dev/null +++ b/roles/serveur_dns_public/templates/bindbackend.conf.j2 @@ -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 %} diff --git a/roles/serveur_dns_public/templates/setops-dns-public.conf.j2 b/roles/serveur_dns_public/templates/setops-dns-public.conf.j2 new file mode 100644 index 0000000..d29d8d0 --- /dev/null +++ b/roles/serveur_dns_public/templates/setops-dns-public.conf.j2 @@ -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= diff --git a/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 b/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 new file mode 100644 index 0000000..3370a6e --- /dev/null +++ b/roles/serveur_dns_public/templates/sonde-zones-publiques.sh.j2 @@ -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;" diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 8f8506b..1d3b14c 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -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-`). `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: [] diff --git a/roles/serveur_powerdns/handlers/main.yml b/roles/serveur_powerdns/handlers/main.yml index aa62f42..52eab17 100644 --- a/roles/serveur_powerdns/handlers/main.yml +++ b/roles/serveur_powerdns/handlers/main.yml @@ -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 diff --git a/roles/serveur_powerdns/meta/flux.yml b/roles/serveur_powerdns/meta/flux.yml index 4860739..5625f93 100644 --- a/roles/serveur_powerdns/meta/flux.yml +++ b/roles/serveur_powerdns/meta/flux.yml @@ -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." diff --git a/roles/serveur_powerdns/tasks/main.yml b/roles/serveur_powerdns/tasks/main.yml index 37ffa8f..b5df2bc 100644 --- a/roles/serveur_powerdns/tasks/main.yml +++ b/roles/serveur_powerdns/tasks/main.yml @@ -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 }}" diff --git a/roles/serveur_powerdns/tasks/zones-publiques.yml b/roles/serveur_powerdns/tasks/zones-publiques.yml new file mode 100644 index 0000000..353f8ef --- /dev/null +++ b/roles/serveur_powerdns/tasks/zones-publiques.yml @@ -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 diff --git a/roles/serveur_powerdns/templates/bindbackend-public.conf.j2 b/roles/serveur_powerdns/templates/bindbackend-public.conf.j2 new file mode 100644 index 0000000..fd0ac03 --- /dev/null +++ b/roles/serveur_powerdns/templates/bindbackend-public.conf.j2 @@ -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 %} diff --git a/roles/serveur_powerdns/templates/pdns-public.conf.j2 b/roles/serveur_powerdns/templates/pdns-public.conf.j2 new file mode 100644 index 0000000..a07c35d --- /dev/null +++ b/roles/serveur_powerdns/templates/pdns-public.conf.j2 @@ -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= diff --git a/roles/serveur_powerdns/templates/zone-publique.db.j2 b/roles/serveur_powerdns/templates/zone-publique.db.j2 new file mode 100644 index 0000000..44330e0 --- /dev/null +++ b/roles/serveur_powerdns/templates/zone-publique.db.j2 @@ -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 %} diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 31a24de..b702441 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -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: diff --git a/scripts/prouver.py b/scripts/prouver.py index a947460..1d35801 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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}, ] diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 8d559b2..3f904e5 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -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. diff --git a/scripts/site_intrants.py b/scripts/site_intrants.py index c2e2321..8d6cd5b 100644 --- a/scripts/site_intrants.py +++ b/scripts/site_intrants.py @@ -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 diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 9ab5542..5fe6c6a 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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