icinga : le pair est verifie — mais pas avec un certificat step-ca

Objectif : supprimer le `curl -k` du rapporteur. Fait, et la maniere a ete
imposee par la mesure.

SERVIR UN CERTIFICAT STEP-CA SUR L'API EST IMPOSSIBLE. Icinga le dit lui-meme :
« Our certificate will expire soon, but we own the CA. Renewing. » Il renouvelle
tout certificat expirant sous 30 jours ; les notres vivent 24 h ; possedant une
AC, il re-emet avec la sienne et ecrase le notre a chaque demarrage. Collision
entre deux politiques de PKI, et la notre n'est pas negociable.

Deux decouvertes en chemin, toutes deux par le garde-fou `icinga2 daemon -C`
ajoute au role, qui a ARRETE le deploiement avant de redemarrer la supervision :
  - cert_path/key_path/ca_path sont DEPRECIES depuis 2.8 ; les poser reveille un
    chemin de code herite qui exige en plus un objet Endpoint ;
  - l'identite de l'API est le CN du certificat. NodeName valait « mon-01 », donc
    le SAN aussi, alors qu'on appelle par le FQDN : aucune verification n'aurait
    pu reussir. NodeName est desormais aligne sur le FQDN.

A LA PLACE : Icinga garde son AC (domaine de confiance FERME, legitime) et
backup-01 verifie le pair contre CETTE AC, recuperee depuis mon-01 au
deploiement. Le pair est authentifie ; seule la racine differe.

Controle negatif — une verification qu'on ne teste pas est un ornement : avec la
mauvaise AC (step-ca), curl refuse (« unable to get local issuer certificate ») ;
avec la bonne, les neuf rapports passent.

Sous-AC step-ca ecartee : elle poserait sur l'hote de supervision une cle capable
d'EMETTRE pour n'importe quel nom, alors qu'on a choisi le sens du flux pour que
compromettre mon-01 ne donne rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-12 08:33:50 -04:00
parent f1a7e43645
commit 4dd4d3755d
8 changed files with 222 additions and 1 deletions

View file

@ -1,5 +1,54 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-12 — Le `curl -k` est mort, mais pas comme prévu
Objectif : que le rapporteur **vérifie** le pair en appelant l'API Icinga, au lieu de
sauter la vérification. C'est fait — et la manière a été imposée par la mesure, pas par
le plan.
### Servir un certificat step-ca sur l'API est IMPOSSIBLE
La tentative était directe : `client_pki` dépose déjà sur `mon-01` un certificat portant
`serverAuth` + `clientAuth` et le bon SAN. Il suffisait de le faire servir. Icinga le
refuse, et le dit lui-même :
```
information/ApiListener: Our certificate will expire soon, but we own the CA. Renewing.
```
Icinga renouvelle tout certificat expirant **sous 30 jours**. Les certificats Set-OPS
vivent **24 h**. Possédant une AC, il ré-émet donc avec la sienne — écrasant le nôtre à
chaque démarrage. Ce n'est pas réparable par configuration : c'est une collision entre
deux politiques de PKI, et la nôtre (certificats courts) n'est pas négociable.
Deux découvertes en chemin, toutes deux par le garde-fou `icinga2 daemon -C` ajouté au
rôle — qui a **arrêté le déploiement avant** de redémarrer la supervision :
- `cert_path` / `key_path` / `ca_path` sont **dépréciés depuis 2.8** ; les poser réveille
un chemin de code hérité qui exige en plus un objet `Endpoint`.
- L'identité de l'API est le **CN du certificat**. `NodeName` valait `mon-01` : le
certificat auto-émis portait donc `SAN=mon-01` alors qu'on appelle par le FQDN, et
**aucune** vérification n'aurait pu réussir. `NodeName` est désormais aligné sur le FQDN.
### Ce qu'on fait à la place
Icinga garde son AC — un domaine de confiance **fermé**, ce qui est légitime — et
`backup-01` vérifie le pair **contre cette AC-là**, récupérée depuis `mon-01` au
déploiement. Le pair est authentifié ; seule la racine diffère. Le `-k` a disparu, ce qui
était le vrai problème.
**Contrôle négatif, parce qu'une vérification qu'on ne teste pas est un ornement** : avec
la mauvaise AC (celle de step-ca), `curl` refuse — `unable to get local issuer
certificate`. Avec la bonne, les neuf rapports passent.
### Et la sous-AC step-ca ?
Écartée, et pas par prudence de principe. Elle poserait sur l'hôte de supervision une clé
capable d'**émettre** pour n'importe quel nom de l'écosystème — alors qu'on a justement
choisi le sens du flux (le dépôt parle à la supervision, jamais l'inverse) pour que
compromettre `mon-01` ne donne rien. Une AC isolée pour un domaine isolé est le bon
design, pas une entorse à la souveraineté.
## 2026-08-11 — Les sauvegardes sont surveillées, et c'est le dépôt qui parle ## 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 Icinga ne surveillait **rien** : aucun objet `Host` ni `Service` de Set-OPS, seulement la

View file

@ -0,0 +1,72 @@
# Preuve de conformite — Set-OPS — 2026-08-12
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (36 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| 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, 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. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ 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 : 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), 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 : 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). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-12._

View file

@ -25,6 +25,13 @@ serveur_backup_age_crit_h: 50
# être manquée sans fausse alerte, deux ne le peuvent pas. # être manquée sans fausse alerte, deux ne le peuvent pas.
serveur_backup_ttl_icinga: 21600 serveur_backup_ttl_icinga: 21600
# AC à opposer au pair en appelant l'API Icinga. C'est celle d'ICINGA, pas step-ca :
# Icinga refuse de servir un certificat qu'il n'a pas émis (il renouvelle tout ce qui
# expire sous 30 jours, nos certificats vivent 24 h). Domaine de confiance fermé, pair
# authentifié malgré tout — ce qui était le vrai enjeu.
serveur_backup_icinga_ca_source: "/var/lib/icinga2/ca/ca.crt"
serveur_backup_ca_verification: "/etc/setops/icinga-ca.crt"
# Compte d'API Icinga (portée : uniquement process-check-result sur « sauvegarde: * »). # Compte d'API Icinga (portée : uniquement process-check-result sur « sauvegarde: * »).
serveur_backup_icinga_utilisateur: "setops-depot" serveur_backup_icinga_utilisateur: "setops-depot"
serveur_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}" serveur_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}"

View file

@ -82,6 +82,23 @@
mode: "0600" mode: "0600"
no_log: true no_log: true
# L'API Icinga presente un certificat emis par l'AC d'Icinga (il refuse d'en servir un
# autre : il renouvelle tout ce qui expire sous 30 jours, or nos certificats vivent 24 h).
# On recupere donc CETTE AC pour verifier le pair — plutot que de sauter la verification.
- name: Récupérer l'AC d'Icinga depuis l'hôte de supervision
ansible.builtin.slurp:
src: "{{ serveur_backup_icinga_ca_source }}"
delegate_to: "{{ serveur_backup_icinga_hote }}"
register: serveur_backup_ca_icinga
- name: Déposer l'AC d'Icinga pour la vérification du pair
ansible.builtin.copy:
content: "{{ serveur_backup_ca_icinga.content | b64decode }}"
dest: "{{ serveur_backup_ca_verification }}"
owner: root
group: root
mode: "0644"
- name: Déployer le script de vérification - name: Déployer le script de vérification
ansible.builtin.template: ansible.builtin.template:
src: verifier-sauvegardes.sh.j2 src: verifier-sauvegardes.sh.j2

View file

@ -35,7 +35,10 @@ rapporter() { # $1=noeud $2=code $3=texte
"type": "Service", "service": sys.argv[1], "exit_status": int(sys.argv[2]), "type": "Service", "service": sys.argv[1], "exit_status": int(sys.argv[2]),
"plugin_output": sys.argv[3], "ttl": int(sys.argv[4])}))' \ "plugin_output": sys.argv[3], "ttl": int(sys.argv[4])}))' \
"${HOTE_DEPOT}!sauvegarde: $1" "$2" "$3" "${TTL}") "${HOTE_DEPOT}!sauvegarde: $1" "$2" "$3" "${TTL}")
reponse=$(curl -sS -k --max-time 20 \ # --cacert, et surtout PAS -k : on verifie A QUI on parle. L'AC opposee est celle
# d'ICINGA (il refuse d'en servir une autre, cf. defaults). Un `-k` laisserait n'importe
# quel intercepteur recevoir l'etat des sauvegardes — et surtout, y repondre.
reponse=$(curl -sS --cacert "{{ serveur_backup_ca_verification }}" --max-time 20 \
-u "{{ serveur_backup_icinga_utilisateur }}:${MOTDEPASSE}" \ -u "{{ serveur_backup_icinga_utilisateur }}:${MOTDEPASSE}" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \ -H 'Accept: application/json' -H 'Content-Type: application/json' \
-X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1) -X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1)

View file

@ -32,6 +32,19 @@ serveur_icinga_schema: "/usr/share/icingadb/schema/pgsql/schema.sql"
serveur_icinga_db_tls: false serveur_icinga_db_tls: false
serveur_icinga_db_ca: "/etc/step/certs/root_ca.crt" serveur_icinga_db_ca: "/etc/step/certs/root_ca.crt"
# --- Identite TLS de l'API (5665) ---
# Icinga s'emet lui-meme son certificat, avec sa propre AC : il renouvelle tout ce qui
# expire sous 30 jours, et les certificats Set-OPS vivent 24 h. Lui servir un certificat
# step-ca est donc impossible — il l'ecrase a chaque demarrage (mesure le 2026-08-11).
# Le consommateur verifie donc le pair contre l'AC d'Icinga : domaine de confiance ferme,
# pair authentifie, plus aucun `curl -k`.
#
# NodeName aligne sur le FQDN : le certificat auto-emis porte NodeName en CN et en SAN,
# et c'est par le FQDN qu'on appelle l'API. Sans cet alignement, aucune verification ne
# peut reussir.
serveur_icinga_node_name: "{{ ansible_fqdn | default(ansible_hostname) }}"
serveur_icinga_ca: "/var/lib/icinga2/ca/ca.crt"
# --- Supervision des sauvegardes (resultats passifs pousses par le depot) --- # --- 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 # 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. # systemd du noeud source : une unite verte sur un depot vide a menti pendant un mois.

View file

@ -89,6 +89,41 @@
no_log: true no_log: true
notify: Redemarrer icingadb notify: Redemarrer icingadb
# --- Identite TLS de l'API (5665) ---
# ARBITRAGE DU 2026-08-11, apres mesure. On a voulu servir ici le certificat step-ca, pour
# que les consommateurs verifient le pair au lieu de faire `curl -k`. Icinga le REFUSE
# structurellement, et le dit :
#
# information/ApiListener: Our certificate will expire soon, but we own the CA. Renewing.
#
# Les certificats Set-OPS vivent 24 H ; Icinga renouvelle tout ce qui expire sous 30 jours
# et, possedant une AC, re-emet avec la sienne. Il ecrasait donc le certificat step-ca a
# chaque demarrage. Ce n'est pas reparable par configuration.
#
# CE QU'ON FAIT A LA PLACE : Icinga garde son AC — un domaine de confiance FERME, ce qui
# est legitime — et le consommateur (serveur_backup) VERIFIE le pair contre cette AC-la.
# Le pair est authentifie ; seule la racine differe. Le `-k` disparait, ce qui etait le
# vrai probleme.
#
# NodeName reste aligne sur le FQDN : le certificat qu'Icinga s'emet porte NodeName en CN
# ET en SAN, et c'est par le FQDN qu'on l'appelle. Sans cet alignement, le SAN vaudrait
# « mon-01 » et toute verification echouerait.
- name: TLS — aligner NodeName sur le FQDN (identite de l'API)
ansible.builtin.lineinfile:
path: /etc/icinga2/constants.conf
regexp: '^const NodeName\s*='
line: 'const NodeName = "{{ serveur_icinga_node_name }}"'
notify: Redemarrer icinga2
- name: API — durcir l'ApiListener (aucune config ni commande acceptee)
ansible.builtin.template:
src: api.conf.j2
dest: /etc/icinga2/features-available/api.conf
owner: root
group: nagios
mode: "0640"
notify: Redemarrer icinga2
# --- Supervision des sauvegardes --- # --- Supervision des sauvegardes ---
# La liste des detenteurs d'etat appartient a `client_backup`. On la LIT chez lui plutot # 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 # que de la recopier : deux listes finissent toujours par diverger, et la divergence se

View file

@ -0,0 +1,25 @@
/*
* Gere par Set-OPS (role serveur_icinga). Ne pas editer a la main.
*
* AUCUN chemin de certificat n'est declare ici, et c'est le resultat d'une mesure.
*
* `cert_path`, `key_path` et `ca_path` sont DEPRECIES depuis Icinga 2.8 : les poser
* reveille un chemin de code herite qui exige en plus un objet `Endpoint` (constate le
* 2026-08-11). Depuis 2.8, Icinga lit `/var/lib/icinga2/certs/${NodeName}.{crt,key}` par
* CONVENTION — et il s'emet lui-meme ce certificat avec sa propre AC.
*
* On a tente de lui servir un certificat step-ca : impossible. Icinga renouvelle tout ce
* qui expire sous 30 jours, les certificats Set-OPS vivent 24 h, et il ecrasait donc le
* notre a chaque demarrage — « Our certificate will expire soon, but we own the CA.
* Renewing. » Le consommateur verifie desormais le pair contre l'AC d'Icinga.
*
* Ce qui EST durci ici : l'API n'accepte ni configuration ni commande a distance. Le seul
* usage legitime est le depot de resultats passifs, qui passe par un ApiUser a portee
* limitee (voir setops-api-users.conf).
*/
object ApiListener "api" {
accept_config = false
accept_commands = false
ticket_salt = TicketSalt
}