diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e76cc7..f0104fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Quatre zones inverses, et rien de plus + +**47 preuves.** Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` dérivait +d'un supernet `/16` — la forme d'un **tenant**, qui tire tout de son index. Un site ne +dérive pas : il déclare plusieurs `/24` et n'a pas de supernet unique, si bien que la +dérivation rendait une chaîne vide et qu'aucune zone n'était générée. + +Le site revendique désormais **exactement ce qu'il occupe** : + +``` +31.0.10.in-addr.arpa 32.0.10.in-addr.arpa +33.0.10.in-addr.arpa 34.0.10.in-addr.arpa +``` + +Revendiquer `0.10.in-addr.arpa` d'un seul geste aurait été plus simple et faux : cette +zone couvre aussi la frontière, le transit et les hyperviseurs, qui ne sont pas à lui. +*Une autorité qu'on s'attribue sans l'exercer est une panne différée.* + +La dérivation vit dans un **filtre** (`zones_inverses`) parce qu'elle a deux appelants : +`serveur_powerdns` écrit ces zones, `serveur_resolveur` les délègue à l'autoritatif. Deux +calculs séparés finiraient par diverger, et la divergence ne se verrait qu'au premier PTR +interrogé. Le nom d'une zone dit sa profondeur — trois étiquettes numériques valent un +`/24`, deux valent un `/16` — et le modèle en déduit seul la forme du PTR. + +### Deux chemins morts trouvés en route + +Les zones étaient servies, et personne ne les demandait. `serveur_resolveur` déléguait la +zone directe par une `stub-zone` mais pas les inverses : `dig -x` rendait vide depuis les +cinq machines alors que la même requête posée directement à l'autoritatif répondait juste. +*Un service correct derrière un chemin que rien n'emprunte.* + +Puis, les stubs posés, Unbound répondait toujours **NXDOMAIN avec le drapeau `aa`** — une +réponse *autoritaire*, sans jamais consulter le stub. Il embarque des `local-zone` pour +tout l'espace RFC1918 inversé. `nodefault` n'y change rien : ce mode n'agit que si le nom +correspond exactement à une zone par défaut, et la sienne est `10.in-addr.arpa`, le `/8` +entier. C'est `transparent` qui laisse la requête suivre son cours — pour nos quatre zones +seulement, là où `unblock-lan-zones` aurait ouvert tout l'espace privé. + +Rien ne distinguait ce blocage d'une absence : le même NXDOMAIN qu'un nom qui n'existe pas. + +### Vérification + +Les cinq PTR résolvent depuis les cinq hôtes par le résolveur, les huit noms directs par +le plancher **et** par le DNS, et les deux rôles sont idempotents (`changed=0`). + +**P47** évalue la dérivation sur cinq cas — un site à quatre zones, un tenant à une, deux +machines d'un même `/24` qui n'en font qu'une, aucune adresse, une adresse illisible. + ## 2026-08-25 — Le plancher survit au redémarrage, et la zone dit les vraies adresses **46 preuves.** Le découpage du site en quatre zones a déplacé cinq machines ; ni le diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index f574e90..76cfde3 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -7,7 +7,7 @@ > [`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/production/hosts.yml` -- **Verdict** : ✅ CONFORME (45 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (46 OK · 0 echec · 1 saute) ## Preuves @@ -59,6 +59,7 @@ | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 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). | ## Couverture des affirmations ✅ du registre diff --git a/filter_plugins/registres.py b/filter_plugins/registres.py index c92efc9..754b20e 100644 --- a/filter_plugins/registres.py +++ b/filter_plugins/registres.py @@ -18,6 +18,7 @@ from inventory_rules import ( # noqa: E402 bases_du_groupe, chaine_connexion, expositions_des_applications, + zones_inverses, ) @@ -31,4 +32,5 @@ class FilterModule: "bases_du_groupe": bases_du_groupe, "chaine_connexion": chaine_connexion, "expositions_des_applications": expositions_des_applications, + "zones_inverses": zones_inverses, } diff --git a/roles/serveur_powerdns/defaults/main.yml b/roles/serveur_powerdns/defaults/main.yml index 4aa8589..5f39c7a 100644 --- a/roles/serveur_powerdns/defaults/main.yml +++ b/roles/serveur_powerdns/defaults/main.yml @@ -50,16 +50,33 @@ serveur_powerdns_inventory_groups: serveur_powerdns_publier_expositions: true serveur_powerdns_expositions: [] -# Zone INVERSE — DERIVEE du supernet, jamais ecrite. Un /16 `10.(10+index).0.0` donne -# `(10+index).10.in-addr.arpa`. Vide si le supernet n'est pas connu ou n'est pas un /16 : -# mieux vaut pas de zone inverse qu'une zone fausse. +# ZONES INVERSES — DERIVEES, JAMAIS ECRITES. # -# Pourquoi elle manquait. Rien dans le moteur ne touchait `in-addr.arpa` — mesure du -# 2026-08-08. Un hote pouvait donc etre resolu par son nom et rester anonyme a l'envers, -# ce que la plupart des services de courriel et de journalisation reprochent en silence. -serveur_powerdns_zone_inverse: >- - {{ (setops_supernet | default('') | ansible.utils.ipaddr('prefix') | int == 16) - | ternary( - (setops_supernet | default('0.0.0.0/0')).split('.')[1] ~ '.' - ~ (setops_supernet | default('0.0.0.0/0')).split('.')[0] ~ '.in-addr.arpa', - '') }} +# Rien dans le moteur ne touchait `in-addr.arpa` avant le 2026-08-08 : un hote pouvait +# etre resolu par son nom et rester anonyme a l'envers, ce que le courriel et la +# journalisation reprochent en silence. +# +# La premiere derivation supposait un supernet /16 — la forme d'un TENANT, qui tire tout +# de son index. Un SITE ne derive pas : il declare ses reseaux, et il en declare +# plusieurs. `setops_supernet` y vaut le /24 de la machine, jamais un /16, si bien que la +# derivation rendait une chaine vide et qu'AUCUNE zone inverse n'etait generee (mesure du +# 2026-08-25 : les cinq machines du site, sans PTR). +# +# On couvre desormais EXACTEMENT ce que l'ecosysteme occupe : un /16 quand il en a un, +# sinon un /24 par reseau ou vit reellement une machine. Pour le site, les quatre zones +# `31.0.10`, `32.0.10`, `33.0.10`, `34.0.10` — et rien au-dela. Revendiquer tout +# `0.10.in-addr.arpa` aurait ete plus simple et faux : cette zone couvre aussi la +# frontiere, le transit et les hyperviseurs, qui ne sont pas a lui. Une autorite qu'on +# s'attribue sans l'exercer est une panne differee. +# +# LA DERIVATION VIT DANS UN FILTRE (`zones_inverses`), parce qu'elle a DEUX appelants : +# `serveur_powerdns` ecrit ces zones, `serveur_resolveur` les delegue a l'autoritatif. +# Deux calculs separes finiraient par diverger, et la divergence ne se verrait qu'au +# premier PTR interroge. +# +# Le nom d'une zone dit sa profondeur : trois etiquettes numeriques valent un /24, deux +# valent un /16. Le modele en deduit seul la forme du PTR ; rien n'est ecrit deux fois. +serveur_powerdns_zones_inverses: >- + {{ (serveur_powerdns_inventory_groups | map('extract', groups) | select | flatten + | map('extract', hostvars, 'ansible_host') | select | list) + | zones_inverses(setops_supernet | default('')) }} diff --git a/roles/serveur_powerdns/handlers/main.yml b/roles/serveur_powerdns/handlers/main.yml index b2a03e7..aa62f42 100644 --- a/roles/serveur_powerdns/handlers/main.yml +++ b/roles/serveur_powerdns/handlers/main.yml @@ -6,6 +6,22 @@ when: not ansible_check_mode # en dry-run le fichier de zone n'existe pas encore listen: Validate and reload PowerDNS +# UNE ZONE NON VALIDEE EST UNE ZONE QUI PEUT NE PAS ETRE SERVIE. La zone directe etait +# verifiee, les inverses non : PowerDNS refuse silencieusement un fichier mal forme et +# continue de servir le reste, si bien qu'un PTR casse ne se voit qu'a l'usage — et +# l'usage, ici, c'est un journal ou un courriel qui reproche un nom sans le dire. +- name: Validate PowerDNS reverse zones + ansible.builtin.command: + cmd: >- + named-checkzone {{ zone_inverse }} + {{ serveur_powerdns_zone_directory }}/{{ zone_inverse }}.zone + changed_when: false + loop: "{{ serveur_powerdns_zones_inverses | default([]) }}" + loop_control: + loop_var: zone_inverse + when: not ansible_check_mode # en dry-run le fichier de zone n'existe pas encore + listen: Validate and reload PowerDNS + - name: Restart PowerDNS ansible.builtin.systemd: name: "{{ serveur_powerdns_service_name }}" diff --git a/roles/serveur_powerdns/tasks/main.yml b/roles/serveur_powerdns/tasks/main.yml index c4a414a..090983d 100644 --- a/roles/serveur_powerdns/tasks/main.yml +++ b/roles/serveur_powerdns/tasks/main.yml @@ -83,14 +83,38 @@ # Meme source que la zone directe : `hotes_actifs` et son `ansible_host`. Deux zones # alimentees par une seule verite — l'endroit ou elles divergeraient n'existe pas. -- name: Générer la zone DNS inverse depuis l'inventaire +- name: Générer les zones DNS inverses depuis l'inventaire ansible.builtin.template: src: zone-inverse.db.j2 - dest: "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone" + dest: "{{ serveur_powerdns_zone_directory }}/{{ zone_inverse }}.zone" owner: root group: root mode: "0644" - when: serveur_powerdns_zone_inverse | length > 0 + loop: "{{ serveur_powerdns_zones_inverses | default([]) }}" + loop_control: + loop_var: zone_inverse + notify: Validate and reload PowerDNS + +# UNE ZONE INVERSE QUI N'EST PLUS REVENDIQUEE DOIT DISPARAITRE. Sans ce retrait, un +# ecosysteme qui abandonne un reseau laisserait derriere lui un fichier que le backend ne +# declare plus mais que le repertoire garde — et le prochain lecteur croirait la zone +# servie. +- name: Relever les zones inverses présentes sur la machine + ansible.builtin.find: + paths: "{{ serveur_powerdns_zone_directory }}" + patterns: "*.in-addr.arpa.zone" + register: serveur_powerdns_inverses_presentes + +- name: Retirer les zones inverses que l'écosystème ne revendique plus + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ serveur_powerdns_inverses_presentes.files | default([]) }}" + loop_control: + label: "{{ item.path | basename }}" + when: >- + (item.path | basename | regex_replace('\.zone$', '')) + not in (serveur_powerdns_zones_inverses | default([])) notify: Validate and reload PowerDNS - name: Activer et démarrer PowerDNS diff --git a/roles/serveur_powerdns/templates/bindbackend.conf.j2 b/roles/serveur_powerdns/templates/bindbackend.conf.j2 index 3c36245..e75cbfb 100644 --- a/roles/serveur_powerdns/templates/bindbackend.conf.j2 +++ b/roles/serveur_powerdns/templates/bindbackend.conf.j2 @@ -2,9 +2,12 @@ zone "{{ serveur_powerdns_zone }}" { type master; file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone }}.zone"; }; -{% if serveur_powerdns_zone_inverse %} -zone "{{ serveur_powerdns_zone_inverse }}" { +{# Une declaration par zone inverse : un ecosysteme qui occupe plusieurs reseaux en a + plusieurs. Un tenant tire un /16 de son index et n'en a qu'une ; un site declare ses + reseaux et en a une par reseau occupe. #} +{% for zone_inverse in serveur_powerdns_zones_inverses | default([]) %} +zone "{{ zone_inverse }}" { type master; - file "{{ serveur_powerdns_zone_directory }}/{{ serveur_powerdns_zone_inverse }}.zone"; + file "{{ serveur_powerdns_zone_directory }}/{{ zone_inverse }}.zone"; }; -{% endif %} +{% endfor %} diff --git a/roles/serveur_powerdns/templates/zone-inverse.db.j2 b/roles/serveur_powerdns/templates/zone-inverse.db.j2 index a620797..ba8fb85 100644 --- a/roles/serveur_powerdns/templates/zone-inverse.db.j2 +++ b/roles/serveur_powerdns/templates/zone-inverse.db.j2 @@ -1,11 +1,19 @@ -; Zone INVERSE — generee par Set-OPS (role serveur_powerdns). Ne pas editer a la main. +; Zone INVERSE {{ zone_inverse }} — generee par Set-OPS (role serveur_powerdns). +; Ne pas editer a la main. ; -; Le nom de la zone est DERIVE du supernet du tenant, comme tout le reste : un /16 -; 10.(10+index).0.0 donne (10+index).10.in-addr.arpa. Rien n'est ecrit. +; LE NOM DE LA ZONE DIT SA PROFONDEUR, et le PTR s'en deduit. Trois etiquettes numeriques +; (`31.0.10.in-addr.arpa`) valent un /24 : le PTR ne porte que le dernier octet. Deux +; (`27.10.in-addr.arpa`) valent un /16 : il en porte deux. Rien n'est ecrit — ni le +; prefixe, ni la forme du nom. ; -; Les PTR viennent de la MEME source que les A — `hotes_actifs` et son `ansible_host`. -; Deux zones alimentees par une seule verite : c'est ce qui empeche l'endroit ou elles -; divergent d'exister. +; Un TENANT tire un /16 de son index et n'a donc qu'une zone. Un SITE declare plusieurs +; reseaux et en a une par reseau occupe. La meme derivation sert les deux. +; +; Les PTR viennent de la MEME source que les A — les groupes d'inventaire et leur +; `ansible_host`. Deux zones alimentees par une seule verite : c'est ce qui empeche +; l'endroit ou elles divergent d'exister. +{% set _labels = zone_inverse | regex_replace('\.in-addr\.arpa\.?$', '') | split('.') %} +{% set _prefixe = _labels | reverse | list %} $TTL {{ serveur_powerdns_ttl }} @ IN SOA {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. {{ serveur_powerdns_contact }}. ( {{ serveur_powerdns_serial }} ; serial @@ -13,12 +21,14 @@ $TTL {{ serveur_powerdns_ttl }} {{ serveur_powerdns_soa_retry }} ; retry {{ serveur_powerdns_soa_expire }} ; expire {{ serveur_powerdns_soa_minimum }} ) ; minimum - @ IN NS {{ serveur_powerdns_nameserver }}.{{ serveur_powerdns_zone }}. - -{% for host_name in groups.get('hotes_actifs', []) | sort %} +{% set _vus = [] %} +{% for _groupe in serveur_powerdns_inventory_groups %} +{% for host_name in groups.get(_groupe, []) | sort if host_name not in _vus %} {% set ip = hostvars[host_name].ansible_host | default('') %} -{% if ip %} -{{ ip.split('.')[3] }}.{{ ip.split('.')[2] }} IN PTR {{ host_name }}.{{ serveur_powerdns_zone }}. +{% if ip and (ip.split('.')[:_prefixe | length] == _prefixe) %} +{% set _ = _vus.append(host_name) %} +{{ ip.split('.')[_prefixe | length:] | reverse | join('.') }} IN PTR {{ host_name }}.{{ serveur_powerdns_zone }}. {% endif %} {% endfor %} +{% endfor %} diff --git a/roles/serveur_resolveur/defaults/main.yml b/roles/serveur_resolveur/defaults/main.yml index 6181e07..012e5d6 100644 --- a/roles/serveur_resolveur/defaults/main.yml +++ b/roles/serveur_resolveur/defaults/main.yml @@ -38,6 +38,23 @@ serveur_resolveur_zone_interne: "{{ domaine_interne }}" serveur_resolveur_autoritatif: "127.0.0.1" serveur_resolveur_autoritatif_port: 5300 +# LE RESOLVEUR DELEGUE A L'AUTORITATIF CE QUE L'AUTORITATIF SERT (2026-08-25). +# +# La zone souveraine lui etait deleguee par une `stub-zone` ; les zones INVERSES ne +# l'etaient pas. PowerDNS servait donc les PTR sur son port, et personne ne les lui +# demandait : `dig -x` rendait vide depuis les cinq machines alors que la meme requete +# posee directement a l'autoritatif repondait juste. +# +# C'est la panne que le depot connait deja sous une autre forme — un service correct +# derriere un chemin que rien n'emprunte. +# +# MEME DERIVATION QUE `serveur_powerdns`, par le meme filtre : deux calculs separes +# finiraient par diverger, et la divergence ne se verrait qu'au premier PTR interroge. +serveur_resolveur_zones_inverses: >- + {{ (groups.get('hotes_actifs', []) | map('extract', hostvars, 'ansible_host') + | select | list) + | zones_inverses(setops_supernet | default('')) }} + # AUCUN TRANSITAIRE : Unbound récurse depuis les serveurs racine. Poser un transitaire ici # reviendrait à confier chaque question de l'écosystème à un tiers — ce que la bascule du # 2026-08-23 avait justement supprimé. diff --git a/roles/serveur_resolveur/templates/setops.conf.j2 b/roles/serveur_resolveur/templates/setops.conf.j2 index 22b110a..36e1074 100644 --- a/roles/serveur_resolveur/templates/setops.conf.j2 +++ b/roles/serveur_resolveur/templates/setops.conf.j2 @@ -13,6 +13,31 @@ server: do-ip6: no # La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC. domain-insecure: "{{ serveur_resolveur_zone_interne }}" +{% for zone_inverse in serveur_resolveur_zones_inverses | default([]) %} + # Les zones inverses ne le sont pas davantage — et `in-addr.arpa`, LUI, est signé + # publiquement. Sans cette ligne, la validation chercherait une chaîne de confiance + # vers une délégation qui n'existe pas, et tous les PTR rendraient SERVFAIL. + domain-insecure: "{{ zone_inverse }}" + # UNBOUND BLOQUE L'INVERSE PRIVÉ PAR DÉFAUT (2026-08-25). + # + # Il embarque des `local-zone` pour tout l'espace RFC1918 inversé, et y répond + # lui-même. Avec la `stub-zone` pourtant bien déclarée, `dig -x` rendait NXDOMAIN + # avec le drapeau `aa` — une réponse AUTORITAIRE d'Unbound, qui n'avait jamais + # interrogé l'autoritatif. Le même NXDOMAIN qu'un nom qui n'existe pas : rien ne + # distinguait le blocage de l'absence. + # + # `transparent` laisse la requête suivre son cours — donc atteindre la `stub-zone` + # ci-dessous — pour CETTE zone seulement. + # + # Pas `nodefault` : ce mode ne retire un blocage que si le nom correspond EXACTEMENT + # à une zone par défaut d'Unbound. La sienne est `10.in-addr.arpa`, le /8 entier ; nos + # `/24` n'y correspondent pas, et la ligne restait sans effet — mesuré, le NXDOMAIN + # autoritaire persistait après rechargement. + # + # Pas `unblock-lan-zones: yes` non plus : il aurait débloqué tout l'espace privé + # inversé, y compris ce que cet écosystème ne sert pas. + local-zone: "{{ zone_inverse }}" transparent +{% endfor %} # UNBOUND REFUSE D'INTERROGER UNE LOOPBACK PAR DÉFAUT (2026-08-24). # # `do-not-query-localhost` vaut `yes` d'origine — une protection contre les boucles. @@ -27,6 +52,16 @@ server: stub-zone: name: "{{ serveur_resolveur_zone_interne }}" stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }} +{# LES ZONES INVERSES SE DELEGUENT COMME LA DIRECTE (2026-08-25). Sans ces stubs, + l'autoritatif servait les PTR et personne ne les lui demandait : `dig -x` rendait vide + depuis les cinq machines, alors que la meme requete posee directement a l'autoritatif + repondait juste. Un service correct derriere un chemin que rien n'emprunte. #} +{% for zone_inverse in serveur_resolveur_zones_inverses | default([]) %} + +stub-zone: + name: "{{ zone_inverse }}" + stub-addr: {{ serveur_resolveur_autoritatif }}@{{ serveur_resolveur_autoritatif_port }} +{% endfor %} {% if serveur_resolveur_transitaires %} forward-zone: diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 8888c84..3452281 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -519,6 +519,37 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str | return resultat +def zones_inverses(adresses, supernet: str = "") -> list[str]: + """Les zones `in-addr.arpa` que cet ecosysteme revendique — et rien de plus. + + Un TENANT tire un supernet /16 de son index : une seule zone suffit a couvrir tous ses + sous-reseaux, et c'est celle qu'on rend. Un SITE ne derive pas — il declare plusieurs + /24 et n'a pas de supernet unique. On rend alors une zone PAR /24 reellement occupe. + + Revendiquer `0.10.in-addr.arpa` pour le site aurait ete plus simple et faux : cette + zone couvre aussi la frontiere, le transit et les hyperviseurs, qui ne sont pas a lui. + Une autorite qu'on s'attribue sans l'exercer est une panne differee — le resolveur + repondrait NXDOMAIN pour des adresses qu'un autre sait nommer. + + DEUX APPELANTS, UNE SEULE DERIVATION : `serveur_powerdns` ecrit ces zones, + `serveur_resolveur` les delegue a l'autoritatif. Deux calculs separes finiraient par + diverger, et la divergence ne se verrait qu'au premier PTR interroge. + """ + texte = str(supernet or "") + if texte.endswith("/16") and texte.count(".") == 3: + a, b = texte.split(".")[0], texte.split(".")[1] + return [f"{b}.{a}.in-addr.arpa"] + vues: list[str] = [] + for adresse in (adresses or []): + bouts = str(adresse or "").split(".") + if len(bouts) != 4 or not all(x.isdigit() for x in bouts): + continue + zone = f"{bouts[2]}.{bouts[1]}.{bouts[0]}.in-addr.arpa" + if zone not in vues: + vues.append(zone) + return sorted(vues) + + def chaine_connexion(entree: dict, serveur: dict, secret: str = "***") -> str: """Derive une chaine de connexion (mot de passe masque par defaut).""" type_bd = (serveur or {}).get("type", "postgres") diff --git a/scripts/prouver.py b/scripts/prouver.py index 48bce26..a40de57 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1197,6 +1197,53 @@ def preuve_un_seul_maitre_du_plancher() -> tuple[bool, str]: f"et le gabarit maitre est pose ({gabarit[0]}).") +def preuve_zones_inverses_couvrent_l_occupe() -> tuple[bool, str]: + """Les zones `in-addr.arpa` couvrent ce que l'ecosysteme occupe, et rien de plus. + + POURQUOI (2026-08-25). La derivation supposait un supernet /16 — la forme d'un TENANT, + qui tire tout de son index. Un SITE ne derive pas : il declare plusieurs /24 et n'a pas + de supernet unique. La derivation rendait donc une chaine vide, aucune zone inverse + n'etait generee, et les cinq machines du site restaient anonymes a l'envers — ce que le + courriel et la journalisation reprochent en silence. + + LE PIEGE INVERSE COMPTE AUTANT. Revendiquer `0.10.in-addr.arpa` aurait couvert le site + d'un seul geste — et aussi la frontiere, le transit et les hyperviseurs, qui ne sont + pas a lui. Une autorite qu'on s'attribue sans l'exercer est une panne differee : le + resolveur repondrait NXDOMAIN pour des adresses qu'un autre sait nommer. + + Cette preuve evalue la derivation elle-meme, sur les deux formes d'ecosysteme plus les + cas degenerees. Le cas tenant DOIT rendre une seule zone /16, et le cas site DOIT en + rendre quatre : sans ces deux-la, une derivation qui rend toujours une liste vide + passerait la preuve sans rien garantir. + """ + from inventory_rules import zones_inverses as _zi + + site = ["10.0.31.11", "10.0.32.11", "10.0.33.11", "10.0.33.21", "10.0.34.11"] + cas = [ + ("site — quatre reseaux declares, un /24 chacun", site, "10.0.34.0/24", + ["31.0.10.in-addr.arpa", "32.0.10.in-addr.arpa", + "33.0.10.in-addr.arpa", "34.0.10.in-addr.arpa"]), + ("tenant — un supernet /16 couvre tout", ["10.27.15.11", "10.27.16.11"], + "10.27.0.0/16", ["27.10.in-addr.arpa"]), + ("deux machines d'un meme /24 ne font qu'une zone", + ["10.0.33.11", "10.0.33.21"], "", ["33.0.10.in-addr.arpa"]), + ("aucune adresse : aucune zone, plutot qu'une zone fausse", [], "", []), + ("une adresse illisible est ignoree, pas devinee", + ["pas-une-adresse", "10.0.31.11"], "", ["31.0.10.in-addr.arpa"]), + ] + manques: list[str] = [] + for titre, adresses, supernet, attendu in cas: + obtenu = _zi(adresses, supernet) + if obtenu != attendu: + manques.append(f"{titre} : rend {obtenu}, attendu {attendu}") + if manques: + return False, ("Les zones inverses ne couvrent pas ce que l'ecosysteme occupe :" + "\n - " + "\n - ".join(manques)) + return True, (f"Les zones inverses couvrent l'occupe et rien de plus " + f"({len(cas)} cas evalues, dont un site a quatre zones et un tenant " + f"a une).") + + def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. @@ -1542,6 +1589,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_parefeu_proxmox_sdn_seulement}, {"id": "P46", "titre": "Plancher /etc/hosts : un seul role en decide", "refs": [], "func": preuve_un_seul_maitre_du_plancher}, + {"id": "P47", "titre": "Zones inverses : couvrir l'occupe, et rien de plus", + "refs": [], "func": preuve_zones_inverses_couvrent_l_occupe}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],