DNS public, phase 1 : le locataire ecrit, le site sert
Role serveur_dns_public (secondaire public, transfert signe TSIG, aucune zone interne) ; serveur_powerdns exerce enfin autorite primaire-cache dans une instance pdns@public a part, pour que le site ne puisse jamais interroger la zone .internal. Relations derivees des plans des locataires, mots de flux dns_public_site et primaires_dns_locataires. Eprouve avant d ecrire : allow-axfr-ips et TSIG sont alternatifs, le primaire notifie aussi ses NS, le serial fige aurait gele le secondaire. P82 refuse l exposition sans DNSSEC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8456d298bf
commit
788c5073dc
36 changed files with 1244 additions and 30 deletions
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
56
CHANGELOG.md
56
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
|
||||
|
|
|
|||
|
|
@ -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 | [0m[0m |
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ README de rôles). Cette page comble ces deux trous.
|
|||
|
||||
| Ce qu'on compte | Combien | Comment on le mesure |
|
||||
|---|---|---|
|
||||
| rôles | 68 | `roles/*/` |
|
||||
| README de rôles | 68 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| rôles | 69 | `roles/*/` |
|
||||
| README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| documents | 44 | `docs/*.md` |
|
||||
| pièces d'audit | 47 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.md` |
|
||||
|
|
@ -75,6 +75,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis
|
|||
| Exploitation de l'hébergeur | ses **opérations** n'appartiennent à aucun tenant et restent **hors overlay** | **à moitié construit** : les *VM* du site ont leur inventaire (`scripts/site_inventaire.py`), leur socle, leur durcissement, leurs sauvegardes et leur supervision (`site-mon-01`, 2026-09-02). Les *équipements* — hyperviseurs, commutateurs, frontière — n'ont toujours ni inventaire, ni sauvegarde de configuration, ni supervision | `hebergeur-exploitation.md` |
|
||||
| Authentification | web → Keycloak ; LDAP source unique ; secours par `sudo`, formulaire local non annoncé | `<rôle>_connexion_locale: false` (grafana, forgejo, nextcloud) ; garde de version Forgejo ≥ 10 | `authentification.md` |
|
||||
| Accès & habilitations | Set-OPS **amorce** un accès sysadmin puis se retire ; les appartenances aux groupes ne sont **jamais réconciliées** — c'est une personne qui gouverne | **construit et éprouvé** (2026-08-08) : rôle `amorcage_acces` (idempotence par existence, D-67), groupes projetés en rôles par `serveur_keycloak`, `meta/acces.yml` dans les 5 rôles web ; chaîne LDAP → Keycloak → groupe → service exercée de bout en bout sur Icinga Web 2 | `autorisation.md` (§6 = runbook de reprise) |
|
||||
| **DNS public** | le locataire **écrit** ses zones `autorite: primaire-cache`, le site les **sert** en secondaire, un site pair les réplique. Transfert ouvert **à la clé TSIG seule** (`allow-axfr-ips` et TSIG sont alternatifs dans PowerDNS) ; instance `pdns@public` à part pour que le site ne puisse jamais interroger la zone `.internal` | `roles/serveur_dns_public/`, `roles/serveur_powerdns/tasks/zones-publiques.yml` ; relations dérivées dans `scripts/site_inventaire.py` ; mots de flux `dns_public_site` / `primaires_dns_locataires` ; garde **P82** | `dns-interne.md` §Zones publiques |
|
||||
| **Remise au client** | remettre un écosystème se fait en **deux temps** : l'*identité* le jour de la livraison (sa clé de voûte, sa voûte, sa racine d'AC), la *machine* à l'échéance (sa clé SSH entre, la nôtre sort, la voûte est re-clétée). Le registre déclare enfin le **responsable désigné** (D-18) | `scripts/remise.py` (`make remise-paquet`, `remise-inscrire`, `remise-recleer`) ; registre `remise.yml` chez le locataire ; garde **P80** | `remise-au-client.md` |
|
||||
| SDN EVPN | ajouter un tenant implique **1 zone + 6 VNets + 6 sous-réseaux**, tous dérivés du seed | `scripts/devis_sdn.py` (`make devis-sdn`) ; nommage dérivé du seed (`t17`, `t17serv`), ≤ 8 caractères ; garde **P30** | `sdn-evpn.md` §2 |
|
||||
| Pools Proxmox | un pool par tenant : les noms courts de VM sont **volontairement identiques** d'un tenant à l'autre (même fonction, même nom), et seule la console Proxmox en souffrait | `scripts/devis_proxmox_pools.py` (`make devis-proxmox-pools`) ; nom dérivé de l'`index` ; garde de collision = preuve **P28** | `decisions-architecture.md` D-37 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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ù
|
||||
|
|
|
|||
19
playbooks/groupes/serveur_dns_public.yml
Normal file
19
playbooks/groupes/serveur_dns_public.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
- name: Appliquer le groupe serveur_dns_public
|
||||
hosts: serveur_dns_public
|
||||
become: true
|
||||
module_defaults:
|
||||
ansible.builtin.apt:
|
||||
lock_timeout: 300
|
||||
|
||||
gather_facts: true
|
||||
|
||||
pre_tasks:
|
||||
- name: Vérifier que la cible est Debian
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts.distribution == "Debian"
|
||||
fail_msg: "Ce playbook est prévu pour Debian."
|
||||
|
||||
roles:
|
||||
- serveur_dns_public
|
||||
54
roles/serveur_dns_public/README.md
Normal file
54
roles/serveur_dns_public/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# serveur_dns_public
|
||||
|
||||
> **Pour qui :** l'hébergeur qui publie les noms de ses locataires sur Internet.
|
||||
|
||||
**Le serveur DNS public du site.** Il est **secondaire** de toutes les zones publiques des
|
||||
locataires qu'il héberge. Il ne porte aucune donnée à lui : le locataire écrit sa zone, le
|
||||
site la sert.
|
||||
|
||||
## Le locataire écrit, le site sert
|
||||
|
||||
```
|
||||
LOCATAIRE infra-dns-01 primaire caché de chezlepro.ca ─┐ NOTIFY
|
||||
(autorite: primaire-cache) │ AXFR signé TSIG, tiré par le site
|
||||
SITE site-dnspub-01 secondaire public ◄──────────────┘
|
||||
```
|
||||
|
||||
- **Le primaire reste chez le locataire.** Quand il part, il emporte sa zone, et le site
|
||||
n'a qu'à cesser d'être secondaire. Écrire les zones au site ferait de l'hébergeur la
|
||||
source des données du client.
|
||||
- **La liste des zones se dérive** de tous les locataires du site, dans
|
||||
`scripts/site_inventaire.py`. Jamais une liste tenue à la main.
|
||||
- **Uniquement `autorite: primaire-cache`.** Le rôle refuse une zone `.internal` : ce
|
||||
serveur répond à Internet.
|
||||
|
||||
## Ce que l'épreuve de l'outil a appris (2026-09-16, PowerDNS 4.9.17)
|
||||
|
||||
Deux instances jetables, sept vérifications, avant d'écrire une ligne de ce rôle.
|
||||
|
||||
| Constat | Ce que le rôle en fait |
|
||||
|---|---|
|
||||
| **`allow-axfr-ips` et TSIG sont alternatifs, pas cumulatifs** : une adresse listée obtient la zone *sans signature* | le primaire n'autorise **aucune** adresse ; seule la clé ouvre le transfert |
|
||||
| `allow-axfr-ips` autorise la boucle locale **par défaut** | un test négatif depuis la boucle locale n'en est pas un |
|
||||
| le backend `bind` seul ne stocke **aucune** métadonnée | `bind-dnssec-db` porte les clés TSIG |
|
||||
| la syntaxe exige `masters { ip:port; };` | sans accolades, **tout** le fichier est refusé |
|
||||
| le primaire notifie **aussi les adresses de ses NS** | le primaire restreint ses notifications au seul serveur public du site |
|
||||
|
||||
## Durcissement
|
||||
|
||||
- **Secondaire seulement** : `secondary=yes`, `primary=no`.
|
||||
- **Aucun transfert sortant** en phase 1 (`disable-axfr=yes`). La réplication vers le
|
||||
site pair passera par le tunnel WireGuard, plus tard.
|
||||
- **Notifications acceptées des seuls primaires déclarés.**
|
||||
- **`version-string=anonymous`** : la version est la première chose qu'un balayage demande.
|
||||
- **`security-poll-suffix=`** vide : PowerDNS interroge par défaut un domaine de l'éditeur
|
||||
à chaque démarrage. Un serveur souverain n'appelle pas un tiers.
|
||||
- **Pas de récursion** : PowerDNS Authoritative ne récurse pas — vérifié, il répond
|
||||
`REFUSED`.
|
||||
|
||||
## Ce que la phase 1 n'expose pas
|
||||
|
||||
**Aucun flux depuis Internet.** Le port 53 public viendra en phase 2, par la redirection
|
||||
de la frontière — et seulement après DNSSEC signé chez le locataire, CAA, et la limitation
|
||||
de débit des réponses. Le déclarer aujourd'hui dans `meta/flux.yml` ferait émettre au
|
||||
devis une règle sur le WAN que personne n'aurait décidée.
|
||||
39
roles/serveur_dns_public/defaults/main.yml
Normal file
39
roles/serveur_dns_public/defaults/main.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
# LE SERVEUR DNS PUBLIC DU SITE — secondaire de toutes les zones publiques de ses locataires.
|
||||
#
|
||||
# Le locataire ECRIT sa zone (primaire cache, `autorite: primaire-cache`) ; le site la SERT.
|
||||
# Ce role ne porte donc aucune donnee a lui : il recoit des repliques, signees TSIG.
|
||||
|
||||
serveur_dns_public_paquets:
|
||||
- pdns-server
|
||||
- pdns-backend-bind
|
||||
- dnsutils
|
||||
|
||||
# LES RELATIONS AVEC LES LOCATAIRES — derivees, jamais ecrites.
|
||||
#
|
||||
# `scripts/site_inventaire.py` les calcule : il connait le plan du site ET le registre des
|
||||
# locataires. Ce role tourne sur une machine qui n'a aucune raison de lire la carte de
|
||||
# l'hebergeur. La derivation appartient a qui detient les deux sources.
|
||||
#
|
||||
# - ecosysteme: OPS-Chezlepro
|
||||
# cle_tsig: tsig-chezlepro # le NOM de la cle, identique des deux cotes
|
||||
# secret_var: vault_tsig_dns_public_chezlepro # la variable de voute qui porte le secret
|
||||
# primaire: 10.17.19.11 # l'adresse DERIVEE de son autoritatif
|
||||
# port: 5300
|
||||
# zones: [chezlepro.ca]
|
||||
serveur_dns_public_relations: []
|
||||
|
||||
serveur_dns_public_ecoute: "{{ ansible_host | default('0.0.0.0') }}"
|
||||
serveur_dns_public_port: 53
|
||||
serveur_dns_public_zones_dir: /var/lib/powerdns/zones-secondaires
|
||||
serveur_dns_public_bind_config: /etc/powerdns/setops-dns-public-zones.conf
|
||||
# LA BASE QUI PORTE LES CLES TSIG. Le backend `bind` seul ne sait stocker aucune metadonnee :
|
||||
# sans elle, `AXFR-MASTER-TSIG` n'a nulle part ou vivre, et le transfert partirait NON
|
||||
# SIGNE. Eprouve le 2026-09-16 sur PowerDNS 4.9.17 : `create-bind-db` est bien pris en
|
||||
# charge par le paquet Debian, meme si `ldd` du module ne montre pas SQLite.
|
||||
serveur_dns_public_dnssec_db: /var/lib/powerdns/dnssec.db
|
||||
|
||||
# SONDE : le nombre de zones que ce serveur doit servir. Mise en defaut par parametre (un
|
||||
# seuil impossible), comme le veut le contrat des sondes.
|
||||
serveur_dns_public_sonde_attendu: >-
|
||||
{{ serveur_dns_public_relations | map(attribute='zones') | flatten | length }}
|
||||
6
roles/serveur_dns_public/handlers/main.yml
Normal file
6
roles/serveur_dns_public/handlers/main.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
- name: Redemarrer le DNS public
|
||||
ansible.builtin.systemd:
|
||||
name: pdns
|
||||
state: restarted
|
||||
when: not ansible_check_mode
|
||||
9
roles/serveur_dns_public/meta/authentification.yml
Normal file
9
roles/serveur_dns_public/meta/authentification.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
# Position de ce role dans la directive d'authentification (D-38..D-41).
|
||||
# Voir docs/authentification.md. Gardee par la preuve P29.
|
||||
authentification:
|
||||
portee: sans-auth-humaine
|
||||
raison: >-
|
||||
Serveur DNS autoritatif public : il repond a qui le demande, et c'est son objet. Ce qui
|
||||
s'authentifie ici, ce sont les SERVEURS entre eux (TSIG sur le transfert), jamais un
|
||||
humain.
|
||||
7
roles/serveur_dns_public/meta/empreinte.yml
Normal file
7
roles/serveur_dns_public/meta/empreinte.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
# Empreinte ressources du logiciel — dimensionnement VM derive (scripts/instancier.py).
|
||||
# Un secondaire autoritatif sert des zones en memoire : peu de coeurs, peu de disque.
|
||||
setops_empreinte:
|
||||
coeurs: 1
|
||||
memoire_mo: 512
|
||||
disque_go: 2
|
||||
38
roles/serveur_dns_public/meta/flux.yml
Normal file
38
roles/serveur_dns_public/meta/flux.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
# Flux reseau du serveur DNS public du site. Voir docs/flux-conception.md.
|
||||
#
|
||||
# PHASE 1 : AUCUN FLUX DEPUIS L'INTERNET. Le port 53 public viendra en phase 2, avec la
|
||||
# redirection de la frontiere, APRES DNSSEC et la limitation de debit. Le declarer ici
|
||||
# aujourd'hui ferait emettre au devis une regle sur le WAN — une exposition que personne
|
||||
# n'aurait decidee.
|
||||
flux:
|
||||
# LES NOTIFICATIONS DES PRIMAIRES. Un locataire previent le site qu'une zone a change ;
|
||||
# le site tire alors la nouvelle version. Sans ce flux, la replique attendrait le
|
||||
# rafraichissement du SOA — une heure de retard sur chaque changement.
|
||||
- sens: ingress
|
||||
port: 53
|
||||
protocole: udp
|
||||
pair: voisins_site
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
NOTIFY des primaires des locataires du site : une zone publique a change. Le contenu
|
||||
d'une zone publique n'a rien de secret ; ce qui compte est QUI peut le modifier, et
|
||||
c'est TSIG qui le garde, sur le transfert.
|
||||
- sens: ingress
|
||||
port: 53
|
||||
protocole: tcp
|
||||
pair: voisins_site
|
||||
chiffrement: clair
|
||||
raison: "Repli TCP des notifications des primaires des locataires."
|
||||
|
||||
# LE TRANSFERT, TIRE PAR LE SITE. Le primaire d'un locataire n'accepte AUCUNE adresse :
|
||||
# seule une requete signee par la cle de sa relation obtient la zone.
|
||||
- sens: egress
|
||||
port: 5300
|
||||
protocole: tcp
|
||||
pair: primaires_dns_locataires
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
AXFR signe TSIG vers l'autoritatif de chaque locataire du site (5300 : il partage sa
|
||||
machine avec le resolveur, qui tient le 53). La signature authentifie, elle ne
|
||||
chiffre pas — et une zone publique n'a rien a cacher.
|
||||
8
roles/serveur_dns_public/meta/supervision.yml
Normal file
8
roles/serveur_dns_public/meta/supervision.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
# Supervision derivee du role. Voir docs/supervision-conception.md.
|
||||
sondes:
|
||||
- nom: zones-publiques
|
||||
ttl: 5400
|
||||
raison: 'Le serveur public sert-il TOUTES les zones de ses locataires ? Un secondaire qui n''a jamais
|
||||
recu une zone repond REFUSED : pour Internet, ce domaine n''a plus de serveur de noms, pendant que le
|
||||
service a l''air parfaitement vivant.'
|
||||
163
roles/serveur_dns_public/tasks/main.yml
Normal file
163
roles/serveur_dns_public/tasks/main.yml
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
# --- LES REFUS, AVANT DE TOUCHER A QUOI QUE CE SOIT -----------------------------------
|
||||
|
||||
# UNE ZONE INTERNE N'ARRIVE JAMAIS ICI. Ce serveur repond a Internet : une zone `.internal`
|
||||
# servie ici serait la carte des machines d'un locataire offerte au monde. La derivation ne
|
||||
# retient que `primaire-cache` ; ce refus garde le cas ou elle se tromperait.
|
||||
- name: Refuser une zone interne sur le serveur public
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(serveur_dns_public_relations | map(attribute='zones') | flatten
|
||||
| select('search', '\.internal\.?$') | list | length) == 0
|
||||
fail_msg: >-
|
||||
Une zone `.internal` est destinee au serveur DNS PUBLIC. Elle publierait la carte
|
||||
des machines d'un locataire. Seules les zones `autorite: primaire-cache` y ont leur
|
||||
place.
|
||||
quiet: true
|
||||
|
||||
# UNE RELATION SANS SECRET N'EST PAS UNE RELATION NON SIGNEE : C'EST UN REFUS. Un
|
||||
# secondaire qui tirerait sans cle ne recevrait rien (le primaire n'autorise aucune
|
||||
# adresse) — ou pire, recevrait tout le jour ou quelqu'un ajouterait son adresse a
|
||||
# `allow-axfr-ips`, et TSIG deviendrait decoratif sans le moindre message.
|
||||
- name: Exiger un secret TSIG pour chaque relation
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- lookup('vars', item.secret_var, default='') | length > 0
|
||||
fail_msg: >-
|
||||
{{ item.ecosysteme }} : `{{ item.secret_var }}` est absent de la voute du site. Sans
|
||||
lui, la zone de ce locataire ne se transfere pas — et aucun repli non signe n'est
|
||||
prevu, par construction.
|
||||
quiet: true
|
||||
loop: "{{ serveur_dns_public_relations }}"
|
||||
loop_control:
|
||||
label: "{{ item.ecosysteme }}"
|
||||
|
||||
# --- L'INSTALLATION -------------------------------------------------------------------
|
||||
|
||||
- name: Installer PowerDNS Authoritative
|
||||
ansible.builtin.apt:
|
||||
name: "{{ serveur_dns_public_paquets }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
|
||||
# Le secondaire ECRIT les zones qu'il recoit : le repertoire appartient a `pdns`.
|
||||
- name: Créer le répertoire des zones secondaires
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_dns_public_zones_dir }}"
|
||||
state: directory
|
||||
owner: pdns
|
||||
group: pdns
|
||||
mode: "0750"
|
||||
|
||||
- name: Déployer la configuration du serveur public
|
||||
ansible.builtin.template:
|
||||
src: setops-dns-public.conf.j2
|
||||
dest: /etc/powerdns/pdns.d/setops-dns-public.conf
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
notify: Redemarrer le DNS public
|
||||
|
||||
- name: Déployer l'index des zones secondaires
|
||||
ansible.builtin.template:
|
||||
src: bindbackend.conf.j2
|
||||
dest: "{{ serveur_dns_public_bind_config }}"
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
notify: Redemarrer le DNS public
|
||||
|
||||
- name: Créer la base des clés TSIG
|
||||
ansible.builtin.command:
|
||||
cmd: "pdnsutil create-bind-db {{ serveur_dns_public_dnssec_db }}"
|
||||
creates: "{{ serveur_dns_public_dnssec_db }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Confier la base des clés à pdns
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_dns_public_dnssec_db }}"
|
||||
owner: pdns
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
when: not ansible_check_mode
|
||||
|
||||
# --- LES CLES TSIG, UNE PAR RELATION --------------------------------------------------
|
||||
#
|
||||
# On COMPARE avant d'ecrire, et c'est ce qui rend la rotation possible : un secret change
|
||||
# dans la voute doit remplacer l'ancien. Importer seulement quand le nom manque laisserait
|
||||
# la vieille cle en place pour toujours, et le transfert echouerait sans rien dire.
|
||||
- name: Relever les clés TSIG en place
|
||||
ansible.builtin.command:
|
||||
cmd: pdnsutil list-tsig-keys
|
||||
register: serveur_dns_public_cles
|
||||
changed_when: false
|
||||
no_log: true
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Poser ou remplacer la clé TSIG de chaque relation
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pdnsutil
|
||||
- import-tsig-key
|
||||
- "{{ item.cle_tsig }}"
|
||||
- hmac-sha256
|
||||
- "{{ lookup('vars', item.secret_var) }}"
|
||||
loop: "{{ serveur_dns_public_relations }}"
|
||||
loop_control:
|
||||
label: "{{ item.ecosysteme }} → {{ item.cle_tsig }}"
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- (item.cle_tsig ~ '. hmac-sha256. ' ~ lookup('vars', item.secret_var))
|
||||
not in (serveur_dns_public_cles.stdout | default(''))
|
||||
changed_when: true # n'arrive ici que si la cle manque ou differe
|
||||
no_log: true
|
||||
notify: Redemarrer le DNS public
|
||||
|
||||
- name: Relever les métadonnées de transfert
|
||||
ansible.builtin.command:
|
||||
argv: [pdnsutil, get-meta, "{{ item.1 }}", AXFR-MASTER-TSIG]
|
||||
loop: "{{ serveur_dns_public_relations | subelements('zones') }}"
|
||||
loop_control:
|
||||
label: "{{ item.1 }}"
|
||||
register: serveur_dns_public_meta
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Exiger la signature TSIG sur chaque transfert entrant
|
||||
ansible.builtin.command:
|
||||
argv: [pdnsutil, set-meta, "{{ item.item.1 }}", AXFR-MASTER-TSIG, "{{ item.item.0.cle_tsig }}"]
|
||||
loop: "{{ serveur_dns_public_meta.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.1 }} ← {{ item.item.0.cle_tsig }}"
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ("= " ~ item.item.0.cle_tsig) not in (item.stdout | default(''))
|
||||
changed_when: true # n'arrive ici que si la metadonnee manque ou differe
|
||||
notify: Redemarrer le DNS public
|
||||
|
||||
- name: Activer et démarrer PowerDNS
|
||||
ansible.builtin.systemd:
|
||||
name: pdns
|
||||
enabled: true
|
||||
state: started
|
||||
when: not ansible_check_mode
|
||||
|
||||
# --- SONDE (docs/supervision-conception.md) -------------------------------------------
|
||||
- name: Assurer le répertoire des sondes de supervision
|
||||
ansible.builtin.file:
|
||||
path: /usr/local/lib/setops/sondes
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Déposer la sonde « zones-publiques »
|
||||
ansible.builtin.template:
|
||||
src: sonde-zones-publiques.sh.j2
|
||||
dest: /usr/local/lib/setops/sondes/zones-publiques.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
16
roles/serveur_dns_public/templates/bindbackend.conf.j2
Normal file
16
roles/serveur_dns_public/templates/bindbackend.conf.j2
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{# GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
|
||||
|
||||
UNE DECLARATION PAR ZONE PUBLIQUE, CHACUNE AVEC SON PRIMAIRE.
|
||||
|
||||
La syntaxe exige des ACCOLADES autour des primaires : `masters { ip:port; };`. Sans
|
||||
elles, PowerDNS refuse TOUT le fichier (« syntax error on line 1 ») et ne sert aucune
|
||||
zone — mesure le 2026-09-16, pendant l'epreuve de l'outil. #}
|
||||
{% for relation in serveur_dns_public_relations %}
|
||||
{% for zone in relation.zones %}
|
||||
zone "{{ zone }}" {
|
||||
type slave;
|
||||
masters { {{ relation.primaire }}:{{ relation.port }}; };
|
||||
file "{{ serveur_dns_public_zones_dir }}/{{ zone }}.zone";
|
||||
};
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
34
roles/serveur_dns_public/templates/setops-dns-public.conf.j2
Normal file
34
roles/serveur_dns_public/templates/setops-dns-public.conf.j2
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# GENERE par Set-OPS (role serveur_dns_public). Ne pas editer a la main.
|
||||
#
|
||||
# LE SERVEUR DNS PUBLIC DU SITE — secondaire de toutes les zones publiques des locataires.
|
||||
#
|
||||
# `launch=bind` est deja declare par le paquet (pdns.d/bind.conf) : ne pas le redeclarer,
|
||||
# PowerDNS refuserait « multiple backends 'bind' ». Ce fichier est lu APRES lui (ordre
|
||||
# alphabetique), et son `bind-config` l'emporte.
|
||||
bind-config={{ serveur_dns_public_bind_config }}
|
||||
bind-dnssec-db={{ serveur_dns_public_dnssec_db }}
|
||||
|
||||
local-address={{ serveur_dns_public_ecoute }}
|
||||
local-port={{ serveur_dns_public_port }}
|
||||
|
||||
# SECONDAIRE, ET RIEN D'AUTRE. Il recoit des repliques ; il n'est primaire de rien.
|
||||
secondary=yes
|
||||
primary=no
|
||||
|
||||
# IL NE REDONNE LES ZONES A PERSONNE. Le transfert sortant servira la replication vers le
|
||||
# site pair, par le tunnel WireGuard — une phase ulterieure. D'ici la, fermé.
|
||||
disable-axfr=yes
|
||||
|
||||
# LES NOTIFICATIONS NE VIENNENT QUE DES PRIMAIRES DECLARES. Un NOTIFY accepte de n'importe
|
||||
# qui ferait tirer ce serveur vers le primaire a la demande de n'importe qui.
|
||||
{% set _primaires = serveur_dns_public_relations | map(attribute='primaire') | unique | list %}
|
||||
allow-notify-from={{ (_primaires | map('regex_replace', '$', '/32') | list | join(',')) if _primaires else '127.0.0.1/32' }}
|
||||
|
||||
# Ne rien dire de ce qui tourne ici. La version exacte est la premiere chose qu'un
|
||||
# balayage demande.
|
||||
version-string=anonymous
|
||||
|
||||
# AUCUN APPEL VERS LES SERVEURS DE L'EDITEUR. Par defaut, PowerDNS interroge un domaine de
|
||||
# PowerDNS.COM pour savoir s'il a une mise a jour de securite : un serveur souverain qui
|
||||
# appellerait un tiers a chaque demarrage. Les mises a jour viennent du cache du site.
|
||||
security-poll-suffix=
|
||||
|
|
@ -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;"
|
||||
|
|
@ -84,3 +84,22 @@ serveur_powerdns_zones_inverses: >-
|
|||
# --- Sonde de supervision -----------------------------------------------------------
|
||||
# La zone souveraine et son inverse : deux au minimum.
|
||||
serveur_powerdns_sonde_minimum: 2
|
||||
|
||||
# --- ZONES PUBLIQUES : `autorite: primaire-cache` (tasks/zones-publiques.yml) ---------
|
||||
#
|
||||
# Deux valeurs viennent DU SITE, par le contrat `site_intrants.py` : le serveur public qui
|
||||
# tire les zones, et l'adresse publique vers laquelle elles pointent. Le locataire ne les
|
||||
# recopie pas — il les recoit, comme ses adresses d'amorcage.
|
||||
serveur_powerdns_dns_public_site: "{{ dns_public_site | default('') }}"
|
||||
serveur_powerdns_ip_publique: "{{ ip_publique_site | default('') }}"
|
||||
# Le NOM de la cle TSIG, identique des deux cotes — derive du nom du depot du locataire,
|
||||
# par la meme regle que `site_inventaire.py` (`tsig-<depot sans ops->`). `realpath`, parce
|
||||
# que sur le poste l'inventaire passe par le lien `instance`.
|
||||
serveur_powerdns_tsig_cle: >-
|
||||
tsig-{{ inventory_dir | realpath | dirname | dirname | basename | lower
|
||||
| regex_replace('^ops-', '') }}
|
||||
serveur_powerdns_public_port: 5300
|
||||
serveur_powerdns_public_zones_dir: /etc/powerdns/zones-publiques
|
||||
serveur_powerdns_public_bind_config: /etc/powerdns/setops-zones-publiques.conf
|
||||
serveur_powerdns_public_dnssec_db: /var/lib/powerdns/public-dnssec.db
|
||||
serveur_powerdns_zones_publiques: []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
239
roles/serveur_powerdns/tasks/zones-publiques.yml
Normal file
239
roles/serveur_powerdns/tasks/zones-publiques.yml
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
---
|
||||
# --- LES ZONES PUBLIQUES DU LOCATAIRE : `autorite: primaire-cache` ---------------------
|
||||
#
|
||||
# Le locataire ECRIT ses zones publiques ; le serveur DNS public de son site les SERT.
|
||||
# Ce fichier fait exercer enfin un mode que le plan declarait depuis longtemps et que rien
|
||||
# ne consommait — « une autorite qu'on s'attribue sans l'exercer est une panne differee ».
|
||||
#
|
||||
# UNE INSTANCE A PART, ET C'EST LE COEUR DE LA CONCEPTION (2026-09-16).
|
||||
#
|
||||
# Pour que le site tire la zone, l'autoritatif doit ecouter sur l'adresse de la machine.
|
||||
# Or l'instance principale sert AUSSI la zone `.internal`. Lui faire ecouter l'adresse de
|
||||
# l'hote aurait permis au serveur public du site d'INTERROGER la zone interne du locataire
|
||||
# — de lire la carte de ses machines. C'est precisement ce que
|
||||
# `docs/responsabilites-locataire-hebergeur.md` refuse a l'hebergeur.
|
||||
#
|
||||
# `pdns@public` ne sert que les zones publiques et est la seule a ecouter sur l'hote.
|
||||
# L'instance interne reste sur la boucle locale, sans une ligne changee.
|
||||
|
||||
- name: Retenir les zones publiques du plan
|
||||
ansible.builtin.set_fact:
|
||||
serveur_powerdns_zones_publiques: >-
|
||||
{{ (domaines_publics | default({})) | dict2items
|
||||
| selectattr('value.autorite', 'defined')
|
||||
| selectattr('value.autorite', 'equalto', 'primaire-cache')
|
||||
| map(attribute='key') | sort | list }}
|
||||
|
||||
- name: Refuser une publication incohérente
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
# UNE ZONE INTERNE N'EST JAMAIS PUBLIQUE, meme si quelqu'un la declare
|
||||
# `primaire-cache` par erreur.
|
||||
- >-
|
||||
(serveur_powerdns_zones_publiques | select('search', '\.internal\.?$') | list
|
||||
| length) == 0
|
||||
# L'ADRESSE PUBLIQUE EST PUBLIQUE. Une adresse privee dans une zone servie a Internet
|
||||
# ne resout pour personne dehors, et renseigne sur le reseau dedans.
|
||||
- serveur_powerdns_ip_publique | length > 0
|
||||
- not (serveur_powerdns_ip_publique is search('^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.)'))
|
||||
# TSIG N'EST PAS DECORATIF. PowerDNS donne la zone a toute adresse de
|
||||
# `allow-axfr-ips` SANS signature — mesure le 2026-09-16. Le serveur public du site
|
||||
# ne doit donc jamais y figurer.
|
||||
- serveur_powerdns_dns_public_site not in (serveur_powerdns_allow_axfr_ips | default([]))
|
||||
- (vault_tsig_dns_public | default('')) | length > 0
|
||||
fail_msg: >-
|
||||
Zones publiques refusees : zone `.internal` declaree publique, adresse publique
|
||||
absente ou privee ({{ serveur_powerdns_ip_publique | default('?') }}), serveur public
|
||||
du site liste dans `allow-axfr-ips` (TSIG deviendrait decoratif), ou
|
||||
`vault_tsig_dns_public` absent de la voute.
|
||||
quiet: true
|
||||
when: serveur_powerdns_zones_publiques | length > 0
|
||||
|
||||
- name: Créer le répertoire des zones publiques
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_powerdns_public_zones_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0750"
|
||||
when: serveur_powerdns_zones_publiques | length > 0
|
||||
|
||||
# --- LE SERIAL SUIT LE CONTENU, PAS LE DEPLOIEMENT -----------------------------------
|
||||
#
|
||||
# LE PIEGE (2026-09-16). La zone interne porte un serial FIGE (`serveur_powerdns_serial`).
|
||||
# Sans secondaire, c'est sans consequence. Avec un secondaire, c'est fatal en silence : il
|
||||
# compare les serials, ne voit jamais de changement, et garde l'ancienne zone POUR
|
||||
# TOUJOURS — pendant que le primaire sert la nouvelle.
|
||||
#
|
||||
# On rend donc d'abord le CORPS de la zone, sans serial. Si le corps change, le serial
|
||||
# prend l'heure (secondes Unix : monotone, et tient dans 32 bits jusqu'en 2106). Sinon il
|
||||
# garde sa valeur. Un deploiement sans changement ne notifie personne.
|
||||
- name: Rendre le corps de chaque zone publique
|
||||
ansible.builtin.template:
|
||||
src: zone-publique.db.j2
|
||||
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.corps"
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
vars:
|
||||
serveur_powerdns_serial_public: "SERIAL"
|
||||
loop: "{{ serveur_powerdns_zones_publiques }}"
|
||||
loop_control:
|
||||
loop_var: zone_publique
|
||||
register: serveur_powerdns_corps_publics
|
||||
|
||||
- name: Relever le serial en place de chaque zone publique
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.serial"
|
||||
loop: "{{ serveur_powerdns_zones_publiques }}"
|
||||
loop_control:
|
||||
loop_var: zone_publique
|
||||
register: serveur_powerdns_serials_publics
|
||||
failed_when: false
|
||||
|
||||
# PAS UN HANDLER, ET C'EST VOULU : le serial doit avancer AVANT que la zone soit rendue,
|
||||
# dans la tache suivante. Un handler s'executerait a la fin du jeu — la zone serait deja
|
||||
# ecrite avec l'ancien serial, et le secondaire ne verrait jamais le changement.
|
||||
- name: Avancer le serial des zones dont le contenu a changé # noqa: no-handler
|
||||
ansible.builtin.copy:
|
||||
content: "{{ ansible_date_time.epoch }}\n"
|
||||
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ item.zone_publique }}.serial"
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
loop: "{{ serveur_powerdns_corps_publics.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.zone_publique }}"
|
||||
when: item.changed | default(false)
|
||||
register: serveur_powerdns_serials_avances
|
||||
|
||||
- name: Écrire chaque zone publique avec son serial
|
||||
ansible.builtin.template:
|
||||
src: zone-publique.db.j2
|
||||
dest: "{{ serveur_powerdns_public_zones_dir }}/{{ zone_publique }}.zone"
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
vars:
|
||||
_avance: >-
|
||||
{{ (serveur_powerdns_serials_avances.results | default([])
|
||||
| selectattr('item.zone_publique', 'equalto', zone_publique)
|
||||
| selectattr('changed') | list | length) > 0 }}
|
||||
_ancien: >-
|
||||
{{ (serveur_powerdns_serials_publics.results | default([])
|
||||
| selectattr('zone_publique', 'equalto', zone_publique)
|
||||
| map(attribute='content') | first | default('') | b64decode | trim) }}
|
||||
serveur_powerdns_serial_public: >-
|
||||
{{ ansible_date_time.epoch if (_avance | bool) or not _ancien else _ancien }}
|
||||
loop: "{{ serveur_powerdns_zones_publiques }}"
|
||||
loop_control:
|
||||
loop_var: zone_publique
|
||||
notify: Valider et redemarrer le DNS public du locataire
|
||||
|
||||
# --- L'INSTANCE ---------------------------------------------------------------------
|
||||
|
||||
- name: Déployer la configuration de l'instance publique
|
||||
ansible.builtin.template:
|
||||
src: pdns-public.conf.j2
|
||||
dest: /etc/powerdns/pdns-public.conf
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
when: serveur_powerdns_zones_publiques | length > 0
|
||||
notify: Valider et redemarrer le DNS public du locataire
|
||||
|
||||
- name: Déployer l'index des zones publiques
|
||||
ansible.builtin.template:
|
||||
src: bindbackend-public.conf.j2
|
||||
dest: "{{ serveur_powerdns_public_bind_config }}"
|
||||
owner: root
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
when: serveur_powerdns_zones_publiques | length > 0
|
||||
notify: Valider et redemarrer le DNS public du locataire
|
||||
|
||||
- name: Créer la base des clés TSIG de l'instance publique
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pdnsutil --config-name=public create-bind-db
|
||||
{{ serveur_powerdns_public_dnssec_db }}
|
||||
creates: "{{ serveur_powerdns_public_dnssec_db }}"
|
||||
when:
|
||||
- serveur_powerdns_zones_publiques | length > 0
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Confier la base des clés à pdns
|
||||
ansible.builtin.file:
|
||||
path: "{{ serveur_powerdns_public_dnssec_db }}"
|
||||
owner: pdns
|
||||
group: pdns
|
||||
mode: "0640"
|
||||
when:
|
||||
- serveur_powerdns_zones_publiques | length > 0
|
||||
- not ansible_check_mode
|
||||
|
||||
# --- TSIG : SEULE LA CLE OUVRE LE TRANSFERT ------------------------------------------
|
||||
- name: Relever les clés TSIG de l'instance publique
|
||||
ansible.builtin.command:
|
||||
cmd: pdnsutil --config-name=public list-tsig-keys
|
||||
register: serveur_powerdns_cles_publiques
|
||||
changed_when: false
|
||||
no_log: true
|
||||
when:
|
||||
- serveur_powerdns_zones_publiques | length > 0
|
||||
- not ansible_check_mode
|
||||
|
||||
# On COMPARE avant d'ecrire : un secret change dans la voute doit remplacer l'ancien.
|
||||
- name: Poser ou remplacer la clé TSIG de la relation avec le site
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pdnsutil
|
||||
- --config-name=public
|
||||
- import-tsig-key
|
||||
- "{{ serveur_powerdns_tsig_cle }}"
|
||||
- hmac-sha256
|
||||
- "{{ vault_tsig_dns_public }}"
|
||||
when:
|
||||
- serveur_powerdns_zones_publiques | length > 0
|
||||
- not ansible_check_mode
|
||||
- vault_tsig_dns_public not in (serveur_powerdns_cles_publiques.stdout | default(''))
|
||||
changed_when: true # n'arrive ici que si la cle manque ou differe
|
||||
no_log: true
|
||||
notify: Valider et redemarrer le DNS public du locataire
|
||||
|
||||
- name: Relever les autorisations de transfert
|
||||
ansible.builtin.command:
|
||||
argv: [pdnsutil, --config-name=public, get-meta, "{{ zone_publique }}", TSIG-ALLOW-AXFR]
|
||||
loop: "{{ serveur_powerdns_zones_publiques }}"
|
||||
loop_control:
|
||||
loop_var: zone_publique
|
||||
register: serveur_powerdns_meta_publiques
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: N'autoriser le transfert qu'à la clé de la relation
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pdnsutil
|
||||
- --config-name=public
|
||||
- set-meta
|
||||
- "{{ item.zone_publique }}"
|
||||
- TSIG-ALLOW-AXFR
|
||||
- "{{ serveur_powerdns_tsig_cle }}"
|
||||
loop: "{{ serveur_powerdns_meta_publiques.results | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.zone_publique }}"
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- ("= " ~ serveur_powerdns_tsig_cle) not in (item.stdout | default(''))
|
||||
changed_when: true # n'arrive ici que si la metadonnee manque ou differe
|
||||
|
||||
- name: Activer et démarrer l'instance publique
|
||||
ansible.builtin.systemd:
|
||||
name: pdns@public
|
||||
enabled: true
|
||||
state: started
|
||||
when:
|
||||
- serveur_powerdns_zones_publiques | length > 0
|
||||
- not ansible_check_mode
|
||||
|
|
@ -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 %}
|
||||
39
roles/serveur_powerdns/templates/pdns-public.conf.j2
Normal file
39
roles/serveur_powerdns/templates/pdns-public.conf.j2
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# GENERE par Set-OPS (role serveur_powerdns). Ne pas editer a la main.
|
||||
#
|
||||
# L'INSTANCE PUBLIQUE DU LOCATAIRE — `pdns@public`, primaire cache de ses zones publiques.
|
||||
#
|
||||
# Elle ne sert QUE les zones `autorite: primaire-cache`. La zone `.internal` reste a
|
||||
# l'instance principale, sur la boucle locale : le serveur public du site peut interroger
|
||||
# celle-ci, jamais celle-la.
|
||||
#
|
||||
# Une instance nommee ne lit pas `pdns.d/` : tout ce dont elle a besoin est dit ici.
|
||||
launch=bind
|
||||
bind-config={{ serveur_powerdns_public_bind_config }}
|
||||
bind-dnssec-db={{ serveur_powerdns_public_dnssec_db }}
|
||||
|
||||
# LA SEULE INSTANCE QUI ECOUTE L'ADRESSE DE L'HOTE. Le pare-feu de la machine ne laisse
|
||||
# frapper que le serveur public du site (flux `dns_public_site`).
|
||||
local-address={{ ansible_host }}
|
||||
local-port={{ serveur_powerdns_public_port }}
|
||||
|
||||
primary=yes
|
||||
secondary=no
|
||||
|
||||
# LE TRANSFERT S'OUVRE A LA CLE, PAS A L'ADRESSE (eprouve le 2026-09-16).
|
||||
#
|
||||
# `allow-axfr-ips` et TSIG sont ALTERNATIFS : une adresse listee obtient la zone sans
|
||||
# signature. Seule la boucle locale y figure — le serveur public du site n'y est jamais,
|
||||
# et ne recoit la zone que signee par `TSIG-ALLOW-AXFR`.
|
||||
disable-axfr=no
|
||||
allow-axfr-ips=127.0.0.1/32
|
||||
|
||||
# NOTIFIER LE SEUL SERVEUR PUBLIC DU SITE (decouvert le 2026-09-16, dans les journaux).
|
||||
#
|
||||
# Par defaut, un primaire notifie AUSSI les adresses de ses enregistrements NS. Ici, ce
|
||||
# sont les adresses PUBLIQUES des serveurs de noms : le primaire enverrait des
|
||||
# notifications vers Internet depuis l'interieur du locataire. `only-notify` les borne.
|
||||
only-notify={{ serveur_powerdns_dns_public_site }}/32
|
||||
also-notify={{ serveur_powerdns_dns_public_site }}
|
||||
|
||||
version-string=anonymous
|
||||
security-poll-suffix=
|
||||
21
roles/serveur_powerdns/templates/zone-publique.db.j2
Normal file
21
roles/serveur_powerdns/templates/zone-publique.db.j2
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
$ORIGIN {{ zone_publique }}.
|
||||
$TTL {{ serveur_powerdns_ttl }}
|
||||
@ IN SOA ns1.{{ zone_publique }}. hostmaster.{{ zone_publique }}. (
|
||||
{{ serveur_powerdns_serial_public }}
|
||||
{{ serveur_powerdns_soa_refresh }}
|
||||
{{ serveur_powerdns_soa_retry }}
|
||||
{{ serveur_powerdns_soa_expire }}
|
||||
{{ serveur_powerdns_soa_minimum }}
|
||||
)
|
||||
|
||||
@ IN NS ns1.{{ zone_publique }}.
|
||||
ns1 IN A {{ serveur_powerdns_ip_publique }}
|
||||
|
||||
{# UNE ZONE PUBLIQUE NE PORTE QUE DES ADRESSES PUBLIQUES.
|
||||
|
||||
Un nom expose sous ce domaine resout vers l'adresse publique du site — la frontiere le
|
||||
relaie ensuite a l'edge. Jamais vers l'adresse interne de l'edge : elle ne resoudrait
|
||||
pour personne dehors, et renseignerait sur le reseau dedans. #}
|
||||
{% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == zone_publique %}
|
||||
{{ expo.fqdn[:-(zone_publique | length + 1)] if expo.fqdn != zone_publique else '@' }} IN A {{ serveur_powerdns_ip_publique }}
|
||||
{% endfor %}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue