diff --git a/AGENTS.md b/AGENTS.md index cdcb9a6..bf1ee68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 233ce2e..2dd3402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/audit/preuve-2026-09-09.md b/docs/audit/preuve-2026-09-09.md index b06c0fe..760391b 100644 --- a/docs/audit/preuve-2026-09-09.md +++ b/docs/audit/preuve-2026-09-09.md @@ -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 diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 57bde35..e9a54c9 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -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` | diff --git a/docs/devis-services.md b/docs/devis-services.md index 5b68041..8e14f06 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -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. diff --git a/docs/supervision-conception.md b/docs/supervision-conception.md new file mode 100644 index 0000000..4c327e1 --- /dev/null +++ b/docs/supervision-conception.md @@ -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/.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//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. diff --git a/roles/client_pki/defaults/main.yml b/roles/client_pki/defaults/main.yml index 1d84cf7..8521d61 100644 --- a/roles/client_pki/defaults/main.yml +++ b/roles/client_pki/defaults/main.yml @@ -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: "" diff --git a/roles/client_pki/meta/supervision.yml b/roles/client_pki/meta/supervision.yml new file mode 100644 index 0000000..3d6850c --- /dev/null +++ b/roles/client_pki/meta/supervision.yml @@ -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. diff --git a/roles/client_pki/tasks/main.yml b/roles/client_pki/tasks/main.yml index 9bbbcec..855551e 100644 --- a/roles/client_pki/tasks/main.yml +++ b/roles/client_pki/tasks/main.yml @@ -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" diff --git a/roles/client_pki/templates/sonde-certificat.sh.j2 b/roles/client_pki/templates/sonde-certificat.sh.j2 new file mode 100644 index 0000000..9fdd0fe --- /dev/null +++ b/roles/client_pki/templates/sonde-certificat.sh.j2 @@ -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." diff --git a/roles/client_sante/defaults/main.yml b/roles/client_sante/defaults/main.yml index bfdb474..4944466 100644 --- a/roles/client_sante/defaults/main.yml +++ b/roles/client_sante/defaults/main.yml @@ -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 diff --git a/roles/client_sante/templates/setops-sante.sh.j2 b/roles/client_sante/templates/setops-sante.sh.j2 index 9c86bf3..bbcaea3 100644 --- a/roles/client_sante/templates/setops-sante.sh.j2 +++ b/roles/client_sante/templates/setops-sante.sh.j2 @@ -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 diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 3c9e959..07e10e5 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -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. # diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index 501e599..be35c55 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -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 diff --git a/roles/serveur_icinga/templates/setops-api-users.conf.j2 b/roles/serveur_icinga/templates/setops-api-users.conf.j2 index 2a26ca3..c0c53ae 100644 --- a/roles/serveur_icinga/templates/setops-api-users.conf.j2 +++ b/roles/serveur_icinga/templates/setops-api-users.conf.j2 @@ -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 %} {{ '}}' }} } ] } diff --git a/roles/serveur_icinga/templates/setops-sondes.conf.j2 b/roles/serveur_icinga/templates/setops-sondes.conf.j2 new file mode 100644 index 0000000..5599ec0 --- /dev/null +++ b/roles/serveur_icinga/templates/setops-sondes.conf.j2 @@ -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 %} diff --git a/scripts/prouver.py b/scripts/prouver.py index 5e1145b..8cdd0cd 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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//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": [],