--- # 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. - 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] }}" 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"