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:
parent
b938897e17
commit
6b6a56ab3f
10 changed files with 225 additions and 16 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
1
Makefile
1
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
26
roles/serveur_nginx/templates/defaut.conf.j2
Normal file
26
roles/serveur_nginx/templates/defaut.conf.j2
Normal 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;
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
scripts/tests/test_sondes_syntaxe.py
Normal file
80
scripts/tests/test_sondes_syntaxe.py
Normal 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())
|
||||
Loading…
Reference in a new issue