SQLite est l’intrus de cette série : pas de serveur, pas de répertoire de données, pas de processus d’arrière-plan. Les fichiers SQLite tiennent dans un seul fichier ordinaire que l’on peut lire avec un vidage hexadécimal. Cet article prolonge les trois premiers, InnoDB, PostgreSQL et Oracle avec les trois côte à côte. Le tableau final ne remplace rien : il ajoute une quatrième colonne à la comparaison.
J’ai rencontré SQLite dans d’autres outils, par exemple ProxySQL, qui garde sa configuration dans une base SQLite. Alors j’ai voulu voir les octets. Tout ce qui suit vient d’une base de laboratoire : testé sur Rocky Linux 10.2, SQLite 3.46.1, WSL2. Je prévois aussi une série plus détaillée en sept parties, « SQLite from the inside », pour 2027. Cet article reste court et suit le même plan que les trois autres.
Un fichier, jusqu’à trois voisins
Une base, c’est un fichier. Le journal, le WAL et le shm n’existent que lorsque SQLite en a besoin.
Une base SQLite est un seul fichier, qu’un programme ouvre directement. Dans le mode par défaut, un deuxième fichier n’apparaît que pendant une transaction d’écriture. C’est le journal de retour arrière, et il garde la version d’origine de chaque page que la transaction modifie.
-rw-r--r--. 1 root root 8192 Nov 2 21:21 demo.db
-rw-r--r--. 1 root root 4616 Nov 2 21:21 demo.db-journal
Le journal fait 4 616 octets. Son en-tête l’explique : un secteur de 512 octets, puis un enregistrement de page de 4 + 4 096 + 4 octets (numéro de page, page, somme de contrôle). Au COMMIT, le journal a disparu. Pendant la transaction, ses huit premiers octets, là où se trouve la chaîne magique, valaient encore zéro. Je n’ai pas testé pourquoi, donc je rapporte seulement ce que j’ai vu.
En mode WAL, deux autres fichiers apparaissent : le fichier -wal, où s’ajoutent les nouvelles versions de pages, et le fichier -shm, un index de 32 Kio en mémoire partagée. Contrairement aux autres modes de journal, le mode WAL est enregistré dans la base : il survit à un redémarrage. Les deux fichiers disparaissent à la fermeture propre de la dernière connexion, comme le dit la documentation.
L’en-tête de 100 octets
L’en-tête, champ par champ, avec les valeurs lues dans la base du laboratoire.
Chaque fichier de base SQLite commence par un en-tête de 100 octets. Les 16 premiers octets sont le texte SQLite format 3 et un octet nul. Viennent ensuite la taille de page, le compteur de changements, la taille en pages, la liste libre, le cookie de schéma, l’encodage du texte et la version de la bibliothèque qui a écrit le fichier. Tous les nombres sont en gros-boutiste.
Deux champs sont à ta disposition. PRAGMA user_version et PRAGMA application_id écrivent directement dans les octets 60 à 71 : après avoir réglé 42 et 1234567890, les octets valent 0000 002a et 4996 02d2. Beaucoup d’outils s’en servent pour marquer le format d’un fichier.
Pages et arbres B dans les fichiers SQLite
Après l’en-tête, le fichier est une suite de pages de même taille, ici 4 096 octets. La page 1 contient l’en-tête et la table de schéma, sqlite_schema. Chaque table est un arbre B classé par son rowid, et chaque index est un autre arbre B. Chaque arbre a une page racine, dont le numéro est dans sqlite_schema. Ce numéro n’a jamais changé pendant mes tests, même quand l’arbre grandissait. La table virtuelle dbstat, compilée dans le paquet Rocky, compte les pages de chaque arbre.
Avec 2 000 lignes, la table a besoin d’une page interne et de 22 feuilles. L’index est un arbre à part, avec une page interne et 14 feuilles. Dans un index, les cellules internes portent aussi des entrées : 13 + 1 987 donnent les 2 000 clés. Une page interne de table ne porte que des clés et des pointeurs vers les enfants.
La forme des arbres mesurée avec dbstat, et la règle de débordement vérifiée avec la documentation.
Une table déclarée WITHOUT ROWID est stockée dans un seul arbre, classé par sa clé primaire. Une table normale avec une clé texte a besoin de deux arbres, la table et un index automatique.
type|name|tbl_name|rootpage
table|kv|kv|45 -- WITHOUT ROWID: one tree
table|kv2|kv2|46 -- rowid table with a TEXT primary key: the table...
index|sqlite_autoindex_kv2_1|kv2|47 -- ...and a second tree for the key
Les grandes valeurs quittent la page. Avec une valeur de 20 000 caractères, la feuille a gardé 3 637 octets et quatre pages de débordement ont porté le reste, 4 092 octets chacune. La documentation donne la règle, avec U = 4 096 et une charge P = 20 005 : X = U – 35 = 4 061 et M = 489. Comme P dépasse X, la partie locale vaut K = M + ((P – M) % (U – 4)) = 3 637. C’est exactement ce que rapporte dbstat.
La page 2 de la base du laboratoire. Les pointeurs croissent depuis le début, les cellules depuis la fin.
Une page d’arbre B a un en-tête de 8 octets sur une feuille (12 sur une page interne), puis un tableau de pointeurs de 2 octets vers les cellules. Les cellules elles-mêmes sont écrites depuis la fin de la page, donc l’espace libre se trouve au milieu. Sur la page 2 du laboratoire, le premier octet est 0d (une feuille de table), il y a 2 cellules, et le contenu commence au décalage 4 038.
L’espace libéré dans la zone de contenu devient un bloc libre, une petite liste chaînée dans la page. Les trous de 1 à 3 octets sont seulement comptés comme octets fragmentés dans l’en-tête.
Un enregistrement, octet par octet
Une cellule contient une taille de données, un rowid et un enregistrement. Les tailles et les rowids sont des entiers variables : 1 à 9 octets, 7 bits à la fois, et le bit de poids fort signifie qu’un octet suit. L’enregistrement commence par la taille de son en-tête, puis un type par colonne. Un type de 13 ou plus et impair désigne du texte, et la longueur vaut (type – 13) / 2. Le type 0 désigne NULL. Ici la colonne id est le rowid : SQLite stocke NULL et garde la valeur dans la clé de la cellule.
1d 01 05 00 17 2f 11 41 6c 69 63 65 61 6c 69 63 65 40 65 78 61 6d 70 6c 65 2e 6e 65 74 46 52
| | | | | | '----- 'Alice' 'alice@example.net' 'FR' (5 + 17 + 2 bytes)
| | | | | '-- serial type 17: text of 2 bytes
| | | | '----- serial type 47: text of 17 bytes
| | | '-------- serial type 23: text of 5 bytes
| | '----------- serial type 0: NULL (id is the rowid)
| '-------------- record header: 5 bytes
'----------------- rowid 1, payload of 29 bytes (1d is the payload size, 01 the rowid)
SELECT rowid, id, name, email, country FROM customers renvoie 1|1|Alice|alice@example.net|FR : le décodage est juste.
UPDATE et DELETE sur le disque
Rien dans les fichiers SQLite ne fonctionne comme un stock d’annulation. SQLite modifie la page elle-même, et il garde l’ancienne version dans le journal ou le WAL. Chaque changement validé incrémente le compteur de changements dans l’en-tête. Un UPDATE de même taille a réécrit la valeur sur place, avec le même nombre de cellules et le même espace libre, mais le compteur est passé de 7 à 8.
-- page 3 is the first leaf of customers
before change counter 7 page 3: ncell 102, payload 3655, unused 25 page_count 39
same-size UPDATE counter 8 page 3: ncell 102, payload 3655, unused 25
larger UPDATE counter 9 page 3: ncell 89, payload 3242, unused 490 page_count 40
L’UPDATE plus grand a fait davantage. La page était presque pleine, donc elle a dû se diviser : la page 3 est passée de 102 à 89 cellules, le fichier a gagné une page et la racine un pointeur. En bref, une ligne qui grossit peut coûter une page.
Un DELETE libère la cellule et transforme son espace en bloc libre. Le paquet Rocky est compilé avec SECURE_DELETE, donc PRAGMA secure_delete renvoie 1 et les octets libérés sont mis à zéro. Avec le réglage désactivé, deux occurrences de l’adresse e-mail sont restées dans le fichier.
$ sqlite3 del.db "PRAGMA secure_delete;"
1
-- page 3 header after DELETE FROM customers WHERE id=5:
cells 101 | first freeblock offset 3921 | fragmented bytes 0
freeblock at 3921: next 0, size 36 bytes
its bytes: 00 00 00 24 00 00 00 00 00 00 00 00 [...] 00 00 00 00 <- zeroed
$ grep -a -c "user5@example.net" del.db # secure_delete on
0
$ sqlite3 del2.db "PRAGMA secure_delete=OFF; DELETE FROM customers WHERE id=5;"
$ grep -a -c "user5@example.net" del2.db # secure_delete off
2
Pourquoi les fichiers SQLite ne rétrécissent pas
Quand une page entière se vide, elle rejoint la liste libre, que décrivent les octets 32 à 39 de l’en-tête. Le fichier garde sa taille. Après un DELETE de 1 800 lignes, 31 pages sur 39 étaient libres, et le fichier faisait toujours 159 744 octets. VACUUM l’a reconstruit en 32 768 octets.
Avec auto_vacuum en mode incrémental, on peut rendre des pages par étapes. L’octet 52 de l’en-tête contient alors la plus grande page racine, et l’octet 64 passe à 1. La page 2 devient une table de pointeurs, faite d’entrées de 5 octets : un type et une page parente. dbstat saute cette page.
Un type de page est documenté mais non testé ici : la page d’octet verrou, qui n’existe que si le fichier dépasse 1 Gio.
Le WAL
Trois trames mesurées dans le WAL, puis des points de contrôle PASSIVE et TRUNCATE.
En mode WAL, les fichiers SQLite changent dans un autre ordre : un écrivain ajoute d’abord des trames au fichier -wal. Chaque trame est un en-tête de 24 octets suivi d’une page. Une trame dont le champ de taille n’est pas nul est un commit. Les lecteurs utilisent le dernier commit et l’index shm pour trouver la bonne version de chaque page.
-rw-r--r--. 1 root root 159744 Nov 2 21:23 wal.db
-rw-r--r--. 1 root root 32768 Nov 2 21:23 wal.db-shm
-rw-r--r--. 1 root root 12392 Nov 2 21:23 wal.db-wal
Un point de contrôle copie les trames dans le fichier principal. Un point de contrôle PASSIVE l’a fait sans toucher au fichier WAL, alors que TRUNCATE l’a vidé. J’ai désactivé le point de contrôle automatique pour garder les trames visibles ; par défaut, il se déclenche à 1 000 pages.
PRAGMA wal_checkpoint(PASSIVE); -> 0|3|3 (busy, frames in the WAL, frames copied) -wal still 12392 bytes
PRAGMA wal_checkpoint(TRUNCATE); -> 0|0|0 -wal now 0 bytes
last connection closes -> demo.db-wal and demo.db-shm are gone
Les quatre moteurs côte à côte
Voici comment les fichiers SQLite se comparent aux trois premières parties, avec les valeurs par défaut.
InnoDB
PostgreSQL
Oracle
SQLite
Où vivent les données
fichiers de tablespace, un par table par défaut
un fichier par table ou index, en segments
datafiles dans des tablespaces
un seul fichier pour tout
Unité d’E/S
page de 16 Ko
page de 8 Ko
bloc, 8 Ko par défaut
page, 4 096 octets ici
Format d’une ligne
index clusterisé : la table est l’arbre de la clé primaire
tas, les index y pointent
bloc avec un répertoire de lignes et un ROWID
arbre B classé par rowid, ou par clé pour WITHOUT ROWID
Anciennes versions
journaux d’annulation, à part
dans la table, nettoyées par VACUUM
tablespace d’annulation
aucune : le journal ou le WAL les garde jusqu’au commit
Sécurité en cas de panne
journal de reprise et doublewrite buffer
WAL et écriture de pages complètes
journaux de reprise
journal de retour arrière ou WAL
Récupérer de la place
reconstruire la table
VACUUM, VACUUM FULL
shrink, move
VACUUM, incremental_vacuum
Le plus proche cousin de SQLite ici est PostgreSQL pour le WAL, et InnoDB pour l’arbre. La grande différence est qu’aucun processus serveur ne se tient entre ton programme et le fichier : le format du fichier et les verrous de fichier du système portent les garanties.
Les sorties enregistrées viennent de mon propre laboratoire : Rocky Linux 10.2, SQLite 3.46.1, WSL2.
Cet article fait partie d’une série de notes d’admin système (étiquette « carnet d’admin »). Série « Dans les fichiers d’une base de données », partie 4/3+1. · ← Article précédent
fail2ban est l’un des outils les plus anciens et les plus répandus de la boîte à outils de l’administrateur : il lit les journaux, compte les échecs d’authentification et bannit au pare-feu les adresses trop insistantes. Simple sur le papier, il est pourtant souvent mal réglé : bannissements de dix minutes qui n’arrêtent personne, réglages écrits dans un fichier écrasé à la mise à jour, jail SSH qui ne voit aucune ligne parce qu’elle lit le mauvais journal, ou administrateur banni de son propre serveur.
Ce tutoriel part de la configuration réelle de mes serveurs Rocky Linux : bannissements progressifs, quatre niveaux de récidive, une jail pour une application maison, et les vérifications qui permettent de savoir que tout cela fonctionne vraiment.
Un bannissement qui grandit à chaque récidive, plafonné à 30 jours.
Trois notions
Notion
Ce que c’est
Où
filtre
les expressions régulières qui reconnaissent un échec dans un journal
filter.d/*.conf
action
ce qui est fait pour bannir et débannir (règle firewalld, nftables, courriel…)
action.d/*.conf
jail
l’association d’un filtre, d’une source de journaux, d’une action et de seuils
jail.local, jail.d/*.local
fail2ban fournit des dizaines de filtres et d’actions prêts à l’emploi. Le travail consiste surtout à écrire les jails : quoi surveiller, avec quels seuils, et comment bannir.
Étape 1 : installer, et ne jamais toucher jail.conf
Le paquet fail2ban-firewalld installe un fichier qui fait de firewalld l’action par défaut. Les fichiers jail.conf et fail2ban.conf appartiennent au paquet et seront remplacés à la prochaine mise à jour : tous vos réglages vont dans les fichiers .local, qui les surchargent clé par clé. Un jail.local peut donc ne contenir que les quelques lignes qui diffèrent.
logtarget : un fichier dédié plutôt que le journal système. Il servira de source à la jail de récidive, et il est plus facile à lire ;
dbfile : la base SQLite où fail2ban garde les bannissements en cours et leur historique. C’est elle qui permet de rétablir les bannissements après un redémarrage, et de savoir combien de fois une adresse a déjà été bannie ;
dbpurgeage : la durée de conservation de l’historique. Par défaut, un jour seulement : trop court pour que les bannissements progressifs et la récidive à six mois aient une mémoire. 35 jours est un compromis ; la récidive longue s’appuie de toute façon sur le fichier journal.
Créez le répertoire et le fichier avant de redémarrer : install -d /var/log/fail2ban && touch /var/log/fail2ban/fail2ban.log. Pensez aussi à adapter la configuration logrotate du paquet à ce nouveau chemin.
ignoreip liste les adresses jamais bannies : la boucle locale, l’adresse publique de chacun de vos serveurs, et celle de votre accès d’administration. Sans elle, quelques tests de connexion ratés suffisent à vous enfermer dehors, et un serveur qui interroge un autre serveur avec un mauvais mot de passe le fera bannir. Mettez la même liste partout.
Les seuils
findtime et maxretry : 5 échecs en 10 minutes déclenchent un bannissement. Plus bas, on bannit des utilisateurs qui se trompent de mot de passe ; plus haut, on laisse aux robots plus d’essais. Pour SSH, si seule l’authentification par clé est autorisée, un utilisateur légitime n’échoue presque jamais et on peut descendre à 3.
Les bannissements progressifs
bantime.increment active l’allongement automatique. La formule multiplie la durée de base par 2 à la puissance du nombre de bannissements précédents de cette adresse (1<<n vaut 2n), plafonné à 220 pour éviter les dépassements, puis par banFactor (1 par défaut). bantime.maxtime limite le résultat à 30 jours :
Bannissement n°
Durée (bantime 1 h)
Durée (jail sshd, 2 h)
1
1 h
2 h
2
2 h
4 h
3
4 h
8 h
5
16 h
32 h
8
128 h (5 j)
256 h (10 j)
10 et plus
plafond : 30 j
plafond : 30 j
L’effet est exactement celui qu’on veut : une erreur ponctuelle coûte une heure, un robot qui revient tous les jours se retrouve banni pour des semaines au bout d’une dizaine de passages.
L’action
banaction = firewallcmd-rich-rules : chaque bannissement devient une règle riche firewalld qui rejette l’adresse sur le port de la jail. banaction_allports est l’action utilisée par les jails qui bannissent sur tous les ports (la récidive). Sur un serveur sans firewalld, utilisez nftables-multiport et nftables-allports.
La jail SSH
backend = systemd est indispensable sur Rocky Linux : sshd écrit dans le journal systemd, et une jail qui lit un fichier ne verrait rien, sans aucune erreur. La jail lit les entrées de l’unité sshd.service. Une seconde subtilité : les versions récentes d’OpenSSH écrivent une partie de leurs messages sous le nom sshd-session. Si fail2ban-client status sshd reste à zéro alors que des échecs apparaissent dans journalctl, vérifiez que le filtre de votre version de fail2ban connaît ce nom (journalmatch).
Étape 4 : punir les récidivistes
La jail recidive lit le propre journal de fail2ban et compte les lignes « Ban ». Une adresse bannie plusieurs fois, quelle que soit la jail, est alors bannie sur tous les ports, pour plus longtemps. Quatre paliers complètent bien les bannissements progressifs :
Chaque palier a sa propre fenêtre : une adresse bannie 4 fois en une journée l’est pour 3 heures sur tous les ports ; 20 fois en six mois, pour un mois. Les fenêtres longues imposent de garder le journal assez longtemps : réglez la rotation pour conserver au moins six mois, compressés. La jail refuse de démarrer si le fichier journal n’existe pas encore, d’où sa création à l’étape 2.
Étape 5 : une jail pour sa propre application
Pour protéger une application qui n’a pas de filtre fourni, écrivez-en un. Prenons une application qui journalise en JSON :
<HOST> : la partie qui capture l’adresse à bannir ; fail2ban la remplace par une expression qui reconnaît IPv4 et IPv6 ;
datepattern : comment lire la date de la ligne. fail2ban ignore les lignes plus vieilles que findtime, il doit donc comprendre la date. Les %% sont des % échappés pour le format du fichier ;
ignoreregex : des lignes à ne jamais compter, même si elles correspondent.
Un filtre qui ne trouve rien (« 0 matched ») sur un journal contenant des échecs connus est faux ; un filtre qui trouve trop (des connexions réussies) bannira vos utilisateurs. Vérifiez aussi la ligne « Date template hits » : si la date n’est pas reconnue, aucune ligne ne sera jamais comptée en production. Pour SSH avec le backend systemd : fail2ban-regex systemd-journal sshd.
Étape 7 : vérifier et exploiter
fail2ban-client -t # la configuration est-elle valide ?
systemctl restart fail2ban
fail2ban-client status # les jails actives
fail2ban-client status sshd # échecs comptés, adresses bannies
fail2ban-client get sshd banip --with-time # avec la fin de chaque bannissement
fail2ban-client set sshd unbanip 192.0.2.66
fail2ban-client unban --all # en cas d'urgence
firewall-cmd --list-rich-rules | grep -c reject
Faites le test pour de vrai, depuis une adresse qui n’est pas dans ignoreip (une connexion mobile, par exemple) : cinq mots de passe faux, puis vérifiez que la sixième tentative est refusée et que l’adresse apparaît dans le statut de la jail.
À quoi ça ressemble après quelques mois
Mon serveur web le 2 novembre 2026 (fail2ban 1.1.0). La jail SSH lit le journal systemd, et quatre jails de récidive s’empilent derrière elle :
# fail2ban-client status
|- Number of jail: 5
`- Jail list: recidive1, recidive2, recidive3, recidive4, sshd
# fail2ban-client status sshd
|- Filter
| |- Currently failed: 0
| |- Total failed: 6
| `- Journal matches: _SYSTEMD_UNIT=sshd.service + _COMM=sshd + _COMM=sshd-session
`- Actions
|- Currently banned: 2
|- Total banned: 2
# for j in recidive1 recidive2 recidive3 recidive4; do ...; done
recidive1: bantime=10800 findtime=86400 maxretry=4
recidive2: bantime=86400 findtime=604800 maxretry=7
recidive3: bantime=604800 findtime=2592000 maxretry=10
recidive4: bantime=2592000 findtime=15552000 maxretry=20
Total failed: 6 depuis le redémarrage du matin. Les connexions par mot de passe étant désactivées, presque personne n’arrive même jusqu’à un échec : la plupart des robots abandonnent dès l’échange de clés. Les compteurs repartent de zéro avec le service ; l’historique est dans la base.
Journal matches cite à la fois sshd et sshd-session : depuis OpenSSH 9.8, les messages d’authentification viennent du processus sshd-session. Un filtre qui ne regarde que _COMM=sshd ne compte plus rien, sans le dire, sur un système récent.
Les quatre jails de récidive lisent le journal de fail2ban lui-même : 4 bannissements en un jour donnent 3 heures, 7 en une semaine une journée, 10 en un mois une semaine, 20 en six mois un mois.
Les bannissements progressifs se lisent dans la base SQLite de fail2ban, qui garde chaque adresse avec son nombre de bannissements :
# sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 \
"select jail, bancount, count(*) from bips group by jail, bancount"
recidive3|1|1
sshd|1|21
sshd|3|1
sshd|7|1
sshd|10|1
# sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 "select max(bantime) from bips where jail='sshd'"
2592000
Vingt et une adresses ont été bannies une fois et ne sont jamais revenues : c’est l’essentiel du trafic, des robots opportunistes. Trois sont revenues, dont une dix fois. Pour celle-là, chaque bannissement a doublé la durée du précédent jusqu’à atteindre bantime.maxtime = 720h : 2 592 000 secondes, trente jours, le plafond. C’est tout l’intérêt de la progression : la première faute coûte peu, les obstinés finissent exclus un mois, sans liste manuelle à entretenir.
Les pannes fréquentes
La jail ne compte rien
Mauvais backend (fichier au lieu de systemd), mauvais chemin de journal, ou date non reconnue. fail2ban-client status <jail> montre la source lue ; fail2ban-regex dit si les lignes correspondent.
Les adresses sont bannies mais continuent de passer
L’action ne correspond pas au pare-feu réellement utilisé (une action iptables sur un système nftables pur, par exemple), ou le trafic arrive par un mandataire inverse et c’est l’adresse du mandataire qui est bannie. Derrière un mandataire, l’application doit journaliser l’adresse réelle du client (X-Forwarded-For), et le bannissement doit avoir lieu sur la machine qui reçoit la connexion.
Une adresse bloquée alors que fail2ban ne la bannit pas
Si CrowdSec tourne aussi, il a peut-être pris sa propre décision : cscli decisions list --ip …. Les deux se complètent bien (fail2ban réagit à vos propres journaux, CrowdSec ajoute des scénarios plus riches et une liste communautaire), mais il faut penser à regarder des deux côtés.
fail2ban met longtemps à démarrer
À chaque démarrage, il rétablit les bannissements en cours depuis sa base et relit la fin des journaux. Avec des milliers d’entrées et des règles riches firewalld, cela peut prendre une minute. Une action nftables par ensemble (nftables-allports) est bien plus rapide quand le nombre de bannissements grossit.
Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »). Série « Pas à pas », tutoriel 11/77. · ← Tutoriel précédent · Tutoriel suivant → (à paraître le 16/11) · English version
En bref : j’ai fait un kill -9 sur les processus postgres : une colonne identity PostgreSQL est passée de l’id 9 à 37, parce que les séquences PostgreSQL journalisent 32 valeurs d’avance dans le WAL. Rollbacks et insertions en échec brûlent aussi des valeurs, et une colonne BY DEFAULT ignore les id explicites. Préférer GENERATED ... AS IDENTITY à serial, et ne jamais compter sur des id continus.
PostgreSQL 18.6, cluster jetable sous Rocky Linux 10.2. Partie 1 sur 5 (PostgreSQL, MariaDB, Oracle, SQL Server, résumé). Toutes les sorties viennent de mes propres tests.
Un kill -9 sur PostgreSQL 18.6 : 27 valeurs réservées et jamais distribuées.
Trois façons d’obtenir une valeur générée
CREATE TABLE t_ident (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, v text);
CREATE TABLE t_serial (id serial PRIMARY KEY, v text);
CREATE SEQUENCE s1;
CREATE TABLE t_seq (id int PRIMARY KEY DEFAULT nextval('s1'), v text);
Chaque INSERT ... RETURNING id renvoie 1 sur une table neuve. Le catalogue montre la différence :
column_name | column_default | is_identity | identity_generation
-------------+--------------------------------------+-------------+---------------------
id | | YES | ALWAYS
id | nextval('s1'::regclass) | NO |
id | nextval('t_serial_id_seq'::regclass) | NO |
Une colonne identity n’a pas d’expression par défaut ; la séquence est attachée à la colonne (pg_get_serial_sequence('t_ident','id') renvoie public.t_ident_id_seq). serial est un raccourci : colonne entière plus défaut appelant nextval.
ALWAYS ou BY DEFAULT
Avec GENERATED BY DEFAULT, une valeur explicite est acceptée mais ne fait pas avancer le compteur :
INSERT INTO t_by(id, v) VALUES (2, 'explicit 2'); -- ok
INSERT INTO t_by(v) VALUES ('default 1') RETURNING id; -- 1
INSERT INTO t_by(v) VALUES ('default 2') RETURNING id; -- ERROR: duplicate key ... (id)=(2) already exists
INSERT INTO t_by(v) VALUES ('default 3') RETURNING id; -- 3
L’insertion en échec a brûlé la valeur 2 du compteur. Avec GENERATED ALWAYS, une valeur explicite est refusée sauf demande expresse :
ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
Après OVERRIDING SYSTEM VALUE VALUES (50, ...), l’insertion par défaut suivante a renvoyé 1 : le compteur est indépendant des données.
Idem avec une violation d’unicité : id 1, erreur sur le doublon, puis 3. Un compteur sans trou demande une autre conception (table de compteur verrouillée), au prix de la sérialisation des écritures.
Concurrence : l’ordre des id n’est pas l’ordre des commits
La session A insère en premier et valide 3 secondes après B ; la session B insère en second et valide aussitôt :
A got id 1
B got id 2
B: committed 08:18:48.652
A: committed 08:18:51.154
La ligne de B est visible avant celle de A, bien que son id soit plus grand. Ne pas utiliser « max(id) déjà vu » comme curseur de réplication ou de polling.
CACHE est par session
CREATE SEQUENCE s_cache CACHE 10;
same session twice : 1|2
new session : 11
another new session: 21
Avec les séquences PostgreSQL, chaque session réserve un bloc de 10. Les valeurs de sessions différentes s’entrelacent et des trous apparaissent dès qu’une session se termine.
Limites et CYCLE
ERROR: nextval: reached maximum value of sequence "s_small" (3)
Après ALTER SEQUENCE s_small CYCLE, la valeur suivante est 1. Une identity sur smallint s’arrête à 32767 avec la même erreur sur t_small_id_seq. Les identity integer s’arrêtent à 2147483647 : prendre bigint pour les tables à longue vie.
Redémarrage et crash
Trois lignes insérées, la dernière supprimée, redémarrage propre :
sequence before the restart: 3|30|t (last_value | log_cnt | is_called)
sequence after the restart: 3|30|t
new id = 4
Puis cinq lignes de plus (dernier id 9), kill -9 de tous les processus postgres, redémarrage :
sequence before the crash: 9|27|t
sequence after the crash: 36|0|t
new id = 37
ids now: 1,2,4,5,6,7,8,9,37
log_cnt est le nombre de valeurs encore couvertes par le dernier enregistrement WAL. PostgreSQL écrit un enregistrement WAL pour 32 valeurs ; après la récupération sur crash, il repart de la fin de la plage réservée (9 + 27 = 36), donc l’id suivant est 37. Un arrêt propre écrit l’état exact, sans trou.
Où lire l’état des séquences PostgreSQL
SELECT * FROM pg_sequences; -- départ, incrément, max, cache_size, cycle, last_value
SELECT last_value, log_cnt, is_called FROM t_ident_id_seq;
SELECT attrelid::regclass, attname, attidentity FROM pg_attribute WHERE attidentity <> ''; -- 'a' ALWAYS, 'd' BY DEFAULT
Suricata est un moteur de détection d’intrusion réseau : il examine les paquets qui entrent et sortent d’une machine, reconstitue les connexions, comprend les protocoles (HTTP, TLS, DNS, SSH…) et compare le tout à des dizaines de milliers de règles décrivant des attaques connues. En mode IPS, il peut aussi bloquer : un paquet qui correspond à une règle de blocage n’arrive jamais à destination.
Ce tutoriel installe Suricata en IPS sur un serveur Rocky Linux exposé à Internet, avec NFQUEUE. C’est le mode le plus utile sur un serveur seul, et aussi le plus délicat : Suricata se retrouve sur le chemin de chaque paquet. Je décris donc aussi, en détail, les pannes que j’ai rencontrées, dont une qui bloquait des connexions locales en silence pendant des semaines.
Le chemin d’un paquet et les réglages qui évitent les mauvaises surprises.
IDS ou IPS
Mode
Fonctionnement
Risque
IDS (af-packet)
Suricata reçoit une copie des paquets et alerte
aucun pour le trafic : au pire, des alertes manquées
IPS (NFQUEUE)
le noyau confie chaque paquet à Suricata, qui l’accepte ou le jette
Suricata est sur le chemin : une erreur bloque le trafic
Commencez en IDS si vous découvrez l’outil. Passez en IPS quand vous savez lire ses alertes et que vous avez éliminé les faux positifs les plus bruyants.
Étape 1 : installer
dnf install epel-release
dnf install suricata
suricata --build-info | grep -E 'NFQueue|AF_PACKET' # les deux doivent être « yes »
Deux modules noyau sont nécessaires pour NFQUEUE : nfnetlink_queue (la file elle-même) et xt_NFQUEUE (la cible utilisée par les règles de pare-feu). Le second est souvent oublié : la règle échoue alors avec un message peu clair sur un module manquant. Chargez-les et rendez-les permanents :
HOME_NET : les adresses que vous protégez. Beaucoup de règles en dépendent (« trafic venant de l’extérieur vers HOME_NET ») ; une valeur fausse donne à la fois des faux positifs et des attaques manquées ;
nfq.mode: accept : Suricata rend son verdict (accepter ou jeter) et le paquet poursuit ensuite son chemin normal dans le pare-feu ;
fail-open: yes : si la file déborde parce que Suricata ne suit pas, les paquets passent au lieu d’être jetés. Sur un serveur seul, mieux vaut laisser passer quelques paquets non inspectés que couper le service ;
flow-timeouts.tcp.established: 7200 : le point qui m’a coûté le plus cher, détaillé plus bas. Par défaut, Suricata oublie une connexion TCP inactive au bout d’une heure ; une connexion qui reste ouverte plus longtemps sans trafic voit ses paquets suivants traités comme des paquets « hors connexion », et parfois jetés ;
midstream: true : Suricata accepte de prendre en compte une connexion dont il n’a pas vu le début, par exemple après son propre redémarrage. Sans cela, toutes les connexions établies avant le démarrage sont considérées comme invalides ;
memcap : les plafonds de mémoire pour le suivi des flux et la reconstitution des données. Trop bas, Suricata abandonne l’inspection des connexions sous charge ; surveillez les compteurs memcap dans les statistiques ;
eve.json : le journal structuré, une ligne JSON par événement. C’est ce fichier qu’on envoie à un SIEM.
Étape 3 : les règles
suricata-update # télécharge le jeu ET Open (gratuit)
suricata-update list-sources
suricata-update enable-source et/open
ls -l /var/lib/suricata/rules/suricata.rules
suricata-update assemble toutes les règles dans un seul fichier, en appliquant vos fichiers de modification. Deux d’entre eux sont essentiels en IPS :
# /etc/suricata/drop.conf : ces familles de règles bloquent au lieu d'alerter
re:classtype:trojan-activity
re:classtype:command-and-control
re:classtype:domain-c2
re:classtype:exploit-kit
re:classtype:coin-mining
# /etc/suricata/disable.conf : protocoles industriels absents ici
re:dnp3
re:modbus
Par défaut, toutes les règles alertent seulement. drop.conf transforme en blocages celles dont la catégorie ne laisse pas de doute : communication avec un serveur de commande, cheval de Troie, minage de cryptomonnaie. Évitez de tout passer en blocage : une règle générique de « trafic suspect » déclenchée par une application légitime coupe cette application sans prévenir. disable.conf retire les règles inutiles, ce qui économise mémoire et processeur.
Planifiez une mise à jour quotidienne par un timer systemd qui lance suricata-update, puis demande à Suricata de recharger ses règles sans redémarrer (suricatasc -c reload-rules).
Puis les règles de pare-feu, avec firewalld en mode « direct » :
# 1. la boucle locale ne passe jamais par Suricata (priorité -1 : avant tout)
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT -1 -i lo -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT -1 -o lo -j ACCEPT
# 2. tout le reste est confié à la file 0 ; --queue-bypass : si Suricata est arrêté, le trafic passe
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT 0 -j NFQUEUE --queue-num 0 --queue-bypass
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 -j NFQUEUE --queue-num 0 --queue-bypass
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -j NFQUEUE --queue-num 0 --queue-bypass
# mêmes règles en ipv6
firewall-cmd --reload
–queue-bypass : sans lui, si Suricata est arrêté ou plante, plus aucun paquet ne passe, SSH compris. Avec lui, le noyau laisse passer le trafic quand personne n’écoute la file. C’est la ligne qui évite d’avoir à appeler la console de secours de l’hébergeur ;
la boucle locale exclue : la leçon la plus coûteuse de ce tutoriel, détaillée ci-dessous ;
l’ordre : les règles directes de priorité -1 passent avant celles de priorité 0.
La leçon de la boucle locale
Pendant six semaines, un service de mon serveur (un gestionnaire de tâches qui parle à Redis sur 127.0.0.1) s’est figé à intervalles irréguliers, toujours exactement 31 minutes et 40 secondes, avant de repartir. Les journaux de l’application ne montraient rien, ceux de Redis non plus. La cause : Suricata inspectait aussi le trafic de la boucle locale. Une longue connexion inactive dépassait le délai de suivi des flux ; au réveil, ses paquets n’appartenaient plus à aucune connexion connue de Suricata, qui les jetait ; il fallait attendre que TCP abandonne et se reconnecte.
Le diagnostic est venu d’eve.json : chaque paquet jeté y apparaît avec sa raison.
Trois corrections, toutes nécessaires : exclure la boucle locale (le trafic entre processus d’une même machine n’a rien à faire dans un IPS), allonger le délai TCP à deux heures, et activer midstream. Retenez le symptôme : une connexion longue qui se fige un temps fixe et reproductible, sans erreur nulle part, c’est presque toujours un équipement intermédiaire (pare-feu, IPS, traduction d’adresses) qui a oublié la connexion.
Le piège des cartes réseau
Les cartes et pilotes modernes regroupent plusieurs paquets en un seul « super-paquet » avant de les remettre au noyau (GRO, LRO). Suricata voit alors des paquets plus gros que la taille réelle du réseau, et sa reconstitution des connexions s’en trouve faussée. Désactivez ces optimisations sur l’interface surveillée, de façon permanente, par NetworkManager plutôt que par un simple ethtool -K qui serait annulé à la reconnexion suivante :
nmcli connection modify eth0 ethtool.feature-gro off ethtool.feature-lro off \
ethtool.feature-gso off ethtool.feature-tso off
nmcli connection up eth0
ethtool -k eth0 | grep -E 'generic-receive|large-receive'
Étape 5 : démarrer et vérifier
suricata -T -c /etc/suricata/suricata.yaml # test de configuration et des règles
systemctl enable --now suricata
tail -f /var/log/suricata/suricata.log # « engine started »
curl -s http://testmynids.org/uid/index.html # doit déclencher l'alerte 2100498
jq -c 'select(.event_type=="alert") | .alert.signature' /var/log/suricata/eve.json | tail -3
Vérifiez aussi que le trafic normal passe toujours : connexion SSH neuve, site web, envoi de courrier, résolution DNS. Puis surveillez les statistiques pendant quelques jours :
Suricata 8.0.8 en mode IPS sur mon serveur web, lu par le socket de contrôle le 2 novembre 2026 après 75 minutes de fonctionnement. suricatasc donne les compteurs en direct, sans attendre la prochaine ligne de statistiques dans eve.json :
NFQ#0 : Suricata est branché sur la file netfilter 0, il voit donc les paquets au passage et peut les jeter. En mode IDS, cette liste montrerait une interface réseau.
53 325 règles chargées, 0 en échec : un rules_failed non nul signifie le plus souvent qu’une règle utilise un mot-clé que votre version ne connaît pas, après une mise à jour des règles ou du moteur.
771 paquets bloqués, 769 pour stream_error, 0 par les règles : c’est le tableau normal d’un serveur web. Les rejets par règle sont rares ; la plupart des paquets jetés n’ont aucun sens pour le flux TCP auquel ils prétendent appartenir (retransmissions après la fermeture, numéros de séquence faux, sommes de contrôle invalides). Si ce nombre grimpe d’un coup, regardez les événements drop dans eve.json : la leçon de la boucle locale, plus haut, a commencé par exactement ce genre de pic.
23 alertes en 75 minutes, aucune bloquante : des anomalies de protocole (trop de SYN pour une même poignée de main, protocoles applicatifs incohérents, horodatages invalides) journalisées pour le SIEM. Pourtant, 23 665 des règles chargées sont réglées pour bloquer, par classe (chevaux de Troie, serveurs de commande, kits d’exploitation, minage…) grâce à drop.conf : pendant cette heure, aucune ne s’est déclenchée. Une règle de blocage qui ne se déclenche jamais, c’est l’état attendu, pas le signe d’une panne.
Faire taire un faux positif
Une règle qui se déclenche sur un trafic légitime se neutralise, du plus précis au plus large : seuil ou suppression pour une adresse dans threshold.config, puis désactivation de la règle par son numéro dans disable.conf. Ne modifiez jamais le fichier de règles directement : la prochaine mise à jour l’écraserait.
eve.json est lisible par Wazuh, Elastic, Graylog et la plupart des SIEM. Avec un agent Wazuh, quelques lignes suffisent (format json, chemin du fichier) et les alertes de Suricata apparaissent à côté des journaux système, ce qui permet de rapprocher une alerte réseau d’une connexion SSH ou d’un changement de fichier. Pensez à la rotation : eve.json grossit vite, et logrotate doit envoyer à Suricata le signal de rouvrir le fichier.
Mettre à jour Suricata
Après chaque mise à jour du paquet, relancez suricata -T avant le redémarrage : une option renommée ou une règle incompatible empêche le démarrage, et grâce à --queue-bypass le trafic passera alors sans inspection, sans que rien ne casse visiblement. C’est le revers de la sécurité de fonctionnement : surveillez l’état du service, pas seulement le trafic.
J’ai lancé un script en lecture seule sur ma propre machine pour regarder à l’intérieur d’une table LanceDB, et un assistant IA (Claude, d’Anthropic) m’a aidée à écrire ce script et à rédiger cet article. Les chiffres ci-dessous sont la sortie réelle de ce lancement, avec LanceDB 0.41.0 et pyarrow 26.0.1. J’ai remplacé le nom de la table et les chemins. C’est le premier article d’une série sur LanceDB vu par une DBA.
Réponse rapide
Une table LanceDB est un répertoire, et rien d’autre : des fichiers de données dans data/, un manifeste par version dans _versions/, un fichier de transaction par validation dans _transactions/, et un répertoire _indices/ dès qu’on construit un index. Ma table contient 1 130 595 lignes de vecteurs à 1024 dimensions et pèse 13,4 Gio sur disque, mais sa version courante, la 265, ne référence qu’un seul fichier de données de 5,8 Gio. Les 7,6 Gio restants environ sont répartis dans 259 anciens fichiers de données dont aucune version courante n’a besoin, et ils restent sur disque tant que personne ne nettoie les anciennes versions. Chaque ajout crée un nouveau fichier de données et une nouvelle version, c’est ainsi qu’une table atteint 265 versions et 260 fichiers de données, et la dernière version a fusionné 226 fragments en un sans supprimer les anciens fichiers.
Versions 263 à 265 et part de data/ que lit la version courante.
Ce que contient le répertoire d’une table LanceDB
La première partie de mon script parcourt le répertoire de la table LanceDB, docs_index.lance, et n’a pas besoin de LanceDB. L’arborescence contient trois sous-répertoires. La documentation de l’agencement des fichiers en liste d’autres, dont deux, _indices/ et _deletions/, sont absents de ma table.
Répertoire
Ce que dit la documentation
Ce que j’ai trouvé
data/
fichiers de données (*.lance)
260 fichiers, 13,4 Gio, médiane 21,6 Mio, le plus gros 5,8 Gio
_versions/
manifestes (*.manifest) et un latest_version_hint.json optionnel
265 manifestes, 2,4 Mio au total, plus un fichier d’indice de 15 octets
_transactions/
fichiers de transaction (*.txn)
265 fichiers, 56,7 Kio au total, médiane 132 octets
_indices/
un dossier par index
absent
La table compte 791 fichiers en tout. Les statistiques donnent num_indices: 0 et num_deleted_rows: 0, ce qui correspond à l’absence de ces deux répertoires : je n’ai jamais construit d’index et jamais supprimé de ligne.
Une version, c’est un manifeste et une transaction
Le manifeste est le cœur du format. La documentation le décrit comme la description immuable d’une version unique, avec le schéma, la liste des fragments, le numéro de version, un horodatage et une référence à la transaction qui l’a créée. Ma table a 265 manifestes pour 265 versions, donc exactement un manifeste par version, et 265 fichiers de transaction, donc chaque validation en a laissé un.
Les noms portent de l’information. Un fichier de transaction s’appelle {read_version}-{uuid}.txn, où le nombre est la version à partir de laquelle la transaction a été construite, et le plus gros de ma table est 263-45522501-…txn, de 20,1 Kio. Les manifestes déroutent au début, mais la logique est simple : le fichier de la version 265 est 18446744073709551350.manifest. Ce nombre est 18446744073709551615, le plus grand entier non signé sur 64 bits, moins 265, et la version 1 est 18446744073709551614.manifest. Les noms décroissent donc quand les versions augmentent. Les pages de documentation que j’ai lues n’expliquent pas ce schéma, donc ma lecture n’est qu’une hypothèse : la version la plus récente arrive en premier dans un listing de répertoire. Le fichier latest_version_hint.json fait 15 octets. La documentation parle d’une « simple optimisation », destinée à accélérer la recherche de la dernière version sur les stockages où lister un répertoire coûte cher.
Chaque ajout crée un fragment
Les cinq dernières versions montrent comment la table a grossi. list_versions a renvoyé ces lignes, que j’ai réduites aux champs intéressants et alignées en tableau :
De la 261 à la 263, chaque version ajoute un fichier de données et quelques milliers de lignes : 4 128 lignes pour le premier ajout et 3 435 pour le second. C’est l’opération Append de la documentation, qui ajoute de nouveaux fragments sans modifier les données existantes. De petits ajouts produisent donc de petits fichiers de données, et une table alimentée par petits lots accumule les fragments : la mienne en avait 226, et le fichier de données médian fait 21,6 Mio.
Les versions 264 et 265 sont séparées de 33 millisecondes, et le nombre de lignes ne change pas. J’ai décodé leurs transactions avec les définitions protobuf publiées dans le dépôt Lance. La version 264 est une transaction reserve_fragments de 46 octets : elle réserve un identifiant de fragment, et le max_fragment_id du manifeste passe de 258 à 259 alors que les 226 fragments restent les mêmes. La version 265 est une transaction rewrite, le compactage : elle remplace les 226 anciens fragments par un nouveau fragment, d’identifiant 259, qui contient les 1 130 595 lignes. La documentation décrit une Rewrite comme une opération qui « réorganise les données sans modification sémantique », et c’est ce que montre le fichier. Les versions 21 et 33 de la même table contiennent des réécritures plus anciennes, de 21 fragments en un et de 6 en un, et la première version est un overwrite de 329 octets.
La version courante d’une table LanceDB est plus petite que la table
Les statistiques de la version 265 donnent la taille de ce dont la table a réellement besoin :
Cela fait 5,8 Gio, soit environ 5 487 octets par ligne. La colonne de vecteurs contient 1024 flottants de 4 octets, donc 4 096 octets par ligne, et les 1 391 octets restants par ligne contiennent le texte, le titre, le chemin et le numéro de segment. Le répertoire data/ pèse 13,4 Gio, ce qui laisse environ 7,6 Gio dans 259 fichiers de données que la version 265 ne référence pas. Ce sont les fragments des versions précédentes, toujours là parce que rien ne les a supprimés. Dans cette table, la fusion des fragments n’a pas réduit le répertoire : le nouveau fichier a été ajouté et les anciens sont restés en place, toujours référencés par les anciennes versions.
Pourquoi le manifeste d’un seul fragment pèse 20,5 Kio
Les manifestes grossissent avec le nombre de fragments : 628 et 736 octets pour les premiers, 20,4 Kio à 226 fragments, soit environ 90 octets par fragment. La version 265 n’a qu’un fragment et son fichier fait pourtant 21 031 octets, et je l’ai décodé pour comprendre pourquoi. Le fichier commence par une longueur sur 4 octets et une copie de la transaction, puis une seconde longueur et le manifeste proprement dit, puis un pied de 16 octets qui se termine par la marque LANC. Le manifeste proprement dit fait 391 octets. Les 20 620 autres octets sont la transaction intégrée, la même réécriture que celle stockée dans 263-45522501-…txn, qui liste les 226 fragments qu’elle remplace, à 88,6 octets chacun en moyenne. À titre de comparaison, les manifestes des versions 263 et 264 font chacun 20,8 Kio de liste de fragments, plus une petite transaction intégrée de 136 et 50 octets.
Les 20 Kio du dernier manifeste ne décrivent donc pas la version courante. Ils enregistrent ce que le compactage a retiré de la table, et ils sont présents deux fois sur disque, dans le manifeste et dans le fichier de transaction. Ce champ s’appelle transaction_section dans le protobuf. Je n’ai pas trouvé cela décrit dans les pages du format que j’ai lues.
Ce que je n’ai pas testé
Je n’ai lancé aucune opération d’écriture sur la table d’origine, parce que je voulais la laisser intacte : ni compactage, ni nettoyage des anciennes versions, ni index. Le script ne fait que lister des fichiers et appeler count_rows, schema, list_versions, list_indices et stats. J’ai décodé les manifestes et les fichiers de transaction uniquement pour les versions 1, 21, 33 et 263 à 265, et uniquement sur une copie de la table. Je n’ai pas non plus mesuré de temps de requête, qui dépend de l’index et viendra dans un prochain article.
systemd-analyze security est la commande que je lance avant de modifier quoi que ce soit dans une unité systemd. Elle attribue à chaque service une note d’exposition, liste les options de sandboxing que l’unité n’utilise pas encore et indique par où commencer. Cet article est la partie 1 d’une série de huit sur le durcissement de systemd, directive par directive. Je couvre ici la méthode : comment fonctionnent les drop-ins, comment lire la sortie de systemd-analyze security, comment tester une modification sans risque et quels services traiter en premier.
Les parties suivantes passent en revue les directives, une famille à la fois. Pour chaque directive, j’explique ce qu’elle fait, quels services de ma stack elle touche et ce qu’elle peut casser. Cette première partie reste sur la méthode, parce que toutes les suivantes s’appuient dessus. Aucune expérience de systemd n’est nécessaire : chaque étape vient avec la commande complète, un commentaire sur chaque ligne et un petit lab sur un service de démonstration inoffensif.
Banc de test. Toutes les sorties de cet article viennent d’exécutions réelles sur Rocky Linux 10.2 avec systemd 257, dans une machine virtuelle WSL2 (noyau 6.18.54.1-microsoft-standard-WSL2, session root). SELinux y est désactivé et certaines fonctions du noyau peuvent différer d’un serveur classique. Je le signale à chaque endroit où cela compte. Quand je coupe des lignes d’une sortie, je les remplace par [...], et je marque non testées les commandes que je n’ai pas lancées.
Avant de commencer
Lisez d’abord cette courte liste. Elle vous évite l’erreur classique : durcir un service de production du premier coup.
Une machine de test. Utilisez une machine virtuelle ou un serveur de rechange sous systemd. J’ai tout exécuté sur Rocky Linux 10.2 uniquement ; ces commandes appartiennent à systemd lui-même et devraient se comporter de la même façon sur d’autres distributions, mais je ne l’ai pas testé.
Un shell avec les droits sudo. Chaque commande qui modifie le système commence par sudo.
Un service inoffensif pour vos premiers essais. Le lab ci-dessous crée un service de démonstration nommé demo-hardening-sandbox.service : vous ne touchez donc à aucun vrai service.
Un moyen de tout annuler. Chaque changement vit dans un seul fichier drop-in, et supprimer ce fichier annule le changement. Le lab se termine par une étape de nettoyage qui supprime tout ce qu’il a créé.
Un systemd récent. Plusieurs options de systemd-analyze security sont apparues dans la version 250 : vérifiez donc la vôtre avec systemctl --version.
Un court glossaire
Terme
Sens dans cet article
Unité
Un fichier qui décrit ce que systemd gère. Une unité de type service se termine par .service.
Service
Une unité qui lance un programme : un démon qui reste actif, ou une commande qui s’exécute une fois puis se termine.
Drop-in
Un petit fichier .conf placé dans un répertoire .d, qui modifie une unité sans toucher au fichier d’origine.
Directive
Un réglage d’un fichier d’unité, par exemple ProtectSystem=strict.
Sandboxing
Les directives qui limitent ce qu’un service peut voir et faire.
Namespace
Une fonction du noyau qui donne à un processus sa propre vue du système, par exemple ses propres points de montage. Plusieurs directives s’appuient dessus.
Capability
Une part des pouvoirs de root, par exemple ouvrir un port bas, que le noyau peut accorder séparément.
Appel système
Une requête d’un programme au noyau, par exemple ouvrir un fichier.
Niveau d’exposition
La note que systemd-analyze security calcule pour une unité.
Unité transitoire
Une unité temporaire que systemd-run crée à la volée ; elle disparaît quand elle s’arrête.
En un coup d’œil
Étape
Commande
Ce qu’elle vous apprend
Noter tous les services actifs
systemd-analyze security
Une ligne par service chargé, avec un niveau d’exposition
Détailler un service
systemd-analyze security UNITÉ
Chaque réglage, appliqué (✓) ou non (✗), avec son exposition
Contrôler un fichier avant de le déployer
systemd-analyze verify FICHIER puis security --offline=true FICHIER
Les erreurs de syntaxe, puis la revue hors ligne
Changer une directive
systemctl edit UNITÉ
Un fichier drop-in, rechargé à l’enregistrement
Expérimenter sans modifier de fichier
systemd-run -p DIRECTIVE=VALEUR ...
Une unité temporaire avec les propriétés de votre choix
Lire un échec
systemctl status UNITÉ et journalctl -u UNITÉ
Le code de sortie, puis les messages du service lui-même
Revenir en arrière
systemctl revert UNITÉ
Supprime les drop-ins de l’unité
Pourquoi durcir les services avec systemd
Un service qui n’a aucune directive de sandboxing tourne sans aucune de ces protections. Le manuel systemd.exec qualifie les options de sandboxing de moyen efficace de limiter l’exposition du système, et recommande d’en activer autant que possible sans empêcher le processus de fonctionner. Mon service de démonstration montre le point de départ : sans aucun sandboxing, systemd-analyze security lui attribue 9,6 et l’étiquette UNSAFE, avec 75 réglages non appliqués et 4 appliqués (vous le mesurerez vous-même dans le lab).
Le sandboxing ne remplace pas les autres couches de défense. En revanche, il en ajoute une qui ne coûte presque rien : quelques lignes dans un drop-in. Deux limites du manuel valent d’être retenues. D’abord, beaucoup d’options sont désactivées sans bruit sur les systèmes où le mécanisme sous-jacent n’existe pas. Ensuite, certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.
Comment une unité et ses drop-ins s’assemblent
Un drop-in est la façon la plus sûre de modifier un service. Au lieu d’éditer le fichier installé par un paquet, vous ajoutez un petit fichier à côté, et systemd fusionne les deux. Le schéma montre quel fichier l’emporte.
Quel fichier l'emporte quand une unité systemd et ses drop-ins sont fusionnés.
Où vivent les drop-ins et lequel gagne
Pour une unité nommée foo.service, systemd cherche un répertoire foo.service.d/. Il ne lit que les fichiers qui se terminent par .conf et les fusionne par ordre alphanumérique après avoir lu le fichier principal de l’unité. Chaque drop-in doit contenir son propre en-tête de section, par exemple [Service].
Emplacement
Rôle
Priorité
/usr/lib/systemd/system/
Unités et drop-ins fournis par les paquets
La plus basse
/run/systemd/system/
Changements d’exécution, perdus au redémarrage
Intermédiaire
/etc/systemd/system/
Vos propres unités et drop-ins
La plus haute
Le manuel ajoute trois règles. Les drop-ins de /etc/ l’emportent sur ceux de /run/, qui l’emportent sur ceux de /usr/lib/. Les drop-ins l’emportent sur les fichiers d’unité, où qu’ils se trouvent. À niveau égal, les fichiers de noms différents s’appliquent dans l’ordre lexicographique. Cette dernière règle explique pourquoi je donne à mes fichiers un nom qui indique leur rôle, comme hardening.conf.
Créer, vérifier et annuler un drop-in
Le lab ci-dessous lance les vraies commandes dans l’ordre, avec leur sortie : systemctl edit pour créer un drop-in, systemctl cat pour voir ce que systemd lit, systemctl show pour voir ce qu’il a chargé, et systemctl revert pour l’annuler. Un point mérite l’attention. systemctl edit recharge la configuration à votre place, et un rechargement conserve l’état d’exécution. Un service déjà lancé garde donc son ancien sandbox jusqu’à son redémarrage. L’erreur 4 du lab le démontre.
Lire la sortie de systemd-analyze security
L’outil systemd-analyze security analyse les réglages de sécurité et de sandboxing des unités de service. Il ne démarre rien. Il lit les réglages, puis les compare aux protections que systemd sait appliquer.
Lire la sortie de systemd-analyze security, ligne par ligne.
Un service ou tout le système
Sans nom d’unité, systemd-analyze security inspecte tous les services chargés qui tournent durablement et affiche un tableau concis, une ligne par unité. Avec un ou plusieurs noms d’unité, il affiche une analyse détaillée de chacune. Voici le tableau de tout le système, coupé après quatre lignes :
systemd-analyze security --no-pager
UNIT EXPOSURE PREDICATE HAPPY
NetworkManager.service 7.8 EXPOSED 🙁
auditd.service 9.4 UNSAFE 😨
cockpit-wsinstance-http.service 5.4 MEDIUM 😐
cockpit.service 6.2 MEDIUM 😐
[...]
Les colonnes sont l’unité, son niveau d’exposition, une étiquette (le « predicate ») et un visage. Sur le banc de test, j’ai vu les étiquettes MEDIUM, EXPOSED et UNSAFE ; le manuel ne les définit pas. Fiez-vous au nombre, et voyez l’étiquette comme un repère visuel rapide.
Le tableau, ligne par ligne
Pour un service, la sortie a trois colonnes : NAME, DESCRIPTION et EXPOSURE. Chaque ligne commence par une marque : ✗ signifie que le réglage n’est pas appliqué, ✓ qu’il l’est. La dernière ligne donne le résultat global. Voici le début du tableau pour le service de démonstration du lab, avant tout sandboxing :
systemd-analyze security --no-pager demo-hardening-sandbox.service | head -n 6
NAME DESCRIPTION EXPOSURE
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
SupplementaryGroups= Service runs as root, option does not matter
RemoveIPC= Service runs as root, option does not apply
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
Lisez-le comme une liste de tâches. Chaque ligne ✗ est une protection que vous pouvez encore ajouter, et le nombre à droite indique l’exposition qu’elle retirerait. Les lignes sans marque et sans nombre ne s’appliquent pas à cette unité : l’outil vous dit par exemple que RemoveIPC= n’a pas d’importance quand le service tourne en root. Pour ne garder que le travail restant, filtrez la sortie :
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
✗ NoNewPrivileges= Service processes may acquire new privileges 0.2
Ce que la note signifie, et ce qu’elle ne signifie pas
Le manuel décrit le niveau d’exposition global comme une estimation de 0,0 à 10,0. Une note élevée signale peu de sandboxing, une note basse des restrictions serrées. Soyez prudent avec ce nombre, car le manuel est explicite sur ses limites :
Il ne couvre que les fonctions de sécurité par service que systemd implémente lui-même. Il ignore ce que le programme fait pour se protéger, et il ignore SELinux.
Une note élevée ne veut pas dire que le service est vulnérable, et une note basse ne prouve pas qu’il est sûr. Elle indique seulement que le service pourrait profiter de réglages supplémentaires.
Beaucoup de réglages peuvent être annulés individuellement par d’autres réglages. L’outil tient compte de certaines combinaisons, pas de toutes.
Les réglages s’appliquent aux opérations propres du service. Les requêtes envoyées par IPC, comme D-Bus, peuvent atteindre des services qui ne sont pas restreints de la même façon : vérifiez donc séparément la politique d’accès IPC.
En pratique, traitez la note de systemd-analyze security comme une boussole, pas comme un objectif. En effet, elle aide à repérer les services sans aucune protection et confirme qu’un changement a produit l’effet voulu.
Les options à connaître
Option
Ce qu’elle fait
Testée ici
--offline=BOOL
Passe en revue des fichiers d’unité sans interroger le gestionnaire en cours d’exécution (ajoutée en version 250)
Oui : fonctionne avec un chemin de fichier et avec un nom d’unité
--threshold=NOMBRE
Renvoie une erreur quand le niveau global dépasse la valeur (ajoutée en 250)
Oui : code de sortie 1 au-dessus du seuil, 0 en dessous
--json=MODE
Affiche le tableau en JSON : off, pretty ou short
Oui : short
--security-policy=CHEMIN
Applique vos propres exigences en JSON ; un poids de 0 signifie « ne pas contrôler » (ajoutée en 250)
Non testée
--profile=CHEMIN
Avec --offline, prend en compte un profil portable (ajoutée en 250)
Non testée
--no-pager
Affiche directement, sans pagineur
Oui
Voici les résultats réels pour le service de démonstration une fois le drop-in du lab en place (sa note est 8,9, comme le montre l’étape 3 du lab). J’ai lancé deux fois --offline, sur le chemin du fichier puis sur le nom de l’unité :
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=1
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=0
Le mode JSON donne un objet par ligne, avec les champs set, name, json_field, description et exposure. Voici les 330 premiers caractères :
systemd-analyze security --json=short --no-pager demo-hardening-sandbox.service | head -c 330
[{"set":false,"name":"RootDirectory=/RootImage=","json_field":"RootDirectoryOrRootImage","description":"Service runs within the host's root directory","exposure":"0.1"},{"set":null,"name":"SupplementaryGroups=","json_field":"SupplementaryGroups","description":"Service runs as root, option does not matter","exposure":null},{"set"
[...]
Un détail mérite un avertissement. Le manuel dit que le niveau global va de 0,0 à 10,0, mais il documente un seuil par défaut de 100, et précise que --offline renvoie une erreur au-dessus de ce seuil. Le seuil par défaut ne se déclenche donc jamais. Si vous utilisez --threshold dans un script, passez une valeur explicite et testez les deux côtés du seuil, comme je l’ai fait ci-dessus.
Contrôler une unité avant de la déployer
Deux commandes vous aident avant qu’un fichier n’entre dans /etc/systemd/system/. systemd-analyze verify détecte les sections et directives inconnues, les dépendances manquantes, les pages de manuel citées dans Documentation= qui n’existent pas, et les commandes de ExecStart= introuvables ou non exécutables. Ensuite, le mode hors ligne de systemd-analyze security (--offline=true) passe le fichier en revue sans interroger le gestionnaire en cours d’exécution. Voici une exécution propre sur l’unité du lab :
L’absence de message et le code 0 signifient que verify n’a rien à signaler. Cependant, le manuel ne donne aucun exemple de revue hors ligne d’un chemin de fichier. Cela fonctionne sur mon banc de test, mais essayez-le sur une copie avant de vous y fier.
Une boucle de durcissement sûre
Le durcissement tourne mal quand vous changez cinq choses à la fois et ne savez plus laquelle a cassé le service. La boucle ci-dessous l’évite. Elle commence par systemd-analyze security, puis ajoute une directive à la fois, et elle se termine par un test du vrai travail du service.
La boucle de durcissement : une directive à la fois, puis la garder ou l'annuler.
Mesurer. Lancez systemd-analyze security UNITÉ et notez les lignes ✗ que vous comptez corriger.
Changer une directive dans un drop-in avec sudo systemctl edit UNITÉ.
Redémarrer avec sudo systemctl restart UNITÉ. Un simple rechargement ne donne pas son nouveau sandbox à un service déjà lancé.
Lire le résultat avec systemctl status UNITÉ et journalctl -u UNITÉ.
Tester le vrai travail : se connecter, envoyer un fichier, lancer la sauvegarde, envoyer un courrier. Un service peut démarrer et échouer plus tard.
Garder ou annuler. Gardez le drop-in dans votre gestion de configuration, ou supprimez-le, lancez sudo systemctl daemon-reload et redémarrez.
Les deux dernières étapes comptent le plus. Un sandbox peut laisser un service démarrer alors que l’une de ses tâches échoue plus tard, par exemple parce qu’elle a besoin d’un chemin désormais en lecture seule. C’est pourquoi la boucle se termine par un test du vrai travail, et non par un démarrage réussi.
Lire un échec
Quand un sandbox casse un service, le code de sortie et le message du service vous indiquent la directive à assouplir. Voici une unité de démonstration qui tourne avec ProtectSystem=strict et tente de créer un fichier dans /etc. La sortie de status est coupée après le résumé, car les lignes du journal qui suivent portent le nom de ma machine de test :
sudo systemctl start demo-hardening-fail.service
systemctl status --no-pager -n 5 demo-hardening-fail.service
× demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)
Loaded: loaded (/etc/systemd/system/demo-hardening-fail.service; static)
Active: failed (Result: exit-code) since Mon 2026-11-02 14:54:40 CET; 35ms ago
Invocation: 55376c422cb741a69786c3968cc455a2
Process: 2008 ExecStart=/bin/touch /etc/demo-hardening-fail (code=exited, status=1/FAILURE)
Main PID: 2008 (code=exited, status=1/FAILURE)
Mem peak: 1.2M
CPU: 5ms
[...]
Le journal montre le message du programme lui-même. -u sélectionne l’unité, -o cat n’affiche que le message et -n 6 limite le nombre de lignes :
Starting demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)...
/bin/touch: cannot touch '/etc/demo-hardening-fail': Read-only file system
demo-hardening-fail.service: Main process exited, code=exited, status=1/FAILURE
demo-hardening-fail.service: Failed with result 'exit-code'.
Failed to start demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless).
Le message dit Read-only file system : la directive responsable est donc ProtectSystem=. La solution est d’autoriser le chemin dont le programme a vraiment besoin avec ReadWritePaths=, que la partie 2 de la série détaille. Les messages de systemd restent en anglais sur le banc de test.
Lab pratique : voir trois directives à l’œuvre
Ce lab prend environ quinze minutes. Il utilise un service de démonstration qui se contente de lister, tester et créer des fichiers, et il supprime tout à la fin. Chaque sortie ci-dessous est une sortie réelle du banc de test. Les lignes qui commencent par --- dans les sorties sont des libellés que mon script de test a affichés entre les commandes, en anglais.
Étape 1 : créer le service de démonstration et le lancer sans sandbox
Créez le fichier /etc/systemd/system/demo-hardening-sandbox.service avec votre éditeur, par exemple sudo nano /etc/systemd/system/demo-hardening-sandbox.service. Les lignes qui commencent par # sont des commentaires. Mettez toujours un commentaire sur sa propre ligne, jamais après un réglage.
# Démonstration inoffensive : compte les entrées de /home, teste une écriture dans /etc et crée un fichier dans /tmp.
[Unit]
Description=Service de démonstration du sandboxing (inoffensif)
[Service]
# oneshot : systemd attend la fin de la commande ; l'unité ne reste jamais « active ».
Type=oneshot
# systemd ne lance pas de shell tout seul : la commande démarre donc /bin/sh explicitement.
ExecStart=/bin/sh -c 'echo "entrées dans /home :"; ls -A /home | wc -l; touch /etc/demo-hardening-test && rm /etc/demo-hardening-test && echo "etc : écriture ok" || echo "etc : écriture refusée"; touch /tmp/demo-hardening-marker; echo "terminé"'
Demandez maintenant à systemd de lire le nouveau fichier, affichez-le, lancez le service et lisez sa sortie. Un service oneshot s’exécute une fois, et systemctl start attend sa fin :
[...]
Starting demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif)...
entrées dans /home :
1
etc : écriture ok
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
-rw-r--r-- root /tmp/demo-hardening-marker
systemctl cat affiche le fichier précédé d’une ligne de commentaire avec son chemin. Le journal garde les exécutions précédentes du service sur le banc de test (celles de la variante anglaise de ce lab) : j’ai coupé ces anciennes lignes avec [...]. Lisez le résultat. Le service a vu une entrée dans /home, il a pu écrire dans /etc et il a créé son fichier témoin dans le vrai /tmp. Rien n’est encore protégé. Mesurez le point de départ :
✗ ProtectSystem= Service has full access to the OS file hierarchy 0.2
✗ ProtectHome= Service has full access to home directories 0.2
✗ PrivateTmp= Service has access to other software's temporary files 0.2
Étape 2 : ajouter le drop-in
La méthode habituelle est l’éditeur interactif : sudo systemctl edit demo-hardening-sandbox.service ouvre un nouveau fichier nommé override.conf (non testé ici, car il demande un terminal). Pour que le lab reste reproductible, utilisez la forme non interactive. --stdin lit le contenu sur l’entrée standard et --drop-in=hardening choisit le nom du fichier :
sudo systemctl edit --stdin --drop-in=hardening demo-hardening-sandbox.service <<'EOF'
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
EOF
Chaque ligne de ce fichier fait une chose. [Service] est l’en-tête de section obligatoire. ProtectSystem=strict monte toute la hiérarchie du système de fichiers en lecture seule, sauf /dev, /proc et /sys. ProtectHome=true rend /home, /root et /run/user inaccessibles et vides. PrivateTmp=true donne au service son propre /tmp et /var/tmp, supprimés à son arrêt. Systemd répond avec le chemin du nouveau fichier :
Vérifiez maintenant ce que systemd lit et ce qu’il a chargé. La sortie de systemctl cat commence par le fichier principal de l’unité ; je coupe cette partie :
systemctl cat demo-hardening-sandbox.service
systemctl show --property=ProtectSystem --property=ProtectHome --property=PrivateTmp demo-hardening-sandbox.service
[...]
# /etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
systemctl show affiche yes là où le fichier dit true : les deux désignent le même booléen. Les trois dernières lignes sont les valeurs que systemd a chargées, et celles d’avant sont le fichier. Relancez le service et comparez avec l’étape 1 :
entrées dans /home :
0
touch: cannot touch '/etc/demo-hardening-test': Read-only file system
etc : écriture refusée
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
ls: cannot access '/tmp/demo-hardening-marker': No such file or directory
Trois choses ont changé, et elles correspondent au manuel. Le service voit maintenant 0 entrée dans /home. Son écriture dans /etc échoue avec Read-only file system. Et le fichier témoin qu’il a créé n’est pas visible depuis l’hôte, car le service a utilisé un /tmp privé.
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
✓ ProtectSystem= Service has strict read-only access to the OS file hierarchy
✓ PrivateTmp= Service has no access to other software's temporary files
✓ ProtectHome= Service has no access to home directories
La note de systemd-analyze security est passée de 9,6 UNSAFE à 8,9 EXPOSED, les trois lignes sont passées de ✗ à ✓, et les compteurs sont passés de 75 non appliqués et 4 appliqués à 71 et 8. Trois directives donnent un gain modeste, ce qui est normal : un service root garde beaucoup d’autres expositions, comme l’utilisateur sous lequel il tourne et ses capabilities. Les parties 3 et 4 de la série s’en occupent.
Étape 4 : essayer une directive à la fois avec systemd-run
systemd-run lance une commande comme unité transitoire, et -p fixe une propriété dans le même format que systemctl set-property. Les options ci-dessous sont celles du manuel : --quiet masque le message de démarrage, --pipe relie la commande à l’entrée et à la sortie de votre terminal, --wait attend la fin et --collect décharge l’unité ensuite, même après un échec. (Le manuel documente aussi -t pour un shell interactif ; je ne l’ai pas testé.) Le manuel ne donne aucun exemple de sandboxing pour systemd-run : les résultats ci-dessous sont donc mes propres tests. Les lignes echo affichent les libellés que vous voyez dans la sortie, et chaque ls contrôle ensuite le vrai système de fichiers :
RUN="sudo systemd-run --quiet --pipe --wait --collect"
echo "--- no sandbox: write to /etc"; $RUN /bin/touch /etc/demo-hardening-t1; echo "exit=$?"; ls /etc/demo-hardening-t1; rm -f /etc/demo-hardening-t1
echo "--- ProtectSystem=strict: write to /etc"; $RUN -p ProtectSystem=strict /bin/touch /etc/demo-hardening-t2; echo "exit=$?"; ls /etc/demo-hardening-t2 2>&1
echo "--- ProtectSystem=true: write to /etc, then to /usr"
$RUN -p ProtectSystem=true /bin/touch /etc/demo-hardening-t3; echo "etc exit=$?"; ls /etc/demo-hardening-t3; rm -f /etc/demo-hardening-t3
$RUN -p ProtectSystem=true /bin/touch /usr/demo-hardening-t4; echo "usr exit=$?"; ls /usr/demo-hardening-t4 2>&1
echo "--- ProtectSystem=full: write to /etc"; $RUN -p ProtectSystem=full /bin/touch /etc/demo-hardening-t5; echo "exit=$?"; ls /etc/demo-hardening-t5 2>&1
echo "--- /home entries seen by the service, without and with ProtectHome=true"
$RUN /bin/sh -c 'ls -A /home | wc -l'
$RUN -p ProtectHome=true /bin/sh -c 'ls -A /home | wc -l'
echo "--- ProtectHome=true: ls -ld /home inside the service"; $RUN -p ProtectHome=true /bin/ls -ld /home | awk '{print $1, $3, $4}'
echo "--- PrivateTmp=true: file created in /tmp by the service, seen from the host?"
rm -f /tmp/demo-hardening-t6
$RUN -p PrivateTmp=true /bin/touch /tmp/demo-hardening-t6; echo "service exit=$?"; ls /tmp/demo-hardening-t6 2>&1
echo "--- without PrivateTmp, same test"
$RUN /bin/touch /tmp/demo-hardening-t7; echo "service exit=$?"; ls /tmp/demo-hardening-t7 2>&1; rm -f /tmp/demo-hardening-t7
--- no sandbox: write to /etc
exit=0
/etc/demo-hardening-t1
--- ProtectSystem=strict: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t2': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t2': No such file or directory
--- ProtectSystem=true: write to /etc, then to /usr
etc exit=0
/etc/demo-hardening-t3
/bin/touch: cannot touch '/usr/demo-hardening-t4': Read-only file system
usr exit=1
ls: cannot access '/usr/demo-hardening-t4': No such file or directory
--- ProtectSystem=full: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t5': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t5': No such file or directory
--- /home entries seen by the service, without and with ProtectHome=true
1
0
--- ProtectHome=true: ls -ld /home inside the service
d--------- root root
--- PrivateTmp=true: file created in /tmp by the service, seen from the host?
service exit=0
ls: cannot access '/tmp/demo-hardening-t6': No such file or directory
--- without PrivateTmp, same test
service exit=0
/tmp/demo-hardening-t7
Ce tableau lit les résultats comme le manuel les décrit :
Directive
Ce que mon test a montré
Manuel
ProtectSystem=true
L’écriture dans /etc fonctionne, l’écriture dans /usr est refusée
/usr et les répertoires du chargeur de démarrage en lecture seule
ProtectSystem=full
L’écriture dans /etc est refusée
/etc en lecture seule également
ProtectSystem=strict
L’écriture dans /etc est refusée
Toute la hiérarchie en lecture seule, sauf /dev, /proc, /sys
ProtectHome=true
0 entrée vue, et /home affiche le mode d---------
Répertoires inaccessibles et vides
PrivateTmp=true
Le fichier créé par le service est absent sur l’hôte
/tmp et /var/tmp non partagés avec les processus extérieurs
Ces résultats confirment le manuel sur mon banc de test. Ils viennent d’un noyau WSL2 : confirmez-les sur votre propre serveur avant de vous appuyer sur une directive en production.
Étape 5 : annuler et nettoyer
systemctl revert supprime les drop-ins d’une unité. Il laisse en place un fichier d’unité qui n’a pas de version fournie par un paquet, comme notre fichier de démonstration. Supprimez ensuite les fichiers de démonstration et prévenez systemd :
La première commande a affiché les deux lignes ci-dessus. Après le revert, le fichier d’unité était toujours là et le répertoire .d avait disparu. Après le nettoyage, j’ai vérifié qu’il ne restait aucune unité de démonstration, aucun fichier dans /tmp, /etc ou /usr, et aucune unité de démonstration chargée (tous les compteurs à 0).
Erreurs courantes
Chaque erreur ci-dessous s’est produite sur le banc de test, avec le message réel. Je les ai reproduites sur une unité jetable nommée demo-hardening-mistakes.service (un service oneshot qui lance /bin/true) et sur un service sleep, puis j’ai supprimé les deux.
Erreur 1 : un drop-in sans en-tête de section
Le drop-in a-no-header.conf ne contenait que ProtectSystem=strict. Systemd a ignoré la ligne, et la valeur chargée est restée no :
Erreur 3 : un commentaire sur la même ligne qu’un réglage
J’ai écrit ProtectSystem=strict # make everything read-only sur une seule ligne. Systemd a lu tout le texte comme la valeur, l’a rejeté et a gardé la valeur par défaut :
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/c-inline-comment.conf:2: Failed to parse ProtectSystem=strict # make everything read-only, ignoring: Invalid argument
Erreur 4 : un rechargement sans redémarrage
Un service sleep tournait déjà. J’ai ajouté ProtectSystem=strict puis lancé daemon-reload. systemctl show a répondu strict, mais le processus a gardé le même PID et le même namespace de montage que le PID 1. Seul le redémarrage lui a donné un nouveau PID et son propre namespace :
PID=$(systemctl show --property=MainPID --value demo-hardening-sleep.service)
readlink /proc/1/ns/mnt
readlink /proc/$PID/ns/mnt
J’ai lancé ces trois lignes avant le changement, après le rechargement et après le redémarrage. Les résultats, avec leurs libellés, sont :
--- mount namespace of PID 1 and of the service
pid1: mnt:[4026532218]
service: mnt:[4026532218]
--- after drop-in + daemon-reload (no restart)
show ProtectSystem: strict
same PID: 1886 (was 1886)
service: mnt:[4026532218]
--- after systemctl restart
new PID: 1948
pid1: mnt:[4026532218]
service: mnt:[4026532287]
Autres erreurs à éviter
Modifier le fichier du paquet dans /usr/lib/systemd/system/. Utilisez plutôt un drop-in dans /etc/systemd/system/ ; le manuel donne la priorité à /etc/ sur /usr/lib/.
Juger un changement sur « le service démarre ». Testez le vrai travail, comme dans la boucle ci-dessus.
Changer plusieurs directives à la fois. Vous ne saurez pas laquelle a cassé le service.
Viser une note de 0. Le manuel précise qu’une note élevée ne signifie pas qu’un service est vulnérable. Servez-vous de la note de systemd-analyze security pour prioriser.
Utiliser des directives de namespace de montage dans un service utilisateur. Le manuel avertit que certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.
Une checklist à suivre
Travaillez d’abord sur une machine de test ou sur un service inoffensif.
Lancez systemd-analyze security UNITÉ et notez la note et les lignes ✗.
Écrivez une directive dans un drop-in sous /etc/systemd/system/UNITÉ.d/, dans un fichier .conf avec un en-tête [Service] et des commentaires sur leur propre ligne.
Lancez systemctl cat UNITÉ pour vérifier que systemd lit votre fichier.
Lancez sudo systemctl restart UNITÉ ; un simple rechargement ne suffit pas pour un service déjà lancé.
Lisez systemctl status UNITÉ et journalctl -u UNITÉ.
Testez le vrai travail du service, pas seulement son démarrage.
Mesurez à nouveau avec systemd-analyze security UNITÉ.
Gardez le drop-in dans votre gestion de configuration, ou supprimez-le avec systemctl revert puis redémarrez.
Recommencez avec la directive suivante.
Les familles de directives des prochaines parties
Les prochaines parties prennent chaque famille à tour de rôle. Chaque nom de directive ci-dessous est documenté dans la page de manuel systemd.exec ; IPAddressAllow= et IPAddressDeny= le sont dans systemd.resource-control.
Quels tours de passe-passe lui restent possibles ?
8
Toute la stack
Une matrice de chaque directive face à chaque service
Que dois-je appliquer où ?
Ma stack, et par où commencer
Le durcissement demande un modèle de menace, même simple. Je pars du principe qu’un attaquant a déjà pris le contrôle du processus d’un service, et je me demande ce que ce processus pourrait faire ensuite : lire des secrets, écrire un mécanisme de persistance, joindre d’autres services ou obtenir plus de privilèges. Chaque directive retire l’une de ces options.
Mes serveurs font tourner une stack auto-hébergée typique, que les parties suivantes couvriront service par service :
un reverse proxy devant chaque application publique, avec son bouncer CrowdSec ;
des applications PHP-FPM, ainsi que des applications Node.js, Python, Go et .NET (un serveur multimédia et un serveur de bibliothèque) ;
MariaDB, PostgreSQL et Valkey, avec pgBackRest pour les sauvegardes PostgreSQL ;
un serveur de messagerie, un serveur SFTP et WebDAV, et une messagerie d’équipe ;
des agents de sécurité et de supervision : un agent SIEM, une protection contre la force brute, un IDS et des exporters de métriques ;
un VPN et une console d’administration web.
Je commence par les services qui réunissent trois critères : ils sont joignables depuis Internet, directement ou derrière le reverse proxy ; des tiers les ont écrits ; et ils n’ont aucune politique SELinux propre. Ce sont les applications web. Les bases de données et les agents viennent après, car le réseau ne peut pas les joindre directement.
Ce que je retiens
Mesurez avant de changer. systemd-analyze security donne une note et une liste de tâches, sans rien modifier du service.
Utilisez des drop-ins. Ils résistent mieux aux mises à jour de paquets qu’une modification du fichier du paquet, et en supprimer un suffit pour revenir en arrière.
Redémarrez après un changement. Un simple rechargement laisse un processus déjà lancé dans son ancien sandbox.
Changez une directive à la fois et testez le vrai travail du service.
Voyez la note comme une boussole. Elle ne compte que ce que systemd restreint lui-même.
Testez les affirmations. Chaque résultat de cet article vient d’une exécution réelle ; lancez le lab vous-même et comparez.
CrowdSec est souvent présenté comme « un fail2ban moderne ». C’est vrai en partie : lui aussi lit les journaux et bannit les adresses agressives. Mais il ajoute deux choses que fail2ban n’a pas. D’abord des scénarios plus riches, qui reconnaissent un balayage de chemins web, une attaque par dictionnaire répartie ou une exploitation de faille connue, au lieu de simplement compter des lignes. Ensuite une intelligence collective : chaque installation signale les attaques qu’elle voit, et reçoit en échange une liste d’adresses déjà repérées ailleurs, bloquées avant même qu’elles ne vous atteignent.
Ce tutoriel installe CrowdSec 1.8 sur Rocky Linux, le branche sur SSH et nginx, ajoute le blocage au pare-feu, et surtout explique comment ne pas se bannir soi-même, ce qui arrive plus vite qu’on ne le croit.
De la ligne de journal au blocage, et comment ne jamais se bannir soi-même.
Les pièces du puzzle
Élément
Rôle
l’agent (crowdsec)
lit les journaux, les découpe (parseurs), les compare à des scénarios, émet des alertes
l’API locale (LAPI)
stocke les alertes et transforme celles qui le méritent en décisions (bannir telle adresse pendant tant de temps)
les bouncers
appliquent les décisions : pare-feu (nftables), serveur web, application
l’API centrale (CAPI)
reçoit vos signalements et vous renvoie une liste de blocage communautaire
le Hub
le catalogue de collections, parseurs, scénarios, installés par cscli
Point important : l’agent ne bloque rien. Sans bouncer, CrowdSec détecte et prend des décisions, mais personne ne les applique. C’est l’erreur la plus fréquente lors d’une première installation.
Étape 1 : installer
CrowdSec publie ses paquets dans un dépôt packagecloud. Déclarez-le dans /etc/yum.repos.d/ avec sa clé GPG, puis :
dnf install crowdsec crowdsec-firewall-bouncer-nftables
systemctl enable --now crowdsec
cscli version
À l’installation, le paquet détecte les services présents (sshd, nginx…) et installe les collections correspondantes. Vérifiez ce qu’il a choisi plutôt que de lui faire confiance : cscli collections list.
Une précaution sur Debian : si vous installez CrowdSec et le bouncer dans la même commande sur une machine neuve, le script post-installation du bouncer peut s’exécuter avant que l’API locale soit prête et échouer à s’enregistrer. Installez l’agent, attendez qu’il démarre, puis le bouncer.
Une collection regroupe des parseurs (qui savent lire un format de journal) et des scénarios (qui reconnaissent un comportement). crowdsecurity/nginx apporte ainsi le parseur des journaux nginx et une dizaine de scénarios : balayage de chemins, sondes de fichiers sensibles, recherche de pages d’administration, robots agressifs. Les collections se mettent à jour avec cscli hub update && cscli hub upgrade, à planifier chaque jour.
Étape 3 : dire à CrowdSec quoi lire
La configuration d’acquisition liste les sources et leur type, qui détermine le parseur utilisé :
labels.type : syslog pour les journaux système (sshd y écrit sur Rocky Linux), nginx pour les journaux d’accès et d’erreur ;
une source, une seule fois : le piège classique est de lister les journaux nginx à la fois dans acquis.yaml et dans acquis.d/nginx.yaml. Chaque ligne est alors lue deux fois, chaque compteur double, et les bannissements tombent deux fois plus vite. Même chose avec une source journalctl ajoutée par-dessus un fichier qui contient déjà les mêmes lignes ;
les jokers (*.log) suivent automatiquement les nouveaux sites.
Pour vérifier que les lignes sont bien lues et comprises :
cscli metrics show acquisition parsers
# « Lines read » augmente, « Lines parsed » aussi ; beaucoup de « unparsed » = mauvais type
Étape 4 : ne jamais se bannir soi-même
C’est la partie à ne pas sauter. CrowdSec n’a aucune raison de faire confiance à vos propres adresses : un script de supervision qui reçoit une série de 403, des tests de connexion SSH ratés, un serveur qui interroge ses propres noms publics… et c’est votre propre serveur que le pare-feu rejette. J’ai vécu les deux : un serveur qui s’est banni lui-même pour « sondage HTTP », et un autre qui a banni l’adresse de mon poste d’administration pendant que je vérifiais des clés SSH.
La parade est une liste blanche locale, en parseur d’enrichissement :
# /etc/crowdsec/parsers/s02-enrich/local-whitelist.yaml
name: local/whitelists
description: "Adresses de confiance, jamais bannies"
whitelist:
reason: "mes serveurs et mon adresse d'administration"
ip:
- "203.0.113.25"
- "203.0.113.40"
- "2001:db8::25"
- "198.51.100.10"
Mettez-y les adresses publiques de chacun de vos serveurs (IPv4 et IPv6), celle de votre accès d’administration, et déployez la même liste sur toutes vos machines : une infrastructure personnelle où chaque serveur peut bannir les autres finit toujours par le faire. Une liste blanche s’applique avant les scénarios, donc aucune alerte n’est créée. Elle ne supprime pas une décision déjà prise : cscli decisions delete --ip … s’en charge.
Étape 5 : appliquer les décisions au pare-feu
cscli bouncers add firewall-bouncer # affiche une clé à copier dans la configuration du bouncer
mode: nftables : le bouncer crée ses propres tables et ensembles nftables, indépendants de firewalld. Les adresses bannies sont dans un ensemble, ce qui reste rapide même avec des dizaines de milliers d’entrées ;
update_frequency : toutes les 10 secondes, le bouncer récupère les nouvelles décisions ;
deny_action: DROP : le paquet est jeté sans réponse, l’attaquant n’apprend rien ;
IPv6 : à activer explicitement ; un attaquant qui dispose des deux familles d’adresses passera sinon par celle qui n’est pas filtrée.
systemctl enable --now crowdsec-firewall-bouncer
cscli bouncers list # « Last API pull » récent
nft list set ip crowdsec crowdsec-blacklists-cscli | head
Étape 6 : la liste communautaire
cscli capi register
cscli console enroll <clé de la console> # facultatif : tableau de bord web
cscli capi status
systemctl restart crowdsec
Une fois inscrit, l’agent envoie les alertes de ses scénarios (adresse et scénario, jamais le contenu des journaux) et reçoit une liste de blocage d’environ quinze mille adresses, renouvelée régulièrement. C’est elle qui fait la plus grande différence au quotidien : la majorité des robots qui balaient Internet sont bloqués dès leur premier paquet. cscli decisions list --origin CAPI | wc -l donne la taille de la liste reçue.
Simuler avant de bannir
Un nouveau scénario, ou un nouveau serveur, peut être placé en simulation : les alertes et décisions sont enregistrées mais marquées « simulées », et aucun bouncer ne les applique. C’est le moyen de voir ce que CrowdSec aurait bloqué pendant une semaine, avant de lui confier la porte.
cscli simulation enable crowdsecurity/http-crawl-non_statics
cscli simulation status
cscli alerts list --since 24h
Exploiter au quotidien
cscli alerts list --since 24h # ce qui a été détecté
cscli decisions list # qui est banni, pour combien de temps
cscli decisions delete --ip 192.0.2.66 # lever un bannissement
cscli decisions add --ip 192.0.2.99 --duration 72h --reason 'manuel'
cscli metrics # sources, parseurs, scénarios, bouncers
La durée de bannissement par défaut est de quatre heures ; un profil (profiles.yaml) permet de l’allonger pour les récidivistes ou de bannir une plage entière plutôt qu’une adresse.
Une heure d’un vrai serveur
Voici les chiffres de mon serveur web le 2 novembre 2026, 75 minutes après un redémarrage (CrowdSec 1.8.2, bouncer nftables 0.0.36). D’abord, ce que l’agent a lu :
Lines parsed égal à Lines read sur les journaux web : l’analyseur nginx comprend chaque ligne. Si un journal affiche tout en unparsed, la collection manque ou le format du journal a été personnalisé.
/var/log/messages : 1,31k non analysées : c’est attendu. La plupart des lignes du journal système ne sont pas des événements de sécurité et aucun analyseur ne les revendique. Les événements SSH et d’authentification sont dans /var/log/secure, lu par sa propre entrée : 3 020 lignes lues, 39 analysées, et c’est bien le but, puisque seules les connexions échouées et événements semblables intéressent CrowdSec.
Whitelisted : les requêtes venant de mes propres adresses et des sondes de supervision, ignorées volontairement (étape 4).
Poured to bucket : les lignes qui ont alimenté un scénario. La plupart ne deviennent jamais une alerte ; un compteur ne déborde que si le comportement se répète.
Puis le côté application des décisions, la seule preuve que la protection est réelle :
# cscli bouncers list
crowdsec-firewall-bouncer 127.0.0.1 ✔️ 2026-11-02T10:21:35+01:00 v0.0.36
openresty-appsec 127.0.0.1 ✔️ 2026-11-02T10:21:37+01:00 v1.2.3
# nft list set ip crowdsec crowdsec-blacklists-CAPI → 14 752 adresses IPv4
# nft list set ip6 crowdsec6 crowdsec6-blacklists-CAPI → 475 adresses IPv6
# nft list set ip crowdsec crowdsec-blacklists-crowdsec → 8 décisions locales
# nft list counters table ip crowdsec
counter processed packets 1286809
counter crowdsec-blacklists-CAPI packets 387 → jetés
counter crowdsec-blacklists-crowdsec packets 60 → jetés
En 75 minutes, 447 paquets ont été jetés avant d’atteindre le moindre service : 387 venaient d’adresses de la liste communautaire, qui n’avaient jamais touché ce serveur, et 60 des 8 adresses bannies par mon propre serveur. La colonne Last API pull, vieille de quelques secondes pour les deux bouncers, est la première chose à regarder quand les bannissements semblent sans effet : un bouncer qui ne tire plus continue d’appliquer une liste figée.
Allonger les bannissements : profiles.yaml
Le profil décide de ce qu’une alerte devient. Celui par défaut bannit l’adresse quatre heures. On peut le rendre progressif, comme avec fail2ban, en s’appuyant sur le nombre de décisions déjà prises contre cette adresse :
filters : le profil ne s’applique qu’aux alertes qui demandent une sanction et visent une adresse IP ;
duration_expr : 4 heures la première fois, 8 la deuxième, 12 la troisième… Le calcul utilise l’historique de l’API locale, il survit donc aux redémarrages ;
on_success: break : les profils suivants ne sont pas évalués. L’ordre des profils dans le fichier compte.
Les pannes fréquentes
Rien n’est jamais banni
Vérifiez dans l’ordre : cscli metrics show acquisition (les lignes sont-elles lues ?), cscli metrics show parsers (sont-elles comprises ?), cscli alerts list (des scénarios se déclenchent-ils ?), cscli bouncers list (le bouncer récupère-t-il les décisions ?). La panne se trouve presque toujours à la première étape qui reste à zéro.
Le bouncer ne démarre pas, « unauthorized »
La clé du bouncer ne correspond à aucune clé de l’API locale, typiquement après une réinstallation qui a vidé la base. Supprimez l’entrée (cscli bouncers delete), recréez-la et recopiez la clé.
L’API locale écoute sur un port déjà pris
Par défaut, l’API locale écoute sur 8080, port très demandé. Changez-le dans config.yaml (api.server.listen_uri) et dans local_api_credentials.yaml, puis dans la configuration de chaque bouncer. Oublier l’un des trois produit un agent qui démarre mais dont les outils ne parlent plus à personne.
Les parseurs ne comprennent pas les journaux SSH
Les versions récentes d’OpenSSH journalisent sous le nom sshd-session au lieu de sshd. Si les parseurs ne suivent pas, les échecs d’authentification ne sont plus reconnus du tout. Un cscli hub upgrade règle généralement la question ; vérifiez avec cscli explain --file /var/log/secure --type syslog, qui montre, ligne par ligne, quel parseur l’a acceptée ou rejetée.
Mettre à jour sans surprise
mettez à jour l’agent, puis vérifiez cscli metrics et la liste des bouncers avant de toucher aux bouncers ;
lisez les notes de version : la version 1.8 a par exemple changé la façon dont certains champs sont exposés aux scénarios ;
après une mise à jour du hub, un cscli hub upgrade peut remplacer un scénario que vous aviez modifié à la main. Les modifications locales vont dans des fichiers à vous, jamais dans ceux du hub.
Et fail2ban ?
Les deux cohabitent sans problème, et c’est ce que je fais : fail2ban réagit vite à mes propres journaux avec des bannissements progressifs, CrowdSec ajoute les scénarios web et la liste communautaire. Seul inconvénient : quand une adresse reste bloquée, il faut regarder des deux côtés (fail2ban-client status et cscli decisions list).
Pour aller plus loin
CrowdSec sait aussi inspecter le contenu des requêtes HTTP grâce à son moteur AppSec, et bloquer les attaques directement dans le serveur web. C’est l’objet d’un prochain tutoriel de cette série, avec OpenResty.
J’indique pour chaque affirmation sa source : Haas, Eisentraut, les pages du projet, un site d’actualité français, Numerama (une source secondaire), ou mon propre comptage. Les chiffres d’un billet restent « selon le billet ». J’ai lu les pages du projet début novembre 2026, et les dates postérieures au 5 novembre sont des prévisions, pas des faits.
Ce que dit Haas sur le retard de PostgreSQL 19
Haas écrit que les versions majeures de la dernière décennie sont sorties entre la mi-septembre et la mi-octobre. Cette année, dit-il, PostgreSQL 19 sortira très probablement en novembre, et un nouveau report n’est pas exclu. Il note aussi que trois grandes fonctionnalités ont quitté la version entre le 27 août et le 15 septembre : UPDATE/DELETE FOR PORTION OF, les requêtes de graphes de propriétés SQL (SQL/PGQ) et ALTER TABLE … MERGE/SPLIT PARTITION. Le gel des fonctionnalités, ajoute-t-il, tombe chaque année le 8 avril.
Son explication, c’est le volume. Selon le billet, les LLM ont rendu les vieux bogues bien plus faciles à trouver, alors que les corriger n’est devenu plus facile que dans une moindre mesure. Beaucoup de gens signalent maintenant des bogues trouvés avec des LLM, et les committers sont très occupés à les corriger. Il n’accuse pas davantage d’erreurs des committers, car il juge peu probable que ce soit la cause principale. Il pense plutôt que les committers sont restés aussi prudents qu’avant et que les LLM ont repéré vite une plus grande part de leurs erreurs.
Il donne ensuite deux raisons aux retraits tardifs. D’abord, les LLM peuvent rédiger des correctifs, mais ceux-ci ne sont pas toujours corrects, donc des humains relisent et valident encore. Ensuite, le temps qui aurait servi à relire la version est parti dans les problèmes de sécurité et les bogues des anciennes branches. À ses yeux, le retard n’est pas particulièrement inquiétant. En revanche, il juge le long terme moins clair : comment le projet rattrapera-t-il des décennies de bogues tout en continuant d’innover ? Il écrit qu’il ne sait pas encore.
Tel que le billet le décrit : un flux qualitatif, sans chiffres.
Ce que Haas n’affirme pas
Il n’affirme pas que les correctifs écrits par des LLM sont fiables.
Il ne donne pas de date de sortie ferme, et il ne compte pas combien de bogues les LLM ont trouvés au total.
Il n’explique pas pourquoi chacune des trois fonctionnalités a été retirée.
Il juge toujours le code de PostgreSQL d’une très grande qualité.
Ce que dit Eisentraut sur les retraits
Eisentraut a écrit plus tôt et donne un tableau plus large. Il dit qu’environ 8 à 10 fonctionnalités importantes, selon la façon de compter, ont quitté la branche de PostgreSQL 19 après le début de la bêta, et qu’il était développeur ou committer de certaines. Il présente son billet comme un ensemble d’opinions personnelles, et il estime que les causes sont probablement une combinaison, différente pour chaque fonctionnalité.
La qualité du code : peut-être que le code n’était pas assez bon, et il dit que l’équipe en tirera des leçons.
Des fonctionnalités trop dures à terminer : certaines avaient des défauts d’architecture difficiles à corriger pendant la bêta.
Une longue traîne de bogues de cas limites : la relecture de code assistée par LLM les trouve désormais bien plus vite, et il donne l’exemple d’une liste de « une quarantaine de rapports de défauts ». Cette phrase illustre l’idée. Ce n’est pas une donnée mesurée.
Moins de relecture : les développeurs seniors ont passé d’avril à août sur des correctifs de sécurité, donc la relecture habituelle de la bêta a diminué.
La complexité : dans PostgreSQL, « tout fonctionne avec tout », ce qui rend certaines fonctionnalités très difficiles à réussir.
Pas de statut expérimental : la communauté en discute, mais elle n’a pas encore de moyen de le faire.
Point important : il dit que la situation ne vient pas du « vibe coding », car le code retiré est plus ancien que cela. Selon lui, la relecture par IA a relevé la barre de qualité après l’écriture du code. Il ajoute que cela devrait se résorber dans les prochains cycles, sauf si la relecture par LLM continue de progresser au rythme actuel. Il dit aussi que la communauté a préféré tenir le calendrier plutôt que de le repousser. Il s’attend à ce que beaucoup de fonctionnalités retirées reviennent d’ici peu. Enfin, il cite REPACK CONCURRENTLY, la réplication logique des séquences et pg_plan_advice parmi les points forts, et il évoque quelques fonctionnalités prêtes à environ 80 % pour PostgreSQL 20.
Ce que le projet a publié lui-même
Pour le retard de PostgreSQL 19, le plus important est l’annonce de la version candidate du 15 octobre 2026, qui donne un calendrier. La prochaine mise à jour planifiée, le 12 novembre, contiendra un grand nombre de correctifs, donc l’équipe de publication a décidé de les inclure dans PostgreSQL 19 avant la disponibilité générale. Par conséquent, l’équipe prévoit une seconde version candidate le 12 novembre, et la disponibilité générale le 19 novembre 2026, sauf si un problème exige un report ou une autre version candidate. Au 5 novembre 2026, ces dates sont des prévisions.
Seulement des dates citées par le billet ou par postgresql.org début novembre 2026.
Certaines pages ne concordent pas avec cette annonce. Par exemple, la page de la feuille de route indiquait encore début novembre que la prochaine version majeure était prévue pour octobre 2026. De son côté, les notes de version de développement affichaient une date de sortie « 2026-??-?? » au 9 octobre, et leur vue d’ensemble ne mentionnait pas les trois fonctionnalités retirées. J’ai cherché leurs noms dans cette page et je n’en ai trouvé aucun.
Deux autres pages comptent pour l’exploitation. La politique de versions dit que chaque version majeure reçoit cinq ans de support et au moins une version mineure tous les trois mois. Elle recommande aussi d’exécuter la version mineure courante. De son côté, la page sur la bêta déconseille fortement les versions bêta en production.
Vérifier les chiffres
Haas donne des nombres de commits derrière le retard de PostgreSQL 19, du 8 avril au 25 septembre de chaque année. Je les ai reproduits sur un clone du dépôt de PostgreSQL le 3 novembre 2026, avec la date du committer et le jour UTC. Mes comptes sont proches sans être identiques, et l’écart reste sous 2,5 % environ. Mon hypothèse est que les limites de fuseau horaire et les règles de comptage l’expliquent.
Branche et année
Selon le billet
Mon comptage : atteignables depuis la pointe de la branche
Mon comptage : seulement sur la branche
master, 2025
1 037
1 056
sans objet
master, 2026
1 443
1 477
sans objet
REL_18_STABLE, 2025 (version en préparation)
724
738
279
REL_19_STABLE, 2026 (version en préparation)
1 169
1 182
600
REL_17_STABLE, 2025 (branche précédente)
248
250
250
REL_18_STABLE, 2026 (branche précédente)
433
437
437
Les deux définitions de mon tableau comptent. Un commit est atteignable depuis la pointe d’une branche stable s’il est sur cette branche ou avant le point de création sur master. Les chiffres du billet pour la version en préparation sont proches du comptage « atteignable », pas du comptage « seulement sur la branche », donc ils incluent très probablement des commits de master faits entre le 8 avril et la création de la branche. Les deux branches se sont séparées de master fin juin, en 2025 et en 2026. Les commits présents seulement sur la branche stable passent plutôt de 279 à 600. Dans les deux définitions, le sens se confirme : les branches stables ont reçu beaucoup plus de travail. Pour la branche précédente, qui existait déjà, les deux définitions concordent : de 250 à 437, soit environ 75 % de plus.
Numerama reformule la première paire comme des modifications « reportées » sur la version en préparation. Cette reformulation se comprend, mais les chiffres incluent aussi des commits antérieurs de master. De plus, un nombre de commits mesure un volume de travail, pas sa cause. Il ne confirme ni ne réfute que l’IA en soit la raison. Cette affirmation causale appartient aux auteurs, et je n’ai pas revérifié que les LLM trouvent ces bogues.
Pour reproduire mes comptes, clonez le dépôt sans le contenu des fichiers, puis lancez un petit script depuis ce dépôt.
import datetime as dt, subprocess
W = {2025: (dt.date(2025, 4, 8), dt.date(2025, 9, 25)), 2026: (dt.date(2026, 4, 8), dt.date(2026, 9, 25))}
day = lambda s: dt.datetime.fromisoformat(s).astimezone(dt.timezone.utc).date()
def log(args):
out = subprocess.run(["git", "log", "--format=%H|%cI"] + args, capture_output=True, text=True, check=True).stdout.strip().split("\n")
return [l.split("|") for l in out if l]
def n(rows, y):
a, b = W[y]
return sum(1 for r in rows if a <= day(r[1]) <= b)
for y in (2025, 2026):
print(f"master {y}: {n(log(['master']), y)}")
for br, y in (("REL_18_STABLE", 2025), ("REL_19_STABLE", 2026), ("REL_17_STABLE", 2025), ("REL_18_STABLE", 2026)):
print(f"{br} {y}: atteignables depuis la pointe {n(log([br]), y)}; seulement sur la branche {n(log([br, '^master']), y)}")
Ces résultats valent pour le jour où je les ai obtenus. Le dépôt continue d’avancer : les comptes de ces fenêtres closes devraient rester les mêmes, mais les pointes des branches changeront.
Ce qu’ajoute Numerama, et où les sources divergent
Le 2 novembre 2026, Numerama a résumé le retard de PostgreSQL 19 en français. Il ajoute quelques éléments que j’ai vérifiés. D’abord, il donne un décompte de sécurité : 28 vulnérabilités corrigées dans la mise à jour du 13 août, contre 11 en mai. Les annonces du projet du 13 août et du 14 mai donnent les mêmes deux nombres. Numerama note lui-même que ni ce décompte ni les chiffres de Haas ne disent combien de problèmes l’IA a trouvés.
Il présente aussi Eisentraut comme un membre de la direction du projet, alors que j’ai trouvé les deux ingénieurs sur la page des committers et que je n’ai pas trouvé Eisentraut sur la page de l’équipe centrale. Par ailleurs, il présente sa phrase sur « une quarantaine de rapports » comme une citation simple, alors qu’il l’écrit comme une illustration. Enfin, il liste d’autres réactions de l’open source aux rapports générés par IA : curl a mis fin à ses primes aux bogues en janvier, Linus Torvalds a parlé des doublons en mai, et Google a suspendu ses récompenses open source le 1er octobre. Je n’ai pas vérifié ces éléments, ils restent donc des affirmations de Numerama.
Là où les sources divergent
Le calendrier : Eisentraut écrit le 9 octobre que la communauté a préféré tenir le calendrier. L’annonce du 15 octobre prévoit la disponibilité générale le 19 novembre, et Haas attend novembre. Numerama dit que l’équipe de publication a repoussé la date début octobre, mais l’annonce que j’ai trouvée date du 15 octobre, et je n’ai pas pu dater la décision. La page de la feuille de route disait toujours octobre.
La cause des retraits : Haas relie les retraits tardifs et le retard au flot de bogues trouvés par des LLM. Eisentraut cite plusieurs causes dont la relecture par IA n’est qu’une, et il dit que le code écrit par IA n’est pas en cause.
Les chiffres : mes comptes diffèrent un peu du billet, et les chiffres de la « version en préparation » incluent des commits antérieurs de master.
L’IA et les bases de données au-delà du calendrier
Les moteurs : la charge des mainteneurs
Les deux billets décrivent une charge de travail qui a grossi. Haas s’attend à ce que chaque nouvelle fonctionnalité soit désormais examinée par l’IA. Eisentraut propose du fuzzing assisté par LLM dans le développement des fonctionnalités, et il s’attend à ce que la pression diminue si la relecture par IA cesse de progresser. Aucun des deux ne dit que les versions mineures deviendront plus grosses ou plus fréquentes. La politique de versions promet seulement au moins une tous les trois mois.
Les fonctionnalités : la recherche vectorielle
Les charges de travail d’IA changent aussi ce que les bases offrent. Selon son README, pgvector ajoute à Postgres la recherche exacte et approchée des plus proches voisins, avec des index HNSW et IVFFlat, et prend en charge Postgres 13 et suivants. Dans PostgreSQL 19, les notes de version de développement ne listent aucune fonction vectorielle (j’ai cherché le mot), donc la recherche vectorielle vient toujours de cette extension. MariaDB, au contraire, documente un type et un index vectoriels intégrés : les vecteurs sont arrivés dans MariaDB Community Server 11.7, une version continue, puis sont devenus disponibles de façon générale à partir de la 11.8, la première version à support long à les inclure. Son index initial utilise un algorithme HNSW modifié et prend la distance euclidienne par défaut. De plus, l’OWASP cite les « faiblesses des vecteurs et des embeddings » parmi les dix risques des applications LLM pour 2025.
L’exploitation : le SQL assisté par LLM
Pour les DBA, la question principale est de savoir comment traiter du SQL qu’un modèle écrit. La liste 2025 de l’OWASP nomme l’injection de prompt, la mauvaise gestion des sorties et l’agentivité excessive parmi ses risques. La mauvaise gestion des sorties consiste à passer la sortie du modèle en aval sans validation suffisante. Les points suivants sont des considérations générales, pas des mesures. Relisez le SQL généré avant de l’exécuter. Testez-le d’abord sur une copie. Donnez à tout outil qui l’exécute un rôle avec le moins de droits possible, de préférence en lecture seule sur un réplica.
Mon avis
Sur le retard de PostgreSQL 19, je suis moins inquiète que les titres des articles. Corriger beaucoup de bogues et de vulnérabilités est plutôt une bonne chose, même quand cela coûte du temps. Si la base devient beaucoup plus stable, je pense que la course à l’innovation peut attendre un peu. C’est ma lecture, pas une mesure : rien de ce que j’ai compté ne montre si la base est plus stable aujourd’hui.
Haas lui-même juge le retard modeste et estime toujours très haut la qualité du code. En revanche, il dit aussi que la question du long terme reste ouverte : comment le projet peut-il rattraper son retard de bogues et continuer d’innover ? Il qualifie aussi le code de l’un des plus propres sur lesquels il ait travaillé, et Eisentraut, de son côté, s’attend à ce que beaucoup de fonctionnalités retirées reviennent d’ici peu. Mon avis et les billets vont donc ensemble, mais l’optimisme est le mien, pas le leur.
Que faire en pratique
Traitez les dates comme des prévisions. Après le retard de PostgreSQL 19, le projet prévoit la disponibilité générale le 19 novembre 2026, et son propre texte admet un report.
Gardez la production loin des versions candidates. La page sur la bêta le déconseille.
Gardez la version mineure courante. La politique la recommande, et elle prévient que certaines mises à jour mineures demandent des étapes en plus : lisez donc les notes d’abord.
Testez une mise à jour majeure sur une copie. La politique exige pg_upgrade ou un export-import, et elle conseille de lire les notes de chaque version que vous sautez.
Sauvegardez avant toute mise à jour. C’est une règle générale.
Traitez le SQL généré par un LLM comme une entrée non fiable.
Ce que nous ignorons encore
Si le retard de PostgreSQL 19 s’arrête le 19 novembre, puisque l’annonce admet un autre report.
Combien de bogues viennent de l’IA. Numerama note qu’aucun chiffre ne le dit.
Si les fonctionnalités retirées reviennent vite. Eisentraut s’y attend, mais c’est une attente, pas un engagement.
Comment le projet rattrapera son retard de bogues. Haas écrit qu’il ne sait pas encore.
Si la pression s’estompe ou reste. Eisentraut et Haas n’ont pas la même perspective.
Si le projet a une politique écrite sur les contributions assistées par IA. Je n’en ai trouvé aucune dans la FAQ des développeurs ni dans la page Submitting a Patch, mais je n’ai pas fouillé les listes de diffusion.
Pour tenir un serveur de courrier que Gmail et Outlook acceptent, quatre points doivent être justes avant que le logiciel compte : une adresse IP propre dont le DNS direct et le DNS inverse concordent, les enregistrements SPF, DKIM et DMARC, MTA-STS et TLS-RPT pour imposer le chiffrement, et les ports ouverts avec les vérifications qui prouvent que tout fonctionne. mox apporte le logiciel en un seul binaire. Ce tutoriel l’installe et vérifie chacun de ces points avec des commandes que vous pouvez lancer.
Héberger son courrier a mauvaise réputation : un serveur mal configuré voit ses messages finir en indésirables ou refusés, et un serveur mal protégé devient un relais pour les spammeurs. mox (code source sur GitHub) est un serveur de courrier complet écrit en Go et livré en un seul binaire : SMTP entrant et sortant, IMAP, webmail, SPF, DKIM, DMARC, MTA-STS, TLS-RPT, filtrage du courrier indésirable par réputation, et ses propres certificats Let’s Encrypt.
Exemple utilisé : le domaine example.org, le serveur mail.example.org en 203.0.113.25. Comptez une demi-journée, dont une bonne partie à attendre la propagation du DNS.
Ce qui doit être juste avant d’envoyer le premier message.
Pourquoi mox plutôt que Postfix et Dovecot
La pile classique (Postfix, Dovecot, un filtre comme Rspamd, OpenDKIM, un webmail, certbot) fonctionne très bien, mais demande d’assembler et de maintenir six logiciels qui doivent s’entendre. mox fait tout cela d’un bloc, avec des valeurs par défaut sûres et des outils de vérification intégrés. Le revers : un projet plus jeune, dont la configuration change parfois d’une version à l’autre. Lisez les notes de version avant chaque mise à jour.
Prérequis : ce qui ne se rattrape pas
Une adresse IP fixe et propre : vérifiez qu’elle ne figure pas sur une liste de blocage (Spamhaus, Barracuda…) avant de commencer ; une adresse héritée d’un ancien spammeur est un boulet.
Le port 25 sortant ouvert : beaucoup d’hébergeurs le bloquent par défaut sur les VPS. Testez-le avant tout : nc -vz gmail-smtp-in.l.google.com 25. S’il est fermé, il faut le demander au support.
Le DNS inverse (PTR) modifiable chez l’hébergeur.
Un serveur dédié au courrier, de préférence : il n’y a alors aucun autre service à surveiller sur cette machine, et une compromission d’une application web n’atteint pas vos boîtes.
Étape 1 : l’identité réseau, avant tout le reste
Les grands hébergeurs de messagerie vérifient que l’adresse IP qui se présente correspond, dans les deux sens, au nom annoncé. Réglez le PTR chez l’hébergeur du VPS (pas dans la zone DNS du domaine), pour IPv4 et pour IPv6 si le serveur en a une :
dig +short -x 203.0.113.25 # mail.example.org.
dig +short mail.example.org A # 203.0.113.25
dig +short -x 2001:db8::25 # mail.example.org. (sinon, désactivez l'IPv6 sortant pour le courrier)
Un PTR IPv6 manquant est un piège courant : si le serveur a une adresse IPv6, il l’utilise pour joindre Gmail, et Gmail peut refuser le message : son erreur indique « does not meet IPv6 sending guidelines regarding PTR records », et ses règles pour les expéditeurs exigent un enregistrement PTR pour chaque adresse IP d’envoi. Soit vous le réglez, soit vous empêchez mox d’envoyer en IPv6.
Étape 2 : installer
useradd -m -d /home/mox -s /sbin/nologin mox
cd /home/mox
# télécharger le binaire mox de la dernière version publiée, vérifier sa somme de contrôle
install -o root -g root -m 0755 mox-vX.Y.Z-linux-amd64 /home/mox/mox
./mox quickstart postmaster@example.org
quickstart fait beaucoup en une commande :
Il écrit config/mox.conf (le serveur : nom d’hôte, adresses d’écoute, ACME) et config/domains.conf (les domaines, les comptes, les adresses).
Il génère deux clés DKIM par domaine (une RSA, une Ed25519).
Il crée le compte postmaster et affiche son mot de passe, ainsi que celui de l’interface d’administration.
Il imprime la liste exacte des enregistrements DNS à créer, et une unité systemd prête à l’emploi.
Conservez cette sortie dans un endroit sûr : elle contient des mots de passe et les valeurs DNS. mox tourne sous le compte mox, sans droits root, et obtient seulement par systemd la capacité d’écouter sur les ports inférieurs à 1024.
Étape 3 : le pare-feu
Port
Usage
Ouvrir à
25/tcp
SMTP : réception depuis les autres serveurs, et envoi vers eux
tout le monde
465/tcp
soumission chiffrée (TLS direct) pour vos clients de messagerie
tout le monde
587/tcp
soumission avec STARTTLS (anciens clients, relais de vos autres serveurs)
Le MX dit aux autres serveurs où livrer le courrier de example.org. Il pointe toujours vers un nom, jamais vers une adresse IP, et ce nom doit avoir ses enregistrements A (et AAAA). La priorité (10) ne sert que s’il y a plusieurs MX.
Prouver qui envoie : SPF, DKIM, DMARC
SPF liste les machines autorisées à envoyer pour le domaine. mx autorise les serveurs du MX ; ip4:127.0.0.1 ip6:::1 sert au courrier que le serveur s’envoie à lui-même (voir plus bas) ; ~all marque les autres comme suspects sans les rejeter. N’utilisez -all que si vous êtes certain de connaître toutes vos sources d’envoi (lettres d’information, formulaires de sites…).
DKIM publie les clés publiques qui permettent de vérifier la signature que mox pose sur chaque message sortant. Deux sélecteurs (2026a, 2026b) permettent de changer de clé sans coupure : on publie la nouvelle, on signe avec, puis on retire l’ancienne quelques semaines plus tard.
DMARC dit aux destinataires quoi faire d’un message dont ni SPF ni DKIM ne sont alignés sur le domaine de l’expéditeur, et où envoyer leurs rapports. Commencez par p=none pendant une à deux semaines en lisant les rapports, passez à p=quarantine, puis à p=reject quand vous êtes sûr qu’aucun envoi légitime n’échoue.
Imposer le chiffrement : MTA-STS et TLS-RPT
Sans MTA-STS, un attaquant placé sur le chemin peut retirer l’offre STARTTLS d’une connexion SMTP et lire le courrier en clair. Avec MTA-STS, les serveurs qui vous écrivent apprennent que votre domaine exige TLS avec un certificat valide. mox sert lui-même la politique sur https://mta-sts.example.org/.well-known/mta-sts.txt. TLS-RPT vous fait envoyer chaque jour les rapports des échecs de chiffrement. Changez l’id de _mta-sts à chaque modification de la politique.
Configurer les clients : autoconfig et SRV
Thunderbird, l’application Mail d’Apple et d’autres trouvent seuls les bons serveurs et ports grâce à autoconfig, _autodiscover et aux enregistrements SRV. L’enregistrement CAA, facultatif, n’autorise que Let’s Encrypt à émettre des certificats pour le domaine.
Une commande imprime à tout moment les valeurs attendues : ./mox config dnsrecords example.org.
Étape 5 : démarrer, puis créer les comptes
systemctl enable --now mox
./mox config test
./mox config account add alice alice@example.org
./mox setaccountpassword alice
./mox config address add contact@example.org alice # une adresse de plus pour le même compte
./mox config account list
Côté client de messagerie : IMAP sur mail.example.org:993 (SSL/TLS), envoi sur mail.example.org:465 (SSL/TLS) avec l’adresse complète comme identifiant. Le webmail et l’administration sont sur https://mail.example.org/.
Étape 6 : vérifier, sérieusement
./mox config dnscheck example.org # SPF, DKIM, DMARC, MTA-STS, TLS-RPT, MX, PTR
./mox queue list # la file d'envoi : vide ou presque
journalctl -u mox -n 50 --no-pager
dig +short MX example.org @ns1.fournisseur-dns.example # interrogez les serveurs faisant autorité
Interrogez toujours les serveurs DNS faisant autorité de votre zone : certains résolveurs publics renvoient des réponses vides trompeuses pendant la propagation, et on finit par corriger un problème qui n’existe pas.
Puis le test qui compte : envoyez un message vers une boîte Gmail et une boîte Outlook. Ouvrez l’en-tête complet (« Afficher l’original » dans Gmail) et cherchez spf=pass, dkim=pass et dmarc=pass. Répondez depuis ces boîtes et vérifiez que la réponse arrive. Les premiers jours, quelques messages peuvent tomber en indésirables le temps que votre adresse se fasse une réputation : demandez aux destinataires de les marquer comme légitimes.
Ce que donnent ces vérifications sur mon serveur mail
Mon propre serveur mox, vérifié le 2 novembre 2026. Les noms de domaine sont réécrits en example.org, les valeurs sont réelles.
# ./mox version
v0.0.17-go1.27.0
linux/amd64
# ./mox config test
config OK
# ss -ltn (ports en écoute, mox seulement)
0.0.0.0:25 0.0.0.0:443 0.0.0.0:465 0.0.0.0:587 0.0.0.0:993 (et les mêmes sur [::])
# dig +short MX example.org
10 mail.example.org.
# dig +short TXT _dmarc.example.org
"v=DMARC1;p=reject;rua=mailto:dmarc-reports@example.org!10m"
# dig +short TXT _mta-sts.example.org
"v=STSv1; id=ansible20260915"
# dig +short TXT _smtp._tls.example.org
"v=TLSRPTv1; rua=mailto:tlsreports@example.org"
# openssl s_client -starttls smtp -connect mail.example.org:25 -servername mail.example.org
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)
config OK : les deux fichiers sont lisibles et cohérents. À lancer avant chaque redémarrage ; mox refuse de démarrer sur une configuration invalide, et un serveur mail qui ne redémarre pas, ce sont des messages qui s’accumulent chez les expéditeurs.
Cinq ports, en IPv4 et en IPv6 : 25 (réception depuis les autres serveurs), 465 et 587 (envoi par vos clients), 993 (IMAP), 443 (webmail, administration, politique MTA-STS et certificats automatiques). Le port 80 n’est pas ouvert : les certificats s’obtiennent sur le 443 avec le défi TLS-ALPN.
p=reject dans DMARC : n’y passez que lorsque les rapports agrégés montrent que toutes vos sources légitimes passent, sinon ce sont vos propres messages qui seront rejetés. Le suffixe !10m limite la taille des rapports à 10 Mo.
id=ansible20260915 dans l’enregistrement MTA-STS : l’identifiant signale aux expéditeurs que la politique a changé. Il faut le modifier à chaque changement du fichier de politique, sinon ils garderont la politique en cache jusqu’à l’expiration de son max_age (RFC 8461).
TLSv1.3, Verify return code: 0 : STARTTLS sur le port 25 présente un certificat valide pour le nom du serveur. C’est ce que vérifie un serveur expéditeur quand MTA-STS est en mode « enforce ».
Le courrier que le serveur s’envoie à lui-même
Les rapports de cron, de logwatch ou des sauvegardes sont souvent adressés à root@example.org, hébergé sur ce même serveur. mox n’a pas de livraison locale interne : il cherche le MX, trouve son propre nom, et repasse par son interface publique. Cela échoue par intermittence. La correction consiste à router ce courrier par la boucle locale :
# domains.conf : router le courrier de nos propres domaines par la boucle locale
Routes:
-
ToDomain:
- example.org
Transport: loopback
# mox.conf
Transports:
loopback:
SMTP:
Host: mail-loopback.example.org # 127.0.0.1 dans /etc/hosts
Port: 25
STARTTLSInsecureSkipVerify: true
Deux conséquences à traiter. mox applique ses contrôles à ce courrier comme à n’importe quel autre : il faut donc ip4:127.0.0.1 ip6:::1 dans le SPF (sans risque, personne d’extérieur ne se connecte depuis la boucle locale). Et le compte qui ne reçoit que ce courrier interne doit avoir NoFirstTimeSenderDelay: true : sinon mox applique à ces expéditeurs « inconnus » un délai de quinze secondes que le client de la boucle n’attend pas.
Faire envoyer vos autres serveurs par mox
Vos autres machines ont besoin d’envoyer du courrier (alertes, formulaires PHP, rapports). Plutôt que d’en faire autant de serveurs de courrier mal vus, configurez sur chacune un Postfix minimal qui confie tout à mox, en s’authentifiant sur le port 587 avec un compte dédié :
# /etc/postfix/main.cf sur un AUTRE serveur, qui confie tout son courrier à mox
relayhost = [mail.example.org]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
inet_interfaces = loopback-only
inet_interfaces = loopback-only : ce Postfix n’écoute que localement, il ne reçoit jamais de courrier de l’extérieur. Sur une machine sous SELinux, PHP a besoin du booléen httpd_can_sendmail pour utiliser mail().
mox backup produit une copie cohérente des bases de comptes et des messages sans arrêter le service ; mox verifydata vérifie le répertoire data de la sauvegarde. Copiez chaque nuit la sauvegarde hors du serveur. mox backup copie la configuration, qui contient les clés DKIM, dans config à côté de data : une seule copie couvre les deux. Un serveur de courrier, c’est aussi une identité : sans les clés DKIM d’origine, il faudrait republier le DNS. Pour restaurer, on arrête mox, on remplace le répertoire de données par la sauvegarde, on lance verifydata, puis on redémarre.
Mettre à jour
Une mise à jour consiste à remplacer le binaire et à redémarrer. Lisez les notes de version avant : certaines versions renomment des options de configuration. Lancez une sauvegarde juste avant, puis ./mox config test avec le nouveau binaire avant de redémarrer le service.
Diagnostiquer un message refusé
Gmail refuse, « PTR » ou « reverse DNS » dans le message d’erreur : PTR absent ou incohérent, souvent en IPv6.
Message en indésirables chez tout le monde : vérifiez que DKIM et DMARC passent, et l’adresse IP sur les listes de blocage.
Messages qui restent dans la file : ./mox queue list donne la dernière erreur de chaque message ; un délai d’attente vers tous les destinataires indique le port 25 sortant bloqué.
Refus pour spf-policy sur le courrier interne : il manque la boucle locale dans le SPF.
Les rapports DMARC arrivent chaque jour dans la boîte dmarc-reports : mox les lit et les présente dans son interface. C’est là qu’on découvre qu’un service tiers envoie en votre nom sans être autorisé.
Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »). Série « Pas à pas », tutoriel 8/77. · Tutoriel suivant → · English version