supervision : la sonde se declare dans le role, comme le flux

LE CONSTAT. 39 roles declarent leurs flux, 32 leur empreinte, 32 leur
authentification — tous derives. Et 19 groupes sur 19 declaraient une
surveillance en prose que RIEN n executait ; Icinga en surveillait deux.
La carte disait ce qui etait surveille, et personne ne surveillait.

LE MECANISME. Un role declare ses sondes dans meta/supervision.yml et
depose lui-meme son script dans /usr/local/lib/setops/sondes/. Le porteur
client_sante les fait toutes tourner et pousse un resultat passif par
sonde, sans savoir ce qu elles mesurent. serveur_icinga derive les objets
Service ET le filtre de permission d API des memes declarations. Ajouter
une sonde ne demande de toucher ni au porteur ni a Icinga.

PREMIERE SONDE : client_pki/certificat. Heures restantes sur le certificat
reellement pose, chaine verifiee, et empreinte SERVIE comparee au disque
quand un service le consomme. 14/14 au tenant, 7/7 au site.

QUATRE OBSTACLES, ET TROIS SONT LA MEME LECON.

La sonde a rendu 14/14 en CRITIQUE sur une PKI saine : openssl verify
-CAfile racine ne trouve pas l intermediaire qui signe nos certificats.
step certificate verify, lui, repond VALIDE.

Deployee au site, elle a rendu 5/7 : le seuil d avertissement (12 h)
etait AU-DESSUS du point de renouvellement (8 h, le tiers restant). Elle
criait avant que le mecanisme ne soit cense agir. Seuils ramenes a 6 h et
3 h. Un seuil se DERIVE du moment ou le mecanisme surveille agit.

Une alarme toujours allumee ne vaut pas mieux qu une alarme jamais
allumee : elle apprend a ne plus regarder. Une sonde se prouve DEUX FOIS,
verte sur le sain et rouge sur le casse.

Le filtre d API etait ecrit avant la lecture des declarations : les
services auraient existe et Icinga aurait refuse leurs resultats.

Et mon controle negatif a casse un service reel : substituer le certificat
d hote a fait propager un cert sans sa clef vers node_exporter. Un controle
negatif se fait sur une COPIE.

P64 tient les deux bouts : declaree sans etre deposee, ou deposee sans
etre declaree. Trois controles negatifs rejoues.

make prouver : CONFORME, 64 OK, 0 echec, 0 saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-09 21:45:49 -04:00
parent 69b84f4e2c
commit 90228cb55e
17 changed files with 601 additions and 12 deletions

View file

@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 63 preuves de `make prouver` : elles lisent le dépôt, sans le
C'est aussi ce que font les 64 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.**

View file

@ -1,5 +1,117 @@
# CHANGELOG — Set-OPS
## 2026-09-09 (6) — La supervision se DECLARE dans le role, comme les flux
**64 preuves (P01-P64).** Nouveau document : `docs/supervision-conception.md`.
Premiere sonde : `client_pki/certificat`, **14/14 OK** sur la flotte.
### Le constat qui a decide
Mesure du depot sur lui-meme, au 2026-09-09 :
39 roles declarent leurs flux -> nftables + OPNsense, derives
32 declarent leur empreinte -> ressources des VM, derivees
32 declarent leur authentification -> habilitations, derivees
19 groupes declarent `surveillance:` -> RIEN
Les dix-neuf lignes `surveillance:` de `docs/dependances-groupes.yml` sont ecrites,
versionnees, relues — et **aucune n'etait executee**. Icinga surveillait deux choses.
C'est la classe d'echec de ce depot, en version documentaire : la carte dit ce qui est
surveille, et personne ne surveille. *Une intention ecrite n'est pas une mesure.*
### Le mecanisme
Un role declare ses sondes dans `meta/supervision.yml` (nom, `ttl`, raison) et **depose
lui-meme** son script dans `/usr/local/lib/setops/sondes/` — il connait ses chemins, ses
seuils, son consommateur. Le porteur (`client_sante`) fait tourner tout ce qui vit dans ce
repertoire et pousse un resultat passif par sonde ; il ne sait pas ce qu'elles mesurent, et
c'est le but. `serveur_icinga` derive les `object Service` ET **le filtre de permission
d'API** des memes declarations.
Ajouter une sonde ne demande de toucher ni au porteur, ni a Icinga. Comme un flux declare
devient une regle nftables.
### La sonde du certificat, et ce qu'elle mesure VRAIMENT
Nos certificats vivent **24 heures**. Une machine dont le renouvellement s'arrete ne casse
pas tout de suite : elle casse le LENDEMAIN, et tout ce qui parle TLS avec elle tombe
ensemble.
Ce qu'on ne mesure pas, et c'est delibere : *« le minuteur est-il actif ? »* — un minuteur
vert qui echoue chaque nuit est exactement le mensonge deja paye avec les sauvegardes.
*« le certificat existe-t-il ? »* — un certificat perime existe.
Ce qu'on mesure : les heures restantes sur le certificat REELLEMENT pose, la chaine
verifiee contre notre racine, et — quand un service le consomme — l'empreinte SERVIE
comparee a celle du disque.
### Trois obstacles, et le premier est le plus instructif
**La sonde a rendu 14 machines sur 14 en CRITIQUE — sur une PKI qui se portait tres bien.**
Elle utilisait `openssl verify -CAfile racine` ; or nos certificats d'hote sont signes par
un INTERMEDIAIRE, et seule la racine vit sur le disque. `step certificate verify`, l'outil
que le role installe deja, repond VALIDE.
*Une alarme toujours allumee ne vaut pas mieux qu'une alarme jamais allumee : elle apprend
a ne plus regarder.* Une sonde se prouve donc DEUX FOIS — verte sur le sain, rouge sur le
casse. Le document de conception porte la regle.
**Le filtre d'API etait ecrit avant la lecture des declarations.** Les services existaient,
et Icinga aurait refuse leurs resultats avec un « 404 No objects found » sur des objets
bien presents — une heure perdue sur ce message le 2026-09-02. La lecture est remontee
avant le compte d'API, avec le pourquoi ecrit la ou l'on serait tente de la redescendre.
**Mon controle negatif a casse un service reel.** Substituer le certificat d'hote par un
auto-signe a fait virer la sonde au rouge, comme voulu — et le script de synchronisation a
propage ce certificat vers `node_exporter`, dont la clef etait restee l'ancienne :
`tls: private key does not match public key`. Un service tombe pour eprouver une sonde. La
regle qui en decoule est au document : **un controle negatif se fait sur une copie**,
jamais en substituant l'artefact que d'autres consomment.
### Les quatre etats, tous eprouves
certificat absent -> [2] CRITIQUE
signe par une autre autorite -> [2] CRITIQUE (24 h restantes : c'est la CHAINE qui parle)
sous le seuil d'avertissement-> [1] AVERTISSEMENT
etat sain -> [0] OK
Puis relu dans IcingaDB : `certificat : 14/14 OK`, `sante : 14/14 OK`.
### P64 tient les deux bouts
Une sonde DECLAREE dont le script n'est pas depose donnerait un service qui n'a jamais de
resultat. Un script DEPOSE que rien ne declare serait refuse par le filtre d'API. Les deux
se rompent seuls, la preuve tient les deux — plus la presence d'une `raison` et d'un `ttl`.
Trois controles negatifs rejoues.
Ce qu'elle ne fait pas, et qu'aucune lecture statique ne fera : juger qu'une sonde MESURE
quelque chose. Ca reste au controle negatif de chacune, exige par le document et trace ici.
### Et une QUATRIEME fois la meme lecon : les seuils criaient trop tot
Deployee sur le site, la sonde a rendu **5/7** — deux machines en AVERTISSEMENT, sur des
certificats parfaitement sains.
Le seuil etait a 12 h. Or `cert-renewer@.service` porte
`ExecCondition=step certificate needs-renewal`, qui dit oui au TIERS RESTANT : **8 h** pour
un certificat de 24 h, reessaye toutes les 15 minutes. La sonde criait donc AVANT que le
mecanisme ne soit cense agir.
Les seuils sont desormais SOUS le point de renouvellement — 6 h (le renouvellement est du
depuis deux heures, huit fenetres manquees : ce n'est plus un hoquet) et 3 h. *Un seuil
au-dessus du point de renouvellement ne previent pas : il ment.*
Un seuil ne se choisit pas a l'intuition : il se DERIVE du moment ou le mecanisme surveille
est cense agir. C'est la meme faute que `openssl verify` quelques heures plus tot, sous une
autre forme — et c'est encore la flotte reelle qui l'a dite.
**Etat final : 14/14 au tenant, 7/7 au site**, sur `certificat` comme sur `sante`.
### Reste
Dix-huit groupes attendent encore leur sonde. Le mecanisme, lui, ne demande plus rien.
## 2026-09-09 (5) — Retrait d'une garde qui ne pouvait pas se declencher
`client_sante` portait une branche « aucun `serveur_icinga` : rien a poser », et un `when`

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (62 OK · 0 echec · 1 saute)
- **Verdict** : ✅ CONFORME (64 OK · 0 echec · 0 saute)
## Preuves
@ -28,7 +28,7 @@
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 36 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
@ -46,7 +46,7 @@
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 67 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 39 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (35 genere(s) exempte(s)). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (35 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
@ -69,13 +69,14 @@
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (63 preuves, 67 roles, 41 groupes). |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (64 preuves, 67 roles, 41 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `2d9dc86` (publie le 2026-09-09). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
| P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s |
| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 1 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_pki/certificat. |
## Couverture des affirmations ✅ du registre

View file

@ -25,7 +25,7 @@ README de rôles). Cette page comble ces deux trous.
|---|---|---|
| rôles | 67 | `roles/*/` |
| README de rôles | 67 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 39 | `docs/*.md` |
| documents | 40 | `docs/*.md` |
| pièces d'audit | 40 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 82 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble
`scripts/prouver.py` porte 63 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
`scripts/prouver.py` porte 64 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur.

View file

@ -0,0 +1,109 @@
# Supervision dérivée des rôles
> **Pour qui :** celui qui ajoute un rôle à Set-OPS et se demande comment sa supervision
> arrive dans Icinga — et celui qui exploite et veut savoir d'où sortent les services
> qu'il voit.
> **La règle en une phrase.** Un rôle déclare les sondes de sa propre supervision ; le
> moteur en dérive les objets Icinga, la permission d'API et les preuves. Comme
> `meta/flux.yml` engendre nftables *et* OPNsense.
## Pourquoi
Le 2026-09-09, mesure du dépôt sur lui-même :
```
au 2026-09-09 :
39 rôles déclarent leurs flux -> nftables + OPNsense, dérivés
32 déclarent leur empreinte -> ressources des VM, dérivées
32 déclarent leur authentification -> habilitations, dérivées
19 groupes déclarent `surveillance:` -> RIEN
```
Au 2026-09-09, les dix-neuf lignes `surveillance:` de `docs/dependances-groupes.yml` sont écrites,
versionnées, relues — et **aucune n'est exécutée**. Icinga surveillait deux choses :
`sauvegarde` et `sante`.
C'est la classe d'échec que ce dépôt nomme partout ailleurs, en version documentaire : la
carte dit ce qui est surveillé, et personne ne surveille. *Une intention écrite n'est pas
une mesure.*
## Le contrat d'une sonde
Une sonde est un **script local**, déposé par le rôle qui la possède, dans
`/usr/local/lib/setops/sondes/<nom>.sh` (0750, root).
| | |
|---|---|
| **sortie standard** | une ligne, le message lisible par un humain |
| **code de sortie** | `0` OK · `1` AVERTISSEMENT · `2` CRITIQUE |
| **réseau** | aucun besoin : c'est le porteur qui pousse le résultat |
| **durée** | courte ; le porteur passe toutes les 15 min |
Le rôle qui possède la sonde la **déploie lui-même** : il connaît ses chemins, ses
secrets, sa vérité de terrain. Il la **déclare** dans `meta/supervision.yml`, et c'est
cette déclaration que le moteur lit.
```yaml
# roles/<rôle>/meta/supervision.yml
sondes:
- nom: certificat
ttl: 5400
raison: "..."
```
## Passif, et à durée de vie — jamais actif
Un contrôle **actif** ne voit pas la machine **muette** : si elle ne répond plus, la sonde
échoue et on met ça sur le compte du réseau. Ici c'est le nœud qui parle, et le `ttl` de
son envoi fait la fraîcheur — sans nouvelle, Icinga périme le service tout seul.
**Le silence alerte autant que l'échec.** C'est exactement ce qui a manqué à
`openipmi.service` : une unité en échec à chaque démarrage pendant une semaine, et
`systemctl --failed` à zéro partout parce qu'aucune machine n'avait redémarré.
C'est aussi ce qu'impose *la vérification suit la clé* : la sonde tourne là où vit la
vérité, pas sur le superviseur. Un dépôt de sauvegarde chiffré côté client ne peut être
jugé que par qui détient la clé.
## Ce qui n'a rien à faire ici
Une **métrique à seuil** — durée de collecte, volume de journaux, taux d'occupation —
appartient à Prometheus et Grafana. Icinga répond à une seule question : *est-ce cassé ?*
Mélanger les deux rendrait les deux moins lisibles.
## Chaque sonde vient avec son contrôle négatif
Une sonde qu'on n'a jamais vue échouer n'est pas une sonde, c'est une habitude. Dix-neuf
voyants verts non éprouvés seraient un recul par rapport à deux voyants prouvés : ils
rassureraient.
Toute sonde ajoutée doit donc être accompagnée de la manière de la faire échouer
**pour de vrai**, et cette manière doit être rejouée au moins une fois, sur une machine
réelle. Le CHANGELOG en porte la trace.
**Et contre un état sain, tout autant.** La première version de la sonde du certificat a
rendu **14 machines sur 14 en CRITIQUE** — sur une PKI qui se portait très bien. Elle
utilisait `openssl verify -CAfile racine`, alors que nos certificats sont signés par un
*intermédiaire* que seul `step certificate verify` sait retrouver. Une alarme toujours
allumée ne vaut pas mieux qu'une alarme jamais allumée : elle apprend à ne plus regarder.
Une sonde se prouve donc **deux fois** — verte sur le sain, rouge sur le cassé.
## Un contrôle négatif ne doit pas pouvoir abîmer
Éprouver la sonde du certificat, le 2026-09-09, a consisté à **remplacer le certificat
d'hôte** par un auto-signé. La sonde a bien viré au rouge — et le script de synchronisation
a propagé ce certificat vers `node_exporter`, dont la clé était restée l'ancienne :
```
failed to load X509KeyPair: tls: private key does not match public key
```
Un service réel est tombé pour éprouver une sonde. Le contrôle avait un **rayon d'action**
que je ne lui avais pas donné volontairement.
La règle qui en découle : un contrôle négatif se fait sur une **copie**, ou sur un chemin
que la sonde accepte en paramètre — jamais en substituant l'artefact que d'autres
consomment. Quand c'est impossible, on le fait sur la machine la moins critique, on
l'annonce, et on vérifie l'état des consommateurs **après**, pas seulement celui de la
sonde.

View file

@ -100,3 +100,30 @@ client_pki_reload_services: []
# d'attendre le renouvellement automatique. 3600 = une heure : large devant le minuteur
# (~14 min), serre devant la duree de vie (24 h).
client_pki_marge_renouvellement: 3600
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
#
# LES SEUILS DECOULENT DU MOMENT OU LE RENOUVELLEMENT EST DU — pas d'une intuition.
#
# `cert-renewer@.service` porte `ExecCondition=step certificate needs-renewal`, qui dit
# oui au TIERS RESTANT : 8 h pour un certificat de 24 h. Le minuteur repasse toutes les
# 15 minutes (`OnCalendar=*:1/15`). En marche normale, un certificat ne descend donc
# jamais durablement sous 8 h.
#
# PREMIERE VERSION FAUSSE, ET INSTRUCTIVE (2026-09-09) : avert a 12 h, crit a 4 h. Deux
# machines du site sur sept sont passees en AVERTISSEMENT — sur des certificats
# parfaitement sains, simplement pas encore eligibles au renouvellement. La sonde criait
# AVANT que le mecanisme ne soit cense agir.
#
# Les seuils sont donc SOUS le point de renouvellement :
# 6 h -> le renouvellement est du depuis 2 h et n'a pas abouti : huit fenetres de
# quinze minutes manquees. Ce n'est plus un hoquet.
# 3 h -> plus aucune marge ; la panne est certaine au prochain cycle.
#
# Un seuil au-dessus du point de renouvellement ne previent pas : il ment.
client_pki_sonde_avert_h: 6
client_pki_sonde_crit_h: 3
# Port local ou comparer le certificat SERVI a celui du disque. Vide = pas de comparaison
# (l'hote n'expose rien en TLS, ou son consommateur n'ecoute pas en local).
client_pki_sonde_port: ""

View file

@ -0,0 +1,37 @@
---
# Supervision derivee du role. Voir docs/supervision-conception.md.
#
# CE QUE CETTE SONDE MESURE, ET POURQUOI CELA PLUTOT QUE LE RESTE.
#
# Nos certificats vivent 24 HEURES. Ce n'est pas un detail d'exploitation : c'est ce qui
# rend la chaine entiere dependante d'un minuteur qui tourne. Une machine dont le
# renouvellement s'arrete ne casse pas tout de suite — elle casse le LENDEMAIN, et alors
# tout ce qui parle TLS avec elle tombe en meme temps.
#
# CE QU'ON NE MESURE PAS, ET C'EST DELIBERE :
#
# - « le minuteur est-il actif ? » — un minuteur vert qui echoue chaque nuit est
# exactement le mensonge que ce depot a deja paye avec les sauvegardes. On mesure le
# RESULTAT, pas l'intention.
# - « le certificat existe-t-il ? » — un certificat perime existe.
# - la duree de la poignee TLS, l'algorithme, la taille de clef : ce sont des metriques
# ou des audits, pas la question « est-ce casse ? ».
#
# CE QU'ON MESURE : le temps qui reste avant expiration, sur le certificat REELLEMENT
# POSE. Un renouvellement qui s'est arrete se voit des la premiere fenetre manquee, et
# bien avant la panne.
#
# ET LE CERTIFICAT SERVI, PAS SEULEMENT CELUI DU DISQUE. Mesure du 2026-08 : un cert
# renouvele sur disque restait servi PERIME en memoire tant que nginx/postfix/slapd
# n'etaient pas recharges. Le fichier disait « tout va bien », le port disait le
# contraire. La sonde compare donc les deux quand un service consomme le certificat.
sondes:
- nom: certificat
# 5400 s = une heure et demie, soit six fois la periode du porteur (15 min). Cinq
# passages peuvent manquer avant qu'Icinga ne perime : on ne crie pas pour un hoquet,
# on crie pour un silence.
ttl: 5400
raison: >-
Le certificat d'hote est-il encore valide assez longtemps, et le service qui le
consomme sert-il bien CELUI-LA ? Nos certificats vivent 24 h : un renouvellement
arrete ne se voit qu'au lendemain, quand tout ce qui parle TLS tombe ensemble.

View file

@ -275,3 +275,27 @@
enabled: true
state: started
daemon_reload: true
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
#
# LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. Il connait ses chemins, ses
# seuils, son consommateur — le superviseur, lui, ne connait que le verdict. C'est la
# meme repartition que pour les flux : le role declare, le moteur derive.
#
# Le porteur (`client_sante`) fait tourner tout ce qui vit dans ce repertoire et pousse
# un resultat passif par sonde. Il n'a pas a savoir ce que celle-ci mesure.
- name: Assurer le repertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
- name: Deposer la sonde du certificat d'hote
ansible.builtin.template:
src: sonde-certificat.sh.j2
dest: /usr/local/lib/setops/sondes/certificat.sh
owner: root
group: root
mode: "0750"

View file

@ -0,0 +1,68 @@
#!/bin/bash
# GENERE par Set-OPS (role client_pki). Ne pas editer a la main.
#
# SONDE : le certificat d'hote tiendra-t-il jusqu'au prochain renouvellement ?
#
# Contrat (docs/supervision-conception.md) : une ligne sur la sortie standard,
# 0 = OK, 1 = AVERTISSEMENT, 2 = CRITIQUE. Aucun acces reseau requis — c'est le porteur
# (`client_sante`) qui pousse le resultat a Icinga.
set -uo pipefail
CERT="{{ client_pki_cert }}"
RACINE="{{ client_pki_steppath }}/certs/root_ca.crt"
AVERT={{ client_pki_sonde_avert_h }}
CRIT={{ client_pki_sonde_crit_h }}
[[ -r "${CERT}" ]] || { echo "Aucun certificat d'hote lisible en ${CERT}."; exit 2; }
fin=$(openssl x509 -in "${CERT}" -noout -enddate 2>/dev/null | cut -d= -f2)
[[ -n "${fin}" ]] || { echo "Certificat illisible : ${CERT}."; exit 2; }
reste=$(( ( $(date -d "${fin}" +%s) - $(date +%s) ) / 3600 ))
# LA CHAINE, PAS SEULEMENT LA DATE. Un certificat non expire mais signe par une autre
# racine que la notre est une panne aussi complete qu'un certificat perime — et elle
# arrive : une AC reconstruite emet des certificats parfaitement valides que plus
# personne ne verifie.
#
# `step certificate verify`, PAS `openssl verify` — et la premiere version de cette sonde
# a fait la faute (2026-09-09). Nos certificats d'hote sont signes par un INTERMEDIAIRE,
# et seule la racine vit sur le disque : `openssl verify -CAfile racine` ne trouve pas le
# maillon manquant et refuse. Resultat, 14 machines sur 14 en CRITIQUE — sur une PKI qui
# se portait tres bien. `step` sait lire la chaine que step-ca ecrit dans le fichier.
#
# LA LECON N'EST PAS « prendre le bon outil » : c'est qu'une sonde doit etre eprouvee
# contre un etat SAIN autant que contre un etat casse. Une alarme toujours allumee ne vaut
# pas mieux qu'une alarme jamais allumee — elle apprend a ne plus regarder.
if ! step certificate verify "${CERT}" --roots "${RACINE}" >/dev/null 2>&1; then
echo "Le certificat d'hote ne se verifie PAS contre ${RACINE} (${reste} h restantes)."
exit 2
fi
{% if client_pki_reload_services %}
# LE CERTIFICAT SERVI, PAS CELUI DU DISQUE (mesure du 2026-08).
#
# Un cert renouvele sur disque restait servi PERIME en memoire tant que le consommateur
# n'etait pas recharge. Le fichier disait « tout va bien », le port disait le contraire —
# et c'est le port que voient les autres machines. On compare donc les empreintes.
disque=$(openssl x509 -in "${CERT}" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)
{% for service in client_pki_reload_services %}
{% if client_pki_sonde_port %}
servi=$(echo | timeout 6 openssl s_client -connect 127.0.0.1:{{ client_pki_sonde_port }} 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)
if [[ -n "${servi}" && "${servi}" != "${disque}" ]]; then
echo "{{ service }} sert un certificat DIFFERENT de celui du disque : recharger le service (${reste} h restantes)."
exit 2
fi
{% endif %}
{% endfor %}
{% endif %}
if (( reste <= CRIT )); then
echo "Certificat d'hote : ${reste} h avant expiration — le renouvellement ne passe plus."
exit 2
fi
if (( reste <= AVERT )); then
echo "Certificat d'hote : ${reste} h avant expiration (seuil ${AVERT} h)."
exit 1
fi
echo "Certificat d'hote valide ${reste} h, chaine verifiee."

View file

@ -31,3 +31,8 @@ client_sante_ttl_icinga: 2700
# et personne ne fait la difference. Si une unite doit etre toleree, elle est ECRITE ICI,
# avec sa raison — pas absorbee par un filtre qui en cacherait d'autres.
client_sante_unites_tolerees: []
# `ttl` des sondes de ROLE. Plus long que celui de `sante` : une sonde de role peut etre
# plus lente (une poignee TLS, une requete), et on ne veut pas qu'un hoquet la perime.
# Six passages du porteur.
client_sante_ttl_sondes: 5400

View file

@ -29,12 +29,12 @@ TTL={{ client_sante_ttl_icinga }}
MOI="{{ inventory_hostname }}"
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
rapporter() { # $1=code $2=texte
rapporter() { # $1=service $2=code $3=texte $4=ttl
local charge reponse
charge=$(python3 -c 'import json,sys; print(json.dumps({
"type": "Service", "service": sys.argv[1], "exit_status": int(sys.argv[2]),
"plugin_output": sys.argv[3], "ttl": int(sys.argv[4])}))' \
"${MOI}!sante" "$1" "$2" "${TTL}")
"${MOI}!$1" "$2" "$3" "$4")
reponse=$(curl -sS --cacert "{{ client_sante_ca_verification }}" --max-time 20 \
-u "{{ client_sante_icinga_utilisateur }}:${MOTDEPASSE}" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
@ -77,13 +77,40 @@ done
echecs=("${restantes[@]}")
{% endif %}
# LES SONDES DES ROLES, APRES LA SIENNE.
#
# Chaque role depose SA sonde dans ce repertoire ; le porteur ne sait pas ce qu'elles
# mesurent, et c'est le but : ajouter une sonde n'oblige pas a toucher a ce script.
# Contrat : une ligne sur stdout, 0/1/2 en code de sortie
# (docs/supervision-conception.md).
#
# UNE SONDE QUI JETTE N'EST PAS UNE SONDE VERTE. Sans ce traitement, un script casse
# sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc
# CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que
# le `ttl` finirait par perimer sans que personne sache pourquoi.
sondes() {
local f nom sortie code
for f in /usr/local/lib/setops/sondes/*.sh; do
[[ -x "$f" ]] || continue
nom=$(basename "$f" .sh)
sortie=$("$f" 2>&1); code=$?
if (( code > 3 )); then
rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "{{ client_sante_ttl_sondes }}"
else
rapporter "$nom" "$code" "${sortie:-(aucun message)}" "{{ client_sante_ttl_sondes }}"
fi
done
}
n=${#echecs[@]}
if [[ $n -eq 0 ]]; then
rapporter 0 "Aucune unite systemd en echec."
rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}"
sondes
exit 0
fi
# CRITIQUE des la premiere, et non un seuil. Une unite en echec est soit un vrai
# probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil
# ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger.
rapporter 2 "$n unite(s) systemd en echec : ${echecs[*]}"
rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}"
sondes

View file

@ -92,6 +92,9 @@ serveur_icinga_hotes_supervises: >-
serveur_icinga_hotes_conf: "/etc/icinga2/conf.d/setops-hotes.conf"
serveur_icinga_sante_conf: "/etc/icinga2/conf.d/setops-sante.conf"
serveur_icinga_sondes_conf: "/etc/icinga2/conf.d/setops-sondes.conf"
# Rempli a l'execution depuis les `meta/supervision.yml` des roles.
serveur_icinga_sondes: {}
# A QUI LA SUPERVISION PARLE — vide = personne, et on le DIT.
#

View file

@ -172,6 +172,40 @@
vault_icinga_api_depot requis : sans lui, ni le depot ni les noeuds ne peuvent
rapporter l'etat de leurs sauvegardes, et personne ne les surveille.
# LU AVANT LE COMPTE D'API, ET CE N'EST PAS COSMETIQUE : le filtre de permission
# DERIVE de ces declarations. Releve apres, il aurait ete ecrit sans les noms des
# sondes — les services auraient existe, et Icinga aurait refuse leurs resultats
# avec un 404 « No objects found » sur des objets bien presents. Mesure du
# 2026-09-09 : `certificat` manquait au filtre pour cette seule raison.
- name: Relever les sondes que les roles declarent
ansible.builtin.find:
paths: "{{ role_path }}/.."
patterns: supervision.yml
recurse: true
depth: 3
delegate_to: localhost
become: false
register: serveur_icinga_metas_supervision
- name: Lire chaque declaration de supervision
ansible.builtin.slurp:
src: "{{ item.path }}"
delegate_to: localhost
become: false
loop: "{{ serveur_icinga_metas_supervision.files }}"
loop_control:
label: "{{ item.path | dirname | dirname | basename }}"
register: serveur_icinga_supervision_brute
- name: Assembler le registre des sondes (role -> sondes)
ansible.builtin.set_fact:
serveur_icinga_sondes: >-
{{ dict(serveur_icinga_supervision_brute.results
| map(attribute='item.path') | map('dirname') | map('dirname') | map('basename')
| zip(serveur_icinga_supervision_brute.results
| map(attribute='content') | map('b64decode') | map('from_yaml')
| map(attribute='sondes'))) }}
- name: Deployer le compte d'API du depot de sauvegarde
ansible.builtin.template:
src: setops-api-users.conf.j2
@ -185,6 +219,14 @@
# LES HOTES D'ABORD : les fichiers de service s'y attachent, et Icinga refuse un service
# dont l'hote n'existe pas. L'ordre dans `conf.d` n'est pas garanti par le nom, mais
# Icinga charge tout le repertoire avant de resoudre — l'ordre de deploiement suffit.
# LES SONDES DECLAREES PAR LES ROLES (docs/supervision-conception.md).
#
# On lit les `meta/supervision.yml` sur le CONTROLEUR, pas sur la cible : c'est le depot
# qui fait foi, et la cible n'a aucune raison de porter les declarations des autres.
#
# Lecture explicite plutot que dependance a l'ordre de chargement des roles : un
# `client_pki_*` visible ici parce qu'un autre role l'a charge avant serait un couplage
# invisible, et il se romprait le jour ou l'ordre change.
- name: Deployer les hotes supervises (definis une seule fois)
ansible.builtin.template:
src: setops-hotes.conf.j2
@ -212,6 +254,15 @@
mode: "0640"
notify: Redemarrer icinga2
- name: Deployer les objets de supervision declares par les roles
ansible.builtin.template:
src: setops-sondes.conf.j2
dest: "{{ serveur_icinga_sondes_conf }}"
owner: root
group: nagios
mode: "0640"
notify: Redemarrer icinga2
- name: Valider la configuration Icinga 2 avant de la rendre vivante
ansible.builtin.command:
cmd: icinga2 daemon -C

View file

@ -36,7 +36,14 @@ object ApiUser "{{ serveur_icinga_api_utilisateur }}" {
La portee reste etroite : ce compte ne peut poser un resultat que sur un service
de sauvegarde, jamais ailleurs.
#}
filter = {{ '{{' }} match("sauvegarde: *", service.name) || service.name == "sauvegarde" || service.name == "sante" {{ '}}' }}
{#
LES NOMS SONT DERIVES, PAS RECOPIES. Une sonde declaree par un role entre dans ce
filtre toute seule ; une sonde retiree en sort. Recopier la liste ici, c'etait la
garantie qu'un jour elle divergerait — et le symptome serait un 404 « No objects
found » sur un objet qui existe, comme le 2026-09-02. Une heure perdue.
La portee reste etroite : des NOMS, jamais un joker.
#}
filter = {{ '{{' }} match("sauvegarde: *", service.name) || service.name == "sauvegarde" || service.name == "sante"{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}{% for s in sondes %} || service.name == "{{ s.nom }}"{% endfor %}{% endfor %} {{ '}}' }}
}
]
}

View file

@ -0,0 +1,34 @@
/*
* Gere par Set-OPS (role serveur_icinga). Ne pas editer a la main.
*
* LES SONDES QUE LES ROLES DECLARENT — voir docs/supervision-conception.md.
*
* Rien ici n'est ecrit a la main : chaque service vient d'un `meta/supervision.yml`, et
* s'attache aux hotes qui portent le role qui le declare. Ajouter une sonde a un role la
* fait apparaitre ici sans toucher a ce fichier — comme un flux declare devient une
* regle nftables.
*
* CE QUE CA REMPLACE : dix-neuf lignes `surveillance:` en prose dans
* `docs/dependances-groupes.yml`, ecrites, versionnees, relues — et qu'aucun mecanisme
* n'executait. La carte disait ce qui etait surveille, et personne ne surveillait.
*
* Les HOTES sont definis dans `setops-hotes.conf` : ici, rien que des services.
*/
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
{% for hote in (groups[role] | default([])) | sort %}
{% for sonde in sondes %}
object Service "{{ sonde.nom }}" {
host_name = "{{ hote }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
vars.setops_source = "{{ hote }}"
vars.setops_role = "{{ role }}"
vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}"
}
{% endfor %}
{% endfor %}
{% endfor %}

View file

@ -2168,6 +2168,88 @@ def preuve_gabarit_minimal_et_repris() -> tuple[bool, str]:
f"repris par le socle ou le durcissement.")
def preuve_sondes_declarees_et_deposees() -> tuple[bool, str]:
"""Une sonde declaree existe-t-elle vraiment, et un role qui en depose une la declare-t-il ?
CE QU'ELLE FERME (2026-09-09). `docs/dependances-groupes.yml` porte une ligne
`surveillance:` pour DIX-NEUF groupes sur dix-neuf. Icinga en surveillait DEUX. Des
intentions ecrites, versionnees, relues — et qu'aucun mecanisme n'executait. C'est la
classe d'echec de ce depot, en version documentaire : la carte dit ce qui est
surveille, et personne ne surveille.
La supervision se declare desormais dans `roles/<role>/meta/supervision.yml`, et le
moteur en derive les objets Icinga ET la permission d'API. Cette preuve tient les deux
bouts de la declaration, parce que chacun peut se rompre seul :
1. une sonde DECLAREE dont le script n'est pas depose : le service existerait dans
Icinga et n'aurait jamais de resultat. Le `ttl` le perimerait — un service
« expire » qui ne l'a jamais ete, que personne ne saurait interpreter ;
2. un script DEPOSE que rien ne declare : la sonde tournerait, pousserait, et le
compte d'API la refuserait (le filtre derive des declarations). Symptome :
« 404 No objects found » sur un objet inexistant — une heure perdue le
2026-09-02 sur exactement ce message.
CE QU'ELLE NE FAIT PAS. Elle ne juge pas si la sonde MESURE quelque chose d'utile, ni
si elle peut echouer. Ca, aucune lecture statique ne le dira : c'est au controle
negatif de chaque sonde, exige par `docs/supervision-conception.md` et trace au
CHANGELOG.
"""
roles = RACINE / "roles"
fautes, mesures = [], []
for meta in sorted(roles.glob("*/meta/supervision.yml")):
role = meta.parent.parent.name
try:
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
fautes.append(f"{role} : `meta/supervision.yml` illisible ({e})")
continue
sondes = data.get("sondes") or []
if not sondes:
fautes.append(f"{role} : `meta/supervision.yml` ne declare aucune sonde")
continue
taches = (roles / role / "tasks" / "main.yml")
corps = taches.read_text(encoding="utf-8") if taches.is_file() else ""
for s in sondes:
nom = s.get("nom")
if not nom:
fautes.append(f"{role} : une sonde sans `nom`")
continue
if not s.get("raison"):
fautes.append(f"{role}/{nom} : aucune `raison` — un service dont personne "
f"ne sait ce qu'il mesure n'est pas une supervision")
if not isinstance(s.get("ttl"), int):
fautes.append(f"{role}/{nom} : `ttl` manquant — sans lui, le SILENCE "
f"n'alerte pas, et c'est la moitie de l'interet")
# Le role doit deposer le script au chemin du contrat.
if f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps:
fautes.append(f"{role}/{nom} : declaree mais jamais deposee dans "
f"`tasks/main.yml` — le service existerait sans jamais avoir "
f"de resultat")
mesures.append(f"{role}/{nom}")
# L'AUTRE SENS : un script depose que rien ne declare.
for taches in sorted(roles.glob("*/tasks/main.yml")):
role = taches.parent.parent.name
corps = taches.read_text(encoding="utf-8")
deposees = set(re.findall(r"/usr/local/lib/setops/sondes/([A-Za-z0-9_.-]+)\.sh", corps))
meta = roles / role / "meta" / "supervision.yml"
declarees = set()
if meta.is_file():
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
declarees = {s.get("nom") for s in (data.get("sondes") or [])}
for orpheline in sorted(deposees - declarees):
fautes.append(f"{role}/{orpheline} : deposee mais absente de "
f"`meta/supervision.yml` — le compte d'API la refuserait")
if fautes:
return False, "Sondes de supervision :\n - " + "\n - ".join(fautes)
if not mesures:
return True, ("Aucune sonde declaree — la supervision se limite a `sante` et "
"`sauvegarde` (cf. docs/supervision-conception.md).")
return True, (f"{len(mesures)} sonde(s) declaree(s) ET deposee(s), chacune avec sa "
f"raison et son `ttl` : {', '.join(mesures)}.")
def preuve_cloud_init_rendu_puis_retire() -> tuple[bool, str]:
"""cloud-init nait avec la VM, et ne lui survit pas.
@ -3097,6 +3179,8 @@ PREUVES: list[dict] = [
"refs": ["AFF-033"], "func": preuve_schema_couvre_les_validateurs},
{"id": "P63", "titre": "cloud-init nait avec la VM et ne lui survit pas", "refs": [],
"func": preuve_cloud_init_rendu_puis_retire},
{"id": "P64", "titre": "Sondes de supervision : declarees ET deposees", "refs": [],
"func": preuve_sondes_declarees_et_deposees},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],