Installation et déploiement
Guide opérateur pour déployer difuzio. Trois processus partagent le même binaire
et la même configuration, coordonnés par la base de données. Ce document reprend
et détaille le runbook de référence du dépôt (docs/runbook/install.md).
1. Base de données
Cette section décrit l'installation avec MariaDB, le backend par défaut. Pour une petite installation mono-hôte, difuzio se compile aussi avec un backend SQLite (un simple fichier, aucun serveur de base à administrer) : voir Base de données pour choisir, construire le bon binaire et connaître les limites de chacun.
Créer deux comptes distincts :
- un compte applicatif à moindre privilège (
SELECT,INSERT,UPDATE,DELETEuniquement, aucun DDL). C'est celui qu'utilisentserve-web,serve-lmtpetworker. - un compte de migration à privilèges DDL, fourni uniquement au moment de migrer.
Le DDL (Data Definition Language) est la famille d'instructions qui définit le
schéma :
CREATE,ALTER,DROP.
Le DSN (Data Source Name) est la chaîne de connexion à la base : compte, hôte,
nom de la base et paramètres du driver. Celui de difuzio doit obligatoirement
porter parseTime=true, loc=UTC, time_zone='+00:00' et
clientFoundRows=true. Le drapeau clientFoundRows est nécessaire pour que les
UPDATE idempotents renvoient la sémantique "lignes correspondantes" attendue
par la couche d'accès.
Appliquer les migrations avant de démarrer les services, avec le compte DDL :
difuzio migrate up --dsn "<DSN du compte DDL>"
Pour vérifier l'état du schéma à tout moment :
difuzio migrate status --dsn "<DSN>"
Les migrations sont embarquées dans le binaire (golang-migrate). En cas de schéma marqué "dirty" après un échec, voir Référence CLI.
2. Secrets (keyrings)
difuzio utilise trois keyrings séparés par durée de vie :
session_keyring(session.key) : cookies de session et jetons CSRF. Rotation libre, une rotation ne coûte au pire que les sessions en cours.token_keyring(token.key) : tokens single-use (double opt-in, reset de mot de passe, invitation, export). Garder l'ancienne version le temps que les liens déjà envoyés expirent.verp_keyring(verp.key) : adresses VERP de retour et liens de désabonnement one-click. Longue durée de vie, garder l'ancienne version au moinsbounce.verp_max_age_days.
Les trois sont requis par les trois processus (serve-web, serve-lmtp,
worker) et par toute commande qui reçoit --config, même si serve-lmtp ne
signe que du VERP : la validation de la configuration construit les trois keyrings
sans condition. Aucun secret n'est généré à la volée, l'absence est fatale.
Un quatrième keyring, credential_keyring (credential.key), est optionnel :
il chiffre les comptes de messagerie saisis par liste dans la console (profil
boîte externe, voir Listes). Une installation qui n'en
utilise pas démarre sans. Le créer suppose de décommenter la ligne
LoadCredential=credential.key:... des trois unités de service
(difuzio-web, difuzio-worker@ et difuzio-lmtp), sans quoi le chemin
${CREDENTIALS_DIRECTORY}/credential.key ne sera pas résolu. serve-lmtp ne lit
jamais de compte de messagerie, mais la validation de la configuration construit
tous les keyrings déclarés avant que le processus ne décide de ce dont il a
besoin : la clé déclarée sans la ligne correspondante fait échouer son
démarrage.
Créer l'utilisateur, les répertoires et les clés
Tous les secrets sur disque vivent dans /etc/difuzio/secrets/ (0700
root:root) : les trois clés de keyring, et le cas échéant les mots de passe
SMTP/IMAP externes. Le répertoire /etc/difuzio reste, lui, lisible par le compte
de service pour config.yaml.
useradd --system --home-dir /var/lib/difuzio --shell /usr/sbin/nologin difuzio
install -d -m 0750 -o root -g difuzio /etc/difuzio
install -d -m 0700 -o root -g root /etc/difuzio/secrets
umask 077
for k in session token verp; do
head -c 32 /dev/urandom | base64 > /etc/difuzio/secrets/$k.key
done
chown root:root /etc/difuzio/secrets/*.key
chmod 0600 /etc/difuzio/secrets/*.key
Les fichiers restent root:root : c'est systemd (root) qui les lit pour
LoadCredential= et en dépose une copie dans un tmpfs privé à chaque service.
Sur les systemd récents (v256+), cette copie est en 0440 root:<groupe du service> : lisible via le bit groupe, et volontairement pas détenue par le
compte de service, qui ne peut donc ni la modifier ni en changer les droits.
Contraintes vérifiées au chargement, chacune fatale : base64 standard décodant au
moins 32 octets ; fichier inaccessible au groupe et aux autres (0640 est refusé,
l'échappatoire DIFUZIO_INSECURE_KEY_PERMS=1 est réservée au développement),
avec une seule exception : sous $CREDENTIALS_DIRECTORY, le bit de lecture
groupe est toléré (le 0440 ci-dessus), tout autre bit restant fatal ;
version current présente parmi les clés déclarées ; pas de doublon de version.
Chemins dans la configuration
Chaque unité systemd expose ses credentials dans un répertoire qui lui est propre,
dont le chemin est donné par $CREDENTIALS_DIRECTORY. La configuration partagée
référence donc la variable plutôt qu'un chemin figé :
session_keyring:
current: 1
keys:
- { version: 1, file: "${CREDENTIALS_DIRECTORY}/session.key" }
Une variable référencée mais non définie est une erreur de configuration explicite qui nomme la variable manquante, jamais un chemin vide silencieux. Hors systemd, poser la variable sur la commande (en root) :
CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config /etc/difuzio/config.yaml
Variante sans LoadCredential= : écrire les chemins en clair
(/etc/difuzio/secrets/session.key, comme dans config.example-external.yaml) ;
il faut alors que le compte de service puisse lire les fichiers, donc
chown difuzio:difuzio sur les clés en gardant 0600, et rendre
/etc/difuzio/secrets/ traversable par ce compte (chgrp difuzio + 0710).
Rotation
Ajouter la nouvelle version sans retirer l'ancienne, ajouter le
LoadCredential=token.key.v2:/etc/difuzio/secrets/token.key.v2 correspondant dans
les unités, puis basculer current :
token_keyring:
current: 2
keys:
- { version: 1, file: "${CREDENTIALS_DIRECTORY}/token.key" }
- { version: 2, file: "${CREDENTIALS_DIRECTORY}/token.key.v2" }
Les nouveaux tokens sont signés en v2, ceux en vol restent vérifiables en v1. Ne retirer v1 qu'après expiration des liens concernés : une version retirée n'est pas une panne (le lien retombe sur le formulaire web), mais l'abonné perd le lien direct.
Sauvegarde
Sauvegarder les trois fichiers à part, chiffrés, séparément des dumps SQL. Perdre
verp.key casse l'attribution des bounces en vol et invalide tous les liens de
désabonnement one-click déjà distribués, y compris ceux des messages archivés chez
les abonnés : une restauration de base sans les clés d'origine est incomplète.
3. Configuration
Copier config.example.yaml vers /etc/difuzio/config.yaml et l'adapter (voir
Configuration pour le détail de chaque clé). Valider la
configuration avant tout démarrage :
CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio config check --config /etc/difuzio/config.yaml
Cette commande charge le fichier, applique les surcharges d'environnement, charge
les keyrings et vérifie les invariants. Un invariant cassé fait échouer la
commande (et le démarrage). En particulier worker.reclaim_seconds doit être
supérieur ou égal à deux fois relay.smtp_timeout, et chaque domaine doit
déclarer un base_url.
4. systemd
Copier les unités de deploy/systemd/ dans /etc/systemd/system/ (rien n'est
installé automatiquement) :
install -m 0755 difuzio /usr/local/bin/difuzio
install -m 0644 deploy/systemd/difuzio-*.service deploy/systemd/difuzio.target /etc/systemd/system/
systemctl daemon-reload
Le durcissement est déjà inline dans chaque unité : difuzio.hardening.conf n'est
qu'une référence, à déployer seulement si on veut un drop-in (un répertoire
difuzio-<unite>.service.d/ par unité). Les trois unités de service déclarent les
trois clés en LoadCredential=, plus la ligne commentée du credential.key
optionnel (systemd refuse de démarrer si un fichier déclaré manque, d'où le
commentaire par défaut).
Les quatre unités partagent RuntimeDirectory=difuzio (/run/difuzio, où vit le
socket LMTP) et déclarent donc RuntimeDirectoryPreserve=yes. Sans cette ligne,
la première unité qui s'arrête efface le répertoire pendant que les autres
démarrent, et celles-ci échouent en construisant leur espace de noms :
Failed to set up mount namespacing: /run/difuzio: No such file or directory
(état 226/NAMESPACE). Si une installation antérieure porte des unités sans
cette directive, l'ajouter par un drop-in suffit.
difuzio-migrate.service, lui, ne lit pas la configuration YAML : il reçoit le
DSN du compte DDL (section 1) par EnvironmentFile=/etc/difuzio/migrate.env. Ce
n'est pas un doublon du db.dsn du YAML : le YAML porte le compte applicatif
sans droits DDL, migrate.env porte le compte privilégié, lisible par la seule
unité de migration et jamais passé sur argv (donc invisible dans
/proc/<pid>/cmdline). Créer ce fichier avant le premier démarrage :
umask 027
cat > /etc/difuzio/migrate.env <<'EOF'
DIFUZIO_MIGRATE_DSN=difuzio_ddl:MOT_DE_PASSE@tcp(127.0.0.1:3306)/difuzio?parseTime=true&loc=UTC
EOF
chown root:difuzio /etc/difuzio/migrate.env
chmod 0640 /etc/difuzio/migrate.env
Sans ce fichier, l'unité échoue avant même de lancer le binaire ("Failed to load
environment files: No such file or directory", résultat resources). Avec le
backend SQLite (voir Base de données), il n'y a
pas de comptes séparés : mettre le chemin du fichier de base dans migrate.env
(DIFUZIO_MIGRATE_DSN=/var/lib/difuzio/difuzio.db), ou remplacer le fichier
d'environnement par un drop-in (systemctl edit difuzio-migrate.service) qui lit
la configuration :
[Service]
EnvironmentFile=
ExecStart=
ExecStart=/usr/local/bin/difuzio migrate up --config /etc/difuzio/config.yaml
Puis :
systemctl enable --now difuzio-migrate.service
systemctl enable difuzio-web.service difuzio-lmtp.service
systemctl enable difuzio-worker@1.service difuzio-worker@2.service
systemctl enable --now difuzio.target
En profil boîte externe, ne PAS activer difuzio-lmtp.service : rien ne lui
remettrait de courrier, et l'unité resterait à écouter une socket que personne
n'alimente. Deux services suffisent, difuzio-web et un seul difuzio-worker@1
(le poller IMAP est un singleton, voir
Comptes de messagerie externes) :
systemctl enable difuzio-web.service difuzio-worker@1.service
systemctl enable --now difuzio.target
difuzio-migrate.service est un oneshot que les trois services tirent par
Requires= et After= : les migrations en attente sont appliquées
automatiquement avant chaque démarrage, sans commande à retenir. Après un
passage réussi, l'unité repasse inactive (dead) : c'est l'état normal, et c'est
ce qui permet qu'elle soit rejouée au démarrage suivant. Quand il n'y a rien à
appliquer, migrate up ne fait rien (une connexion et une lecture de version).
Deux directives de cette unité ne doivent pas être touchées :
- pas de
RemainAfterExit=yes: l'unité resteraitactive (exited)et ne serait plus jamais rejouée, ce qui remettrait la migration à la charge de l'administrateur ; TimeoutStartSec=infinity: le délai par défaut de systemd (90 s) tuerait une migration longue en pleinALTER TABLEet laisserait le schémadirty, état qui bloque tous les services jusqu'à undifuzio migrate force.
Conséquence à connaître : sur une grosse base, une migration lourde retarde le
démarrage de la pile d'autant. C'est voulu (un service arrêté vaut mieux qu'un
service qui interroge des colonnes absentes), mais cela veut dire qu'un
systemctl restart juste après une mise à jour de binaire n'est pas toujours
instantané.
Les services sont rattachés à la cible difuzio.target (WantedBy= +
PartOf=) : activer un service l'accroche à la cible, et démarrer la cible
démarre tous les services activés. Toute la pile se pilote ensuite en une seule
commande :
systemctl restart difuzio.target # redémarre web + lmtp + tous les workers
systemctl stop difuzio.target # arrête toute la pile
difuzio-migrate.service n'est volontairement pas rattaché à la cible : chaque
service le tire déjà par Requires=, ce qui suffit à le rejouer au démarrage.
L'y rattacher par PartOf= ne changerait rien d'utile et ferait dépendre l'ordre
des migrations de la cible plutôt que des services qui en ont besoin.
On multiplie les unités difuzio-worker@N pour augmenter le débit (instances
nommées avec un chiffre : le template dérive le port de métriques de l'instance,
/metrics sur 9091 pour @1, 9092 pour @2, cf
Exploitation). Chaque
worker exécute le pipeline de traitement des posts, l'envoi sortant, la
livraison des webhooks et les tâches périodiques (kill-switch, digests, événements
de fin d'envoi, purges) ; les tâches périodiques sont des singletons protégés par
verrou applicatif, sûres à lancer en plusieurs exemplaires (voir
Exploitation). Un seul serve-lmtp par hôte MTA suffit ; seul
serve-web est exposé derrière le reverse proxy (vhosts Apache et Nginx complets :
Proxy web frontal). Les services daemon s'arrêtent
proprement sur SIGTERM (arrêt gracieux).
5. Premier super_admin
Créer le premier super administrateur, de préférence AVANT d'exposer le vhost :
CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio bootstrap-admin --config /etc/difuzio/config.yaml \
--email vous@example.org --password-stdin
Le mot de passe est lu sur l'entrée standard avec --password-stdin (préférable à
--password qui apparaît dans l'historique shell). Si un super_admin existe déjà,
la commande refuse (utiliser --force pour en ajouter un autre).
La page web /setup est un repli atomique : tant qu'aucun super_admin n'existe,
elle permet d'en créer un ; dès qu'il y en a un, elle renvoie 404.
6. Domaine et intégration Postfix
Profil MTA local uniquement. En profil boîte externe, il n'y a pas de Postfix ni de LMTP : provisionner le domaine avec
difuzio add-domain, sauter le reste de cette section et toute la section 7, et suivre Comptes de messagerie externes.
Provisionner un domaine puis imprimer les lignes Postfix :
CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio add-domain --config /etc/difuzio/config.yaml \
--name lists.example.org --base-url https://lists.example.org
difuzio mta-config --domain lists.example.org
Reporter les lignes imprimées dans la configuration Postfix. Le domaine entier
(posts, bounce+*@, fbl@) est routé vers la socket LMTP de difuzio. Voir
Domaines pour le détail.
7. Prérequis Postfix (sécurité et délivrabilité)
Ces points conditionnent la sécurité et la délivrabilité ; ils ne sont pas assurés par difuzio seul :
- Anti-forge
Authentication-Results: OpenDKIM/OpenDMARC en frontal DOIVENT supprimer toutAuthentication-Resultspréexistant portant notre authserv-id à l'entrée. - Antivirus : un scan amavis/clamav AVANT la remise LMTP est recommandé (difuzio n'assure qu'un filet par extension et type MIME).
- ARC : OpenARC en frontal pour sceller la chaîne (recommandé pour les listes à volume et les abonnés qui forwardent).
- Routage FBL :
fbl@<domaine>routé vers difuzio et enregistré auprès des programmes FBL (Yahoo CFL, Microsoft JMRP). - Rétention des logs :
journald(MaxRetentionSec/SystemMaxUse) oulogrotatealignés sur la rétention de 90 jours des logs applicatifs (PII). - Paramètres DSN : le relais submission doit transmettre les paramètres DSN RFC 3461 posés par difuzio.
- Pools d'envoi : pour isoler la réputation, mapper chaque pool nommé vers une IP source dédiée (voir Délivrabilité).
Le POST /p/unsubscribe-oneclick (sans authentification, sans CSRF) doit figurer
dans la liste d'exceptions du reverse proxy / WAF pour ne pas être bloqué : c'est
le point d'entrée du désabonnement en un clic (RFC 8058).
8. Vérifications post-installation
CREDENTIALS_DIRECTORY=/etc/difuzio/secrets difuzio doctor --config /etc/difuzio/config.yaml
difuzio check-dns --domain lists.example.org --dkim-selector default
curl -fsS http://127.0.0.1:8080/readyz
doctor vérifie la joignabilité de la base et l'état du schéma. check-dns
vérifie SPF, le sélecteur DKIM et _dmarc (>= p=none) et remonte un drapeau
"bulk-sender ready". /readyz renvoie 200 si la base est joignable et le schéma à
jour, 503 sinon.
Compléter le monitoring par la profondeur de file Postfix (mailq) : un 250 du
relais signifie "remis au relais", pas "livré au destinataire".
9. Sauvegarde
mariadb-dump --single-transaction nocturne (compressé) plus binlogs pour le
point-in-time recovery. Sous SQLite, sqlite3 <fichier> ".backup <cible>" (jamais
un cp à chaud, le journal WAL est actif) : voir
Base de données. Sauvegarder les keyrings à
part, chiffrés : sans eux, une restauration de base ne permet pas de revérifier
les tokens en circulation.
Sauvegarder également /var/lib/difuzio/messages (storage.blob_dir) : c'est là
que vit le brut des messages, la base n'en garde que le chemin. Détails et ordre
recommandé dans Exploitation.
10. Mise à jour
L'ordre compte : migrations d'abord, nouveau code ensuite. Le nouveau code interroge les colonnes ajoutées par ses migrations ; sur l'ancien schéma, il échouerait sur chaque requête concernée. L'inverse est sans danger : le binaire en place ignore une colonne qui vient d'apparaître.
Sous systemd, cet ordre est garanti sans rien faire : copier le nouveau binaire
sur le disque ne le démarre pas (l'ancien processus continue de tourner sur son
image en mémoire), et au redémarrage difuzio-migrate.service passe avant les
services.
difuzio le vérifie lui-même. Un service démarré sur un schéma en retard refuse de démarrer en nommant les deux versions et la commande à lancer :
worker: the database schema is at version 32 but this binary expects 33
(1 migration(s) pending); run "difuzio migrate up" with the DDL account
(systemctl start difuzio-migrate.service) before starting the new binary
/readyz répond alors 503 avec le même détail, ce qui évite qu'un répartiteur de
charge envoie du trafic vers un processus incapable de répondre. Un schéma plus
récent que le binaire (retour arrière sur la version) n'est qu'un avertissement
dans le journal : les migrations sont additives.
Sous systemd, il n'y a aucune commande de migration à lancer : chaque service
tire difuzio-migrate.service par Requires=, et cette unité applique les
migrations en attente avant que la pile ne démarre (section 4). Le compte
applicatif n'a toujours pas les droits DDL : c'est l'unité de migration qui porte
le compte privilégié, via migrate.env.
Déroulé complet :
# 1. Sauvegarder AVANT de redémarrer : le redémarrage applique les migrations
mysqldump ... > difuzio-$(date +%F).sql
tar czf messages-$(date +%F).tgz /var/lib/difuzio/messages
# 2. Construire (make bin injecte la version et la date de build)
make bin
make webui-build
# 3. Binaire, puis redémarrage : les migrations passent au démarrage
install -m 0755 difuzio /usr/local/bin/difuzio
systemctl restart difuzio.target
# 4. Console (elle se livre séparément du binaire)
rsync -a --delete webui/dist/ root@serveur:/var/www/difuzio-console/
L'étape 1 n'est pas décorative : c'est la seule fenêtre où la base est encore dans son état d'avant migration. Une fois la pile redémarrée, le schéma a changé.
Pour garder la main sur le moment de la migration (grosse base, fenêtre de maintenance négociée), l'appliquer d'abord, services en marche :
systemctl start difuzio-migrate.service
difuzio migrate status --dsn "<DSN>" # version attendue, clean
Le redémarrage qui suit n'aura alors plus rien à appliquer. L'ordre reste le même : migrations d'abord, binaire ensuite.
Hors systemd (conteneur, service lancé à la main), rien ne joue les migrations :
c'est là que le refus de démarrer décrit plus haut sert de filet, et il faut
lancer difuzio migrate up soi-même.
Vérifier ensuite avec difuzio doctor --config /etc/difuzio/config.yaml, la page
"À propos" de la console (les deux révisions doivent concorder, voir
Exploitation) et un message de test.
Retour arrière : revenir au binaire précédent AVANT de défaire une migration
(difuzio migrate down), sinon le nouveau code tourne sur un schéma qu'il ne
connaît plus.