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:
parent
8eedeaf31f
commit
f1a7e43645
17 changed files with 476 additions and 5 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: >-
|
||||
|
|
|
|||
23
roles/client_backup/vars/main.yml
Normal file
23
roles/client_backup/vars/main.yml
Normal 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
|
||||
|
|
@ -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([])) }}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
roles/serveur_backup/templates/setops-verification.timer.j2
Normal file
14
roles/serveur_backup/templates/setops-verification.timer.j2
Normal 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
|
||||
108
roles/serveur_backup/templates/verifier-sauvegardes.sh.j2
Normal file
108
roles/serveur_backup/templates/verifier-sauvegardes.sh.j2
Normal 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
|
||||
|
|
@ -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([])) }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
16
roles/serveur_icinga/templates/setops-api-users.conf.j2
Normal file
16
roles/serveur_icinga/templates/setops-api-users.conf.j2
Normal 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) {{ '}}' }}
|
||||
}
|
||||
]
|
||||
}
|
||||
36
roles/serveur_icinga/templates/setops-sauvegardes.conf.j2
Normal file
36
roles/serveur_icinga/templates/setops-sauvegardes.conf.j2
Normal 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 %}
|
||||
Loading…
Reference in a new issue