--- - name: Exiger le mot de passe admin LDAP (Vault) ansible.builtin.assert: that: - serveur_openldap_admin_password | length > 0 fail_msg: "serveur_openldap_admin_password est requis (a fournir via Ansible Vault)." - name: Installer debconf-utils (requis par le module debconf, fournit debconf-get-selections) ansible.builtin.apt: name: debconf-utils state: present update_cache: true cache_valid_time: 3600 - name: Preconfigurer slapd (debconf) ansible.builtin.debconf: name: slapd question: "{{ item.question }}" vtype: "{{ item.vtype }}" value: "{{ item.value }}" loop: - { question: "slapd/no_configuration", vtype: "boolean", value: "false" } - { question: "slapd/domain", vtype: "string", value: "{{ serveur_openldap_domaine }}" } - { question: "shared/organization", vtype: "string", value: "{{ serveur_openldap_organisation }}" } - { question: "slapd/backend", vtype: "select", value: "MDB" } - { question: "slapd/purge_database", vtype: "boolean", value: "true" } - { question: "slapd/move_old_database", vtype: "boolean", value: "true" } - { question: "slapd/allow_ldap_v2", vtype: "boolean", value: "false" } loop_control: label: "{{ item.question }}" - name: Preconfigurer le mot de passe admin slapd (debconf) ansible.builtin.debconf: name: slapd question: "{{ item }}" vtype: password value: "{{ serveur_openldap_admin_password }}" loop: - slapd/password1 - slapd/password2 no_log: true - name: Installer OpenLDAP ansible.builtin.apt: name: "{{ serveur_openldap_paquets }}" state: present update_cache: true cache_valid_time: 3600 - name: Activer et demarrer slapd when: not ansible_check_mode ansible.builtin.systemd: name: "{{ serveur_openldap_service }}" enabled: true state: started # L'ANNUAIRE EST-IL VIERGE ? Mesure AVANT que ce role n'y cree quoi que ce soit (unites, # comptes de service) : apres, il ne le serait plus jamais. Le paquet ne pose que la # racine — `cn=admin` n'y figure que sur les installations anciennes. # # `awk`, PAS `grep -v | wc -l` : sur un annuaire VRAIMENT vierge, `grep -v` ne garde aucune # ligne et sort en 1 — `pipefail` faisait alors echouer la mesure precisement dans le cas # qu'elle doit reconnaitre (reconstruction de Chezlepro, 2026-09-30). - name: Restauration — l'annuaire est-il vierge ? ansible.builtin.shell: >- set -o pipefail; slapcat -b '{{ serveur_openldap_base_dn }}' 2>/dev/null | awk -v base='dn: {{ serveur_openldap_base_dn | lower }}' 'tolower($0) ~ /^dn: / && tolower($0) != base && tolower($0) != "dn: cn=admin," substr(base, 5) {n++} END {print n + 0}' args: executable: /bin/bash register: serveur_openldap_entrees_initiales changed_when: false check_mode: false # LA ROTATION PASSE AVANT TOUT CE QUI SE LIE EN ADMINISTRATEUR (2026-09-13). # # Placee plus bas, elle arrivait trop tard : « Creer les unites organisationnelles » se # lie deja avec le mot de passe de la VOUTE, tandis que slapd detient encore l'ANCIEN. # Le deploiement echouait sur la huitieme tache, et `no_log` — qui protege la valeur — # masquait aussi la cause. Un secret qu'on fait tourner doit descendre AVANT que quoi que # ce soit s'en serve. # # Ces taches-ci n'ont pas ce probleme : elles ecrivent par `ldapi:///` en s'identifiant # par la socket unix (root local), sans mot de passe. C'est precisement ce qui leur # permet de reparer la liaison qu'elles changent. - name: Trouver la base mdb (pour y poser les droits) community.general.ldap_search: dn: "cn=config" scope: onelevel filter: "(olcDatabase={1}mdb)" attrs: - dn server_uri: "ldapi:///" register: serveur_openldap_base_droits changed_when: false - name: Retenir le DN de la base pour les droits ansible.builtin.set_fact: serveur_openldap_droits_dn: "{{ (serveur_openldap_base_droits.results | first).dn }}" when: (serveur_openldap_base_droits.results | default([]) | length) > 0 # --- ROTATION DU COMPTE D'ADMINISTRATION (2026-09-13) ------------------------------- # # CE MOT DE PASSE N'ETAIT POSE QU'A L'INSTALLATION, par debconf. Le faire tourner en # voute ne descendait donc nulle part : slapd gardait l'ancien, la voute annoncait le # neuf, et les deux divergeaient EN SILENCE. Le premier a s'en apercevoir aurait ete # `amorcage_acces`, au pire moment — quand on cree un acces. # # Un secret qu'on ne peut pas faire tourner est un secret qu'on ne fera pas tourner. # # `slappasswd` SALE le condensat : deux appels sur la meme valeur rendent deux chaines # differentes, et comparer l'existant ne dit donc rien. On memorise l'empreinte de la # valeur VOULUE, comme la federation Keycloak le fait pour sa liaison — meme probleme, # meme remede, et c'est le signe qu'il s'agit d'un patron, pas d'un bricolage. - name: Lire l'empreinte du mot de passe d'administration en place ansible.builtin.slurp: src: "{{ serveur_openldap_marqueur_admin }}" register: serveur_openldap_marqueur_lu failed_when: false changed_when: false - name: Hacher le mot de passe d'administration voulu ansible.builtin.command: argv: ["slappasswd", "-s", "{{ serveur_openldap_admin_password }}"] register: serveur_openldap_admin_hash changed_when: false no_log: true when: serveur_openldap_admin_empreinte != (serveur_openldap_marqueur_lu.content | default('') | b64decode | trim) - name: Aligner le mot de passe d'administration de l'annuaire sur la voute community.general.ldap_attrs: dn: "{{ serveur_openldap_droits_dn }}" attributes: olcRootPW: "{{ serveur_openldap_admin_hash.stdout | trim }}" state: exact server_uri: "ldapi:///" no_log: true when: - serveur_openldap_droits_dn | default('') | length > 0 - serveur_openldap_admin_hash is not skipped # LE MARQUEUR S'ECRIT APRES, JAMAIS AVANT. Ecrit d'abord, une pose ratee laisserait le # depot convaincu d'avoir fait tourner un secret qui n'a pas bouge — et la prochaine # execution ne retenterait rien. - name: Retenir l'empreinte du mot de passe d'administration pose ansible.builtin.copy: content: "{{ serveur_openldap_admin_empreinte }}\n" dest: "{{ serveur_openldap_marqueur_admin }}" owner: root group: root mode: "0600" when: serveur_openldap_admin_hash is not skipped - name: Creer les unites organisationnelles de base community.general.ldap_entry: dn: "ou={{ item }},{{ serveur_openldap_base_dn }}" objectClass: organizationalUnit server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" loop: "{{ serveur_openldap_ou }}" loop_control: label: "ou={{ item }}" no_log: true # --- COMPTES DE SERVICE (2026-09-13) ------------------------------------------------ # # Quatre services interrogeaient l'annuaire avec son compte d'administration. Voir # `defaults/main.yml` pour le raisonnement ; ici on cree les comptes et on pose les droits. - name: Exiger un secret pour chaque compte de service ansible.builtin.assert: that: - lookup('vars', item.secret, default='') | length > 0 fail_msg: >- Le compte de service « {{ item.cn }} » n'a pas de secret : {{ item.secret }} est absent ou vide dans la voute. Un compte sans mot de passe ne se lie pas, et le service qui en depend echouerait a la premiere requete. loop: "{{ serveur_openldap_comptes_service }}" loop_control: label: "{{ item.cn }}" no_log: true - name: Creer les comptes de service de l'annuaire community.general.ldap_entry: dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}" objectClass: - simpleSecurityObject - organizationalRole attributes: description: "{{ item.raison }}" userPassword: "{{ lookup('vars', item.secret) }}" server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" loop: "{{ serveur_openldap_comptes_service }}" loop_control: label: "{{ item.cn }}" no_log: true # `ldap_entry` ne fait que CREER : un secret tourne en voute ne descendrait jamais dans # l'annuaire, et la rotation paraitrait faite. `ldap_passwd` compare avant d'ecrire, donc # il est idempotent ET il suit la voute. La lecon est deja celle de la politique de mots # de passe, quinze lignes plus bas. - name: Aligner le mot de passe de chaque compte de service sur la voute community.general.ldap_passwd: dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}" passwd: "{{ lookup('vars', item.secret) }}" server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" loop: "{{ serveur_openldap_comptes_service }}" loop_control: label: "{{ item.cn }}" no_log: true # L'ORDRE DES REGLES EST LA REGLE. slapd applique la PREMIERE dont la cible correspond et # s'arrete : une regle generale placee trop haut rend muettes toutes celles qui suivent. # On pose donc la liste ENTIERE (`state: exact`) plutot que d'inserer — inserer, ici, # c'est parier sur ce que le paquet Debian aura mis avant nous. # # {0} LES MOTS DE PASSE, D'ABORD. Cette regle porte sur un ATTRIBUT : elle vaut pour # toutes les entrees, comptes de service compris — c'est elle qui leur permet de se # lier (`by anonymous auth`). La retirer ou la deplacer plus bas ferme l'annuaire a # tout le monde, y compris a l'exploitant. # {2} LES COMPTES DE SERVICE NE SE VOIENT PAS ENTRE EUX. Compromettre Postfix ne doit pas # livrer le compte de Keycloak. Le rootDN, lui, contourne les ACL : l'exploitant garde # la main. # {3} LES PERSONNES. Keycloak ecrit (mots de passe, creations) ; les trois autres lisent ; # chacun se lit soi-meme. # {5} LE RACINE DE L'ANNUAIRE reste lisible : sans lui, aucun client ne peut meme # decouvrir les contextes servis, et les messages parlent alors de « base introuvable ». - name: Poser les droits d'acces de l'annuaire community.general.ldap_attrs: dn: "{{ serveur_openldap_droits_dn }}" attributes: olcAccess: "{{ serveur_openldap_acces }}" state: exact server_uri: "ldapi:///" when: serveur_openldap_droits_dn | default('') | length > 0 # --- Politique de mot de passe (overlay ppolicy) ------------------------------ # Sans cet overlay, `pwdReset` n'existe pas dans le schema : `amorcage_acces` ne peut # pas forcer le changement du jeton d'amorcage, et rien ne contraint la qualite ni ne # verrouille apres des echecs. Voir docs/autorisation.md §3. - name: Ppolicy — Charger le module community.general.ldap_attrs: dn: "cn=module{0},cn=config" attributes: olcModuleLoad: "ppolicy" state: present server_uri: "ldapi:///" when: serveur_openldap_ppolicy_actif | bool # Le DN de la base varie (`olcDatabase={1}mdb` en general, mais l'index n'est pas # garanti) : on le LIT au lieu de le supposer. - name: Ppolicy — Trouver la base mdb community.general.ldap_search: dn: "cn=config" scope: onelevel filter: "(olcDatabase={1}mdb)" attrs: - dn server_uri: "ldapi:///" register: serveur_openldap_base_mdb changed_when: false when: serveur_openldap_ppolicy_actif | bool - name: Ppolicy — Retenir le DN de la base ansible.builtin.set_fact: serveur_openldap_mdb_dn: >- {{ (serveur_openldap_base_mdb.results | first).dn }} when: - serveur_openldap_ppolicy_actif | bool - (serveur_openldap_base_mdb.results | default([]) | length) > 0 - name: Ppolicy — Résoudre la politique (déclaration unique) ansible.builtin.include_role: name: resoudre_politique_mdp when: serveur_openldap_ppolicy_actif | bool - name: Ppolicy — Creer l unite des politiques community.general.ldap_entry: dn: "ou=policies,{{ serveur_openldap_base_dn }}" objectClass: organizationalUnit server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" no_log: true when: serveur_openldap_ppolicy_actif | bool # L'overlay AVANT la politique : `olcPPolicyDefault` reference une entree qui doit # exister, mais l'overlay doit etre en place pour que le schema pwd* soit connu. # L'ordre inverse echouerait des deux cotes. - name: Ppolicy — Activer l overlay sur la base community.general.ldap_entry: dn: "olcOverlay=ppolicy,{{ serveur_openldap_mdb_dn }}" objectClass: - olcOverlayConfig - olcPPolicyConfig attributes: olcOverlay: "ppolicy" olcPPolicyDefault: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" olcPPolicyHashCleartext: "{{ 'TRUE' if serveur_openldap_ppolicy_hash_cleartext | bool else 'FALSE' }}" olcPPolicyUseLockout: "{{ 'TRUE' if serveur_openldap_ppolicy_use_lockout | bool else 'FALSE' }}" server_uri: "ldapi:///" when: - serveur_openldap_ppolicy_actif | bool - serveur_openldap_mdb_dn is defined - name: Ppolicy — Creer la politique par defaut community.general.ldap_entry: dn: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" objectClass: - pwdPolicy - person - top attributes: >- {{ {'cn': 'default', 'sn': 'default'} | combine(resoudre_politique_mdp_ldap, serveur_openldap_ppolicy_extra) }} server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" no_log: true when: serveur_openldap_ppolicy_actif | bool # `ldap_entry` ne fait que CREER : l'entree restait figee a ce qu'elle valait le # jour de sa creation, et toute modification ulterieure de la declaration etait # ignoree en silence. Constate le 2026-08-08 — le depot annoncait # `pwdMustChange: TRUE`, le serveur portait `FALSE` (corrige a la main apres la # boucle de changement de mot de passe du 2026-08-07), et une reconstruction # from-zero aurait ressuscite le defaut. Il faut donc RECONCILIER en plus. - name: Ppolicy — Reconcilier la politique par defaut community.general.ldap_attrs: dn: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" attributes: "{{ resoudre_politique_mdp_ldap | combine(serveur_openldap_ppolicy_extra) }}" state: exact server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" no_log: true when: serveur_openldap_ppolicy_actif | bool # Meme raison : l'overlay non plus n'etait pose qu'a la creation. Son DN porte un # index d'ordre (`{0}`, `{1}`…) attribue par slapd — on le LIT, on ne le devine pas. - name: Ppolicy — Trouver le DN de l overlay community.general.ldap_search: dn: "{{ serveur_openldap_mdb_dn }}" scope: onelevel filter: "(olcOverlay=*ppolicy)" attrs: - dn server_uri: "ldapi:///" register: serveur_openldap_overlay_ppolicy changed_when: false when: - serveur_openldap_ppolicy_actif | bool - serveur_openldap_mdb_dn is defined - name: Ppolicy — Reconcilier les reglages de l overlay community.general.ldap_attrs: dn: "{{ (serveur_openldap_overlay_ppolicy.results | first).dn }}" attributes: olcPPolicyDefault: "cn=default,ou=policies,{{ serveur_openldap_base_dn }}" olcPPolicyHashCleartext: "{{ 'TRUE' if serveur_openldap_ppolicy_hash_cleartext | bool else 'FALSE' }}" olcPPolicyUseLockout: "{{ 'TRUE' if serveur_openldap_ppolicy_use_lockout | bool else 'FALSE' }}" state: exact server_uri: "ldapi:///" when: - serveur_openldap_ppolicy_actif | bool - (serveur_openldap_overlay_ppolicy.results | default([]) | length) > 0 # --- TLS (LDAPS + STARTTLS) via le certificat step_ca depose par client_pki --- - name: TLS — creer le repertoire pont lisible par openldap ansible.builtin.file: path: "{{ serveur_openldap_tls_dir }}" state: directory owner: openldap group: openldap mode: "0750" when: serveur_openldap_tls_actif | bool - name: TLS — deployer le script de synchronisation du certificat ansible.builtin.template: src: setops-slapd-cert-sync.sh.j2 dest: /usr/local/sbin/setops-slapd-cert-sync owner: root group: root mode: "0755" when: serveur_openldap_tls_actif | bool - name: TLS — deployer les unites de synchronisation (service + path) ansible.builtin.template: src: "{{ item.s }}" dest: "{{ item.d }}" owner: root group: root mode: "0644" loop: - { s: "setops-slapd-cert-sync.service.j2", d: "/etc/systemd/system/setops-slapd-cert-sync.service" } - { s: "setops-slapd-cert-sync.path.j2", d: "/etc/systemd/system/setops-slapd-cert-sync.path" } loop_control: label: "{{ item.d | basename }}" when: serveur_openldap_tls_actif | bool - name: TLS — synchroniser le certificat maintenant (si client_pki l'a depose) ansible.builtin.command: /usr/local/sbin/setops-slapd-cert-sync changed_when: false when: - serveur_openldap_tls_actif | bool - not ansible_check_mode - name: TLS — activer la surveillance du certificat (renouvellement) ansible.builtin.systemd: name: setops-slapd-cert-sync.path enabled: true state: started daemon_reload: true when: - serveur_openldap_tls_actif | bool - not ansible_check_mode - name: TLS — verifier que le certificat pont est en place ansible.builtin.stat: path: "{{ serveur_openldap_tls_dir }}/cert.pem" register: serveur_openldap_cert_pont when: serveur_openldap_tls_actif | bool - name: TLS — avertir si le certificat n'est pas encore disponible ansible.builtin.debug: msg: >- client_pki n'a pas encore depose de certificat sur ce noeud ({{ serveur_openldap_tls_source_cert }}). TLS slapd NON configure : deploie l'integration client_pki sur ce noeud, puis relance le deploiement. when: - serveur_openldap_tls_actif | bool - not ansible_check_mode - not (serveur_openldap_cert_pont.stat.exists | default(false)) - name: TLS — configurer slapd (olcTLS* dans cn=config) community.general.ldap_attrs: dn: cn=config server_uri: ldapi:/// attributes: olcTLSCACertificateFile: "{{ serveur_openldap_tls_dir }}/ca.pem" olcTLSCertificateFile: "{{ serveur_openldap_tls_dir }}/cert.pem" olcTLSCertificateKeyFile: "{{ serveur_openldap_tls_dir }}/key.pem" state: exact when: - serveur_openldap_tls_actif | bool - not ansible_check_mode - serveur_openldap_cert_pont.stat.exists | default(false) notify: Redemarrer slapd - name: TLS — exposer LDAPS (SLAPD_SERVICES) ansible.builtin.lineinfile: path: /etc/default/slapd regexp: '^SLAPD_SERVICES=' line: 'SLAPD_SERVICES="{{ serveur_openldap_services }}"' when: - serveur_openldap_tls_actif | bool - serveur_openldap_cert_pont.stat.exists | default(false) notify: Redemarrer slapd # --- L'ANNUAIRE D'UNE INCARNATION PRECEDENTE (2026-09-30) --------------------------- # # EN FIN DE ROLE, PAS AU DEBUT. Le LDIF porte des attributs de `ppolicy` (`pwdChangedTime`, # `pwdReset`...) que slapd refuse tant que l'overlay n'est pas charge — c'est-a-dire plus # haut dans ce role. On remplace donc ce que le role vient de creer par ce qui vivait # avant ; `setops-restaurer` le rejoue d'abord a blanc, et met la base neuve de cote. # # Sans ceci, une reconstruction rendait a `sysadmin` le mot de passe d'amorcage : c'est # ainsi que l'absence de restauration a ete decouverte, le 2026-09-30. - name: Restauration — l'annuaire d'une incarnation precedente ? ansible.builtin.include_role: name: client_backup tasks_from: restaurer.yml vars: client_backup_restaurer_jeu: serveur_openldap client_backup_restaurer_vierge: "{{ (serveur_openldap_entrees_initiales.stdout | default('1') | int) == 0 }}" - name: Restauration — remettre l'annuaire changed_when: true ansible.builtin.command: argv: - /usr/local/sbin/setops-restaurer - annuaire - --instantane - "{{ client_backup_restauration.instantane }}" - "{{ serveur_openldap_base_dn }}" when: client_backup_restauration.etat == 'a_restaurer' # Les comptes de service reviennent avec le mot de passe de l'EPOQUE. On les realigne sur # la voute tout de suite : Keycloak, Postfix et Dovecot s'y lient avec la valeur actuelle. - name: Restauration — realigner les comptes de service sur la voute community.general.ldap_passwd: dn: "cn={{ item.cn }},{{ serveur_openldap_services_dn }}" passwd: "{{ lookup('vars', item.secret) }}" server_uri: "ldapi:///" bind_dn: "{{ serveur_openldap_admin_dn }}" bind_pw: "{{ serveur_openldap_admin_password }}" loop: "{{ serveur_openldap_comptes_service }}" loop_control: label: "{{ item.cn }}" no_log: true when: client_backup_restauration.etat == 'a_restaurer' - name: Restauration — acter l'annuaire remis ansible.builtin.include_role: name: client_backup tasks_from: acter.yml vars: client_backup_acter_etat: restaure when: client_backup_restauration.etat == 'a_restaurer' # --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # fait tourner et pousse le verdict, sans savoir ce qu'elle 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 « annuaire » ansible.builtin.template: src: sonde-annuaire.sh.j2 dest: /usr/local/lib/setops/sondes/annuaire.sh owner: root group: root mode: "0750"