Courriel : pivot de Stalwart vers Postfix/Dovecot/rspamd

Stalwart trop jeune/volatil pour un pilier mail critique (config cassée
0.15→0.16, `config apply` annoncé non livré, API REST supprimée pour
JMAP, gros backlog). Pivot vers la stack mature Postfix/Dovecot/rspamd,
100 % configurable par fichiers (alignée au modèle déclaratif Set-OPS).

- Rôle serveur_stalwart retiré (Phase 1 prototypée ; git en garde la trace).
- docs/courriel-conception.md mis à jour ; vault_stalwart_admin retiré.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-02 12:52:49 -04:00
parent e3efbb5b1a
commit e01adac67b
11 changed files with 59 additions and 228 deletions

View file

@ -2,16 +2,18 @@
## 2026-07-02
### Décidé
- **Service courriel : pivot de Stalwart vers Postfix + Dovecot + rspamd.** La Phase 1
Stalwart (`serveur_stalwart`) avait été prototypée et déployée (v0.16.11, install +
démarrage en mode récupération). Le prototypage a révélé un projet **trop jeune/volatil
pour un pilier mail critique** : config cassée entre 0.15 et 0.16, outil IaC
`stalwart config apply` **annoncé mais non livré** dans le binaire, API REST supprimée
(JMAP), gros backlog. Pivot vers la stack **mature Postfix/Dovecot/rspamd**, en prime
**100 % configurable par fichiers** (alignée au modèle déclaratif Set-OPS). Le rôle
`serveur_stalwart` est **retiré** (git en garde la trace) ; `docs/courriel-conception.md`
mis à jour. Réévaluer Stalwart ~2028.
### 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

@ -9,8 +9,8 @@
|---|---|
| Périmètre | **Vrai service de courriel** (boîtes utilisateurs, réception publique, IMAP) — pas un simple relais sortant. |
| Modèle | **Full self-host** : MX entrant **et** sortant chez Chezlepro, réputation d'IP propre. |
| Suite | **Stalwart Mail Server** (binaire unique Rust, AGPL ; SMTP/IMAP/JMAP/POP3 + anti-spam + DKIM intégrés). Adoptée et enveloppée par un rôle Set-OPS mince — pas de réimplémentation. |
| Identité | Boîtes/utilisateurs dans l'**OpenLDAP** existant (annuaire Stalwart → LDAP). Pas de comptes en double. |
| Stack | **Postfix (MTA) + Dovecot (IMAP/LMTP) + rspamd (antispam/DKIM)** — la stack libre **mature**, éprouvée depuis 20+ ans, **entièrement configurable par fichiers**. Trois rôles Set-OPS (`serveur_postfix`, `serveur_dovecot`, `serveur_rspamd`), comme on configure nginx/powerdns. |
| Identité | Boîtes/utilisateurs dans l'**OpenLDAP** existant (Postfix + Dovecot → LDAP). Pas de comptes en double. |
| DNS | Enregistrements publics dans la zone `chezlepro.ca` ; noms internes dans **PowerDNS**. |
| PKI | **Deux CA distinctes** (voir §6) : Let's Encrypt pour les faces publiques, **step_ca** pour l'interne. |
| Démarche | **Deux étapes** (voir §11) : **(A) fonctionnement INTERNE** d'abord (step_ca, LDAP, SMTP/IMAP internes, zéro dépendance publique) ; **(B) fonctionnement EXTERNE** ensuite (Namespro, Let's Encrypt, reprise du MX `.53`). |
@ -19,6 +19,17 @@ Le rôle existant `serveur_sendmail` (relais sortant Postfix, sans boîtes) **re
courrier de notification système (via `client_smtp`/msmtp). Il ne fait pas partie de ce
service et ne le remplace pas.
> **Historique — pivot depuis Stalwart (2026-07-02).** On avait d'abord choisi Stalwart
> (binaire unique, moderne). Le prototypage a révélé un projet **jeune et volatil** :
> modèle de config **cassé entre 0.15 et 0.16**, outil IaC `stalwart config apply`
> **annoncé mais non livré** dans le binaire, API REST supprimée au profit de JMAP,
> backlog d'issues/PR important. Pour un **pilier critique souverain censé durer**, c'est
> trop précaire. On pivote vers la stack **mature Postfix/Dovecot/rspamd**, qui a en prime
> l'avantage d'être **100 % déclarable par fichiers** — donc parfaitement alignée avec le
> modèle Set-OPS (déclaratif, idempotent, exploitable sans IA). La Phase 1 Stalwart
> (rôle `serveur_stalwart`) est **retirée** ; git en garde la trace. Stalwart à réévaluer
> dans ~2 ans, une fois mûri et son `config apply` livré.
## 2. Prérequis IP publique — VÉRIFIÉ (recon DNS, 2026-07-02)
Le full self-host sortant exige un **PTR propre, forward-confirmed, hors blocklist**.
@ -56,28 +67,30 @@ Réserves :
┌───────────────┴───────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ mail-01 (PRIMAIRE) │ │ mail-02 (MX secours)│
│ Stalwart complet │◄──relais──│ Stalwart mode relais│
│ SMTP 25 / 587 / 465│ quand │ SMTP 25 seulement │
│ IMAP 993 / JMAP 443│ mail-01 │ file d'attente puis │
│ antispam + DKIM │ est down │ transfert à mail-01 │
│ BOÎTES stockées │ └───────────────────┘
┌────────────────────┐ ┌───────────────────┐
│ mail-01 (PRIMAIRE) │ │ mail-02 (MX secours)│
│ Postfix (MTA) │◄──relais──│ Postfix (relais) │
│ SMTP 25 / 587 / 465│ quand │ SMTP 25 seulement │
│ Dovecot (IMAP 993 │ mail-01 │ file d'attente puis │
│ + LMTP + Sieve) │ est down │ transfert à mail-01 │
│ rspamd (spam + DKIM)│ └───────────────────┘
│ BOÎTES stockées │
└─────────┬──────────┘
│ (réseau interne, mTLS step_ca)
┌───────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼
OpenLDAP step_ca PowerDNS PostgreSQL
(identité)(certs int.)(DNS interne)(métadonnées, option HA)
(identité)(certs int.)(DNS interne)(métadonnées, option)
```
- **mail-01 (primaire)** — Stalwart complet : réception `:25`, soumission authentifiée
`:587`/`:465`, accès client IMAP `:993` + JMAP/web `:443`, anti-spam, **signature DKIM**,
**hébergement des boîtes**.
- **mail-02 (MX de secours)** — Stalwart en mode relais, écoute seulement `:25`. Quand
mail-01 est indisponible, **accepte + met en file** l'entrant, puis **retransmet** au
rétablissement → **zéro perte à l'entrée**. À placer idéalement sur un **lien/IP
indépendant** (autre site ou VPS), sinon la résilience est illusoire.
- **mail-01 (primaire)****Postfix** (réception `:25`, soumission authentifiée `:587`/`:465`)
+ **Dovecot** (IMAP `:993`, livraison LMTP, filtres Sieve, **hébergement des boîtes**,
auth SASL) + **rspamd** (anti-spam, **signature DKIM**, greylisting). Auth des comptes via
**LDAP**.
- **mail-02 (MX de secours)****Postfix** en relais, écoute seulement `:25`. Quand mail-01
est indisponible, **accepte + met en file** l'entrant, puis **retransmet** au rétablissement
**zéro perte à l'entrée**. À placer idéalement sur un **lien/IP indépendant** (autre site
ou VPS), sinon la résilience est illusoire.
**Décision ouverte : emplacement du MX secours** (même cluster ? VPS ? autre site ?).
@ -135,15 +148,17 @@ _dmarc TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@chezlepro.
- **Nomenclature** : 2 fonctions, p. ex. `comm-mail` (primaire) + `comm-mx2` (secours) ;
VMID / IP / VLAN dérivés comme le reste.
- **Rôles** :
- `serveur_stalwart` — mince : installe le binaire Stalwart, dépose la config
(annuaire LDAP, ACME public, DKIM, certs internes step_ca), unité systemd, règles
nftables mail.
- variante « relais » (ou paramètre du même rôle) pour le MX de secours.
- **Intégrations** des nœuds mail : `client_ldap` (auth), `client_pki` (certs internes),
`client_dns`.
- **Secrets (voûte)** : clé/mots de passe d'admin Stalwart, éventuel secret ACME, clé
privée DKIM — via `vault_*`, câblés au rôle (comme les autres secrets).
- **Rôles** (config 100 % par fichiers, comme nginx/powerdns) :
- `serveur_postfix` — MTA : `main.cf`/`master.cf`, TLS step_ca, cartes LDAP (domaines,
boîtes, alias), remise LMTP vers Dovecot, soumission `:587`/`:465`, milter rspamd.
- `serveur_dovecot` — IMAP `:993` + LMTP + Sieve, stockage des boîtes, **auth SASL** (pour
la soumission Postfix), backend **LDAP**, TLS step_ca.
- `serveur_rspamd` — anti-spam, **signature DKIM**, greylisting ; branché à Postfix par milter.
- `serveur_postfix` en **mode relais** (paramètre) pour le MX de secours.
- **Intégrations** des nœuds mail : `client_ldap` (auth/annuaire), `client_pki` (certs
internes), `client_dns`.
- **Secrets (voûte)** : bind LDAP, **clé privée DKIM**, éventuel secret ACME (Étape B) —
via `vault_*`, câblés aux rôles.
## 10. Décisions
@ -151,7 +166,7 @@ _dmarc TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@chezlepro.
- ⬜ **Emplacement du MX de secours** (`.55` existe ; lien/PTR à cadrer) (§3).
- ⬜ **Stockage** : embarqué v1 vs PostgreSQL dès le départ (§4).
- ⬜ **Domaine(s)** desservi(s) : `chezlepro.ca` seul, ou multi-domaines (tenants) ?
- ⬜ **Reprise du MX existant** : bascule directe de `.53` vers Stalwart, ou cohabitation transitoire ?
- ⬜ **Reprise du MX existant** : bascule directe de `.53` vers la nouvelle stack, ou cohabitation transitoire ?
## 11. Démarche en deux étapes
@ -160,13 +175,13 @@ _dmarc TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@chezlepro.
But : prouver **toute la pile en interne**, en **code de prod**, dans le bac à sable.
Aucune dépendance au public.
1. **Pilier identité** : `serveur_openldap` (TLS **step_ca**) validé sur un nœud du bac à sable.
2. **`serveur_stalwart`** sur `mail-01` : TLS **step_ca** (mode `fichiers`), annuaire **LDAP**,
DKIM interne, nftables mail.
3. **Prouver** : réception → boîte → accès **IMAP/JMAP** → envoi **intra-écosystème**, le tout
en TLS interne, auth LDAP.
1. **Pilier identité** : `serveur_openldap` (TLS **step_ca**) — ✅ **déployé et prouvé** en bac à sable.
2. **`serveur_postfix` + `serveur_dovecot` + `serveur_rspamd`** sur `mail-01` : TLS **step_ca**,
annuaire/auth **LDAP**, DKIM interne, nftables mail.
3. **Prouver** : réception → boîte → accès **IMAP** → envoi **intra-écosystème**, le tout en
TLS interne, auth LDAP.
*(Étape actuelle : `serveur_openldap` écrit — reste à déployer/éprouver en bac à sable.)*
*(Étape actuelle : identité OK ; on démarre `serveur_postfix`.)*
### Étape B — Fonctionnement EXTERNE (transition prod, plus tard)

View file

@ -38,9 +38,6 @@ 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

@ -1,15 +0,0 @@
---
- 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

@ -1,29 +0,0 @@
---
# 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

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

View file

@ -1,8 +0,0 @@
---
# 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

@ -1,92 +0,0 @@
---
- 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

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

View file

@ -1,5 +0,0 @@
{% 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

@ -1,26 +0,0 @@
# 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