diff --git a/CHANGELOG.md b/CHANGELOG.md index d6dbde0..9ca1da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # CHANGELOG — Set-OPS +## 2026-08-08 — Le devis des certificats trouve l'autorité expirée depuis huit heures + +Deuxième application du patron devis/applicateur aux services, sur le défaut le plus +coûteux qu'on connaisse : un certificat renouvelé **sur disque** mais toujours servi +**périmé** depuis la mémoire du service. + +**Trouvé à la première exécution.** Sur `infra-pki-01` — l'autorité elle-même — le +certificat était expiré depuis plus de huit heures, et le renouvellement échouait toutes +les quatorze minutes : + +``` +'step ca renew' requires the '--ca-url' flag +notAfter=Aug 8 02:51:21 2026 GMT (il était 11:14 UTC) +``` + +Cause : sur l'hôte de l'AC, `/etc/step` est le STEPPATH du **serveur**, pas un amorçage +client — il n'y a donc pas de `defaults.json`, et l'unité de renouvellement, identique +partout, en dépendait. **La leçon avait déjà été apprise**, et écrite noir sur blanc dans +le commentaire de la tâche d'émission (« l'autorité ne bootstrape pas »), qui passe +`--ca-url` et `--root` explicitement. Elle n'avait jamais été reportée sur l'unité de +renouvellement. + +**Et le rôle ne pouvait pas se soigner.** La condition de ré-émission ne regardait que la +*forme* — cert absent, ou SAN manquant. Un certificat expiré portant les bons SAN ne +déclenchait rien. `client_pki` vérifie désormais aussi la **validité** +(`client_pki_marge_renouvellement`, une heure). + +**Ce qu'il a fallu désapprendre pour écrire le devis.** Les certificats vivent 24 h et le +minuteur les renouvelle toutes les ~14 min : une empreinte servie *différente* de celle sur +disque est l'état **normal**. Comparer les empreintes aurait donné un vérificateur qui crie +en permanence — et qu'on aurait appris à ignorer. Le signal utile est l'échéance de ce qui +est **réellement servi**, plus l'absence de `client_pki_reload_services`. + +**Le devis a d'abord menti, du défaut même qu'il traque.** `include_vars` au niveau du play +prime sur les `group_vars` : le premier jet rapportait `client_pki_reload_services: []` sur +les quatorze hôtes alors que quatre groupes le déclarent. Le correctif suivant a *paru* +fonctionner — `set_fact` accepte un dictionnaire entier en argument libre sans erreur et +n'en fait rien. Il faut réimposer clé par clé, en boucle. Les deux devis sont corrigés et +le piège est consigné dans `docs/devis-services.md`, avant d'écrire le prochain. + +Deux latents relevés et corrigés au passage : `step-ca` (8443) et `icinga2` (5665) servaient +une copie qu'aucun rechargement ne rafraîchissait ; les deux déclarent maintenant leur +service, en `reload` (SIGHUP pour step-ca, `safe-reload` pour icinga2), sans interruption. + +**Vérifié dans les deux sens** : `CONFORME` sur les quatorze hôtes après correction ; sur un +relevé où l'on rejoue une copie périmée en mémoire, deux écarts listés et code de sortie 1. + ## 2026-08-08 — Un devis pour l'identité : rien ne comparait le déployé au déclaré Constat de l'exploitant après trois séries de corrections : « ça fait beaucoup de trucs diff --git a/Makefile b/Makefile index eb3c75c..8b5fb0f 100644 --- a/Makefile +++ b/Makefile @@ -418,6 +418,11 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S | tail -1 | tr -d ' \r' .PHONY: frontiere-plan frontiere-appliquer +certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture) + @rm -f $(SETOPS_INSTANCE)/devis-certificats.json.* + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null + @python3 scripts/devis_certificats.py + identite-plan: ansible-runtime ## Ecart entre l'identite deployee et ce que le plan derive (aucune ecriture) @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-identite.yml >/dev/null @python3 scripts/devis_identite.py diff --git a/docs/audit/preuve-2026-08-08.md b/docs/audit/preuve-2026-08-08.md index 4d83e29..2853877 100644 --- a/docs/audit/preuve-2026-08-08.md +++ b/docs/audit/preuve-2026-08-08.md @@ -13,7 +13,7 @@ | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| -| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | | +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | | P02 | Tests unitaires (inventory_host) | — | ✅ OK | 5 tests passes. | | 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 | | diff --git a/docs/devis-identite.md b/docs/devis-identite.md deleted file mode 100644 index fd7880f..0000000 --- a/docs/devis-identite.md +++ /dev/null @@ -1,52 +0,0 @@ -# Le devis d'identité : ce qui tourne correspond-il à ce qui est déclaré ? - -> **Instrument ajouté le 2026-08-08**, après une série de défauts qu'aucun test n'avait -> signalés. Lecture seule — il ne modifie rien. - -``` -make identite-plan -``` - -## Le trou qu'il comble - -`scripts/prouver.py` porte 30 preuves. Elles sont toutes **statiques** : elles lisent le -dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est -cohérent **avec lui-même** — que les handlers existent, que les intrants ont un -propriétaire, que rien n'est codé en dur. - -Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce. - -Les quatre défauts du 2026-08-08 vivaient tous dans cet angle : - -| Défaut | Le dépôt disait | La machine portait | -|---|---|---| -| politique de mot de passe | `length(12)…` | *aucune* — `abcd` accepté | -| entrée `ppolicy` figée | `pwdMustChange: TRUE` | `FALSE` | -| fédération en écriture | comptes écrits dans LDAP | `syncRegistrations` absent | -| prénom | `givenName` | `cn` (le nom complet) | - -Chacun a été trouvé en relisant après avoir écrit. Aucun n'a été signalé. - -## Ce qu'il vérifie - -Le playbook `playbooks/maintenance/devis-identite.yml` **relève** deux dictionnaires — le -déclaré et le réel — et les dépose en JSON. `scripts/devis_identite.py` **compare**. La -séparation est délibérée : la comparaison en Jinja est illisible, et le dépôt a déjà cette -forme pour les devis réseau (Python raisonne, Ansible relève). - -- **realm** — politique de mot de passe, anti-force-brute, « mot de passe oublié », relais SMTP -- **fédération** — mode d'écriture et les six clés de durcissement -- **mappeurs** — chaque attribut LDAP projeté -- **annuaire** — l'entrée `pwdPolicy`, attribut par attribut -- **comptes** — ceux qui n'ont pas d'adresse, pour qui « mot de passe oublié » ne mène nulle part - -Le déclaré n'est jamais recopié ici : le playbook charge les défauts du rôle et appelle les -résolveurs. **Un devis qui redéclare ce qu'il vérifie ne vérifie rien.** - -## Ce qu'il ne fait pas - -Il ne corrige pas — c'est `make deployer` qui réconcilie. Il répond à l'autre question, et -sort en code 1 s'il y a un écart. - -Il ne couvre que l'identité. Les autres services attendent le même traitement ; le patron -est là pour être repris. diff --git a/docs/devis-services.md b/docs/devis-services.md new file mode 100644 index 0000000..7e412c7 --- /dev/null +++ b/docs/devis-services.md @@ -0,0 +1,89 @@ +# Les devis de service : ce qui tourne correspond-il à ce qui est déclaré ? + +> **Instrument ajouté le 2026-08-08**, après une série de défauts qu'aucun test n'avait +> signalés. Lecture seule — il ne modifie rien. + +``` +make identite-plan # realm, fédération, mappeurs, politique, comptes +make certificats-plan # certificats sur disque contre certificats réellement servis +``` + +## Le trou qu'il comble + +`scripts/prouver.py` porte 30 preuves. Elles sont toutes **statiques** : elles lisent le +dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est +cohérent **avec lui-même** — que les handlers existent, que les intrants ont un +propriétaire, que rien n'est codé en dur. + +Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce. + +Les quatre défauts du 2026-08-08 vivaient tous dans cet angle : + +| Défaut | Le dépôt disait | La machine portait | +|---|---|---| +| politique de mot de passe | `length(12)…` | *aucune* — `abcd` accepté | +| entrée `ppolicy` figée | `pwdMustChange: TRUE` | `FALSE` | +| fédération en écriture | comptes écrits dans LDAP | `syncRegistrations` absent | +| prénom | `givenName` | `cn` (le nom complet) | + +Chacun a été trouvé en relisant après avoir écrit. Aucun n'a été signalé. + +## Ce qu'il vérifie + +Le playbook `playbooks/maintenance/devis-identite.yml` **relève** deux dictionnaires — le +déclaré et le réel — et les dépose en JSON. `scripts/devis_identite.py` **compare**. La +séparation est délibérée : la comparaison en Jinja est illisible, et le dépôt a déjà cette +forme pour les devis réseau (Python raisonne, Ansible relève). + +- **realm** — politique de mot de passe, anti-force-brute, « mot de passe oublié », relais SMTP +- **fédération** — mode d'écriture et les six clés de durcissement +- **mappeurs** — chaque attribut LDAP projeté +- **annuaire** — l'entrée `pwdPolicy`, attribut par attribut +- **comptes** — ceux qui n'ont pas d'adresse, pour qui « mot de passe oublié » ne mène nulle part + +Le déclaré n'est jamais recopié ici : le playbook charge les défauts du rôle et appelle les +résolveurs. **Un devis qui redéclare ce qu'il vérifie ne vérifie rien.** + +## Le devis des certificats + +Il traque un défaut connu et récurrent : un certificat renouvelé **sur disque** mais +toujours servi **périmé** depuis la mémoire du service, tant que nginx / postfix / dovecot +/ slapd / step-ca n'ont pas été rechargés. + +**Ce qu'il a fallu désapprendre pour l'écrire.** Les certificats step-ca vivent 24 h et le +minuteur les renouvelle toutes les ~14 minutes. Une empreinte servie *différente* de celle +sur disque est donc l'état **normal** : comparer les empreintes aurait produit un +vérificateur qui crie en permanence, et qu'on aurait appris à ignorer. Le signal utile est +l'**échéance de ce qui est réellement servi**, plus l'absence de `client_pki_reload_services` +— car alors rien ne rafraîchira jamais la copie en mémoire. + +Il a trouvé quelque chose à sa première exécution : sur `infra-pki-01`, l'autorité +elle-même, le certificat était **expiré depuis plus de huit heures** et le renouvellement +échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`. +Rien ne le signalait. + +## Un piège de construction, à connaître avant d'écrire le prochain + +`include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un +rôle tels quels fait donc mentir le devis : il compare au défaut du dépôt au lieu de la +valeur réellement déclarée par l'inventaire. Le premier jet rapportait +`client_pki_reload_services: []` sur les quatorze hôtes alors que quatre groupes le +déclarent — **le devis portait exactement le défaut qu'il est censé traquer.** + +La forme correcte, reprise dans les deux devis : + +1. retenir ce que l'inventaire déclare, **avant** tout chargement de défauts ; +2. charger les défauts du rôle (`include_vars`, sans `name:` — les défauts se citent entre + eux, et ces références ne se résolvent plus une fois enfermées dans un dictionnaire) ; +3. réimposer les valeurs de l'inventaire **clé par clé**, en boucle. `set_fact` n'accepte + pas un dictionnaire entier passé en argument libre : il le prend sans erreur et n'en + fait rien — c'est ainsi que le premier correctif a paru fonctionner tout en laissant le + devis mentir. + +## Ce qu'ils ne font pas + +Ils ne corrigent pas — c'est `make deployer` qui réconcilie. Ils répondent à l'autre +question, et sortent en code 1 s'il y a un écart. + +Ils couvrent l'identité et les certificats. Le courriel, la base de données et les +expositions web attendent le même traitement ; le patron est là pour être repris. diff --git a/playbooks/maintenance/devis-certificats.yml b/playbooks/maintenance/devis-certificats.yml new file mode 100644 index 0000000..e69c169 --- /dev/null +++ b/playbooks/maintenance/devis-certificats.yml @@ -0,0 +1,113 @@ +--- +# Devis des certificats — RELEVE seul, sur toute la flotte. Ne modifie rien (D-23). +# +# Le defaut qu'il traque est connu et recurrent : un certificat renouvele SUR DISQUE +# mais toujours servi PERIME depuis la memoire du service, tant que nginx / postfix / +# dovecot / slapd n'ont pas ete recharges. Rien ne le signale — le fichier est bon, +# `step` est content, et le navigateur voit un certificat expire. +# +# La methode ne suppose rien du service : on releve l'empreinte du certificat sur +# disque, puis l'empreinte REELLEMENT SERVIE par chaque port TLS en ecoute. Un port +# qui sert un certificat portant le meme sujet mais une autre empreinte tient une +# copie perimee en memoire. +# +# make certificats-plan + +- name: Devis des certificats — disque contre mémoire + hosts: client_pki + become: true + gather_facts: true + + vars: + devis_certificats_sortie: "{{ playbook_dir }}/../../instance/devis-certificats.json" + + tasks: + # PRECEDENCE — `include_vars` au niveau du play PRIME sur les `group_vars`. + # Charger les defauts d'un role tels quels ferait donc mentir le devis : il + # comparerait au defaut du depot au lieu de la valeur reellement declaree par + # l'inventaire. Mesure du 2026-08-08 : le premier jet rapportait + # `client_pki_reload_services: []` sur les 14 hotes alors que quatre groupes le + # declarent. Le devis portait exactement le defaut qu'il est cense traquer. + # + # On retient donc ce que l'inventaire declare AVANT de charger les defauts, puis + # on le reimpose. (Charger sous un `name:` ne marche pas : les defauts se citent + # entre eux — `client_pki_cert` reference `client_pki_steppath` — et ces + # references ne se resolvent plus une fois enfermees dans un dictionnaire.) + - name: Retenir ce que l'inventaire déclare pour client_pki + ansible.builtin.set_fact: + devis_inventaire: >- + {{ hostvars[inventory_hostname] | dict2items + | selectattr('key', 'match', '^client_pki_') | items2dict }} + + - name: Charger les défauts du rôle client_pki + ansible.builtin.include_vars: + dir: "{{ playbook_dir }}/../../roles/client_pki/defaults" + + # Cle par cle : `set_fact` ne prend pas un dictionnaire entier en argument libre — + # il l'accepte sans erreur et n'en fait rien. C'est ainsi que le premier correctif + # a paru fonctionner tout en laissant le devis mentir. + - name: Rendre le dernier mot à l'inventaire + ansible.builtin.set_fact: + "{{ item.key }}": "{{ item.value }}" + loop: "{{ devis_inventaire | dict2items }}" + loop_control: + label: "{{ item.key }}" + + - name: Relever le certificat sur disque + ansible.builtin.command: + argv: + - openssl + - x509 + - -in + - "{{ client_pki_cert }}" + - -noout + - -subject + - -fingerprint + - -enddate + - -issuer + register: devis_cert_disque + changed_when: false + failed_when: false + + - name: Relever les ports TLS en écoute et le certificat que chacun sert + ansible.builtin.shell: + executable: /bin/bash + cmd: | + set -uo pipefail + for P in $(ss -H -ltn 2>/dev/null | awk '{print $4}' | sed 's/.*://' | sort -un); do + # STARTTLS n'est pas optionnel sur ces ports : sans le dialecte, la poignee + # de main n'a pas lieu et on conclurait a tort que le port ne sert rien. + case "$P" in + 25|587) ARG="-starttls smtp" ;; + 143) ARG="-starttls imap" ;; + 110) ARG="-starttls pop3" ;; + 389) ARG="-starttls ldap" ;; + *) ARG="" ;; + esac + OUT=$(timeout 5 openssl s_client -connect 127.0.0.1:"$P" $ARG \ + -servername {{ ansible_fqdn }} /dev/null \ + | openssl x509 -noout -subject -fingerprint -enddate 2>/dev/null) + if [ -n "$OUT" ]; then + echo "PORT=$P" + echo "$OUT" + fi + done + register: devis_cert_servis + changed_when: false + + - name: Déposer le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ devis_certificats_sortie }}.{{ inventory_hostname }}" + mode: "0600" + content: >- + {{ { + 'hote': inventory_hostname, + 'fqdn': ansible_fqdn, + 'chemin': client_pki_cert, + 'disque': devis_cert_disque.stdout | default(''), + 'disque_rc': devis_cert_disque.rc | default(1), + 'servis': devis_cert_servis.stdout | default(''), + 'recharges_declares': client_pki_reload_services, + } | to_nice_json }} + delegate_to: localhost + become: false diff --git a/playbooks/maintenance/devis-identite.yml b/playbooks/maintenance/devis-identite.yml index b2063e2..34da692 100644 --- a/playbooks/maintenance/devis-identite.yml +++ b/playbooks/maintenance/devis-identite.yml @@ -24,12 +24,37 @@ devis_identite_sortie: "{{ playbook_dir }}/../../instance/devis-identite.json" tasks: - # Les valeurs DECLAREES vivent dans les defauts du role. On les charge plutot que - # de les recopier ici : un devis qui redeclare ce qu'il verifie ne verifie rien. - - name: Charger les valeurs déclarées par le rôle Keycloak + # PRECEDENCE — `include_vars` au niveau du play PRIME sur les `group_vars`. + # Charger les defauts d'un role tels quels ferait donc mentir le devis : il + # comparerait au defaut du depot au lieu de la valeur reellement declaree par + # l'inventaire. Mesure du 2026-08-08 : le premier jet rapportait + # `client_pki_reload_services: []` sur les 14 hotes alors que quatre groupes le + # declarent. Le devis portait exactement le defaut qu'il est cense traquer. + # + # On retient donc ce que l'inventaire declare AVANT de charger les defauts, puis + # on le reimpose. (Charger sous un `name:` ne marche pas : les defauts se citent + # entre eux — `serveur_keycloak_cert` reference `serveur_keycloak_steppath` — et ces + # references ne se resolvent plus une fois enfermees dans un dictionnaire.) + - name: Retenir ce que l'inventaire déclare pour serveur_keycloak + ansible.builtin.set_fact: + devis_inventaire: >- + {{ hostvars[inventory_hostname] | dict2items + | selectattr('key', 'match', '^serveur_keycloak_') | items2dict }} + + - name: Charger les défauts du rôle serveur_keycloak ansible.builtin.include_vars: dir: "{{ playbook_dir }}/../../roles/serveur_keycloak/defaults" + # Cle par cle : `set_fact` ne prend pas un dictionnaire entier en argument libre — + # il l'accepte sans erreur et n'en fait rien. C'est ainsi que le premier correctif + # a paru fonctionner tout en laissant le devis mentir. + - name: Rendre le dernier mot à l'inventaire + ansible.builtin.set_fact: + "{{ item.key }}": "{{ item.value }}" + loop: "{{ devis_inventaire | dict2items }}" + loop_control: + label: "{{ item.key }}" + - name: Résoudre la politique de mot de passe (déclaration unique) ansible.builtin.include_role: name: resoudre_politique_mdp diff --git a/roles/client_pki/defaults/main.yml b/roles/client_pki/defaults/main.yml index a858b00..3794dc8 100644 --- a/roles/client_pki/defaults/main.yml +++ b/roles/client_pki/defaults/main.yml @@ -39,3 +39,8 @@ client_pki_depot_suite: "debs" # (nginx sur l'edge, postfix/dovecot sur le mail, slapd sur l'annuaire). Sans ca, # le cert est renouvele sur disque mais le service sert l'ancien jusqu'a un reload. client_pki_reload_services: [] + +# Marge avant echeance (secondes) sous laquelle le role RE-EMET le certificat plutot que +# d'attendre le renouvellement automatique. 3600 = une heure : large devant le minuteur +# (~14 min), serre devant la duree de vie (24 h). +client_pki_marge_renouvellement: 3600 diff --git a/roles/client_pki/tasks/main.yml b/roles/client_pki/tasks/main.yml index 9e7a57d..5847907 100644 --- a/roles/client_pki/tasks/main.yml +++ b/roles/client_pki/tasks/main.yml @@ -110,6 +110,17 @@ path: "{{ client_pki_steppath }}/certs/root_ca.crt" mode: "0644" +# La validite, pas seulement la forme. Sans ce controle, un certificat expire mais +# portant les bons SAN ne declenchait AUCUNE re-emission : le role ne savait pas se +# soigner, et sur l'hote de l'autorite — ou le renouvellement automatique etait casse — +# rien ne pouvait plus le rattraper. Constate le 2026-08-08. +- name: Verifier que le certificat d hote est encore valide + ansible.builtin.command: + cmd: "openssl x509 -in {{ client_pki_cert }} -noout -checkend {{ client_pki_marge_renouvellement }}" + register: client_pki_validite + changed_when: false + failed_when: false + - name: Lire les SAN du certificat d'hote existant (detection de derive) ansible.builtin.command: cmd: "openssl x509 -in {{ client_pki_cert }} -noout -ext subjectAltName" @@ -119,7 +130,7 @@ # Re-emet si le cert est absent OU si un SAN voulu manque (ex: nouvelle exposition # ajoutee au plan -> client_pki_sans mis a jour). Plus de garde 'creates' aveugle. -- name: Obtenir / re-emettre le certificat d'hote (absent ou SAN derives) +- name: Obtenir / re-emettre le certificat d'hote (absent, perime ou SAN derives) ansible.builtin.command: # `--ca-url` et `--root` EXPLICITES : sur un hote ordinaire ils sont redondants avec # le `defaults.json` qu'ecrit `step ca bootstrap`, mais l'autorite ne bootstrape pas @@ -138,6 +149,7 @@ STEPPATH: "{{ client_pki_steppath }}" when: >- client_pki_san_actuels.rc != 0 + or client_pki_validite.rc != 0 or (client_pki_sans | select | unique | reject('equalto', '') | reject('in', client_pki_san_actuels.stdout | default('')) | list | length > 0) diff --git a/roles/client_pki/templates/cert-renewer@.service.j2 b/roles/client_pki/templates/cert-renewer@.service.j2 index 04a1cc7..ccd0ce5 100644 --- a/roles/client_pki/templates/cert-renewer@.service.j2 +++ b/roles/client_pki/templates/cert-renewer@.service.j2 @@ -13,7 +13,15 @@ Environment=STEPPATH={{ client_pki_steppath }} Environment=CERT_LOCATION={{ client_pki_steppath }}/certs/%i.crt Environment=KEY_LOCATION={{ client_pki_steppath }}/certs/%i.key ExecCondition=/usr/bin/step certificate needs-renewal ${CERT_LOCATION} -ExecStart=/usr/bin/step ca renew --force ${CERT_LOCATION} ${KEY_LOCATION} +# `--ca-url` et `--root` EXPLICITES, pour la meme raison que la tache d'emission : +# l'autorite ne bootstrape pas, elle n'a donc pas de `defaults.json`, et `step ca renew` +# sortait en erreur toutes les 14 minutes — « 'step ca renew' requires the '--ca-url' +# flag ». Le certificat de l'AC est reste expire plus de 8 h sans que rien ne le dise. +# Constate le 2026-08-08. La lecon avait ete apprise a l'emission et jamais reportee ici. +ExecStart=/usr/bin/step ca renew --force \ + --ca-url {{ client_pki_ca_url }} \ + --root {{ client_pki_steppath }}/certs/root_ca.crt \ + ${CERT_LOCATION} ${KEY_LOCATION} {% if client_pki_reload_services | default([]) | length > 0 %} # Recharge les VRAIS consommateurs du cert (nginx, postfix…), pas un service nomme d'apres le cert. ExecStartPost=/usr/bin/env sh -c "{% for svc in client_pki_reload_services %}systemctl try-reload-or-restart {{ svc }}; {% endfor %}true" diff --git a/scripts/devis_certificats.py b/scripts/devis_certificats.py new file mode 100755 index 0000000..450cbfd --- /dev/null +++ b/scripts/devis_certificats.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Devis des certificats : ce que le disque porte contre ce que la mémoire sert. + +Lit les relevés déposés par `playbooks/maintenance/devis-certificats.yml`. Aucun accès +réseau ici. Sort en code 1 s'il y a un écart. + +Le défaut traqué est connu et récurrent (voir `docs/autorisation.md` et le journal) : un +certificat renouvelé SUR DISQUE mais toujours servi PÉRIMÉ depuis la mémoire du service, +tant que nginx / postfix / dovecot / slapd n'ont pas été rechargés. Rien ne le signale — +le fichier est bon, `step` est content, et le client voit un certificat expiré. + +**Ce que ce devis a appris en se construisant.** Les certificats step-ca vivent 24 h et le +minuteur les renouvelle toutes les ~14 min. Une empreinte servie DIFFÉRENTE de l'empreinte +sur disque est donc l'état NORMAL, pas un défaut : comparer les empreintes aurait produit +un vérificateur qui crie en permanence. Le signal utile est ailleurs : + + 1. l'échéance de ce qui est RÉELLEMENT SERVI — c'est ça qui tombe ; + 2. un hôte qui sert un certificat sans déclarer aucun service à recharger : rien ne + rafraîchira jamais sa copie en mémoire. +""" + +from __future__ import annotations + +import glob +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +MOTIF = str(RACINE / "instance" / "devis-certificats.json.*") + +# Un certificat servi qui expire dans moins que ça n'a plus de marge : le service doit +# etre recharge avant. Genereux par rapport au renouvellement (~14 min), serre par +# rapport a la duree de vie (24 h). +SEUIL_HEURES = 6 + + +def _champ(texte: str, cle: str) -> str: + m = re.search(rf"^{re.escape(cle)}=(.*)$", texte, re.M) + return m.group(1).strip() if m else "" + + +def _echeance(valeur: str) -> datetime | None: + try: + return datetime.strptime(valeur, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _servis(texte: str) -> list[dict]: + blocs, courant = [], None + for ligne in texte.splitlines(): + if ligne.startswith("PORT="): + courant = {"port": ligne[5:], "brut": ""} + blocs.append(courant) + elif courant is not None: + courant["brut"] += ligne + "\n" + for b in blocs: + b["subject"] = _champ(b["brut"], "subject") + b["fin"] = _echeance(_champ(b["brut"], "notAfter")) + m = re.search(r"Fingerprint=([0-9A-F:]+)", b["brut"]) + b["empreinte"] = m.group(1) if m else "" + return blocs + + +def analyser(releves: list[dict]) -> tuple[list[str], list[str]]: + ecarts, notes = [], [] + maintenant = datetime.now(timezone.utc) + + for r in sorted(releves, key=lambda x: x["hote"]): + hote, recharges = r["hote"], r.get("recharges_declares") or [] + + if r.get("disque_rc", 1) != 0 or not r.get("disque"): + ecarts.append(f"{hote}: aucun certificat lisible en {r.get('chemin', '?')}") + continue + + fin_disque = _echeance(_champ(r["disque"], "notAfter")) + emp_disque = (re.search(r"Fingerprint=([0-9A-F:]+)", r["disque"]) or [None, ""])[1] + if fin_disque and (fin_disque - maintenant).total_seconds() < SEUIL_HEURES * 3600: + reste = (fin_disque - maintenant).total_seconds() / 3600 + ecarts.append(f"{hote}: le certificat SUR DISQUE expire dans {reste:.1f} h " + f"— le renouvellement automatique ne fait plus son travail") + + for s in _servis(r["servis"]): + if not s["fin"]: + continue + reste = (s["fin"] - maintenant).total_seconds() / 3600 + perime = s["empreinte"] != emp_disque + if reste < SEUIL_HEURES: + ecarts.append( + f"{hote}:{s['port']} sert un certificat qui expire dans {reste:.1f} h" + + (f" — recharger {', '.join(recharges)}" if recharges + else " — et AUCUN service a recharger n'est declare")) + elif perime and not recharges: + notes.append( + f"{hote}:{s['port']} sert une copie plus ancienne que le disque, et " + f"aucun `client_pki_reload_services` n'est declare : rien ne la " + f"rafraichira jamais") + return ecarts, notes + + +def main(argv: list[str] | None = None) -> int: + fichiers = sorted(glob.glob(argv[0] if argv else MOTIF)) + if not fichiers: + print("Relevés absents. Lancer d'abord : make certificats-plan", file=sys.stderr) + return 2 + + releves = [json.loads(Path(f).read_text(encoding="utf-8")) for f in fichiers] + ecarts, notes = analyser(releves) + print(f"Devis des certificats — {len(releves)} hôte(s), seuil {SEUIL_HEURES} h\n") + + for n in notes: + print(f" [latent] {n}") + if notes and ecarts: + print() + for e in ecarts: + print(f" [écart] {e}") + + if not ecarts: + print(("\n" if notes else "") + "CONFORME : aucun certificat servi n'est en fin de vie.") + return 0 + print(f"\nÉCART : {len(ecarts)} certificat(s) en difficulté.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))