devis des certificats : disque contre memoire, et l'AC etait expiree
Deuxieme application du patron devis/applicateur aux services. Trouve a la premiere execution : sur infra-pki-01 — l'autorite elle-meme — le certificat etait expire depuis plus de 8 h et le renouvellement echouait toutes les 14 minutes sur « 'step ca renew' requires the '--ca-url' flag ». Rien ne le signalait. Cause : sur l'hote de l'AC, /etc/step est le STEPPATH du SERVEUR, pas un amorcage client — pas de defaults.json, et l'unite de renouvellement en dependait. La lecon etait deja ecrite dans le commentaire de la tache d'emission (« l'autorite ne bootstrape pas »), jamais reportee sur l'unite. Le role ne pouvait pas non plus se soigner : la re-emission ne regardait que la FORME (cert absent ou SAN manquant), jamais la validite. client_pki verifie desormais l'echeance (client_pki_marge_renouvellement). Ce qu'il a fallu desapprendre : les certificats vivent 24 h et se renouvellent toutes les ~14 min ; « empreinte servie != empreinte disque » est l'etat NORMAL. Comparer les empreintes aurait donne un verificateur qui crie en permanence. Le signal est l'echeance de ce qui est SERVI, plus l'absence de client_pki_reload_services. Le devis a d'abord menti, du defaut meme qu'il traque : include_vars au niveau du play prime sur les group_vars. Et le premier correctif a PARU marcher — set_fact accepte un dictionnaire entier en argument libre sans erreur et n'en fait rien. Il faut reimposer cle par cle. Les deux devis sont corriges et le piege est consigne dans docs/devis-services.md avant d'ecrire le prochain. Verifie dans les deux sens : CONFORME sur 14 hotes ; sur un releve ou l'on rejoue une copie perimee en memoire, 2 ecarts et code de sortie 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5aa5f2e479
commit
5fde136e9f
11 changed files with 439 additions and 58 deletions
47
CHANGELOG.md
47
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
|
||||
|
|
|
|||
5
Makefile
5
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
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||
| 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 | |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
89
docs/devis-services.md
Normal file
89
docs/devis-services.md
Normal file
|
|
@ -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.
|
||||
113
playbooks/maintenance/devis-certificats.yml
Normal file
113
playbooks/maintenance/devis-certificats.yml
Normal file
|
|
@ -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 2>/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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
129
scripts/devis_certificats.py
Executable file
129
scripts/devis_certificats.py
Executable file
|
|
@ -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:]))
|
||||
Loading…
Reference in a new issue