Set-OPS-Public/roles/client_pki/tasks/main.yml
Daniel Allaire b42bc51752 client_pki : l'empreinte de la racine se derive une fois, pas une fois par hote
Treize modules Python en parallele sur l'AC (765 Mo, 1 vCPU) : le noyau
a tue celui de collab-01 (rc=137) a la reconstruction de Technolibre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:25:08 -04:00

443 lines
20 KiB
YAML

---
# SANS AUTORITÉ DÉCLARÉE, ON NE DEVINE PAS SON NOM.
#
# L'URL de l'AC se dérivait d'un littéral (`infra-pki-01`), vrai chez tout tenant par
# coïncidence de nomenclature. Le premier écosystème à nommer sa PKI autrement a vu ses
# cinq machines s'enrôler auprès d'un hôte inexistant, et le message accusait le DNS.
#
# Elle se dérive maintenant de `groups['serveur_step_ca']`. Si ce groupe est vide, il n'y
# a pas d'autorité du tout : le dire ici vaut mieux que de laisser `step ca bootstrap`
# échouer sur un nom tronqué, trois tâches plus loin, en parlant de résolution.
- name: Une autorité de certification est-elle déclarée ?
ansible.builtin.assert:
that:
- client_pki_ca_hote | length > 0
fail_msg: >-
Aucun hôte ne porte `serveur_step_ca` dans cet inventaire : il n'y a pas d'autorité
interne à qui demander un certificat. Déclarer une PKI, ou exempter cet écosystème
de `client_pki` — mais avec une raison, jamais en silence.
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
#
# UNE FOIS POUR TOUTE LA FLOTTE (`run_once`, 2026-09-30). L'empreinte est la meme pour
# chaque hote : la deriver par hote lancait TREIZE modules Python en parallele sur l'AC —
# 765 Mo, 1 vCPU. A la reconstruction de Technolibre, le noyau a tue le module de
# `collab-01` (rc=137) et l'agent de journaux au passage. Avec `run_once`, le resultat
# enregistre vaut pour tous les hotes du play.
- name: Dériver l'empreinte du root CA depuis l'autorité
ansible.builtin.command:
cmd: "step certificate fingerprint {{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
delegate_to: "{{ groups['serveur_step_ca'][0] }}"
run_once: true
changed_when: false
check_mode: false
register: client_pki_fingerprint_ac
- name: Retenir l'empreinte (dérivée de l'AC, sauf override explicite)
ansible.builtin.set_fact:
client_pki_ca_fingerprint: "{{ client_pki_ca_fingerprint_override | default(client_pki_fingerprint_ac.stdout | trim, true) }}"
- name: Exiger l'empreinte AC et le mot de passe provisioner (Vault)
ansible.builtin.assert:
that:
- client_pki_ca_fingerprint | length > 0
- client_pki_provisioner_password | length > 0
fail_msg: >-
Empreinte du root CA indisponible (step-ca joignable et déployé ?) ;
client_pki_provisioner_password requis (via Ansible Vault).
- name: Assurer le repertoire des trousseaux apt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
# Recuperee UNE FOIS. Sans garde, chaque deploiement recontactait le serveur du
# fournisseur : cinq cles x quatorze hotes = soixante-dix allers-retours externes pour
# des cles deja installees, et autant d'occasions qu'un tiers lent fasse tomber le
# deploiement. Arbitrage rendu le 2026-08-09 : une plateforme souveraine ne depend pas
# de six serveurs etrangers pour redeployer ce qu'elle possede deja.
#
# CONSEQUENCE ASSUMEE : une rotation de cle amont n'est plus recuperee toute seule. Elle
# ne passe pas inapercue pour autant — `apt` refuse alors le depot, bruyamment. Pour
# forcer le rafraichissement : supprimer le fichier et rejouer le role.
# UN FICHIER VIDE EXISTE (mesure du 2026-09-10).
#
# Cette garde demandait « ce fichier est-il la ? ». Un telechargement interrompu — un 503,
# un delai depasse, une coupure — laisse un fichier de ZERO octet, qui existe. La tache
# est donc sautee a tous les passages suivants, et la machine garde une ressource morte
# pour toujours, en silence.
#
# CE QUE CA A COUTE : `infra-mail-01` a garde une cle smallstep de 0 octet apres une
# epreuve hors ligne. Treize machines portaient 1022 octets, elle portait le vide :
#
# E: Le depot http://packages.smallstep.com/... n'est pas signe.
#
# Le meme jour, l'index `dists/trixie` du cache du site etait tombe de 140 416 a 4 332
# octets — meme famille, autre endroit. Un telechargement partiel ne se signale pas : il
# se fait passer pour un succes.
#
# On demande donc « est-il ENTIER ? » — a defaut de pouvoir demander « est-il JUSTE ? »,
# ce qui exigerait une empreinte de reference que l'amont ne publie pas toujours.
- name: Cette ressource est-elle deja recuperee ? (Telecharger la cle de signature Sm)
ansible.builtin.stat:
path: "{{ client_pki_depot_cle_fichier }}"
register: telecharger_la_cle_de_signature_smallste_present
# Un serveur tiers intermittent ne doit pas arreter un deploiement de quarante
# minutes (mesure du 2026-08-23 : la meme URL pend, puis rend 200 en 0,48 s au
# second essai). Defaut de `get_url` : 10 s et aucune reprise.
# ... ET IL FAUT LE RETIRER, PAS SEULEMENT LE REDEMANDER (mesure du 2026-09-10).
#
# Laisser la garde autoriser la tache ne suffit PAS. `get_url` sur une destination qui
# existe emet une requete CONDITIONNELLE : l'amont repond « non modifie », le module rend
# `ok`, et le fichier corrompu reste en place. Mesure : la tache s'executait bien
# (`ok: [infra-mail-01]`), et la cle faisait toujours 0 octet au passage suivant. Un
# `force: yes` manuel l'a reparee en une seconde.
#
# On efface donc la ruine avant de reconstruire. `state: absent` ne mord que sur un
# fichier vide : une cle valide n'est jamais retiree, et la doctrine « on ne retelecharge
# pas ce qu'on possede deja » reste intacte.
- name: Retirer une ressource VIDE avant de la redemander
ansible.builtin.file:
path: "{{ client_pki_depot_cle_fichier }}"
state: absent
when:
- telecharger_la_cle_de_signature_smallste_present.stat.exists
- (telecharger_la_cle_de_signature_smallste_present.stat.size | default(0)) == 0
- name: Telecharger la cle de signature Smallstep
when: not (telecharger_la_cle_de_signature_smallste_present.stat.exists and telecharger_la_cle_de_signature_smallste_present.stat.size | default(0) > 0)
# `force` PARCE QU'UNE REPRISE CONDITIONNELLE NE REPREND RIEN (2026-09-12).
#
# Sans lui, `get_url` envoie un `If-Modified-Since` des que le fichier existe. Le cache
# repond `304 Not Modified`, la tache reussit sans rien ecrire, et la boucle `until`
# — qui exige une taille non nulle — retente cinq fois contre un serveur qui repondra
# toujours 304 :
#
# HTTP Error 304: Not Modified size: 0 attempts: 5
#
# C'est la boucle de reprise qui se bat contre elle-meme. Le `when:` ci-dessus garantit
# deja qu'on ne retelecharge pas une cle valide ; `force` ne concerne donc que le cas
# ou l'on a DECIDE d'aller chercher.
ansible.builtin.get_url:
force: true
url: "{{ client_pki_depot_cle_url }}"
dest: "{{ client_pki_depot_cle_fichier }}"
owner: root
group: root
mode: "0644"
timeout: 30
register: client_pki_cle_depot_telechargee
retries: 5
delay: 6
until: client_pki_cle_depot_telechargee is succeeded and (client_pki_cle_depot_telechargee.size | default(0)) > 0
# LA CLE PASSE PAR LE CACHE, ELLE AUSSI (mesure du 2026-09-10).
#
# `get_url` ignore la configuration d'apt : le mandataire pose dans
# `/etc/apt/apt.conf.d/` ne vaut que pour apt. Les cles de signature sortaient donc
# TOUJOURS en direct, meme apres que tous les depots soient passes par le cache — un
# trou reste ouvert derriere une porte qu'on croyait fermee.
#
# Et ce n'est pas theorique : depuis `collab-01`, la route directe vers
# `www.collaboraoffice.com` EXPIRE, quand le cache l'atteint sans peine.
#
# en direct grafana 200 collabora TIMEOUT
# via le cache collabora 200
#
# Le meme geste corrige donc les deux : plus rien ne sort, et la machine qui n'avait
# pas de route en trouve une.
#
# DEGRADE, JAMAIS DEVINE : sans cache d'amorcage declare, la valeur est VIDE et
# `get_url` sort en direct comme avant. On n'invente pas un mandataire.
environment:
http_proxy: >-
{{ ('http://' ~ artefacts_amorcage) if (artefacts_amorcage | default('') | string | length > 0) else '' }}
https_proxy: ""
- name: Mesurer la cle de signature Smallstep qui vient d'etre posee
ansible.builtin.stat:
path: "{{ client_pki_depot_cle_fichier }}"
register: cle_smallstep_posee
- name: Exiger une cle NON VIDE avant de s'en servir
# UNE REUSSITE N'EST PAS UN CONTENU (mesure du 2026-09-11). Sur une reconstruction a
# froid, `get_url` a rendu 0 octet sans erreur sur UNE machine des quatorze : le cache
# a servi un 200 au corps vide. La garde « retirer une ressource VIDE » ne mord qu'au
# passage SUIVANT, quand le fichier existe deja — au premier, elle n'a rien a retirer.
#
# Le defaut ne se lisait pas ici. Il se lisait deux cents lignes plus loin, dans un
# `apt` qui accusait la SIGNATURE : « Missing key ..., which is needed to verify
# signature ». Un message qui envoie chercher une cle revoquee chez le fournisseur,
# alors que le fichier local fait zero octet.
ansible.builtin.assert:
that:
- (cle_smallstep_posee.stat.size | default(0)) > 0
fail_msg: >-
La cle de signature Smallstep ({{ client_pki_depot_cle_fichier }}) fait 0 octet.
Le depot a repondu sans corps — cache vide, ou amont injoignable. apt echouera
plus loin en accusant la signature : c'est ICI qu'il faut regarder.
- name: Ajouter le depot apt Smallstep
ansible.builtin.template:
src: smallstep.sources.j2
dest: /etc/apt/sources.list.d/smallstep.sources
owner: root
group: root
mode: "0644"
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT ENSUITE (2026-09-03).
#
# `client_pki` est une integration UNIVERSELLE : chaque machine de chaque ecosysteme
# installe `step-cli` ici, a sa naissance. Le depot Smallstep est en HTTPS, et
# `client_artefacts` pose `Acquire::https::Proxy "DIRECT"` — il CONTOURNE donc le cache
# du site et sort sur Internet. Sans lien, une VM neuve n'obtenait pas son client
# d'autorite, donc pas de certificat, donc n'entrait dans aucun flux chiffre.
#
# C'etait le dernier obstacle a une reconstruction hors ligne, et il tenait dans un mot :
# `DIRECT`, pose a juste titre pour une autre raison.
- name: Poser step-cli depuis le cache du controleur, s'il y est
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ client_pki_paquets }}"
# FILET, ET SEULEMENT SI LE CACHE N'A RIEN DONNE.
#
# `update_cache: true` interroge TOUS les depots configures, Smallstep compris. Hors
# ligne, cette tache echouerait donc APRES que le cache ait deja pose le paquet — le
# deploiement tomberait sur un travail deja fait. Le repli ne doit exister que quand il
# y a quelque chose a rattraper.
#
# Cache vide — poste jamais connecte, paquet retire de la declaration — on retombe sur le
# depot distant comme avant. Degrader, jamais deviner.
- name: Installer depuis le depot distant ce que le cache n'a pas fourni
ansible.builtin.apt:
name: "{{ client_pki_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (client_pki_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Creer le repertoire STEPPATH des certificats
ansible.builtin.file:
path: "{{ client_pki_steppath }}/certs"
state: directory
owner: root
group: root
mode: "0755"
- name: Deployer le mot de passe du provisioner
ansible.builtin.copy:
content: "{{ client_pki_provisioner_password }}"
dest: "{{ client_pki_steppath }}/provisioner.pass"
owner: root
group: root
mode: "0600"
no_log: true
# L'AUTORITE EST UN CAS A PART, mais pas une exception. Elle n'a pas a « s'enroler »
# aupres d'elle-meme : sa racine est deja sur son disque, et un bootstrap la ferait
# aller la chercher par le reseau, chez elle, en verifiant une empreinte qu'elle vient
# de produire. Elle a en revanche besoin de CERTIFICATS comme tout le monde — sans quoi
# ses propres services (node_exporter) restent en clair, et `client_metrique` echoue.
#
# La distinction est donc : pas d'enrolement, mais emission locale. C'est ce qui permet
# de retirer l'exemption de `client_pki` sans contredire « l'AC est la source de la
# confiance » — elle l'est, et c'est precisement pourquoi elle peut se signer elle-meme.
- name: Reconnaitre l'hote qui PORTE l'autorite
ansible.builtin.set_fact:
client_pki_est_autorite: "{{ inventory_hostname in (groups['serveur_step_ca'] | default([])) }}"
- name: Etablir la confiance dans l'AC interne (bootstrap + installation racine)
ansible.builtin.command:
cmd: >-
step ca bootstrap
--ca-url {{ client_pki_ca_url }}
--fingerprint {{ client_pki_ca_fingerprint }}
--install --force
creates: "{{ client_pki_steppath }}/certs/root_ca.crt"
environment:
STEPPATH: "{{ client_pki_steppath }}"
when: not client_pki_est_autorite | bool
- name: Poser la racine depuis le disque local (l'autorite ne s'enrole pas aupres d'elle-meme)
ansible.builtin.copy:
src: "{{ serveur_step_ca_steppath | default('/etc/step-ca') }}/certs/root_ca.crt"
dest: "{{ client_pki_steppath }}/certs/root_ca.crt"
remote_src: true
owner: root
group: root
mode: "0644"
when: client_pki_est_autorite | bool
- name: Rendre le certificat racine lisible par tous (cert public, requis par les clients TLS)
ansible.builtin.file:
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"
register: client_pki_san_actuels
changed_when: false
failed_when: false
# 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, 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
# — elle n'aurait donc aucune de ces deux valeurs. Les nommer ici vaut mieux que de
# dependre d'un fichier ecrit par une etape qu'on saute volontairement.
cmd: >-
step ca certificate {{ client_pki_nom_cert }}
{{ client_pki_cert }} {{ client_pki_cle }}
--ca-url {{ client_pki_ca_url }}
--root {{ client_pki_steppath }}/certs/root_ca.crt
--provisioner {{ client_pki_provisioner }}
--provisioner-password-file {{ client_pki_steppath }}/provisioner.pass
{% for s in client_pki_sans | select | unique %}--san {{ s }} {% endfor %}
--force
environment:
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)
changed_when: true
notify: Recharger les consommateurs du cert
# POSÉ À CHAQUE PASSAGE, PAS SEULEMENT À L'ÉMISSION.
#
# `step ca certificate` réécrit la clé avec ses propres droits, et le renouvellement
# automatique aussi. Ne régler les droits qu'au moment où le certificat change les
# perdrait au premier renouvellement — une panne qui surviendrait des semaines plus tard,
# sans rapport visible avec cette tâche.
# LE CONSOMMATEUR N'EXISTE PAS ENCORE AU PREMIER JOUR (2026-09-12).
#
# `client_pki` (couche `pki_client`) precede `serveur_forgejo` (couche `apps`), et c'est
# le paquet de Forgejo qui cree le groupe `git`. Sur une machine deja construite le
# groupe est la, herite d'un passage precedent ; a froid il n'existe pas :
#
# chgrp failed: failed to look up group git
#
# ET C'ETAIT UN BLOCAGE CIRCULAIRE, pas un ordre qui converge : l'echec arretait le play
# pour cet hote AVANT la couche qui aurait cree le groupe. Rejouer n'y changeait rien —
# mesure deux fois, le 2026-09-12.
#
# ON NE FABRIQUE PAS LE GROUPE ICI : le paquet du service le creera avec le GID qu'il
# veut, et un groupe pose d'avance l'obligerait a composer avec le notre. On constate
# son absence, on laisse la cle en `root:root` — donc FERMEE, jamais plus ouverte — et
# le passage suivant, une fois le service installe, donne l'acces.
- name: Le groupe qui doit lire la clé existe-t-il ?
ansible.builtin.getent:
database: group
key: "{{ client_pki_cle_groupe }}"
fail_key: false
register: client_pki_groupe_lu
when: client_pki_cle_groupe != 'root'
changed_when: false
- name: Donner accès à la clé privée au service qui doit la lire
ansible.builtin.file:
path: "{{ client_pki_cle }}"
owner: root
group: "{{ client_pki_cle_groupe }}"
mode: "{{ client_pki_cle_mode }}"
when:
- client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600'
- client_pki_cle_groupe == 'root'
or (client_pki_groupe_lu.ansible_facts.getent_group | default({}))
.get(client_pki_cle_groupe) is not none
notify: Recharger les consommateurs du cert
- name: Dire que l'accès à la clé attend son consommateur
ansible.builtin.debug:
msg: >-
Le groupe « {{ client_pki_cle_groupe }} » n'existe pas encore : la clé reste en
root:root, donc FERMEE. Le service qui doit la lire n'est pas installé — le
passage suivant, après sa couche, lui donnera l'accès.
when:
- client_pki_cle_groupe != 'root'
- (client_pki_groupe_lu.ansible_facts.getent_group | default({}))
.get(client_pki_cle_groupe) is none
# Le certificat est public. `step` l'ecrit en 0600 comme la cle ; on le rend lisible, sans
# quoi un service non-root echoue sur le CERT apres avoir obtenu la CLE — et le message
# parle de permission sur un fichier que rien ne justifie de proteger.
- name: Rendre le certificat d'hôte lisible (il est public par nature)
ansible.builtin.file:
path: "{{ client_pki_cert }}"
owner: root
group: root
mode: "{{ client_pki_cert_mode }}"
notify: Recharger les consommateurs du cert
- name: Deployer l'unite systemd de renouvellement
ansible.builtin.template:
src: cert-renewer@.service.j2
dest: /etc/systemd/system/cert-renewer@.service
owner: root
group: root
mode: "0644"
notify: Recharger systemd
- name: Deployer le minuteur de renouvellement
ansible.builtin.template:
src: cert-renewer@.timer.j2
dest: /etc/systemd/system/cert-renewer@.timer
owner: root
group: root
mode: "0644"
notify: Recharger systemd
- name: Activer le renouvellement automatique du certificat d'hote
ansible.builtin.systemd:
name: "cert-renewer@{{ client_pki_nom_cert }}.timer"
enabled: true
state: started
daemon_reload: true
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
#
# LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. Il connait ses chemins, ses
# seuils, son consommateur — le superviseur, lui, ne connait que le verdict. C'est la
# meme repartition que pour les flux : le role declare, le moteur derive.
#
# Le porteur (`client_sante`) fait tourner tout ce qui vit dans ce repertoire et pousse
# un resultat passif par sonde. Il n'a pas a savoir ce que celle-ci mesure.
- name: Assurer le repertoire des sondes de supervision
ansible.builtin.file:
path: /usr/local/lib/setops/sondes
state: directory
owner: root
group: root
mode: "0755"
- name: Deposer la sonde du certificat d'hote
ansible.builtin.template:
src: sonde-certificat.sh.j2
dest: /usr/local/lib/setops/sondes/certificat.sh
owner: root
group: root
mode: "0750"