serveur_stalwart : Phase 1 (install + démarrage), code prod

Stalwart Mail Server v0.16.11, binaire unique natif. Mécaniques réelles
validées empiriquement sur le binaire :
- config.json = objet typé {"@type":"RocksDb","path":…} (DataStore seul) ;
- démarrage IaC en mode récupération (STALWART_RECOVERY_MODE +
  STALWART_RECOVERY_ADMIN), admin depuis la voûte (vault_stalwart_admin).

Rôle : install version-épinglée, user système, unité systemd durcie
(CAP_NET_BIND_SERVICE, ProtectSystem), EnvironmentFile pour le secret.
Phase 2 (écouteurs/TLS step_ca/annuaire LDAP/DKIM via API d'admin) à venir.
Validé statiquement (ansible-lint, syntax, rendu config.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-02 10:24:50 -04:00
parent 1a59af4d5b
commit e3efbb5b1a
10 changed files with 195 additions and 0 deletions

View file

@ -3,6 +3,15 @@
## 2026-07-02
### Ajouté
- **Rôle `serveur_stalwart` — Phase 1 (install + démarrage).** Stalwart Mail Server
v0.16.11 (binaire unique natif, AGPL). Mécaniques réelles validées sur le binaire :
`config.json` = objet typé `{"@type":"RocksDb","path":…}` (DataStore seul), démarrage
IaC en **mode récupération** (`STALWART_RECOVERY_MODE` + `STALWART_RECOVERY_ADMIN`,
admin depuis la voûte `vault_stalwart_admin`). Install version-épinglée, user système,
unité systemd durcie (`CAP_NET_BIND_SERVICE`, `ProtectSystem`), EnvironmentFile pour le
secret. **Phase 2 à venir** (écouteurs SMTP/IMAP/JMAP, TLS step_ca, annuaire LDAP, DKIM —
provisionnés via l'API d'admin, le binaire n'ayant pas de CLI `apply`). Validé
statiquement ; déploiement réel à suivre.
- **`serveur_openldap` durci pour la prod : TLS via step_ca + organisation en intrant.**
- **TLS (LDAPS + STARTTLS)** : le certificat d'hôte step_ca (déposé par `client_pki`,
`root:root 600`) est synchronisé vers un emplacement lisible par `openldap` (`/etc/ldap/tls`)

View file

@ -38,6 +38,9 @@ vault_forgejo_admin: ""
vault_forgejo_secret_key: ""
vault_forgejo_internal_token: ""
# --- Courriel (Stalwart) ---
vault_stalwart_admin: ""
# --- Observabilité / divers ---
vault_grafana_admin: ""
vault_redis: ""

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_stalwart
hosts: serveur_stalwart
become: true
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_stalwart

View file

@ -0,0 +1,29 @@
---
# Stalwart Mail Server (binaire unique natif, AGPL). Rôle en 2 phases :
# Phase 1 (ce fichier) : installer + démarrer (mode récupération pour l'IaC).
# Phase 2 (à venir) : provisionner écouteurs/TLS/annuaire LDAP/DKIM.
# Cf. docs/courriel-conception.md.
serveur_stalwart_version: "0.16.11"
serveur_stalwart_arch: "x86_64-unknown-linux-gnu"
serveur_stalwart_url: "https://github.com/stalwartlabs/stalwart/releases/download/v{{ serveur_stalwart_version }}/stalwart-{{ serveur_stalwart_arch }}.tar.gz"
serveur_stalwart_binaire: "/usr/local/bin/stalwart"
serveur_stalwart_utilisateur: "stalwart"
serveur_stalwart_etc: "/etc/stalwart"
serveur_stalwart_data: "/var/lib/stalwart"
serveur_stalwart_store: "{{ serveur_stalwart_data }}/data"
serveur_stalwart_config: "{{ serveur_stalwart_etc }}/config.json"
serveur_stalwart_env: "{{ serveur_stalwart_etc }}/stalwart.env"
# DataStore de config.json. RocksDb embarqué par défaut (v1, cf. §4 du doc conception).
serveur_stalwart_store_type: "RocksDb"
# Mode récupération : expose l'API d'admin (port ci-dessous) pour provisionner les
# réglages (Phase 2). À basculer à false une fois la configuration chargée.
serveur_stalwart_recovery_mode: true
serveur_stalwart_recovery_port: 8080
# Identifiant admin de récupération. Le mot de passe vient de la voûte.
serveur_stalwart_admin_user: "admin"
serveur_stalwart_admin_password: "{{ vault_stalwart_admin | default('') }}"

View file

@ -0,0 +1,7 @@
---
- name: Redémarrer stalwart
ansible.builtin.systemd:
name: stalwart
state: restarted
daemon_reload: true
when: not ansible_check_mode

View file

@ -0,0 +1,8 @@
---
# Empreinte ressources du logiciel — dimensionnement VM derive (scripts/instancier.py).
# Sommee au socle SE + marge pour estimer coeurs/RAM/disque de la VM hote.
# Override possible par hote dans le plan (serveurs.yml: coeurs/memoire/disque).
setops_empreinte:
coeurs: 2
memoire_mo: 2048
disque_go: 10

View file

@ -0,0 +1,92 @@
---
- name: Exiger le mot de passe admin Stalwart (Vault)
ansible.builtin.assert:
that:
- serveur_stalwart_admin_password | length > 0
fail_msg: "serveur_stalwart_admin_password est requis (via Ansible Vault : vault_stalwart_admin)."
- name: Créer l'utilisateur système stalwart
ansible.builtin.user:
name: "{{ serveur_stalwart_utilisateur }}"
system: true
home: "{{ serveur_stalwart_data }}"
create_home: false
shell: /usr/sbin/nologin
- name: Créer les répertoires stalwart
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ serveur_stalwart_utilisateur }}"
group: "{{ serveur_stalwart_utilisateur }}"
mode: "0750"
loop:
- "{{ serveur_stalwart_etc }}"
- "{{ serveur_stalwart_data }}"
- "{{ serveur_stalwart_store }}"
- name: Lire la version de Stalwart installée
ansible.builtin.command: "{{ serveur_stalwart_binaire }} --version"
register: serveur_stalwart_version_installee
changed_when: false
failed_when: false
- name: Installer le binaire Stalwart (absent ou version différente)
when: serveur_stalwart_version not in (serveur_stalwart_version_installee.stdout | default(''))
block:
- name: Télécharger l'archive Stalwart
ansible.builtin.get_url:
url: "{{ serveur_stalwart_url }}"
dest: "/tmp/stalwart-{{ serveur_stalwart_version }}.tar.gz"
mode: "0644"
- name: Extraire le binaire stalwart vers /usr/local/bin
ansible.builtin.unarchive:
src: "/tmp/stalwart-{{ serveur_stalwart_version }}.tar.gz"
dest: /usr/local/bin
remote_src: true
owner: root
group: root
mode: "0755"
notify: Redémarrer stalwart
- name: Nettoyer l'archive téléchargée
ansible.builtin.file:
path: "/tmp/stalwart-{{ serveur_stalwart_version }}.tar.gz"
state: absent
- name: Déployer config.json (DataStore)
ansible.builtin.template:
src: config.json.j2
dest: "{{ serveur_stalwart_config }}"
owner: "{{ serveur_stalwart_utilisateur }}"
group: "{{ serveur_stalwart_utilisateur }}"
mode: "0640"
notify: Redémarrer stalwart
- name: Déployer l'EnvironmentFile (secret admin de récupération)
ansible.builtin.template:
src: stalwart.env.j2
dest: "{{ serveur_stalwart_env }}"
owner: root
group: "{{ serveur_stalwart_utilisateur }}"
mode: "0640"
no_log: true
notify: Redémarrer stalwart
- name: Déployer l'unité systemd stalwart
ansible.builtin.template:
src: stalwart.service.j2
dest: /etc/systemd/system/stalwart.service
owner: root
group: root
mode: "0644"
notify: Redémarrer stalwart
- name: Activer et démarrer stalwart
when: not ansible_check_mode
ansible.builtin.systemd:
name: stalwart
enabled: true
state: started
daemon_reload: true

View file

@ -0,0 +1 @@
{"@type":"{{ serveur_stalwart_store_type }}","path":"{{ serveur_stalwart_store }}"}

View file

@ -0,0 +1,5 @@
{% if serveur_stalwart_recovery_mode %}
STALWART_RECOVERY_MODE=1
STALWART_RECOVERY_MODE_PORT={{ serveur_stalwart_recovery_port }}
STALWART_RECOVERY_ADMIN={{ serveur_stalwart_admin_user }}:{{ serveur_stalwart_admin_password }}
{% endif %}

View file

@ -0,0 +1,26 @@
# Géré par Set-OPS (rôle serveur_stalwart). Ne pas éditer à la main.
[Unit]
Description=Stalwart Mail Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{ serveur_stalwart_utilisateur }}
Group={{ serveur_stalwart_utilisateur }}
EnvironmentFile=-{{ serveur_stalwart_env }}
ExecStart={{ serveur_stalwart_binaire }} --config {{ serveur_stalwart_config }}
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# Autorise l'écoute sur les ports privilégiés (25/443/465/587/993) sans être root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths={{ serveur_stalwart_data }}
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target