From eee6851c3bdd08ab143647ac3dfc26e496c6d661 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 29 Sep 2026 12:34:47 -0400 Subject: [PATCH] sonde plancher ; une exposition sans port n est plus attribuee a l edge La zone publie l empreinte de ce que le plancher doit porter ; chaque machine la compare a son /etc/hosts. Au site, elle a revele que sauvegarde, pki et dns.genese.internal pointaient vers l edge (regression de mon deploiement du matin) : la derivation ne donne plus a l edge ce qui n a pas de port. Zone du site corrigee, SFTP des locataires verifie. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 51 ++++++++ Makefile | 2 + roles/client_sante/defaults/main.yml | 4 + roles/client_sante/meta/supervision.yml | 10 ++ roles/client_sante/tasks/main.yml | 12 ++ .../templates/sonde-plancher.sh.j2 | 96 +++++++++++++++ roles/serveur_powerdns/templates/zone.db.j2 | 29 +++++ scripts/inventory_rules.py | 11 +- scripts/prouver.py | 4 +- scripts/tests/test_empreinte_plancher.py | 115 ++++++++++++++++++ scripts/tests/test_expositions_sans_port.py | 43 +++++++ wiki/Rôle-client_sante.md | 2 + wiki/Rôles.md | 2 +- 13 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 roles/client_sante/templates/sonde-plancher.sh.j2 create mode 100644 scripts/tests/test_empreinte_plancher.py create mode 100644 scripts/tests/test_expositions_sans_port.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcacc3..06dd83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-09-29 (31) — Sonde `plancher` ; elle a trouvé une régression que j'avais causée au site, corrigée à la source + +**La sonde `plancher` (rôle `client_sante`, sur toute machine qui rapporte).** Le plancher +`/etc/hosts` et la zone PowerDNS suivent le même plan, chacun quand son rôle est rejoué +(voir (30)). La zone publie désormais `_plancher. TXT "n=… sha256=…"` : le nombre et +l'empreinte des paires « nom adresse » que le plancher DOIT porter — flotte active ET +planifiée, expositions du domaine. La sonde calcule la même chose sur `/etc/hosts`, en +interrogeant l'autoritatif directement, sans transfert de zone (AXFR reste fermé). En cas +d'écart elle nomme ce qu'elle peut : noms publiés absents (nombre), adresses différentes, +noms inconnus de l'autoritatif. Manquant ou réadressé : critique ; en trop seulement : +avertissement. `seulement_si: hosts_statiques_actif`. `test_empreinte_plancher.py` rend les +deux gabarits sur un même inventaire et exige la même empreinte. + +Chez les deux locataires : 26 machines sur 26 conformes (19 noms chacun). Mises en défaut +sur `mon-01` (Technolibre), sur des copies du plancher : vide, sans `console` → critique, +« 1 nom publié absent » ; `forge-01` en trop → avertissement nommé ; `mon-01` réadressé → +critique, les deux adresses données. + +**Ce qu'elle a trouvé au site — et ce que j'y avais cassé.** En (30), j'ai redéployé la zone +du site sans simulation préalable, en attribuant la réécriture au seul `site-dnspub-01`. La +zone faisait en réalité pointer `sauvegarde`, `pki` et `dns.genese.internal` vers l'edge du +site (10.37.37.11), qui ne les sert pas. Les locataires sauvegardent vers +`sauvegarde.genese.internal`, résolu par le DNS du site : de 05 h 23 à 11 h 51, ce nom menait +à l'edge, où aucun SFTP n'attend. Aucune sauvegarde n'est tombée dans la fenêtre (elles +tournent vers 02 h 30), mais celles de la nuit auraient toutes échoué. + +**La cause est dans la dérivation.** `expositions_des_applications` attribuait tout FQDN +d'un domaine à l'edge de ce domaine. Or un edge ne publie que ce qui a un port : nginx n'écrit +un vhost que pour une exposition qui en porte un. Depuis que `genese.internal` a un edge +(14 septembre), les trois expositions sans port lui étaient attribuées ; les anciens +planchers du site, antérieurs, les envoyaient encore aux bonnes machines, et la zone chargée +avant (30) aussi. **Corrigé** : une exposition sans port se sert elle-même +(`test_expositions_sans_port.py`) ; la preuve de `prouver.py` qui refuse qu'une exposition +retombe sur son propre groupe dans un écosystème à edge excepte désormais celles sans port. +Zone du site redéployée après simulation (trois A, vers 10.37.35.11, .32.11, .34.11), cache +d'Unbound vidé pour `genese.internal`. Depuis les deux locataires, `sauvegarde` rend +10.37.35.11 et une session SFTP s'ouvre dans le bon dépôt. `site-dnspub-01`, dont le plancher +avait été généré avec la dérivation fautive, rejoué seul : conforme. + +**Correction de (30)** : la zone du site n'y avait pas seulement reçu `site-dnspub-01` — +elle avait aussi envoyé ces trois noms vers l'edge. + +**Ce qui reste, et qui est une décision.** Les 8 autres machines du site ont un plancher +antérieur à l'edge : `forge`, `observatoire` et `vigie` y mènent directement aux services, +et `console`, `site-dnspub-01` y manquent (le DNS du site les résout). La sonde les montre +critiques. Les rejouer ferait passer le runner du site par l'edge pour joindre la forge en +HTTPS : la lecture y a été éprouvée (`ls-remote` identique), mais l'edge limite une requête à +16 Mo et `Set-OPS-public` en pèse 171 — un envoi de rattrapage n'y passerait pas. Et +`client_pki` retiendra, à son prochain passage au site, `pki.genese.internal` sur +`site-pki-01` plutôt que sur l'edge — le nom qu'il sert vraiment. + ## 2026-09-29 (30) — `console` ne se résolvait pas : un plancher périmé et une zone jamais rechargée **Constat.** Depuis l'edge de chaque locataire, `console.` ne se résolvait pas, diff --git a/Makefile b/Makefile index fe25387..4f5bcfc 100644 --- a/Makefile +++ b/Makefile @@ -317,6 +317,8 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire) python3 scripts/tests/test_fraicheur_icinga.py python3 scripts/tests/test_sondes_conditionnelles.py python3 scripts/tests/test_sondes_syntaxe.py + python3 scripts/tests/test_empreinte_plancher.py + python3 scripts/tests/test_expositions_sans_port.py # LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine diff --git a/roles/client_sante/defaults/main.yml b/roles/client_sante/defaults/main.yml index 21e2a0a..ed41cfc 100644 --- a/roles/client_sante/defaults/main.yml +++ b/roles/client_sante/defaults/main.yml @@ -69,3 +69,7 @@ client_sante_paquets: # machines ; ces seuils laissent donc voir une croissance longtemps avant la panne. client_sante_disque_avert: 80 client_sante_disque_crit: 90 + +# Le plancher que la sonde « plancher » compare a l'autoritatif. Mise en defaut PAR +# PARAMETRE : un fichier vide ou absent — la sonde doit alors voir tous les noms manquer. +client_sante_plancher_fichier: "/etc/hosts" diff --git a/roles/client_sante/meta/supervision.yml b/roles/client_sante/meta/supervision.yml index 9533269..4cc0871 100644 --- a/roles/client_sante/meta/supervision.yml +++ b/roles/client_sante/meta/supervision.yml @@ -14,3 +14,13 @@ sondes: Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait la base ou le courriel sans prevenir. + - nom: plancher + ttl: 5400 + # Posee sous la condition du plancher (voir `setops-sondes.conf.j2`, 2026-09-28). + seulement_si: hosts_statiques_actif + defini_par: hosts_statiques + raison: >- + Le plancher /etc/hosts de la machine dit-il la meme chose que l'autoritatif ? Les deux + suivent le plan, chacun quand son role est rejoue. Le 2026-09-29, la console manquait + aux planchers des deux locataires : depuis l'edge, elle ne se resolvait pas, et le + plancher y est la seule resolution des noms internes. diff --git a/roles/client_sante/tasks/main.yml b/roles/client_sante/tasks/main.yml index f7470f2..1a8f9e3 100644 --- a/roles/client_sante/tasks/main.yml +++ b/roles/client_sante/tasks/main.yml @@ -163,6 +163,18 @@ group: root mode: "0750" +# LA SONDE « plancher » (2026-09-29), sous la condition meme du plancher : sans plancher +# gere (`hosts_statiques_actif: false`), il n'y a rien a comparer. Posee ici pour la raison +# de « disque » : ce role tourne sur tout hote qui rapporte, sans toucher au systeme. +- name: Deposer la sonde du plancher de resolution + ansible.builtin.template: + src: sonde-plancher.sh.j2 + dest: /usr/local/lib/setops/sondes/plancher.sh + owner: root + group: root + mode: "0750" + when: hosts_statiques_actif | default(true) | bool + - name: Relever les sondes reellement posees ansible.builtin.find: paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}" diff --git a/roles/client_sante/templates/sonde-plancher.sh.j2 b/roles/client_sante/templates/sonde-plancher.sh.j2 new file mode 100644 index 0000000..d8ab2fa --- /dev/null +++ b/roles/client_sante/templates/sonde-plancher.sh.j2 @@ -0,0 +1,96 @@ +#!/bin/bash +# GENERE par Set-OPS (role client_sante). Ne pas editer a la main. +# +# SONDE « plancher » — le plancher /etc/hosts de cette machine dit-il la meme chose que +# l'autoritatif du domaine ? +# +# POURQUOI (2026-09-29). Le plancher et la zone PowerDNS derivent du meme plan, mais +# chacun ne le suit que quand son role est rejoue. La console a ete ajoutee au plan : la +# zone l'a recue, les planchers non — sauf celui d'`ops-01`, redeploye depuis. Depuis +# l'edge, `console` ne se resolvait pas, et rien ne le signalait. Sur ces machines, le +# plancher est la SEULE resolution des noms internes : un nom qui y manque n'existe pas. +# +# COMMENT, SANS TRANSFERT DE ZONE. La zone publie `_plancher. TXT "n=… sha256=…"` +# : le nombre et l'empreinte des paires « nom adresse » que le plancher doit porter pour ce +# domaine. On calcule la meme chose ici sur /etc/hosts. Si elles different, on nomme ce +# qu'on peut : chaque nom du plancher est demande a l'autoritatif (en trop, readresse), et +# l'ecart de nombre dit combien de noms publies manquent ici. +# +# L'autoritatif est interroge DIRECTEMENT : le resolveur qu'utilisent ces machines peut +# ignorer ce domaine (c'est le cas chez les locataires, qui passent par celui du site). +# +# Mise en defaut PAR PARAMETRE : `client_sante_plancher_fichier` (un plancher vide). +set -uo pipefail + +DOMAINE='{{ domaine_interne | default('') }}' +AUTORITE='{{ hostvars[groups['serveur_powerdns'][0]].ansible_host | default('') if (groups.get('serveur_powerdns') or []) else '' }}' +FICHIER='{{ client_sante_plancher_fichier }}' + +if [[ -z "${DOMAINE}" || -z "${AUTORITE}" ]]; then + echo "Pas d'autoritatif interne dans cet ecosysteme : rien a quoi comparer le plancher." + exit 0 +fi + +txt=$(dig +short +time=3 +tries=2 TXT "_plancher.${DOMAINE}" "@${AUTORITE}" 2>/dev/null | tr -d '"') +if [[ "${txt}" != n=*" sha256="* ]]; then + echo "L'autoritatif (${AUTORITE}) ne publie pas l'empreinte du plancher (_plancher.${DOMAINE} : ${txt:-rien}) — zone anterieure, ou injoignable : comparaison impossible." + exit 1 +fi + +# Les paires « nom adresse » du plancher pour CE domaine : toute ligne, tout nom qui finit +# par `.` (le nom court des hotes, en alias, n'est pas compte ; la zone non plus). +local_=$(python3 - "${FICHIER}" "${DOMAINE}" <<'PY' +import hashlib, sys +fichier, domaine = sys.argv[1], sys.argv[2] +paires = set() +try: + lignes = open(fichier, encoding="utf-8").read().splitlines() +except OSError: + lignes = [] +for l in lignes: + champs = l.split("#", 1)[0].split() + if len(champs) < 2: + continue + for nom in champs[1:]: + if nom.endswith("." + domaine): + paires.add(f"{nom} {champs[0]}") +tri = sorted(paires) +print(f"n={len(tri)} sha256={hashlib.sha256(chr(10).join(tri).encode()).hexdigest()}") +for p in tri: + print(p) +PY +) +empreinte=$(printf '%s\n' "${local_}" | head -1) +n_pub=${txt#n=}; n_pub=${n_pub%% *} +n_loc=${empreinte#n=}; n_loc=${n_loc%% *} +perf="| noms=${n_loc};;;0; publies=${n_pub};;;0;" + +if [[ "${empreinte}" == "${txt}" ]]; then + echo "Plancher conforme a l'autoritatif : ${n_loc} nom(s) de ${DOMAINE}, memes adresses. ${perf}" + exit 0 +fi + +# ILS DIFFERENT : on nomme ce qu'on peut. Chaque nom du plancher est demande a l'autoritatif. +# Un nom qu'il ignore est en trop ici (ou une machine planifiee, que la zone ne publie pas +# encore) ; une autre adresse est un readressage que l'un des deux n'a pas suivi. +en_trop=(); readresses=(); connus=0; i=0 +while read -r nom ip; do + (( i++ >= 40 )) && break + pub=$(dig +short +time=2 +tries=1 A "${nom}" "@${AUTORITE}" 2>/dev/null | tail -1) + if [[ -z "${pub}" ]]; then en_trop+=("${nom%%.*}") + elif [[ "${pub}" != "${ip}" ]]; then readresses+=("${nom%%.*}=${ip}/${pub}"); connus=$((connus + 1)) + else connus=$((connus + 1)); fi +done < <(printf '%s\n' "${local_}" | tail -n +2) +manquants=$(( n_pub - connus )) + +msg="Le plancher de cette machine et l'autoritatif divergent (${n_loc} nom(s) ici, ${n_pub} publie(s))" +[[ ${manquants} -gt 0 ]] && msg+=" ; ${manquants} nom(s) publie(s) absent(s) d'ici" +[[ -n "${readresses[*]}" ]] && msg+=" ; autre adresse : ${readresses[*]}" +[[ -n "${en_trop[*]}" ]] && msg+=" ; inconnu(s) de l'autoritatif : ${en_trop[*]}" +msg+=". Rejouer hosts_statiques (ou serveur_powerdns, si c'est la zone qui date)." +# Un nom qui MANQUE ou une adresse qui differe casse une resolution : critique. Des noms en +# trop seulement (une machine retiree du plan) ne cassent rien : avertissement. +if (( manquants > 0 )) || [[ -n "${readresses[*]}" ]]; then + echo "${msg} ${perf}"; exit 2 +fi +echo "${msg} ${perf}"; exit 1 diff --git a/roles/serveur_powerdns/templates/zone.db.j2 b/roles/serveur_powerdns/templates/zone.db.j2 index 0a0f7b7..1ff2438 100644 --- a/roles/serveur_powerdns/templates/zone.db.j2 +++ b/roles/serveur_powerdns/templates/zone.db.j2 @@ -52,3 +52,32 @@ $TTL {{ serveur_powerdns_ttl }} {% endif %} {% endfor %} {% endif %} + +{# L'EMPREINTE DU PLANCHER (2026-09-29). Le plancher `/etc/hosts` de chaque machine et + cette zone derivent du meme plan, mais pas au meme moment : chacun suit le plan quand son + role est rejoue. Le 2026-09-29, la console manquait aux planchers des deux locataires + pendant que la zone la portait. Pour que chaque machine puisse comparer SANS transfert de + zone (AXFR reste ferme), la zone publie ce que le plancher DOIT contenir pour ce domaine : + le nombre de paires « nom adresse » et leur empreinte sha256. + + LE MEME ENSEMBLE QUE `hosts_statiques/templates/hosts.j2`, pas celui de cette zone : la + flotte ACTIVE ET PLANIFIEE (le plancher resout un clone avant qu'il soit actif ; la zone + non), plus les expositions de ce domaine. `test_empreinte_plancher.py` rend les deux + gabarits sur un meme inventaire et exige la meme empreinte : si l'un change seul, le + test le dit. La sonde `plancher` (role `client_sante`) calcule le cote machine. #} +{% set _plancher = [] %} +{% for h in (groups.get('hotes_actifs', []) + groups.get('hotes_planifies', [])) | unique | sort %} +{% if hostvars[h].ansible_host is defined %} +{% set _ = _plancher.append(h ~ '.' ~ serveur_powerdns_zone ~ ' ' ~ hostvars[h].ansible_host) %} +{% endif %} +{% endfor %} +{% if serveur_powerdns_publier_expositions | default(false) %} +{% for expo in serveur_powerdns_expositions | default([]) if expo.domaine == serveur_powerdns_zone %} +{% set edge_hosts = groups.get(expo.edge, []) %} +{% if edge_hosts and hostvars[edge_hosts[0]].ansible_host is defined %} +{% set _ = _plancher.append(expo.fqdn ~ ' ' ~ hostvars[edge_hosts[0]].ansible_host) %} +{% endif %} +{% endfor %} +{% endif %} +{% set _paires = _plancher | unique | sort %} +_plancher IN TXT "n={{ _paires | length }} sha256={{ _paires | join('\n') | hash('sha256') }}" diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index e801704..bff3bd9 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -568,7 +568,16 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str | # ecrivait un plancher SANS alias. Le certificat de la forge portait # `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct # sur un nom que personne ne pouvait appeler. - edge_dom = conf.get("edge") or app.get("groupe") + # + # SANS PORT, PAS D'EDGE (2026-09-29). Un edge ne publie que ce qui a un port : + # nginx n'ecrit un vhost que pour une exposition qui en porte un. Le site expose + # aussi `sauvegarde` (SFTP), `pki` et `dns`, sans port web. Depuis que + # `genese.internal` a un edge, ces trois noms lui etaient attribues : la zone et le + # plancher les faisaient pointer vers `site-edge-01`, qui ne les sert pas. Le + # 2026-09-29, `sauvegarde.genese.internal` repondait l'edge, et les sauvegardes des + # deux locataires y seraient allees frapper un sshd qui n'est pas le leur. + # Une exposition sans port se sert donc elle-meme, comme avant l'edge. + edge_dom = (conf.get("edge") if app.get("port") else None) or app.get("groupe") if edge is not None and edge_dom != edge: continue # SANS DOMAINE PUBLIC DECLARE, LE DOMAINE EST CELUI QUE PORTE LE FQDN diff --git a/scripts/prouver.py b/scripts/prouver.py index 198885f..8febee0 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -4013,7 +4013,9 @@ def _repli_edge(nom: str, applications: dict, domaines: dict, a_un_edge: bool) - return [f"edge : {nom} — `{e['fqdn']}` retombe sur son propre groupe (`{e['edge']}`) " f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront" for e in expositions_des_applications(applications, domaines) - if e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits] + # Sans port, aucun vhost ne peut la servir : elle se sert elle-meme, a juste + # titre (`sauvegarde`, `pki`, `dns` au site — voir `expositions_des_applications`). + if e.get("port") and e["edge"] != "serveur_nginx" and str(e["edge"]) not in ecrits] def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]: diff --git a/scripts/tests/test_empreinte_plancher.py b/scripts/tests/test_empreinte_plancher.py new file mode 100644 index 0000000..69cfaec --- /dev/null +++ b/scripts/tests/test_empreinte_plancher.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""L'empreinte que la zone publie est celle que la sonde calcule sur le plancher rendu. + +POURQUOI (2026-09-29). La zone PowerDNS publie `_plancher. TXT "n=… sha256=…"` : +ce que le plancher `/etc/hosts` DOIT porter pour ce domaine. La sonde `plancher` calcule la +meme chose sur /etc/hosts. Les deux calculs vivent dans deux gabarits de deux roles +(`serveur_powerdns/templates/zone.db.j2`, `hosts_statiques/templates/hosts.j2`) et dans le +script de la sonde (`client_sante/templates/sonde-plancher.sh.j2`) : une liste qui en suit +une autre prend du retard. Si l'un change seul, chaque machine crierait « divergent » sur +une flotte saine. + +On rend donc les deux gabarits sur UN MEME inventaire fictif — hotes actifs ET planifies +(le plancher resout un clone avant la zone), expositions de deux domaines, un hote sans +adresse — et on execute le code Python de la sonde sur le plancher rendu. Les empreintes +doivent etre identiques. Controle negatif : un nom retire du plancher doit la changer. +""" +from __future__ import annotations + +import hashlib +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +import jinja2 + +ROLES = Path(__file__).resolve().parents[2] / "roles" +DOMAINE = "exemple.internal" + + +def environnement() -> jinja2.Environment: + env = jinja2.Environment(undefined=jinja2.ChainableUndefined, extensions=["jinja2.ext.do"]) + env.filters["hash"] = lambda v, algo="sha1": hashlib.new(algo, str(v).encode()).hexdigest() + env.filters["bool"] = bool + return env + + +def contexte() -> dict: + hostvars = { + "mon-01": {"ansible_host": "10.9.20.21"}, + "edge-01": {"ansible_host": "10.9.16.11"}, + "dns-01": {"ansible_host": "10.9.19.11"}, + "neuf-01": {"ansible_host": "10.9.21.99"}, # planifie : au plancher, pas en A + "sans-ip": {}, + } + expositions = [ + {"fqdn": f"console.{DOMAINE}", "domaine": DOMAINE, "edge": "serveur_nginx"}, + {"fqdn": f"vigie.{DOMAINE}", "domaine": DOMAINE, "edge": "serveur_nginx"}, + {"fqdn": "www.exemple.ca", "domaine": "exemple.ca", "edge": "serveur_nginx"}, + ] + return { + "groups": {"hotes_actifs": ["mon-01", "edge-01", "dns-01", "sans-ip"], + "hotes_planifies": ["neuf-01"], "serveur_nginx": ["edge-01"], + "serveur_powerdns": ["dns-01"]}, + "hostvars": hostvars, + # plancher + "hosts_statiques_domaine": DOMAINE, "hosts_statiques_publier_expositions": True, + "hosts_statiques_expositions": expositions, "hosts_statiques_externes": [], + # zone + "serveur_powerdns_zone": DOMAINE, "serveur_powerdns_publier_expositions": True, + "serveur_powerdns_expositions": expositions, "serveur_powerdns_records": [], + "serveur_powerdns_inventory_groups": ["hotes_actifs"], "serveur_powerdns_nameserver": "ns1", + } + + +def code_de_la_sonde() -> str: + gabarit = (ROLES / "client_sante/templates/sonde-plancher.sh.j2").read_text(encoding="utf-8") + m = re.search(r"<<'PY'\n(.*?)\nPY\n", gabarit, re.S) + assert m, "bloc Python introuvable dans sonde-plancher.sh.j2" + return m.group(1) + + +def empreinte_sonde(plancher: str) -> str: + with tempfile.NamedTemporaryFile("w", suffix=".hosts", delete=False) as f: + f.write(plancher) + r = subprocess.run([sys.executable, "-", f.name, DOMAINE], input=code_de_la_sonde(), + capture_output=True, text=True) + Path(f.name).unlink() + return r.stdout.splitlines()[0] + + +def main() -> int: + env, ctx = environnement(), contexte() + plancher = env.from_string((ROLES / "hosts_statiques/templates/hosts.j2") + .read_text(encoding="utf-8")).render(**ctx) + zone = env.from_string((ROLES / "serveur_powerdns/templates/zone.db.j2") + .read_text(encoding="utf-8")).render(**ctx) + m = re.search(r'^_plancher IN TXT "([^"]+)"', zone, re.M) + if not m: + print("ECHEC la zone ne publie pas `_plancher IN TXT`.") + return 1 + publie, calcule = m.group(1), empreinte_sonde(plancher) + echecs = 0 + if publie != calcule: + print(f"ECHEC la zone publie « {publie} », la sonde calcule « {calcule} » sur le plancher " + f"rendu du MEME inventaire : les deux gabarits ne comptent plus la meme chose.") + echecs += 1 + if not publie.startswith("n=6 "): + print(f"ECHEC attendu 6 paires (4 hotes avec adresse dont 1 planifie, 2 expositions du " + f"domaine) ; publie : {publie}") + echecs += 1 + ampute = "\n".join(l for l in plancher.splitlines() if "console." not in l) + if empreinte_sonde(ampute) == publie: + print("ECHEC controle negatif : un plancher sans `console` rend la meme empreinte.") + echecs += 1 + if echecs: + return 1 + print(f"OK — zone et plancher rendus sur le meme inventaire : meme empreinte ({publie[:18]}…) ; " + f"un nom retire la change.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_expositions_sans_port.py b/scripts/tests/test_expositions_sans_port.py new file mode 100644 index 0000000..d837da1 --- /dev/null +++ b/scripts/tests/test_expositions_sans_port.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Une exposition SANS PORT n'est jamais attribuee a l'edge du domaine. + +POURQUOI (2026-09-29). Un edge ne publie que ce qui a un port : nginx n'ecrit un vhost que +pour une exposition qui en porte un. Le site expose aussi `sauvegarde` (SFTP), `pki` et +`dns` sans port web. Quand `genese.internal` a recu un edge, la derivation lui a attribue +ces trois noms ; la zone et le plancher les ont fait pointer vers `site-edge-01`, et les +sauvegardes des deux locataires y seraient allees frapper un sshd qui n'est pas le leur. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from inventory_rules import expositions_des_applications # noqa: E402 + +APPS = {"applications": { + "forge": {"groupe": "serveur_forgejo", "hote": "forge-01", "port": 443, "expose": ["forge.ex.internal"]}, + "backup": {"groupe": "serveur_backup_site", "hote": "backup-01", "expose": ["sauvegarde.ex.internal"]}, + "pki": {"groupe": "serveur_step_ca", "hote": "pki-01", "port": None, "expose": ["pki.ex.internal"]}, +}} +DOMS = {"domaines_publics": {"ex.internal": {"edge": "serveur_nginx"}}} + + +def main() -> int: + edges = {e["fqdn"]: e["edge"] for e in expositions_des_applications(APPS, DOMS)} + attendu = {"forge.ex.internal": "serveur_nginx", + "sauvegarde.ex.internal": "serveur_backup_site", + "pki.ex.internal": "serveur_step_ca"} + if edges != attendu: + print(f"ECHEC attendu {attendu}, obtenu {edges}") + return 1 + sans_edge = {e["fqdn"]: e["edge"] for e in expositions_des_applications(APPS, {"domaines_publics": {}})} + if sans_edge["forge.ex.internal"] != "serveur_forgejo": + print(f"ECHEC sans edge declare, le service doit se servir lui-meme : {sans_edge}") + return 1 + print("OK — avec port : l'edge du domaine ; sans port : le service lui-meme.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wiki/Rôle-client_sante.md b/wiki/Rôle-client_sante.md index 2b4ba0b..7fdd9eb 100644 --- a/wiki/Rôle-client_sante.md +++ b/wiki/Rôle-client_sante.md @@ -9,6 +9,7 @@ graph LR R["sante"] R -.->|"sonde « disque »"| ICINGA[["Icinga"]] + R -.->|"sonde « plancher »"| ICINGA[["Icinga"]] ``` ## Qui lui parle @@ -20,6 +21,7 @@ graph LR | sonde | TTL | ce qu'elle voit | |---|---|---| | `disque` | 5400 s | Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait la base ou le courriel sans prevenir. | +| `plancher` | 5400 s | Le plancher /etc/hosts de la machine dit-il la meme chose que l'autoritatif ? Les deux suivent le plan, chacun quand son role est rejoue. Le 2026-09-29, la console manquait aux planchers des deux locataires : depuis l'edge, elle ne se resolvait pas, et le plancher y est la seule resolution des noms internes. | ## Ce qu'il expose en séries diff --git a/wiki/Rôles.md b/wiki/Rôles.md index e4224b0..72d6b62 100644 --- a/wiki/Rôles.md +++ b/wiki/Rôles.md @@ -20,7 +20,7 @@ Chaque fiche est construite depuis ce que le rôle **déclare lui-même** — `m | [`client_metrique`](Rôle-client_metrique) | 1 | 1 | — | | [`client_pki`](Rôle-client_pki) | — | 1 | — | | [`client_resolveur`](Rôle-client_resolveur) | — | — | — | -| [`client_sante`](Rôle-client_sante) | — | 1 | — | +| [`client_sante`](Rôle-client_sante) | — | 2 | — | | [`client_smtp`](Rôle-client_smtp) | — | — | — | | [`cloud_init`](Rôle-cloud_init) | — | — | — | | [`cloud_init_retrait`](Rôle-cloud_init_retrait) | — | — | — |