supervision : c'est le DEPOT qui dit ou en sont les sauvegardes

Icinga ne surveillait rien : aucun objet Host ni Service de Set-OPS, seulement
la config Debian d'origine sur localhost.

Superviser setops-sauvegarde.service aurait reproduit le defaut du jour meme :
l'unite etait VERTE sur onze noeuds pendant qu'elle n'emportait rien. Le noeud
sait qu'il a LANCE sa sauvegarde, pas qu'elle est ARRIVEE. backup-01 evalue donc
ses depots et pousse un resultat passif par noeud vers l'API Icinga.

Trois criteres, parce qu'un seul suffit a mentir : l'instantane existe, il est
recent (26 h / 50 h), il contient au moins un fichier.

Le sens du flux est delibere : le depot parle a la supervision, jamais l'inverse
— compromettre mon-01 ne donne aucun acces aux sauvegardes.

Le ttl de 6 h fait la fraicheur : si le rapporteur se tait, Icinga perime les
services tout seul. C'est le silence qui a laisse le defaut vivre un mois.

Deux erreurs corrigees par la mesure :
  - --data-urlencode refuse en Bad Request (l'API veut du JSON) ; le flux, le TLS
    et l'auth marchaient, seule la charge etait perdue.
  - le seuil « vide » en octets signalait a tort idm-01 (2363 o) : un export LDIF
    d'un annuaire a un compte pese cela. « Vide » se mesure en FICHIERS. Et le
    verdict est un AVERTISSEMENT : la machine ne distingue pas « les donnees ont
    disparu » de « il n'y en a pas encore ».

Reserve assumee : curl -k — l'API presente le cert de sa propre AC, pas step-ca.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-11 21:39:05 -04:00
parent 8eedeaf31f
commit f1a7e43645
17 changed files with 476 additions and 5 deletions

View file

@ -1,5 +1,61 @@
# CHANGELOG — Set-OPS
## 2026-08-11 — Les sauvegardes sont surveillées, et c'est le dépôt qui parle
Icinga ne surveillait **rien** : aucun objet `Host` ni `Service` de Set-OPS, seulement la
configuration Debian d'origine pointant sur `localhost`.
### On ne supervise pas l'unité — on supervise ce qui est arrivé
Superviser `setops-sauvegarde.service` aurait reproduit le défaut du jour même : l'unité
était **verte** sur onze nœuds pendant qu'elle n'emportait rien. Le nœud sait qu'il a
*lancé* sa sauvegarde ; il ne sait pas qu'elle est *arrivée*. Seul le dépôt le voit.
`backup-01` évalue donc ses dépôts restic et **pousse un résultat passif par nœud** vers
l'API Icinga. Trois critères, parce qu'un seul suffit à mentir :
| Critère | Ce qu'il attrape |
|---|---|
| l'instantané **existe** | la sauvegarde n'arrive pas |
| il est **récent** (26 h / 50 h) | elle a cessé d'arriver |
| il contient **au moins un fichier** | elle arrive mais ne porte rien |
Le sens du flux est délibéré : le dépôt parle à la supervision, jamais l'inverse. Un seul
flux nouveau, et compromettre `mon-01` ne donne aucun accès aux sauvegardes.
### Le silence alerte
Le `ttl` de 6 h porté par chaque envoi fait la fraîcheur : si le rapporteur se tait,
Icinga périme les services tout seul. **C'est le silence qui a laissé le défaut vivre un
mois** — il devait devenir la première chose qui alerte. Le rapporteur, lui, refuse
d'avaler ses propres échecs et sort en erreur.
### Deux erreurs de conception, corrigées par la mesure
**Le corps `--data-urlencode` était refusé** en `Bad Request` : l'API veut du JSON. Le flux,
le TLS et l'authentification fonctionnaient — seule la charge était perdue. Sans lecture du
journal d'Icinga, un `curl` silencieux aurait été pris pour un succès.
**Le seuil « vide » en octets était faux.** Il signalait `idm-01` (2 363 octets) alors qu'un
export LDIF d'un annuaire à un compte pèse légitimement cela. « Vide » se mesure en
**fichiers**, pas en taille : zéro fichier, c'est exact quelle que soit la taille. Et le
verdict est un **avertissement**, pas un critique — la machine ne peut pas distinguer « les
données ont disparu » de « il n'y en a pas encore », mais l'humain doit le voir.
### Mesuré de bout en bout
```
idm-01 OK 1 fichier, 2 363 o (l'annuaire) infra-mail-01 AVERT. aucun fichier
infra-pki-01 OK 20 621 o (les clés de l'AC) web-frontal-01 AVERT. aucun fichier
collab-01 OK 67 129 221 o web-dorsal-01 AVERT. aucun fichier
data-sql-01 OK 1 085 158 o (toutes les bases)
```
**Réserve assumée** : le rapporteur appelle l'API en `curl -k`. L'API Icinga présente le
certificat de sa propre AC (`icinga2 api setup`), pas celui de step-ca — la liaison est
chiffrée mais le pair n'est pas vérifié. C'est la réserve connue sur `5665`, et elle reste
ouverte.
## 2026-08-11 — La sauvegarde emporte enfin quelque chose
**Correction de l'entrée précédente** : j'y attribuais le défaut à la reconstruction

View file

@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 30 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 75 flux, schéma + matrice OK. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 29 rôles, 77 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -30,21 +30,21 @@
| 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 | ✅ OK | 14 hotes, 31 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 : 25 secret(s) exige(s), tous presents. Voute reelle : 28 cle(s), aucun manque. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 26 secret(s) exige(s), tous presents. Voute reelle : 29 cle(s), aucun manque. |
| 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. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (20 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 6 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 39 regles, 12 routes, admin=10.0.0.0/24,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 38 groupe(s), 64 regle(s). |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 40 groupe(s), 66 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 0 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 44 scripts expliques et atteignables, 92 cibles make documentees, 54 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 35 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 38 document(s) declarent leur lecteur (14 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). |

View file

@ -17,6 +17,7 @@
| `client_unbound` | egress | 53 | udp | externe | clair | Récursion DNS depuis la racine (UDP d'abord), validée par DNSSEC — la confidentialité du transport n'est pas l'enjeu, l'authenticité l'est. |
| `client_unbound` | egress | 53 | tcp | externe | clair | Récursion DNS en TCP : repli obligatoire quand la réponse dépasse la taille UDP (fréquent avec DNSSEC). |
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
| `serveur_backup` | egress | 5665 | tcp | serveur_icinga | tls-requis | Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive. |
| `serveur_collabora` | ingress | 9980 | tcp | edge | clair | Éditeur servi au navigateur via l'edge (WebSocket WOPI ; TLS terminé à l'edge). |
| `serveur_collabora` | ingress | 9980 | tcp | localhost | clair | Vérifications WOPI serveur→Collabora depuis Nextcloud co-localisé. |
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
@ -36,6 +37,7 @@
| `serveur_grafana` | ingress | 3000 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge). |
| `serveur_grafana` | egress | 443 | tcp | edge | tls-requis | Authentification OIDC auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_icinga` | ingress | 5665 | tcp | localhost | clair | API Icinga 2 consommée en local par Icinga Web 2 co-localisé. |
| `serveur_icinga` | ingress | 5665 | tcp | serveur_backup | tls-requis | Le depot de sauvegarde depose ses resultats passifs (portee : process-check-result sur « sauvegarde: * »). |
| `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). |
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). |
| `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). |
@ -90,4 +92,4 @@
- **starttls** : 6 flux
- **tls** : 8 flux
- **tls-cible** : 2 flux
- **tls-requis** : 24 flux
- **tls-requis** : 26 flux

View file

@ -86,3 +86,6 @@ client_backup_catalogue:
client_backup_jobs: >-
{{ client_backup_catalogue | dict2items
| selectattr('key', 'in', group_names) | map(attribute='value') | list }}
# La liste EN CLAIR des memes groupes vit dans `vars/main.yml` — sans Jinja, pour que la
# supervision et P36 puissent la lire par `include_vars` sans rendre ce catalogue.

View file

@ -15,6 +15,17 @@
# On ne refuse donc pas le deploiement : on refuse d'installer une sauvegarde vide, et on
# RETIRE celle qui existerait. Que tout detenteur d'etat porte bien `client_backup` est
# lisible dans le plan, donc prouve statiquement (D-75, P36) — pas ici.
- name: Refuser une porte d'entree du catalogue devenue fausse
ansible.builtin.assert:
that:
- (client_backup_catalogue.keys() | list | sort) == (client_backup_groupes_etat | sort)
fail_msg: >-
`client_backup_groupes_etat` ne decrit plus `client_backup_catalogue` :
catalogue={{ client_backup_catalogue.keys() | list | sort }},
liste={{ client_backup_groupes_etat | sort }}. La supervision et P36 lisent la
liste — la laisser diverger, c'est sauvegarder sans surveiller, ou surveiller ce
qui n'existe pas.
- name: Etat de la sauvegarde pour ce noeud
ansible.builtin.debug:
msg: >-

View file

@ -0,0 +1,23 @@
---
# Groupes DETENTEURS D'ETAT — la porte d'entree publique du catalogue de sauvegarde.
#
# Pourquoi un fichier a part, et SANS Jinja. Deux consommateurs externes ont besoin de
# cette liste : `serveur_backup` (qui rapporte a Icinga l'etat reel des instantanes) et
# `serveur_icinga` (qui cree les objets a surveiller). Tous deux la lisent par
# `include_vars` — et `include_vars` charge le FICHIER ENTIER. Charger `defaults/main.yml`
# forcerait le rendu de `client_backup_repo`, qui reference des variables absentes de leur
# play : le deploiement echouait sur « client_backup_utilisateur_distant is undefined »
# (mesure le 2026-08-11). Ce fichier ne contient donc que des litteraux.
#
# `tasks/main.yml` REFUSE si cette liste et `client_backup_catalogue` divergent : on ne
# peut pas ajouter un detenteur d'etat sans que la supervision et P36 l'apprennent.
client_backup_groupes_etat:
- serveur_step_ca
- serveur_openldap
- serveur_postgresql
- serveur_dovecot
- serveur_forgejo
- serveur_nextcloud
- serveur_rspamd
- serveur_web_frontal
- serveur_web_dorsal

View file

@ -5,3 +5,34 @@ serveur_backup_utilisateur: "restic"
serveur_backup_racine: "/srv/restic"
# Clé PUBLIQUE de sauvegarde autorisée (la privée est dans la voûte, côté client_backup).
serveur_backup_pubkey: ""
# --- Vérification des dépôts et rapport passif vers Icinga ---
# Le dépôt est le SEUL à voir ce qui est réellement arrivé. Un nœud sait qu'il a lancé sa
# sauvegarde ; il ne sait pas qu'elle a abouti. D'où la vérification ici, et pas là-bas.
# Toutes les 4 h, pas une fois par jour : la vérification doit renouveler le `ttl` bien
# avant qu'il n'expire, sinon un simple retard du timer se lirait comme une sauvegarde
# perdue. Elle est peu coûteuse (lecture des métadonnées restic).
serveur_backup_verification_horaire: "*-*-* 00/4:00:00"
# Seuils d'âge. Le critère « n'emporte rien » ne se règle pas ici : il se mesure en
# NOMBRE DE FICHIERS dans l'instantané (zéro = rien emporté), ce qui est exact, alors
# qu'un seuil en octets signalait à tort un export LDIF de 2,3 Ko.
serveur_backup_age_warn_h: 26
serveur_backup_age_crit_h: 50
# `ttl` du résultat passif : au-delà, Icinga périme le service de lui-même — c'est ce qui
# fait que le SILENCE alerte. 6 h pour une vérification toutes les 4 h : une exécution peut
# être manquée sans fausse alerte, deux ne le peuvent pas.
serveur_backup_ttl_icinga: 21600
# Compte d'API Icinga (portée : uniquement process-check-result sur « sauvegarde: * »).
serveur_backup_icinga_utilisateur: "setops-depot"
serveur_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
serveur_backup_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first) | default('') }}"
# Nœuds dont un instantané est ATTENDU : ceux qui portent client_backup ET détiennent
# réellement de l'état. Même règle que `client_backup_jobs`, même source que P36.
serveur_backup_noeuds_attendus: >-
{{ (client_backup_groupes_etat | default([]) | map('extract', groups)
| select('defined') | flatten | unique | list)
| intersect(groups['client_backup'] | default([])) }}

View file

@ -12,3 +12,9 @@ flux:
# le 22 — alors qu'il n'y a qu'un seul sshd.
partage: true
raison: "Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés."
- sens: egress
port: 5665
protocole: tcp
pair: serveur_icinga
chiffrement: tls-requis
raison: "Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive."

View file

@ -26,3 +26,85 @@
user: "{{ serveur_backup_utilisateur }}"
key: "{{ serveur_backup_pubkey }}"
state: present
# --- Vérification des dépôts et rapport passif vers Icinga ---
# La liste des détenteurs d'état appartient à `client_backup`. On la LIT chez lui : deux
# listes finissent toujours par diverger, et la divergence se lirait « tout va bien ».
- name: Lire la liste des détenteurs d'état chez client_backup
ansible.builtin.include_vars:
file: "{{ role_path }}/../client_backup/vars/main.yml"
name: _catalogue_sauvegarde
- name: Adopter la liste des détenteurs d'état
ansible.builtin.set_fact:
client_backup_groupes_etat: "{{ _catalogue_sauvegarde.client_backup_groupes_etat }}"
- name: Exiger de quoi rapporter à Icinga (Vault + hôte de supervision)
ansible.builtin.assert:
that:
- serveur_backup_icinga_motdepasse | length > 0
- serveur_backup_icinga_hote | length > 0
- serveur_backup_noeuds_attendus | length > 0
fail_msg: >-
vault_icinga_api_depot requis, un hôte du groupe `serveur_icinga` doit exister, et
au moins un nœud doit détenir de l'état. Sans cela, les sauvegardes ne seraient
surveillées par personne — c'est exactement le silence qui a laissé le défaut du
2026-07-03 vivre un mois.
- name: Installer curl et le mot de passe restic pour la vérification
ansible.builtin.apt:
name: [curl, restic]
state: present
- name: Créer le répertoire des secrets Set-OPS
ansible.builtin.file:
path: /etc/setops
state: directory
owner: root
group: root
mode: "0700"
- name: Déposer le mot de passe restic (lecture des dépôts)
ansible.builtin.copy:
content: "{{ vault_restic_password }}\n"
dest: /etc/setops/restic.pass
owner: root
group: root
mode: "0600"
no_log: true
- name: Déposer le mot de passe d'API Icinga
ansible.builtin.copy:
content: "{{ serveur_backup_icinga_motdepasse }}"
dest: /etc/setops/icinga-api.pass
owner: root
group: root
mode: "0600"
no_log: true
- name: Déployer le script de vérification
ansible.builtin.template:
src: verifier-sauvegardes.sh.j2
dest: /usr/local/sbin/setops-verifier-sauvegardes.sh
owner: root
group: root
mode: "0700"
- name: Déployer l'unité et le timer de vérification
ansible.builtin.template:
src: "{{ item.s }}"
dest: "/etc/systemd/system/{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: setops-verification.service.j2, d: setops-verification.service }
- { s: setops-verification.timer.j2, d: setops-verification.timer }
- name: Activer le timer de vérification
when: not ansible_check_mode
ansible.builtin.systemd:
name: setops-verification.timer
enabled: true
state: started
daemon_reload: true

View file

@ -0,0 +1,11 @@
# Géré par Set-OPS (rôle serveur_backup). Ne pas éditer à la main.
[Unit]
Description=Vérification des dépôts restic et rapport passif vers Icinga
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/setops-verifier-sauvegardes.sh
Nice=10
IOSchedulingClass=idle

View file

@ -0,0 +1,14 @@
# Géré par Set-OPS (rôle serveur_backup). Ne pas éditer à la main.
# Plus frequent que la sauvegarde elle-meme : le `ttl` envoye a Icinga doit etre
# renouvele bien avant d'expirer, sinon un simple retard de ce timer se lirait comme
# une sauvegarde perdue.
[Unit]
Description=Planification de la vérification des sauvegardes Set-OPS
[Timer]
OnCalendar={{ serveur_backup_verification_horaire }}
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,108 @@
#!/bin/bash
# Gere par Set-OPS (role serveur_backup). Ne pas editer a la main.
#
# Verifie les depots restic et POUSSE un resultat passif par noeud vers l'API Icinga.
#
# Pourquoi ici et pas sur le noeud source. Le noeud sait s'il a LANCE sa sauvegarde ; il
# ne sait pas si elle est ARRIVEE. Le depot, lui, voit ce qui existe reellement — et c'est
# la seule chose qui compte le jour d'une restauration. Le 2026-08-11, onze noeuds
# lancaient chaque nuit une sauvegarde qui n'emportait RIEN : une unite verte sur un depot
# vide. Trois criteres, donc, et pas un seul :
#
# 1. l'instantane EXISTE (sinon : la sauvegarde n'arrive pas)
# 2. il est RECENT (sinon : elle a cesse d'arriver)
# 3. il n'est pas VIDE (sinon : elle arrive mais ne porte rien)
#
# Le `ttl` envoye a Icinga fait la fraicheur : si ce script cesse de tourner, Icinga passe
# tout seul en « expire ». C'est le SILENCE qui alerte — c'est lui qui n'a alerte personne.
set -uo pipefail
RACINE="{{ serveur_backup_racine }}"
API="https://{{ serveur_backup_icinga_hote }}.{{ domaine_interne }}:5665"
TTL={{ serveur_backup_ttl_icinga }}
AGE_WARN={{ serveur_backup_age_warn_h }}
AGE_CRIT={{ serveur_backup_age_crit_h }}
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
# L'API Icinga veut du JSON : un envoi `--data-urlencode` est refuse en « Bad Request »
# et le corps est perdu (mesure le 2026-08-11). On construit donc la charge avec python3,
# qui echappe correctement le texte du diagnostic.
ECHECS=0
rapporter() { # $1=noeud $2=code $3=texte
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])}))' \
"${HOTE_DEPOT}!sauvegarde: $1" "$2" "$3" "${TTL}")
reponse=$(curl -sS -k --max-time 20 \
-u "{{ serveur_backup_icinga_utilisateur }}:${MOTDEPASSE}" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1)
# Ne PAS avaler l'echec : un rapporteur muet recreerait exactement le defaut qu'on
# corrige. Le `ttl` reste le filet — si ce script cesse d'aboutir, Icinga perime les
# services de lui-meme et c'est le silence qui alerte.
if ! printf '%s' "${reponse}" | grep -q '"code": *200'; then
echo "ECHEC du rapport Icinga pour $1 : ${reponse}" >&2
ECHECS=$((ECHECS + 1))
fi
}
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
HOTE_DEPOT="{{ inventory_hostname }}"
maintenant=$(date +%s)
for noeud in {{ serveur_backup_noeuds_attendus | sort | join(' ') }}; do
depot="${RACINE}/${noeud}"
if [[ ! -d "${depot}" ]]; then
rapporter "${noeud}" 2 "AUCUN DEPOT : ${depot} n'existe pas — ce noeud n'a jamais depose."
continue
fi
json=$(restic -r "${depot}" snapshots --latest 1 --json 2>/dev/null)
horodatage=$(printf '%s' "${json}" | python3 -c \
'import sys,json;d=json.load(sys.stdin);print(d[0]["time"] if d else "")' 2>/dev/null)
if [[ -z "${horodatage}" ]]; then
rapporter "${noeud}" 2 "AUCUN INSTANTANE dans ${depot} — depot present mais vide."
continue
fi
# restic ecrit des fractions de seconde a precision variable : on tronque avant %z.
secondes=$(date -d "$(printf '%s' "${horodatage}" | sed -E 's/\.[0-9]+//')" +%s 2>/dev/null || echo 0)
age_h=$(( (maintenant - secondes) / 3600 ))
taille=$(restic -r "${depot}" stats latest --mode raw-data --json 2>/dev/null | python3 -c \
'import sys,json;print(json.load(sys.stdin).get("total_size",0))' 2>/dev/null || echo 0)
# « Vide » se mesure en FICHIERS, pas en octets. Un seuil en octets est un mauvais
# critere : l'export LDIF d'un annuaire a un compte pese 2,3 Ko et se ferait signaler a
# tort (mesure le 2026-08-11). Un instantane qui ne contient AUCUN fichier — seulement
# les repertoires traverses — n'emporte rien, et c'est exact quelle que soit la taille.
fichiers=$(restic -r "${depot}" ls latest --json 2>/dev/null | python3 -c \
'import sys,json
n=0
for l in sys.stdin:
try: d=json.loads(l)
except ValueError: continue
if d.get("struct_type")=="node" and d.get("type")=="file": n+=1
print(n)' 2>/dev/null || echo 0)
if (( fichiers == 0 )); then
# AVERTISSEMENT et non CRITIQUE : la machine ne peut pas distinguer « les donnees ont
# disparu » de « il n'y en a pas encore » (un /var/vmail sans courriel, un /srv/web
# sans webapp). C'est a un humain de trancher — mais il doit le VOIR.
rapporter "${noeud}" 1 \
"N'EMPORTE RIEN : instantane sans aucun fichier (${taille} octets). Legitime si ce noeud n'a pas encore de donnees — a confirmer."
elif (( age_h >= AGE_CRIT )); then
rapporter "${noeud}" 2 "PERIME : dernier instantane il y a ${age_h} h (seuil ${AGE_CRIT} h), ${fichiers} fichier(s)."
elif (( age_h >= AGE_WARN )); then
rapporter "${noeud}" 1 "EN RETARD : dernier instantane il y a ${age_h} h (seuil ${AGE_WARN} h), ${fichiers} fichier(s)."
else
rapporter "${noeud}" 0 "OK : instantane il y a ${age_h} h, ${fichiers} fichier(s), ${taille} octets."
fi
done
if (( ECHECS > 0 )); then
echo "${ECHECS} rapport(s) non deposes : Icinga ne sait donc pas ou en sont ces sauvegardes." >&2
exit 1
fi

View file

@ -31,3 +31,22 @@ serveur_icinga_schema: "/usr/share/icingadb/schema/pgsql/schema.sql"
# contre le root_ca step-ca (host dans le SAN). root_ca doit etre lisible (0644).
serveur_icinga_db_tls: false
serveur_icinga_db_ca: "/etc/step/certs/root_ca.crt"
# --- Supervision des sauvegardes (resultats passifs pousses par le depot) ---
# Le controle porte sur la VERITE DE TERRAIN (l'instantane cote depot), pas sur l'unite
# systemd du noeud source : une unite verte sur un depot vide a menti pendant un mois.
serveur_icinga_setops_conf: "/etc/icinga2/conf.d/setops-sauvegardes.conf"
serveur_icinga_api_conf: "/etc/icinga2/conf.d/setops-api-users.conf"
serveur_icinga_api_utilisateur: "setops-depot"
serveur_icinga_api_motdepasse: "{{ vault_icinga_api_depot | default('') }}"
# Hote portant les depots : DERIVE du groupe, jamais ecrit en dur.
serveur_icinga_hote_sauvegarde: "{{ (groups['serveur_backup'] | default([]) | first) | default('') }}"
# Noeuds dont on ATTEND un instantane : ceux qui portent client_backup ET detiennent
# reellement de l'etat. Meme regle que `client_backup_jobs`, et meme source que P36 —
# la liste en clair du catalogue, lue depuis le role qui la possede.
serveur_icinga_sauvegarde_attendue: >-
{{ (client_backup_groupes_etat | default([]) | map('extract', groups)
| select('defined') | flatten | unique | list)
| intersect(groups['client_backup'] | default([])) }}

View file

@ -8,6 +8,13 @@ flux:
pair: localhost
chiffrement: clair
raison: "API Icinga 2 consommée en local par Icinga Web 2 co-localisé."
- sens: ingress
port: 5665
protocole: tcp
pair: serveur_backup
chiffrement: tls-requis
partage: true
raison: "Le depot de sauvegarde depose ses resultats passifs (portee : process-check-result sur « sauvegarde: * »)."
- sens: egress
port: 5432
protocole: tcp

View file

@ -89,6 +89,52 @@
no_log: true
notify: Redemarrer icingadb
# --- Supervision des sauvegardes ---
# La liste des detenteurs d'etat appartient a `client_backup`. On la LIT chez lui plutot
# que de la recopier : deux listes finissent toujours par diverger, et la divergence se
# lirait « tout va bien » des deux cotes.
- name: Lire la liste des detenteurs d'etat chez client_backup
ansible.builtin.include_vars:
file: "{{ role_path }}/../client_backup/vars/main.yml"
name: _catalogue_sauvegarde
- name: Adopter la liste des detenteurs d'etat
ansible.builtin.set_fact:
client_backup_groupes_etat: "{{ _catalogue_sauvegarde.client_backup_groupes_etat }}"
- name: Exiger le secret d'API du depot (Vault)
ansible.builtin.assert:
that:
- serveur_icinga_api_motdepasse | length > 0
- serveur_icinga_hote_sauvegarde | length > 0
fail_msg: >-
vault_icinga_api_depot requis, et un hote du groupe `serveur_backup` doit exister :
sans l'un ou l'autre, les sauvegardes ne seraient surveillees par personne.
- name: Deployer le compte d'API du depot de sauvegarde
ansible.builtin.template:
src: setops-api-users.conf.j2
dest: "{{ serveur_icinga_api_conf }}"
owner: root
group: nagios
mode: "0640"
no_log: true
notify: Redemarrer icinga2
- name: Deployer les objets de supervision des sauvegardes
ansible.builtin.template:
src: setops-sauvegardes.conf.j2
dest: "{{ serveur_icinga_setops_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
changed_when: false
- name: Activer et demarrer Icinga 2 et Icinga DB
when: not ansible_check_mode
ansible.builtin.systemd:

View file

@ -0,0 +1,16 @@
/*
* Gere par Set-OPS (role serveur_icinga). Ne pas editer a la main.
*
* Compte d'API dedie au DEPOT de sauvegarde, pour deposer ses resultats passifs.
* Portee minimale : uniquement `actions/process-check-result`, et uniquement sur les
* services de sauvegarde. Ce compte ne peut ni lire la configuration, ni agir ailleurs.
*/
object ApiUser "{{ serveur_icinga_api_utilisateur }}" {
password = "{{ serveur_icinga_api_motdepasse }}"
permissions = [
{
permission = "actions/process-check-result"
filter = {{ '{{' }} match("sauvegarde: *", service.name) {{ '}}' }}
}
]
}

View file

@ -0,0 +1,36 @@
/*
* Gere par Set-OPS (role serveur_icinga). Ne pas editer a la main.
*
* Supervision des sauvegardes. Le controle ne porte PAS sur l'unite systemd du noeud
* source : une unite verte sur un depot vide est exactement ce qui a menti pendant un
* mois (2026-07-03 -> 2026-08-11). Il porte sur la VERITE DE TERRAIN, cote depot :
* l'instantane du noeud existe-t-il, est-il RECENT, et n'est-il pas VIDE.
*
* Les resultats sont POUSSES par `backup-01`, qui est le seul a pouvoir lire ses depots.
* Le `ttl` porte dans chaque envoi fait la fraicheur : sans nouvelle, Icinga bascule tout
* seul en « expire ». C'est le SILENCE qui doit alerter, pas seulement l'echec — le
* silence est precisement ce qui n'a alerte personne.
*/
object CheckCommand "setops-passif" {
// Les resultats arrivent par l'API ; cette commande n'est jamais executee.
command = [ "/bin/true" ]
}
object Host "{{ serveur_icinga_hote_sauvegarde }}" {
check_command = "hostalive"
address = "{{ serveur_icinga_hote_sauvegarde }}.{{ domaine_interne }}"
vars.role = "depot de sauvegarde"
}
{% for noeud in serveur_icinga_sauvegarde_attendue | sort %}
object Service "sauvegarde: {{ noeud }}" {
host_name = "{{ serveur_icinga_hote_sauvegarde }}"
check_command = "setops-passif"
enable_active_checks = false
enable_passive_checks = true
volatile = false
max_check_attempts = 1
vars.setops_source = "{{ noeud }}"
}
{% endfor %}