From 6b6a56ab3f50d76b8adbedcf5eee377fed489cf9 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 28 Sep 2026 19:40:26 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 35 ++++++++ Makefile | 1 + roles/serveur_nginx/defaults/main.yml | 6 +- roles/serveur_nginx/tasks/main.yml | 20 +++++ roles/serveur_nginx/templates/defaut.conf.j2 | 26 ++++++ .../serveur_nginx/templates/sonde-edge.sh.j2 | 31 ++++++- roles/serveur_rspamd/defaults/main.yml | 2 + roles/serveur_rspamd/meta/supervision.yml | 2 +- .../templates/sonde-filtrage.sh.j2 | 38 ++++++--- scripts/tests/test_sondes_syntaxe.py | 80 +++++++++++++++++++ 10 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 roles/serveur_nginx/templates/defaut.conf.j2 create mode 100644 scripts/tests/test_sondes_syntaxe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1275d6f..a261f38 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Makefile b/Makefile index 8e6fdf6..fe25387 100644 --- a/Makefile +++ b/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 diff --git a/roles/serveur_nginx/defaults/main.yml b/roles/serveur_nginx/defaults/main.yml index 5e12168..5aa8a66 100644 --- a/roles/serveur_nginx/defaults/main.yml +++ b/roles/serveur_nginx/defaults/main.yml @@ -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. diff --git a/roles/serveur_nginx/tasks/main.yml b/roles/serveur_nginx/tasks/main.yml index 67cbe4d..adce154 100644 --- a/roles/serveur_nginx/tasks/main.yml +++ b/roles/serveur_nginx/tasks/main.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: diff --git a/roles/serveur_nginx/templates/defaut.conf.j2 b/roles/serveur_nginx/templates/defaut.conf.j2 new file mode 100644 index 0000000..0012985 --- /dev/null +++ b/roles/serveur_nginx/templates/defaut.conf.j2 @@ -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; +} diff --git a/roles/serveur_nginx/templates/sonde-edge.sh.j2 b/roles/serveur_nginx/templates/sonde-edge.sh.j2 index 0b7f7b9..3a41c3e 100644 --- a/roles/serveur_nginx/templates/sonde-edge.sh.j2 +++ b/roles/serveur_nginx/templates/sonde-edge.sh.j2 @@ -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}" diff --git a/roles/serveur_rspamd/defaults/main.yml b/roles/serveur_rspamd/defaults/main.yml index fbceb6d..697ff0d 100644 --- a/roles/serveur_rspamd/defaults/main.yml +++ b/roles/serveur_rspamd/defaults/main.yml @@ -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" diff --git a/roles/serveur_rspamd/meta/supervision.yml b/roles/serveur_rspamd/meta/supervision.yml index 69d9714..7384f8d 100644 --- a/roles/serveur_rspamd/meta/supervision.yml +++ b/roles/serveur_rspamd/meta/supervision.yml @@ -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.' diff --git a/roles/serveur_rspamd/templates/sonde-filtrage.sh.j2 b/roles/serveur_rspamd/templates/sonde-filtrage.sh.j2 index 9f7a3de..a0d5176 100644 --- a/roles/serveur_rspamd/templates/sonde-filtrage.sh.j2 +++ b/roles/serveur_rspamd/templates/sonde-filtrage.sh.j2 @@ -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 diff --git a/scripts/tests/test_sondes_syntaxe.py b/scripts/tests/test_sondes_syntaxe.py new file mode 100644 index 0000000..93d5b87 --- /dev/null +++ b/scripts/tests/test_sondes_syntaxe.py @@ -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())