From f1a7e43645a472ddfc7a2c8bde63682ff01dc9d7 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 11 Aug 2026 21:39:05 -0400 Subject: [PATCH] supervision : c'est le DEPOT qui dit ou en sont les sauvegardes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 56 +++++++++ docs/audit/preuve-2026-08-11.md | 8 +- docs/registre-flux.md | 4 +- roles/client_backup/defaults/main.yml | 3 + roles/client_backup/tasks/main.yml | 11 ++ roles/client_backup/vars/main.yml | 23 ++++ roles/serveur_backup/defaults/main.yml | 31 +++++ roles/serveur_backup/meta/flux.yml | 6 + roles/serveur_backup/tasks/main.yml | 82 +++++++++++++ .../templates/setops-verification.service.j2 | 11 ++ .../templates/setops-verification.timer.j2 | 14 +++ .../templates/verifier-sauvegardes.sh.j2 | 108 ++++++++++++++++++ roles/serveur_icinga/defaults/main.yml | 19 +++ roles/serveur_icinga/meta/flux.yml | 7 ++ roles/serveur_icinga/tasks/main.yml | 46 ++++++++ .../templates/setops-api-users.conf.j2 | 16 +++ .../templates/setops-sauvegardes.conf.j2 | 36 ++++++ 17 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 roles/client_backup/vars/main.yml create mode 100644 roles/serveur_backup/templates/setops-verification.service.j2 create mode 100644 roles/serveur_backup/templates/setops-verification.timer.j2 create mode 100644 roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 create mode 100644 roles/serveur_icinga/templates/setops-api-users.conf.j2 create mode 100644 roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7644bc..a1b89b8 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/audit/preuve-2026-08-11.md b/docs/audit/preuve-2026-08-11.md index 52c0c03..9879e6a 100644 --- a/docs/audit/preuve-2026-08-11.md +++ b/docs/audit/preuve-2026-08-11.md @@ -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). | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index ed49094..4554a89 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -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 diff --git a/roles/client_backup/defaults/main.yml b/roles/client_backup/defaults/main.yml index ca9a78d..17b0380 100644 --- a/roles/client_backup/defaults/main.yml +++ b/roles/client_backup/defaults/main.yml @@ -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. diff --git a/roles/client_backup/tasks/main.yml b/roles/client_backup/tasks/main.yml index af2c7ec..9d34830 100644 --- a/roles/client_backup/tasks/main.yml +++ b/roles/client_backup/tasks/main.yml @@ -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: >- diff --git a/roles/client_backup/vars/main.yml b/roles/client_backup/vars/main.yml new file mode 100644 index 0000000..09b6be8 --- /dev/null +++ b/roles/client_backup/vars/main.yml @@ -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 diff --git a/roles/serveur_backup/defaults/main.yml b/roles/serveur_backup/defaults/main.yml index b586cfb..a042c13 100644 --- a/roles/serveur_backup/defaults/main.yml +++ b/roles/serveur_backup/defaults/main.yml @@ -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([])) }} diff --git a/roles/serveur_backup/meta/flux.yml b/roles/serveur_backup/meta/flux.yml index a43aa10..5d3013d 100644 --- a/roles/serveur_backup/meta/flux.yml +++ b/roles/serveur_backup/meta/flux.yml @@ -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." diff --git a/roles/serveur_backup/tasks/main.yml b/roles/serveur_backup/tasks/main.yml index 57de9dd..f3ed511 100644 --- a/roles/serveur_backup/tasks/main.yml +++ b/roles/serveur_backup/tasks/main.yml @@ -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 diff --git a/roles/serveur_backup/templates/setops-verification.service.j2 b/roles/serveur_backup/templates/setops-verification.service.j2 new file mode 100644 index 0000000..e1be875 --- /dev/null +++ b/roles/serveur_backup/templates/setops-verification.service.j2 @@ -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 diff --git a/roles/serveur_backup/templates/setops-verification.timer.j2 b/roles/serveur_backup/templates/setops-verification.timer.j2 new file mode 100644 index 0000000..e1d85fb --- /dev/null +++ b/roles/serveur_backup/templates/setops-verification.timer.j2 @@ -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 diff --git a/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 new file mode 100644 index 0000000..5837f23 --- /dev/null +++ b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 @@ -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 diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 4918093..a0fb072 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -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([])) }} diff --git a/roles/serveur_icinga/meta/flux.yml b/roles/serveur_icinga/meta/flux.yml index 359af1f..cc810cf 100644 --- a/roles/serveur_icinga/meta/flux.yml +++ b/roles/serveur_icinga/meta/flux.yml @@ -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 diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index d80e56f..db5d4bd 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -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: diff --git a/roles/serveur_icinga/templates/setops-api-users.conf.j2 b/roles/serveur_icinga/templates/setops-api-users.conf.j2 new file mode 100644 index 0000000..4267fe0 --- /dev/null +++ b/roles/serveur_icinga/templates/setops-api-users.conf.j2 @@ -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) {{ '}}' }} + } + ] +} diff --git a/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 b/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 new file mode 100644 index 0000000..0ec5def --- /dev/null +++ b/roles/serveur_icinga/templates/setops-sauvegardes.conf.j2 @@ -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 %}