diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b89b8..8386fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # 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 Icinga ne surveillait **rien** : aucun objet `Host` ni `Service` de Set-OPS, seulement la diff --git a/docs/audit/preuve-2026-08-12.md b/docs/audit/preuve-2026-08-12.md new file mode 100644 index 0000000..9b3a64e --- /dev/null +++ b/docs/audit/preuve-2026-08-12.md @@ -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._ diff --git a/roles/serveur_backup/defaults/main.yml b/roles/serveur_backup/defaults/main.yml index a042c13..a857ede 100644 --- a/roles/serveur_backup/defaults/main.yml +++ b/roles/serveur_backup/defaults/main.yml @@ -25,6 +25,13 @@ serveur_backup_age_crit_h: 50 # être manquée sans fausse alerte, deux ne le peuvent pas. 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: * »). serveur_backup_icinga_utilisateur: "setops-depot" serveur_backup_icinga_motdepasse: "{{ vault_icinga_api_depot | default('') }}" diff --git a/roles/serveur_backup/tasks/main.yml b/roles/serveur_backup/tasks/main.yml index f3ed511..e093d4d 100644 --- a/roles/serveur_backup/tasks/main.yml +++ b/roles/serveur_backup/tasks/main.yml @@ -82,6 +82,23 @@ mode: "0600" 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 ansible.builtin.template: src: verifier-sauvegardes.sh.j2 diff --git a/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 index 5837f23..7800026 100644 --- a/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 +++ b/roles/serveur_backup/templates/verifier-sauvegardes.sh.j2 @@ -35,7 +35,10 @@ rapporter() { # $1=noeud $2=code $3=texte "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 \ + # --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}" \ -H 'Accept: application/json' -H 'Content-Type: application/json' \ -X POST "${API}/v1/actions/process-check-result" -d "${charge}" 2>&1) diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index a0fb072..8789d75 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -32,6 +32,19 @@ serveur_icinga_schema: "/usr/share/icingadb/schema/pgsql/schema.sql" serveur_icinga_db_tls: false 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) --- # 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. diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index db5d4bd..3f9f526 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -89,6 +89,41 @@ no_log: true 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 --- # 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 diff --git a/roles/serveur_icinga/templates/api.conf.j2 b/roles/serveur_icinga/templates/api.conf.j2 new file mode 100644 index 0000000..18c81b2 --- /dev/null +++ b/roles/serveur_icinga/templates/api.conf.j2 @@ -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 +}