sondes filtrage et edge : elles verifient le service rendu ; l edge refuse les noms inconnus

filtrage soumet GTUBE ; edge interroge chaque exposition en local. Sans serveur
par defaut, un nom inconnu recevait Keycloak : 000-defaut.conf (444, rejet TLS).
Garde test_sondes_syntaxe : chaque gabarit de sonde se rend en bash valide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 19:40:26 -04:00
parent b938897e17
commit 6b6a56ab3f
10 changed files with 225 additions and 16 deletions

View file

@ -1,5 +1,40 @@
# CHANGELOG — Set-OPS
## 2026-09-28 (25) — Deux sondes qui disaient « répond » apprennent à dire « sert » ; l'edge refuse les noms inconnus
Revue de toutes les sondes, à la recherche de celles qui resteraient vertes pendant que le
service ne rend plus rien — le cas de `tableaux`. La plupart vont déjà au fond (vraie requête
SQL, recherche LDAP, résolution réelle, cibles collectées…). Cinq ne le font pas :
`filtrage`, `edge`, `identite`, `console`, `passerelle`. Les deux premières sont faites.
**`filtrage` (rspamd).** S'arrêtait à `/ping`. Soumet désormais GTUBE, le message de test
que tout filtre doit rejeter : rien n'est envoyé ni livré, `rspamc` lit le verdict. Chez les
deux locataires : rejeté, 15/15. Mise en défaut par paramètre : critique, « n'analyse plus ».
**`edge` (nginx).** Vérifiait la configuration et les ports. Interroge désormais chaque
exposition EN LOCAL (son nom, son SNI, vers 127.0.0.1) : un 5xx ou un silence la fait passer
critique, en la nommant. Même filtre que les vhosts (hôte, adresse, port) : au site, `dns`,
`pki` et `sauvegarde` n'ont pas de vhost et ne sont pas attendus.
**Ce que l'épreuve de `edge` a révélé : un nom inconnu recevait Keycloak.** Sans serveur par
défaut sur 443, nginx confiait tout nom inconnu au premier vhost — `absente.chezlepro.internal`
obtenait un 302 vers `/admin/`. Une porte qui ne devrait pas exister, et une sonde aveugle :
une exposition dont le vhost aurait disparu restait « servie »… par Keycloak. Et le site par
défaut de Debian restait actif sur 80 (`serveur_nginx_desactiver_defaut: false`, sans raison
écrite). Désormais `000-defaut.conf` : 444 sur 80, `ssl_reject_handshake` sur 443 ; le site
Debian est retiré. Posé sur les trois edges : expositions toujours servies (6, 6, 4), noms
inconnus et IP nue refusés, et la sonde voit une exposition fictive (critique).
**Une erreur de ma part, en direct, et sa garde.** `sonde-edge.sh.j2` redéfinit la syntaxe des
commentaires Jinja (`{=# … #=}`) ; j'y ai écrit un commentaire ordinaire, sorti tel quel dans le
script : la sonde a rendu une erreur de syntaxe sur les trois edges (les edges servaient). Corrigé
aussitôt. `test_sondes_syntaxe.py` (dans `make test`) rend chaque gabarit de sonde — en
respectant l'en-tête `#jinja2:` — et exige que `bash -n` l'accepte : 44 gabarits valides, et
l'erreur recréée est refusée.
**Relevé en passant, non réglé** : `console.chezlepro.internal` ne se résout pas DEPUIS l'edge
(ni dans son `/etc/hosts`, ni au DNS) ; le poste le résout par le sien. L'edge le sert bien.
## 2026-09-28 (24) — La sonde « tableaux » vérifie aussi les sources de données
Elle était verte pendant que les tableaux des locataires n'affichaient rien : elle

View file

@ -316,6 +316,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_sonder.py
python3 scripts/tests/test_fraicheur_icinga.py
python3 scripts/tests/test_sondes_conditionnelles.py
python3 scripts/tests/test_sondes_syntaxe.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

View file

@ -17,7 +17,11 @@ serveur_nginx_taille_max_corps: "16m"
# Comportement des sites.
serveur_nginx_redirection_http: true # 80 -> 443
serveur_nginx_desactiver_defaut: false # retirer le site Debian par defaut
# LE SITE DEBIAN PAR DEFAUT CEDE LA PLACE AU SERVEUR PAR DEFAUT DE SET-OPS (2026-09-28) :
# voir `templates/defaut.conf.j2`. Il valait `false`, sans raison ecrite ; le site Debian
# restait donc actif sur le port 80, et le port 443 n'avait aucun defaut.
serveur_nginx_desactiver_defaut: true
serveur_nginx_serveur_defaut: true
# Sites de reverse proxy declares a la main. Vide par defaut.
# S'AJOUTENT aux vhosts derives automatiquement de instance/plan/domaines.yml.

View file

@ -115,6 +115,26 @@
when: serveur_nginx_publier_expositions | bool
notify: Valider et recharger nginx
# LE SERVEUR PAR DEFAUT (2026-09-28) : un nom que l'edge ne publie pas n'obtient rien.
# Prefixe `000-` : lu avant les autres, sans que l'ordre importe (`default_server` decide).
- name: Deployer le serveur par defaut de Set-OPS
ansible.builtin.template:
src: defaut.conf.j2
dest: /etc/nginx/sites-available/000-defaut.conf
owner: root
group: root
mode: "0644"
when: serveur_nginx_serveur_defaut | bool
notify: Valider et recharger nginx
- name: Activer le serveur par defaut de Set-OPS
ansible.builtin.file:
src: /etc/nginx/sites-available/000-defaut.conf
dest: /etc/nginx/sites-enabled/000-defaut.conf
state: link
when: serveur_nginx_serveur_defaut | bool
notify: Valider et recharger nginx
- name: Activer et demarrer nginx
when: not ansible_check_mode
ansible.builtin.systemd:

View file

@ -0,0 +1,26 @@
# GENERE par Set-OPS (role serveur_nginx). Ne pas editer a la main.
#
# LE SERVEUR PAR DEFAUT : UN NOM QUE L'EDGE NE PUBLIE PAS N'OBTIENT RIEN (2026-09-28).
#
# Sans lui, nginx confie tout nom inconnu au PREMIER vhost declare. Mesure du jour :
# `absente.chezlepro.internal` recevait Keycloak (302 vers /admin/). Deux defauts d'un coup :
# une porte qui ne devrait pas exister, et une sonde aveugle — une exposition dont le vhost
# aurait disparu restait « servie »… par Keycloak. Le site par defaut de Debian, lui, ne
# couvrait que le port 80, avec sa page d'accueil.
#
# 80 : fermeture sans reponse (444). 443 : poignee de main refusee, aucun certificat
# presente (`ssl_reject_handshake`, nginx >= 1.19.4). Les expositions declarees ont chacune
# leur `server_name` explicite : rien ne change pour elles.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}

View file

@ -2,7 +2,7 @@
#!/bin/bash
# GENERE par Set-OPS (role serveur_nginx). Ne pas editer a la main.
#
# SONDE « edge » — l'edge ecoute et sa configuration tient au prochain demarrage
# SONDE « edge » — l'edge ecoute, sa configuration tient, et ce qu'il publie REPOND
#
# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2.
# Mise en defaut PAR PARAMETRE : `serveur_nginx_sonde_ports` (port qui n'ecoute pas).
@ -26,4 +26,31 @@ if (( ${#manquants[@]} > 0 )); then
echo "L'edge n'ecoute pas sur : ${manquants[*]} — ce qui est publie derriere est injoignable."
exit 2
fi
echo "Edge : configuration valide, ecoute sur ${PORTS}."
# CE QUI EST PUBLIE REPOND-IL ? (2026-09-28) Ecouter n'est pas servir : un service publie
# dont l'amont est tombe rend 502, et l'edge reste « valide, a l'ecoute ». On interroge chaque
# exposition EN LOCAL — son nom et son SNI, vers 127.0.0.1 — : on teste ce que l'EDGE sert,
# sans dependre de la resolution de qui que ce soit. `-k` : le certificat est l'affaire de la
# sonde `certificat` ; ici, la question est la reponse. Une redirection (302 vers la
# connexion) est une reponse saine ; un 5xx ou un silence ne l'est pas.
{=# LE MEME FILTRE QUE LES VHOSTS (`expositions.conf.j2`) : un hote connu, une adresse, un
port. Une exposition sans port web (`dns`, `pki`, `sauvegarde` au site) n'a pas de vhost :
l'attendre ici faisait crier la sonde pour trois noms que l'edge ne publie pas. #=}
{% set publiees = [] %}
{% for expo in serveur_nginx_expositions | default([]) %}
{% if expo.hote in hostvars and hostvars[expo.hote].ansible_host is defined and expo.port %}
{% set _ = publiees.append(expo.fqdn) %}
{% endif %}
{% endfor %}
EXPOSITIONS="{{ publiees | unique | sort | join(' ') }}"
en_defaut=(); servies=0
for n in ${EXPOSITIONS}; do
code=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 --resolve "${n}:443:127.0.0.1" "https://${n}/" 2>/dev/null)
if [[ "${code}" =~ ^[234][0-9][0-9]$ ]]; then servies=$((servies + 1)); else en_defaut+=("${n}=${code:-000}"); fi
done
perf="| expositions=${servies};;;0; en_defaut=${#en_defaut[@]}"
if (( ${#en_defaut[@]} > 0 )); then
echo "L'edge ecoute, mais des expositions ne sont PAS servies : ${en_defaut[*]} (000 = silence, 5xx = amont en defaut). ${perf}"
exit 2
fi
echo "Edge : configuration valide, ecoute sur ${PORTS}, ${servies} exposition(s) servie(s). ${perf}"

View file

@ -32,3 +32,5 @@ serveur_rspamd_dkim_repertoire: "/var/lib/rspamd/dkim"
# CRITIQUE sans rien casser, ce qui rend la seconde preuve REJOUABLE. Une sonde se prouve
# deux fois — verte sur le sain, rouge sur le casse.
serveur_rspamd_sonde_attendu: "pong"
# Le verdict attendu pour GTUBE, le message de test que tout filtre doit rejeter (2026-09-28).
serveur_rspamd_sonde_action: "reject"

View file

@ -15,6 +15,6 @@
sondes:
- nom: filtrage
ttl: 5400
raison: 'Le filtrage du courrier repond-il ? Un filtre muet ne bloque pas le courrier, il le
raison: 'Le filtrage du courrier repond-il, et ANALYSE-T-IL (GTUBE rejete) ? Un filtre muet ne bloque pas le courrier, il le
laisse passer : Postfix sans verdict delivre sans filtrer ou differe, et le service de
courriel a l''air sain pendant que le pourriel entre.'

View file

@ -1,20 +1,34 @@
#!/bin/bash
# GENERE par Set-OPS (role serveur_rspamd). Ne pas editer a la main.
#
# SONDE « filtrage » — Le filtrage du courrier repond-il ?
#
# UNE ENVELOPPE, PAS UNE SONDE ECRITE A LA MAIN. `check_http` vient de
# `monitoring-plugins` : il parle deja l'API des greffons Nagios, gere les delais, les
# redirections et vingt ans de cas limites. On lui passe l'adresse et ce qu'on attend, et
# on rend SON code de sortie tel quel. Ecrire du shell ici serait se donner du code a
# maintenir et se priver de tout ca — voir docs/supervision-conception.md.
# SONDE « filtrage » — Le filtrage du courrier repond-il, et ANALYSE-T-IL vraiment ?
#
# UN FILTRE MUET NE BLOQUE PAS LE COURRIER — IL LE LAISSE PASSER. Postfix qui n'obtient
# pas de verdict de rspamd delivre sans filtrer, ou differe : dans les deux cas le service
# de courriel a l'air sain pendant que le pourriel entre ou que la file grossit.
#
# Mise en defaut PAR PARAMETRE : `serveur_rspamd_sonde_attendu` (chaine introuvable).
exec /usr/lib/nagios/plugins/check_http \
-H 127.0.0.1 -p 11334 -u '/ping' \
-s '{{ serveur_rspamd_sonde_attendu }}' \
-t 8
# REPONDRE N'EST PAS FILTRER (2026-09-28). Cette sonde s'arretait a `/ping` — la meme
# question que celle qui a laisse `tableaux` au vert pendant que Grafana n'affichait rien.
# Un rspamd dont les regles ne se chargent plus, ou dont le moteur d'analyse est tombe,
# repond encore `pong`. On lui fait donc ANALYSER un message : GTUBE, le message de test
# standard que tout filtre doit rejeter (score maximal, action `reject`). Rien n'est envoye,
# rien n'est livre : rspamc soumet le texte au moteur et lit le verdict.
#
# Mise en defaut PAR PARAMETRE : `serveur_rspamd_sonde_attendu` (chaine introuvable) pour
# la premiere etape, `serveur_rspamd_sonde_action` pour la seconde.
set -uo pipefail
ping=$(curl -s --max-time 8 http://127.0.0.1:11334/ping 2>/dev/null | tr -d '[:space:]')
[[ "${ping}" == "{{ serveur_rspamd_sonde_attendu }}" ]] \
|| { echo "rspamd ne repond pas a /ping (${ping:-rien}) — le courrier passe sans filtre, ou attend."; exit 2; }
GTUBE='XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X'
verdict=$(printf 'Subject: sonde Set-OPS\n\n%s\n' "${GTUBE}" | timeout 10 rspamc 2>&1)
action=$(printf '%s' "${verdict}" | awk -F': ' '/^Action:/{print $2; exit}')
score=$(printf '%s' "${verdict}" | awk -F': ' '/^Score:/{print $2; exit}')
if [[ "${action}" != "{{ serveur_rspamd_sonde_action }}" ]]; then
echo "rspamd repond, mais N'ANALYSE PLUS : le message de test GTUBE rend « ${action:-aucun verdict} » au lieu de « {{ serveur_rspamd_sonde_action }} » — le pourriel entrerait. ${verdict:0:80}"
exit 2
fi
echo "Filtrage actif : le message de test GTUBE est rejete (score ${score})."
exit 0

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Chaque gabarit de sonde se rend en un script bash SYNTAXIQUEMENT valide.
POURQUOI (2026-09-28). `sonde-edge.sh.j2` redefinit la syntaxe des commentaires Jinja dans
son en-tete (`#jinja2: comment_start_string:'{=#', ...`). Un commentaire ecrit avec la syntaxe
ordinaire est sorti tel quel dans le script : `bash` a refuse la ligne 35, et la sonde a
rendu une erreur de syntaxe sur les trois edges — l'edge, lui, servait tres bien. Rien ne
l'avait vu avant le deploiement.
On rend donc chaque gabarit avec des variables FACTICES (tout ce qui manque vaut vide), en
respectant l'en-tete `#jinja2:` s'il existe, et on demande a `bash -n` si le resultat se lit.
On ne juge pas ce que la sonde FAIT — seulement qu'elle peut s'executer.
"""
from __future__ import annotations
import re
import subprocess
import sys
import tempfile
from pathlib import Path
import jinja2
ROLES = Path(__file__).resolve().parents[2] / "roles"
class Vide(jinja2.ChainableUndefined):
"""Toute variable absente vaut une chaine vide, iterable et indexable."""
def __str__(self) -> str:
return ""
def __iter__(self):
return iter([])
def rendre(texte: str) -> str:
options = {}
premiere, _, reste = texte.partition("\n")
if premiere.startswith("#jinja2:"):
for cle, val in re.findall(r"(\w+):'([^']*)'", premiere):
options[cle] = val
texte = reste
env = jinja2.Environment(undefined=Vide, **options)
env.filters.update({"to_json": lambda v, **k: "[]", "to_nice_json": lambda v, **k: "[]",
"b64decode": lambda v: "", "from_yaml": lambda v: {}, "bool": bool,
"regex_replace": lambda v, *a, **k: str(v), "quote": lambda v: f"'{v}'"})
# Les filtres d'ANSIBLE, absents de Jinja seul : des substituts neutres suffisent, on ne
# juge que la syntaxe du resultat.
for nom in ("flatten", "dict2items", "items2dict", "ternary", "basename", "dirname",
"mandatory", "ipaddr", "combine", "product", "zip"):
liste = nom in ("flatten", "dict2items", "zip", "product")
env.filters.setdefault(nom, lambda v=None, *a, _liste=liste, **k: [] if _liste else (v or ""))
return env.from_string(texte).render(hostvars={}, groups={})
def main() -> int:
echecs = 0
gabarits = sorted(ROLES.glob("*/templates/sonde-*.sh.j2"))
for g in gabarits:
try:
script = rendre(g.read_text(encoding="utf-8"))
except jinja2.TemplateError as e:
print(f"ECHEC {g.relative_to(ROLES)} : ne se rend pas — {e}")
echecs += 1
continue
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f:
f.write(script)
r = subprocess.run(["bash", "-n", f.name], capture_output=True, text=True)
Path(f.name).unlink()
if r.returncode != 0:
print(f"ECHEC {g.relative_to(ROLES)} : bash refuse — {r.stderr.strip()[:160]}")
echecs += 1
if echecs:
print(f"\n{echecs} gabarit(s) de sonde qui ne s'executeraient pas.")
return 1
print(f"OK — {len(gabarits)} gabarits de sonde, tous rendus en bash valide.")
return 0
if __name__ == "__main__":
sys.exit(main())