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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-29 12:34:47 -04:00
parent ba2020d486
commit eee6851c3b
13 changed files with 378 additions and 3 deletions

View file

@ -1,5 +1,56 @@
# CHANGELOG — Set-OPS # 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.<domaine> 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 ## 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.<domaine>` ne se résolvait pas, **Constat.** Depuis l'edge de chaque locataire, `console.<domaine>` ne se résolvait pas,

View file

@ -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_fraicheur_icinga.py
python3 scripts/tests/test_sondes_conditionnelles.py python3 scripts/tests/test_sondes_conditionnelles.py
python3 scripts/tests/test_sondes_syntaxe.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 # 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 # journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine

View file

@ -69,3 +69,7 @@ client_sante_paquets:
# machines ; ces seuils laissent donc voir une croissance longtemps avant la panne. # machines ; ces seuils laissent donc voir une croissance longtemps avant la panne.
client_sante_disque_avert: 80 client_sante_disque_avert: 80
client_sante_disque_crit: 90 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"

View file

@ -14,3 +14,13 @@ sondes:
Reste-t-il de la place, et des inodes, sur chaque systeme de fichiers ? Seuls le 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 cache apt et les depots de sauvegarde etaient surveilles : un disque plein arretait
la base ou le courriel sans prevenir. 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.

View file

@ -163,6 +163,18 @@
group: root group: root
mode: "0750" 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 - name: Relever les sondes reellement posees
ansible.builtin.find: ansible.builtin.find:
paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}" paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}"

View file

@ -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.<domaine> 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 `.<domaine>` (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

View file

@ -52,3 +52,32 @@ $TTL {{ serveur_powerdns_ttl }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% 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') }}"

View file

@ -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 # ecrivait un plancher SANS alias. Le certificat de la forge portait
# `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct # `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct
# sur un nom que personne ne pouvait appeler. # 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: if edge is not None and edge_dom != edge:
continue continue
# SANS DOMAINE PUBLIC DECLARE, LE DOMAINE EST CELUI QUE PORTE LE FQDN # SANS DOMAINE PUBLIC DECLARE, LE DOMAINE EST CELUI QUE PORTE LE FQDN

View file

@ -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']}`) " 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" f"alors que l'ecosysteme a un edge : ni vhost ni SAN ne le retiendront"
for e in expositions_des_applications(applications, domaines) 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]: def _repli_hotes_du_site(enfants: dict, hv: dict) -> list[str]:

View file

@ -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.<domaine> 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())

View file

@ -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())

View file

@ -9,6 +9,7 @@
graph LR graph LR
R["<b>sante</b>"] R["<b>sante</b>"]
R -.->|"sonde « disque »"| ICINGA[["Icinga"]] R -.->|"sonde « disque »"| ICINGA[["Icinga"]]
R -.->|"sonde « plancher »"| ICINGA[["Icinga"]]
``` ```
## Qui lui parle ## Qui lui parle
@ -20,6 +21,7 @@ graph LR
| sonde | TTL | ce qu'elle voit | | 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. | | `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 ## Ce qu'il expose en séries

View file

@ -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_metrique`](Rôle-client_metrique) | 1 | 1 | — |
| [`client_pki`](Rôle-client_pki) | — | 1 | — | | [`client_pki`](Rôle-client_pki) | — | 1 | — |
| [`client_resolveur`](Rôle-client_resolveur) | — | — | — | | [`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) | — | — | — | | [`client_smtp`](Rôle-client_smtp) | — | — | — |
| [`cloud_init`](Rôle-cloud_init) | — | — | — | | [`cloud_init`](Rôle-cloud_init) | — | — | — |
| [`cloud_init_retrait`](Rôle-cloud_init_retrait) | — | — | — | | [`cloud_init_retrait`](Rôle-cloud_init_retrait) | — | — | — |