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
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.
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.
On trouve sur Internet des fichiers my.cnf de cent lignes, copiés de serveur en serveur, dont personne ne sait plus pourquoi chaque ligne est là. La plupart des valeurs par défaut de MariaDB sont raisonnables ; une dizaine de réglages seulement font une vraie différence. Ce tutoriel les passe en revue à partir d’un serveur réel : MariaDB 12.3 sur Rocky Linux, une trentaine de sites (WordPress, Matomo, Piwigo…), 32 Go de RAM partagés avec PHP, nginx et d’autres services, des disques rotatifs.
Pour chaque réglage : ce qu’il fait, comment choisir la valeur, et surtout comment vérifier qu’elle est bonne. Un réglage qu’on ne sait pas mesurer est un réglage qu’on ne devrait pas toucher.
Les réglages MariaDB qui comptent vraiment, regroupés par famille, avec comment les vérifier.
Où vit la configuration de MariaDB, et comment lire la valeur réelle
MariaDB lit /etc/my.cnf puis les fichiers de /etc/my.cnf.d/. Les réglages du serveur vont dans une section [mariadb] (lue seulement par MariaDB) ou [mysqld] (lue aussi par les outils compatibles MySQL). Le piège classique : une même variable définie dans deux fichiers, la dernière lue l’emporte. Deux commandes donnent la vérité :
mariadbd --print-defaults # ce que le serveur lira au prochain démarrage
mariadb -e "SHOW GLOBAL VARIABLES LIKE 'innodb_buffer_pool_size'" # la valeur en cours
Une variable mal orthographiée dans le fichier est ignorée avec un simple avertissement dans le journal d’erreurs. Comparez toujours la valeur en cours avec celle que vous croyez avoir mise.
innodb_buffer_pool_size, le réglage qui compte le plus
C’est le cache dans lequel InnoDB garde les pages de tables et d’index. Tant que les données réellement utilisées y tiennent, les lectures ne touchent pas le disque, et c’est souvent un facteur cent sur les temps de réponse. La règle habituelle : 60 à 75 % de la RAM sur un serveur dédié à la base, 30 à 40 % sur un serveur qui fait aussi tourner l’application et le serveur web. Ici, 12 Go sur 32.
Pour savoir s’il suffit, mesurez la proportion de lectures servies par le cache :
SELECT ROUND(100 * (1 - r.v / q.v), 2) AS hit_ratio_pct
FROM (SELECT VARIABLE_VALUE v FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') r,
(SELECT VARIABLE_VALUE v FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') q;
Au-dessus de 99 % sur une journée d’activité normale, la mémoire n’est pas votre problème. En dessous de 95 %, chaque requête qui touche des données froides va lire le disque : augmentez le cache si la RAM le permet, ou cherchez les requêtes qui balaient des tables entières. Depuis MariaDB 10.5, il n’y a plus lieu de découper le cache en innodb_buffer_pool_instances : c’est géré en interne.
innodb_log_file_size : dimensionner le journal redo
Chaque modification est d’abord écrite dans le journal redo, puis les pages modifiées sont écrites dans les fichiers de données en tâche de fond. Un journal trop petit oblige InnoDB à vider ses pages en urgence quand il se remplit, ce qui produit des pics de latence. Une bonne taille couvre environ une heure d’écritures. Mesurez-la :
SELECT VARIABLE_VALUE INTO @a FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME='Innodb_lsn_current';
SELECT SLEEP(3600);
SELECT VARIABLE_VALUE INTO @b FROM information_schema.GLOBAL_STATUS WHERE VARIABLE_NAME='Innodb_lsn_current';
SELECT ROUND((@b - @a) / 1024 / 1024) AS redo_mb_per_hour;
Si le résultat est de 400 Mo par heure, un journal de 512 Mo à 1 Go suffit ; ici 2 Go laissent de la marge pour les imports. Un journal plus grand allonge seulement la récupération après un crash, de quelques secondes à quelques minutes.
La mémoire par connexion : le piège de la multiplication
sort_buffer_size, join_buffer_size, read_buffer_size, read_rnd_buffer_size sont alloués par connexion, et parfois plusieurs fois par requête. Une valeur de 64 Mo pour join_buffer_size semble anodine, mais multipliée par 400 connexions elle peut théoriquement réclamer 25 Go. Gardez les valeurs par défaut sauf mesure contraire, et relevez-les seulement pour une session précise (SET SESSION) lors d’un traitement lourd.
tmp_table_size et max_heap_table_size vont ensemble : la plus petite des deux fixe la taille maximale d’une table temporaire en mémoire avant son passage sur disque. Surveillez le rapport entre Created_tmp_disk_tables et Created_tmp_tables : s’il dépasse 10 à 20 %, des requêtes trient ou regroupent de gros volumes.
La durabilité
innodb_flush_log_at_trx_commit = 1 écrit et synchronise le journal redo à chaque validation ; sync_binlog = 1 fait de même pour le binlog. Ensemble, ils garantissent qu’une coupure de courant ne fait perdre aucune transaction validée et que le réplica reste cohérent avec le serveur principal. Les valeurs 2 ou 0 réduisent le nombre d’écritures synchrones, au prix de jusqu’à une seconde de transactions perdues en cas de crash du système : acceptable sur un réplica qu’on reconstruit, jamais sur le principal.
innodb_flush_method = O_DIRECT fait écrire InnoDB directement sur le disque sans passer par le cache du système : les données ne sont pas mises en cache deux fois (dans le cache d’InnoDB et dans celui du noyau). C’est la valeur par défaut des versions récentes ; vérifiez-la plutôt que de la supposer.
Les entrées/sorties
innodb_io_capacity indique à InnoDB combien d’opérations par seconde le stockage peut absorber pour ses travaux de fond (vidage des pages, purge). La valeur par défaut convient aux SSD. Sur des disques rotatifs, 200 évite qu’InnoDB sature le disque au détriment des requêtes ; innodb_io_capacity_max fixe le plafond en cas de retard. Pour vérifier : iostat -x 5 pendant une période chargée, la colonne %util ne doit pas rester collée à 100.
Les connexions
max_connections doit couvrir la somme des connexions que les applications peuvent ouvrir, avec une marge, sans plus : chaque connexion consomme de la mémoire, et augmenter cette limite pour « réparer » des erreurs Too many connections cache souvent une application qui ne ferme pas ses connexions. Regardez le pic réel :
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW GLOBAL STATUS LIKE 'Aborted_c%';
SHOW GLOBAL STATUS LIKE 'Threads_created';
thread_cache_size garde des fils d’exécution prêts à resservir : si Threads_created augmente rapidement, augmentez-le. wait_timeout ferme les connexions inactives ; 8 heures conviennent aux applications web, qui se reconnectent d’elles-mêmes.
Les tables et les fichiers
table_open_cache garde les tables ouvertes entre deux requêtes ; il est découpé en table_open_cache_instances pour limiter la contention entre fils. Si Opened_tables augmente sans cesse, le cache est trop petit.
Le piège qui m’a coûté du temps : ce cache dépend du nombre de fichiers que le processus a le droit d’ouvrir. MariaDB calcule ses limites à partir d’open_files_limit, mais systemd impose sa propre limite (LimitNOFILE). Si celle-ci est plus basse, MariaDB réduit silencieusement ses caches au démarrage : j’ai ainsi trouvé un serveur réglé sur 8 instances qui n’en utilisait que 3. La correction est un fichier de surcharge systemd :
systemctl daemon-reload && systemctl restart mariadb
mariadb -e "SHOW GLOBAL VARIABLES WHERE Variable_name IN ('open_files_limit','table_open_cache_instances')"
Les binlogs et la réplication
Le binlog enregistre chaque modification ; il sert à la réplication et à la restauration à un instant précis. binlog_format = ROW enregistre les lignes modifiées plutôt que les requêtes : plus volumineux, mais sans ambiguïté (une requête avec NOW() ou RAND() ne donne pas le même résultat rejouée ailleurs). binlog_expire_logs_seconds purge les anciens fichiers : gardez au moins l’intervalle entre deux sauvegardes complètes, sinon la restauration à un instant précis devient impossible pour la période intermédiaire. server_id doit être unique dans toute la topologie de réplication, et log_slave_updates permet à un réplica de servir lui-même de source.
L’hygiène
utf8mb4 : le vrai UTF-8 sur 4 octets, émojis compris. Le jeu utf8 historique est limité à 3 octets et tronque certains caractères ;
collation_server : les versions récentes proposent les collations UCA 14.0 (utf8mb4_uca1400_ai_ci), plus justes pour les comparaisons et les tris en français ;
READ-COMMITTED : moins de verrous que le REPEATABLE-READ par défaut, adapté aux applications web ;
skip_name_resolve : aucune résolution DNS à chaque connexion (un DNS lent rendrait les connexions lentes), et les droits se donnent par adresse IP ;
local_infile = OFF : ferme une porte qui permet à un client de faire lire des fichiers ;
max_allowed_packet : la taille maximale d’une requête ou d’une ligne ; trop petite, les imports et les gros contenus échouent ;
le journal des requêtes lentes : au-delà de 5 secondes, avec le plan d’exécution. À relire chaque semaine : c’est la meilleure source d’optimisation qui soit.
La méthode
changez un seul réglage à la fois, et notez la mesure avant et après ;
beaucoup de variables se modifient à chaud (SET GLOBAL) pour un essai ; écrivez-les ensuite dans le fichier, sinon elles disparaissent au redémarrage ;
celles qui exigent un redémarrage (taille du journal redo, open_files_limit) se planifient ;
gardez la configuration sous gestion de version, avec en commentaire la raison de chaque valeur non standard. Dans six mois, ce commentaire vaudra plus que la valeur elle-même.
Si un exporteur Prometheus surveille la base, comparez sur quelques jours le taux de réussite du cache, les tables temporaires sur disque, les connexions et la latence des requêtes. Un réglage n’est bon que si ces courbes le confirment.
Une vraie vérification, 75 minutes après un redémarrage
Pour montrer ce que donnent ces vérifications sur un vrai serveur, voici le MariaDB 12.3 de mon serveur web le 2 novembre 2026, lu avec le compte de supervision (qui n’a besoin que de lire variables et compteurs). La machine avait redémarré 75 minutes plus tôt, ce qui rend les chiffres plus instructifs, pas moins.
Taux de réussite du buffer pool : 1 − 121 600 / 5 163 358 = 97,6 %. Sur un serveur démarré depuis des semaines, j’attends plus de 99,9 %. Ici, le cache se remplit encore : chaque page lue sur disque depuis le redémarrage compte comme un échec. C’est pour cela que le taux se compare sur plusieurs jours, jamais juste après un redémarrage, et que la première heure qui suit est plus lente.
Innodb_log_waits = 0 : aucune transaction n’a dû attendre de la place dans le tampon du journal. Avec un journal de reprise de 2 Go, même l’activité du démarrage ne l’a pas saturé.
Max_used_connections = 61 sur 400 autorisées, et Threads_created = 61 pour 11 846 connexions : le cache de threads réutilise les threads au lieu d’en créer un par connexion. Si Threads_created grimpait presque aussi vite que Connections, thread_cache_size serait trop petit.
Tables temporaires sur disque : 5 223 sur 37 637, soit 14 %. Ce sont surtout des requêtes WordPress qui trient des colonnes de texte, lesquelles partent sur disque quelle que soit la valeur de tmp_table_size. Au-delà de 25 %, il vaut la peine de chercher les requêtes responsables avant de relever une limite.
innodb_flush_log_at_trx_commit = 1 et sync_binlog = 1 : durabilité complète sur le primaire. Le réplica tourne avec 2 et 0 ; les raisons sont dans la série consacrée à MariaDB.
Temps total : deux requêtes, une minute. Lancez-les avant et après chaque changement, et gardez les sorties : c’est ce qui transforme « j’ai l’impression que c’est plus rapide » en mesure.
Ce tutoriel relie un serveur hébergé (un VPS sous Rocky Linux) au réseau de la maison par un tunnel WireGuard. À la fin, le serveur joint les machines de la maison, la maison joint le serveur, et seul un port UDP est exposé sur Internet. C’est la base de nombreux usages : sauvegardes envoyées vers un NAS, supervision d’un serveur public depuis la maison, accès à des services qu’on ne veut surtout pas publier.
Chaque ligne de configuration est expliquée, ainsi que les vérifications à faire et les pannes les plus fréquentes. Les adresses sont des exemples ; adaptez-les à votre plan d’adressage.
Le tunnel WireGuard entre le serveur et la maison, et les trois étapes sur le serveur.
Pourquoi WireGuard
WireGuard tient en quelques milliers de lignes de code dans le noyau Linux, contre des centaines de milliers pour OpenVPN ou IPsec. Il n’offre aucun choix d’algorithme : la cryptographie est fixée (Curve25519, ChaCha20-Poly1305, BLAKE2s), ce qui supprime une source entière d’erreurs de configuration. Il est sans état apparent : pas de session à négocier à la main, une poignée de main renouvelée automatiquement toutes les deux minutes environ, et un tunnel qui survit aux changements d’adresse d’un des pairs. Enfin, il est silencieux : un paquet qui n’est pas authentifié par une clé connue ne reçoit aucune réponse, le port semble fermé à un balayage.
Les notions à avoir en tête
Une paire de clés par extrémité. La clé privée reste sur la machine ; la clé publique sert d’identité auprès de l’autre côté. Il n’y a ni utilisateur ni mot de passe ;
Des pairs, pas un client et un serveur. Chaque côté déclare l’autre comme pair. Ce qui ressemble à un « serveur » est simplement le pair qui a une adresse fixe et attend qu’on l’appelle ;
Le routage par clé. La directive AllowedIPs associe des réseaux à un pair. Un paquet destiné à ces réseaux part chiffré vers ce pair ; un paquet reçu de ce pair n’est accepté que si son adresse source appartient à ces réseaux ;
UDP uniquement. Tout passe dans un seul port UDP, ce qui traverse bien les traductions d’adresses des box.
Le plan d’adressage
Élément
Valeur
Rôle
Réseau du tunnel
10.99.0.0/24
adresses internes au tunnel, une par extrémité
Routeur (tunnel)
10.99.0.2
côté maison, derrière la box
Serveur (tunnel)
10.99.0.3
le VPS
Réseau de la maison
192.168.10.0/24
ce que le serveur doit pouvoir joindre
Serveur (public)
203.0.113.25
adresse publique fixe du VPS, port UDP 51820
Choisissez un réseau de tunnel qui n’existe nulle part ailleurs chez vous, ni chez l’hébergeur : un chevauchement d’adresses produit des pannes très difficiles à comprendre.
Prérequis
un serveur Rocky Linux 9 ou 10 avec une adresse IPv4 publique fixe et un accès root ;
un routeur à la maison qui sait faire du WireGuard (RouterOS 7 de MikroTik, OpenWrt, pfSense, OPNsense…), ou à défaut une machine Linux allumée en permanence ;
un accès console au serveur chez l’hébergeur, au cas où une erreur de pare-feu couperait SSH.
Étape 1 : installer les outils
Le module noyau fait partie de Rocky Linux depuis la version 9. Il ne manque que les outils en espace utilisateur, wg et wg-quick :
dnf install wireguard-tools
modprobe wireguard
lsmod | grep wireguard # le module est chargé
Étape 2 : générer les clés
umask 077
cd /etc/wireguard
wg genkey | tee server.key | wg pubkey > server.pub
wg genpsk > maison.psk # clé partagée, facultative mais recommandée
cat server.pub
Le umask 077 garantit que les fichiers sont créés lisibles par root seulement, sans fenêtre pendant laquelle un autre utilisateur pourrait les lire. La clé partagée (preshared key) ajoute une couche de chiffrement symétrique par-dessus l’échange de clés : elle ne remplace rien, mais protège le trafic enregistré aujourd’hui contre un futur ordinateur quantique capable de casser Curve25519. Elle doit être identique des deux côtés et ne circuler que par un canal sûr.
Faites de même sur le routeur, et notez sa clé publique. Les clés privées n’apparaissent jamais dans un fichier versionné en clair : placez-les dans un coffre à secrets (Vault, OpenBao, Ansible Vault) si vous automatisez le déploiement.
Étape 3 : écrire /etc/wireguard/wg0.conf
[Interface]
Address = 10.99.0.3/24
ListenPort = 51820
PrivateKey = <clé privée du serveur>
MTU = 1420
SaveConfig = false
[Peer]
# routeur de la maison
PublicKey = <clé publique du routeur>
PresharedKey = <clé partagée>
AllowedIPs = 192.168.10.0/24, 10.99.0.0/24
PersistentKeepalive = 25
La section [Interface]
Address : l’adresse du serveur dans le tunnel. Le masque /24 fait créer par wg-quick une route vers tout le réseau du tunnel, utile si d’autres serveurs rejoignent le même tunnel plus tard ;
ListenPort : le port UDP d’écoute. 51820 est la convention, rien n’oblige à l’utiliser ;
PrivateKey : le contenu de server.key. Une variante consiste à ne pas l’écrire dans le fichier et à la charger par PostUp = wg set %i private-key /etc/wireguard/server.key ;
MTU = 1420 : la taille maximale des paquets dans le tunnel. WireGuard ajoute 60 octets d’en-tête en IPv4 (80 en IPv6) ; 1420 laisse la place sur un lien Ethernet classique à 1500. Si la box de la maison utilise PPPoE (MTU 1492), descendez à 1412, voire 1380 en cas de doute ;
SaveConfig = false : empêche wg-quick de réécrire le fichier à l’arrêt avec l’état courant, ce qui effacerait vos commentaires et pourrait y figer une modification faite à la main pour un test.
La section [Peer]
PublicKey : l’identité du routeur ;
PresharedKey : la clé partagée générée plus haut ;
AllowedIPs : la directive la plus importante, et la plus mal comprise. Elle sert à la fois de table de routage (les paquets vers 192.168.10.0/24 et 10.99.0.0/24 partent vers ce pair) et de filtre (un paquet reçu de ce pair dont la source n’est pas dans ces réseaux est jeté). On n’y met pas 0.0.0.0/0 : seul le trafic destiné à la maison passe par le tunnel, le reste du trafic du serveur sort normalement. C’est ce qu’on appelle un tunnel partagé (split tunnel) ;
pas d’Endpoint : le serveur ne sait pas où joindre la maison, dont l’adresse publique peut changer. C’est le routeur qui l’appelle ; le serveur apprend son adresse à la première poignée de main et la met à jour s’il change ;
PersistentKeepalive = 25 : un petit paquet toutes les 25 secondes. Indispensable côté routeur, car la box oublie une traduction d’adresses UDP inactive au bout de 30 à 120 secondes et le serveur ne pourrait plus joindre la maison. Côté serveur, c’est sans inconvénient et cela garde le tunnel actif dans les deux sens.
Étape 4 : le côté maison
Le routeur déclare le serveur comme pair, avec cette fois un Endpoint : l’adresse publique du VPS. Ses AllowedIPs pour ce pair se limitent à l’adresse du serveur dans le tunnel. Exemple sur un MikroTik sous RouterOS 7 :
Il faut aussi autoriser, dans le pare-feu du routeur, le trafic entre l’interface WireGuard et le réseau local (chaîne forward), et le placer avant les règles de blocage génériques. Une règle courante qui rejette les adresses privées (« bogons ») doit ne s’appliquer qu’à l’interface côté Internet : sinon elle rejette le réseau du tunnel lui-même.
Étape 5 : le pare-feu du serveur
firewall-cmd --permanent --add-port=51820/udp
# rattacher wg0 à une zone : 'trusted' pour tout autoriser depuis la maison,
# ou une zone dédiée qui n'ouvre que le nécessaire (SSH, exporteurs…)
firewall-cmd --permanent --new-zone=wireguard
firewall-cmd --permanent --zone=wireguard --add-interface=wg0
firewall-cmd --permanent --zone=wireguard --add-service=ssh
firewall-cmd --reload
Une zone dédiée est plus prudente que trusted : si une machine de la maison était compromise, elle ne verrait du serveur que les services que vous avez choisis. Le serveur n’a pas besoin de faire suivre des paquets entre réseaux (net.ipv4.ip_forward peut rester à 0) puisqu’il n’est qu’une extrémité.
Étape 6 : démarrer
systemctl enable --now wg-quick@wg0
wg-quick crée l’interface wg0, lui donne son adresse, charge la configuration WireGuard et ajoute les routes déduites d’AllowedIPs. Le service se relance au démarrage. Pour appliquer une modification du fichier sans couper le tunnel : wg syncconf wg0 <(wg-quick strip wg0).
Étape 7 : vérifier, dans l’ordre
wg show wg0
# peer: <clé publique du routeur>
# endpoint: 198.51.100.7:13231
# allowed ips: 192.168.10.0/24, 10.99.0.0/24
# latest handshake: 41 seconds ago
# transfer: 1.20 MiB received, 3.45 MiB sent
latest handshake de moins de deux minutes : les clés et le port UDP sont bons ;
endpoint renseigné : le serveur connaît l’adresse actuelle de la maison ;
transfer qui augmente dans les deux sens : du trafic circule.
ip route get 192.168.10.20 # doit répondre « dev wg0 »
ping -c3 10.99.0.2 # le routeur dans le tunnel
ping -c3 192.168.10.20 # une machine de la maison
ssh admin@192.168.10.20 # une vraie connexion TCP
# depuis une machine de la maison, dans l'autre sens :
ssh root@10.99.0.3
Ne vous arrêtez pas au ping. Un pare-feu peut accepter l’ICMP tôt dans sa chaîne et rejeter les nouvelles connexions TCP plus loin : le ping passe, rien d’autre. Testez une vraie connexion, dans les deux sens.
À quoi ça ressemble sur mon serveur
Voici la même vérification sur mon serveur web, relevée le 2 novembre 2026, une heure environ après un redémarrage. Les adresses sont réécrites selon le plan de ce tutoriel ; tout le reste est tel qu’affiché.
# wg show wg0
interface: wg0
public key: (masquée)
private key: (masquée)
listening port: 51820
peer: (clé publique du routeur)
endpoint: 198.51.100.7:13231
allowed ips: 192.168.10.0/24, 10.99.0.0/24
latest handshake: 1 minute, 9 seconds ago
transfer: 4.01 MiB received, 74.50 MiB sent
persistent keepalive: every 25 seconds
# ip route get 192.168.10.20
192.168.10.20 dev wg0 src 10.99.0.3 uid 0
# ping -c3 -q 10.99.0.2
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 8.060/8.424/8.976/0.396 ms
latest handshake: 1 minute, 9 seconds ago : WireGuard renouvelle la clé de session toutes les deux minutes tant que du trafic circule ; tout ce qui reste sous deux minutes et demie environ est normal. Une valeur en heures, ou l’absence de la ligne, signifie que les deux côtés ne s’entendent plus sur les clés ou que le port UDP est bloqué.
transfer: 4.01 MiB received, 74.50 MiB sent : l’asymétrie est attendue ici, le serveur pousse sauvegardes et métriques vers la maison. Ce qui compte, c’est que les deux compteurs augmentent.
persistent keepalive: every 25 seconds : le réglage de la section [Peer], bien appliqué. Sans lui, la traduction d’adresses du routeur oublie le flux après quelques minutes de silence.
8,4 ms aller-retour entre un centre de données et une fibre domestique : le tunnel lui-même ajoute bien moins d’une milliseconde, le reste, c’est la distance.
Deux autres tests méritent une place dans le carnet. Le premier mesure le plus gros paquet que le tunnel accepte sans fragmentation, ce qui permet de confirmer la MTU de 1420 :
# ping -c2 -M do -s 1392 192.168.10.20 # 1392 + 28 octets d'en-tête = 1420
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
# ping -c1 -M do -s 1393 192.168.10.20 # un octet de plus
ping: sendmsg: Message too long
-M do interdit la fragmentation, -s fixe la taille des données, et les en-têtes ICMP et IP ajoutent 28 octets. 1420 passe, 1421 est refusé localement avec Message too long : la MTU de l’interface est exactement celle demandée par la configuration. Si les gros transferts se figent alors que les petits échanges passent, c’est le premier test à faire, depuis les deux bouts.
Le second est une vraie connexion TCP, parce qu’un ping ne prouve rien sur le pare-feu : une session SSH depuis le serveur vers une machine de la maison, à travers le tunnel, s’est ouverte en 0,27 s, bannière comprise.
Les pannes les plus fréquentes
Aucune poignée de main
Le port UDP est fermé (chez l’hébergeur ou dans firewalld), les clés publiques sont inversées, ou le routeur a une mauvaise adresse d’Endpoint. tcpdump -ni eth0 udp port 51820 sur le serveur montre si les paquets du routeur arrivent : s’ils arrivent sans réponse, c’est une clé ; s’ils n’arrivent pas, c’est le réseau.
Poignée de main, mais aucun trafic
Les AllowedIPs ne correspondent pas des deux côtés : le routeur envoie des paquets dont la source n’est pas dans la liste du serveur, qui les jette sans bruit. Ou la route de retour manque côté maison.
Le ping passe, le TCP non
Une règle de pare-feu traite l’ICMP à part. Le cas typique est une liste d’adresses « bogons » contenant 10.0.0.0/8 et appliquée à toutes les interfaces : elle rejette le réseau du tunnel dans la chaîne forward. Limitez-la à l’interface Internet.
Les petits échanges passent, les gros transferts se figent
Un problème de MTU. Les paquets pleins dépassent la taille permise quelque part sur le chemin et sont perdus. Testez avec ping -M do -s 1372 192.168.10.20 en augmentant la taille jusqu’à l’échec, puis baissez le MTU du tunnel en conséquence des deux côtés.
Tout marche, puis plus rien au bout d’un moment
Le PersistentKeepalive manque côté routeur, et la box a oublié la traduction d’adresses. Ou l’adresse publique de la maison a changé et le serveur ne l’a pas encore apprise : c’est au routeur de relancer la poignée de main, ce que le keepalive fait tout seul.
Les noms des machines de la maison ne se résolvent pas
Le tunnel transporte des adresses, pas des noms. Pour résoudre nas.maison.lan depuis le serveur, faites passer uniquement ce domaine par le serveur DNS de la maison, par exemple avec le greffon dnsmasq de NetworkManager, plutôt que de remplacer tout le DNS du serveur.
Entretenir le tunnel
Changer de clés sans perdre l’accès : ajoutez d’abord la nouvelle clé publique du serveur comme second pair sur le routeur, basculez le serveur sur sa nouvelle clé, vérifiez la poignée de main, puis retirez l’ancien pair. Ne modifiez jamais les deux côtés à l’aveugle depuis l’extérieur ;
Ajouter un second serveur : nouvelle adresse dans le tunnel (10.99.0.4), nouveau pair sur le routeur avec allowed-address=10.99.0.4/32. Chaque adresse ne doit apparaître que dans un seul pair, sinon le routage devient imprévisible ;
Surveiller : l’âge de la dernière poignée de main est le meilleur indicateur. Au-delà de trois minutes, le tunnel est tombé. Un script qui lit wg show wg0 latest-handshakes et publie la valeur pour Prometheus, ou une simple sonde depuis la maison, suffit ;
Modifier un routeur à distance : activez son mode sans échec (Safe Mode sur RouterOS) avant toute modification. Si la session est perdue, le routeur annule tout ce qui a été fait depuis.
Pour aller plus loin
Le même tunnel peut transporter la supervision (Prometheus qui interroge les exporteurs du serveur), les sauvegardes (rsync vers un compte enfermé dans un répertoire du NAS) et l’administration (SSH limité à l’interface du tunnel). Une fois le tunnel en place, beaucoup de ports qu’on aurait été tenté d’ouvrir sur Internet n’ont plus besoin de l’être.
Un mercredi soir, mise à jour classique du serveur de la maison : dnf update -y, une centaine de paquets dont un nouveau noyau et de nouveaux firmwares, puis reboot. D’habitude, la machine revient en moins d’une minute. Cette fois, rien. Pas de ping, pas de SSH, et surtout aucune réponse ARP sur le réseau local : vu du switch, la carte réseau n’existait plus. Cet article reprend l’enquête dans l’ordre : le symptôme, les traces qu’un arrêt laisse derrière lui, puis le coupable : la carte mère.
Ce que les traces d’un reboot prouvent, et où elles s’arrêtent.
En un coup d’œil
Indice
Ce que j’ai observé
Ce que ça prouve
Réseau
Ni ping, ni SSH, aucune réponse ARP
Le noyau ne tourne pas, ou la carte réseau n’est pas initialisée
Wazuh
Dernier événement « Wazuh agent stopped » à l’heure du reboot
Arrêt propre, pas un crash
rsyslog
Le journal s’arrête à « System is rebooting »
Silence normal : rsyslog s’arrête parmi les premiers services
wtmp
Entrée shutdown à 19:50:04
systemd est allé au bout de la séquence d’arrêt
Montages XFS
« Ending clean mount » sur les quatre systèmes de fichiers
Tous les disques ont été démontés proprement
Conclusion
Trente minutes sans aucune activité
Blocage après l’appel de redémarrage du noyau : carte mère ou firmware
Le symptôme : une machine muette
Le détail de l’ARP compte. Une machine qui démarre mais dont un service plante répond toujours à l’ARP, car c’est le noyau qui répond, pas une application. L’absence d’ARP signifie que le noyau ne tourne pas, ou que la carte réseau n’est pas initialisée. Le problème est donc bas niveau : démarrage du noyau, initramfs, firmware de la carte mère, ou machine tout simplement éteinte.
Ce qu’on peut vérifier à distance
Avant de descendre devant la machine, j’ai rassemblé ce qui était visible depuis le reste du réseau :
arping -c 3 -I eth0 192.168.10.17 # aucune réponse : la carte réseau est muette
ip neigh show 192.168.10.17 # entrée FAILED dans la table ARP du voisin
Le SIEM (Wazuh) avait reçu un dernier événement de l’agent de la machine : « Wazuh agent stopped », à l’heure exacte du reboot. C’est le signe d’un arrêt propre, pas d’un crash. Un crash n’aurait laissé aucun message.
Les autres machines du réseau fonctionnaient normalement : le problème ne venait ni du switch ni du routeur.
La machine virtuelle hébergée sur ce serveur était tombée avec lui, ce qui est cohérent avec un hôte éteint.
Ce qu’il fallait voir à l’écran
Sans carte de gestion à distance (pas d’IPMI sur une carte mère grand public), la seule façon de savoir est de regarder l’écran. Voici la liste que je me suis préparée, de la cause la plus probable à la moins probable, pour ne pas improviser devant la console :
Bloqué dans GRUB ? Choisir le noyau précédent dans le menu. S’il démarre, le nouveau noyau ou son initramfs sont en cause. On le fixe ensuite avec grubby --set-default.
Réétiquetage SELinux ou fsck en cours ? C’est long sur un gros disque. Il faut attendre, surtout ne pas couper.
Démarré mais sans réseau ?ip -br a et nmcli dev : une carte renommée ou « unmanaged » après une mise à jour arrive plus souvent qu’on ne croit.
Écran noir, ventilateurs qui tournent, rien ne bouge ? C’est le cas le plus déroutant. C’était le mien.
La machine a fini par repartir après une demi-heure, avec un appui long sur le bouton d’alimentation. Le système a démarré normalement sur le nouveau noyau, sans aucune erreur. Restait à comprendre ce qui s’était passé, alors que le journal système ne gardait que le démarrage en cours. C’est l’objet de la section suivante.
Lire les traces d’un arrêt
La machine est revenue, mais journalctl ne montre que le démarrage en cours. Sur ce serveur, le journal n’est pas persistant (Storage=auto sans répertoire /var/log/journal). Les traces du démarrage précédent sont donc perdues de ce côté-là. Heureusement, d’autres sources survivent à un redémarrage.
Source 1 : rsyslog et /var/log/messages
grep -E "System is rebooting|Command line:" /var/log/messages | tail -4
# 19:49:42 systemd-logind: System is rebooting.
# 20:20:22 kernel: Command line: BOOT_IMAGE=(hd0,gpt2)/vmlinuz-6.12.0-211.67.1…
Le journal s’arrête net à « System is rebooting », puis reprend trente minutes plus tard avec la ligne de commande du nouveau noyau. Ce silence n’est pas suspect : rsyslog fait partie des premiers services arrêtés pendant un reboot, il n’écrit donc jamais la fin de la séquence d’arrêt. Il faut chercher la suite ailleurs.
Source 2 : wtmp, le registre des connexions et des arrêts
last -x -F | head -6
# reboot system boot 6.12.0-211.67.1 Wed Oct 21 20:20:26 2026 still running
# shutdown system down 6.12.0-211.54.1 Wed Oct 21 19:50:04 2026 - 20:20:26 (00:30)
last -x affiche aussi les entrées système, pas seulement les connexions. La ligne shutdown system down est écrite par systemd-update-utmpà la toute fin de la séquence d’arrêt, quand presque tous les services sont déjà arrêtés. Sa présence prouve que systemd est allé au bout. Elle est horodatée à 19:50:04, soit 22 secondes après le début du reboot. La colonne « (00:30) » est la durée jusqu’au démarrage suivant : trente minutes pendant lesquelles la machine n’a rien fait.
Source 3 : le montage des systèmes de fichiers au démarrage suivant
Quand un système de fichiers XFS n’a pas été démonté proprement, le montage suivant rejoue son journal et affiche « Starting recovery » puis « Ending recovery ». Ici, les quatre systèmes de fichiers indiquent « Ending clean mount » : ils avaient tous été démontés correctement. Le système d’exploitation avait donc terminé son arrêt avant que la machine ne se fige.
Où s’arrêtent les traces : tout est journalisé jusqu’à l’appel de redémarrage du noyau, le blocage est côté matériel.
Ce que ça permet de conclure
l’arrêt systemd est allé au bout (entrée shutdown dans wtmp) ;
les disques ont été démontés proprement (montage « clean » ensuite) ;
la machine n’a ensuite plus rien fait pendant trente minutes, jusqu’à ce qu’on la relance à la main.
Le blocage s’est donc produit après que le noyau a demandé à la carte mère de redémarrer, quelque part entre cet appel et le BIOS. Ce n’est ni un paquet, ni un service, ni un système de fichiers.
Pour la prochaine fois
Rendre le journal persistant simplifie beaucoup ce genre d’enquête. C’est une ligne :
Même persistant, le journal ne verra jamais ce qui se passe après l’arrêt du noyau. Les indices de wtmp et des montages restent les plus fiables pour savoir si un arrêt s’est terminé proprement.
Le coupable : la carte mère
Récapitulons les preuves : l’arrêt systemd s’est terminé, les systèmes de fichiers ont été démontés proprement, puis la machine n’a plus rien fait pendant trente minutes. Le coupable se trouve donc entre l’appel de redémarrage du noyau et le premier écran du BIOS. Autrement dit, la carte mère n’a jamais effectué sa réinitialisation.
Comment Linux redémarre une machine
À la fin de l’arrêt, le noyau appelle reboot(), et celui-ci doit déclencher un vrai reset matériel. Sur x86, le noyau essaie plusieurs méthodes, dans un ordre par défaut qui dépend de la machine :
acpi : écrire dans le registre de reset déclaré par le firmware (table ACPI FADT) ;
kbd : la méthode historique, une impulsion via le contrôleur clavier ;
pci : écrire dans le port 0xCF9, le registre de reset du chipset ;
efi : demander au firmware UEFI de redémarrer ;
triple : provoquer une « triple faute » du processeur, en dernier recours.
Si la méthode choisie ne fonctionne pas sur une carte précise, le noyau a tout arrêté et attend un reset qui ne vient jamais. C’est exactement le tableau observé : écran noir, ventilateurs qui tournent, plus aucune activité réseau.
Pourquoi cette fois-là, et pas les autres ?
Le redémarrage suivant, sur le nouveau noyau, a pris 34 secondes, sans aucun souci. Plusieurs facteurs ont pu jouer le soir du blocage : l’ancien noyau avec 35 jours d’uptime, et une mise à jour des firmwares (linux-firmware) dans la même transaction. Ces blocages au reset sont connus pour être intermittents sur certaines cartes mères grand public. Je n’ai pas pu reproduire le problème, et je n’ai donc rien changé dans l’immédiat.
Si ça se reproduit
La piste est de forcer une autre méthode de reset avec le paramètre noyau reboot=, puis de tester plusieurs redémarrages :
grubby --update-kernel=ALL --args="reboot=pci" # ou reboot=efi, reboot=acpi
grubby --info=DEFAULT | grep args # vérifier
reboot # tester, plusieurs fois
grubby --update-kernel=ALL --remove-args="reboot" # pour revenir en arrière
reboot=pci (le registre 0xCF9 du chipset) est souvent la plus fiable sur le matériel Intel grand public, et reboot=efi sur les firmwares UEFI récents. Le paramètre s’applique à tous les noyaux installés, y compris les prochains.
Ce que j’en retiens
avant d’accuser une mise à jour, vérifiez si l’arrêt s’est terminé : wtmp et les montages « clean » le disent sans ambiguïté ;
un blocage après un arrêt propre relève du matériel ou du firmware, pas du système : aucun retour arrière de paquets ne l’aurait corrigé ;
rendez le journal persistant (/var/log/journal) pour que les démarrages précédents restent consultables ;
si la machine héberge un service dont tout dépend, notez la procédure de reprise. Ici, le coffre à secrets doit être descellé à la main après chaque redémarrage : c’est le sujet d’un autre article.
Petite matinée de ménage sur le serveur de la maison (un Rocky Linux 10 sur une vieille carte mère ASRock). Au programme : une partition /boot devenue trop petite, des disques qui dormaient dans la machine et qu’il était temps de mettre en miroir, et des alertes Prometheus pour ne plus découvrir un disque mort par hasard. Retour d’expérience, commandes comprises.
Pourquoi /boot déborde, la migration en cinq étapes, et le détour pour mettre /data en miroir.
Le problème : un /boot de 1 Go qui ne suffit plus
L’installeur de Rocky crée par défaut un /boot de 1 Go. Ça paraissait large… jusqu’à ce que je regarde ce qu’il contient vraiment :
Environ 217 Mo par noyau, plus 175 Mo pour l’image de secours. Avec installonly_limit=3 dans dnf.conf, on arrive à ~825 Mo sur 960. Et le piège : pendant une mise à jour, dnf installe le nouveau noyau avant de supprimer le plus ancien. Il y a donc brièvement quatre noyaux, et ça ne passe plus.
Pourquoi un initramfs de 152 Mo ? lsinitrd donne la réponse : une centaine de Mo de firmwares NVIDIA (GSP) tirés par le pilote nouveau, parce qu’il y a une GeForce GTX 1060 dans la machine. Je garde nouveau, donc la solution, c’est un /boot plus grand.
Pourquoi ne pas simplement agrandir la partition ?
Parce que la partition qui suit /boot est le volume physique LVM du système, collé juste derrière. Et XFS ne sait pas rétrécir. Agrandir sur place voudrait dire déplacer tout le LVM (pvmove vers une nouvelle partition, supprimer l’ancienne, agrandir /boot dans le trou). Faisable à chaud, mais c’est beaucoup de manipulations sur le disque système d’une machine qui venait justement de rester plantée au redémarrage deux jours plus tôt.
Il restait plus de 300 Go libres en fin de disque. Plan plus simple : créer une nouvelle partition /boot de 2 Go dans cet espace libre, y copier l’ancien /boot, et réinstaller GRUB pour qu’il la prenne en compte. L’ancienne partition reste intacte et sert de filet de sécurité.
Migrer /boot vers une nouvelle partition (BIOS + GPT)
Le point important, c’est le mode de démarrage. Ici c’est du BIOS « legacy » sur une table GPT : GRUB loge son étage principal (core.img) dans la petite partition bios_grub de 1 Mo, et ce core.img contient en dur l’emplacement de /boot/grub2. Si on déplace /boot sans relancer grub2-install, GRUB continue de lire l’ancienne partition. En UEFI, ce serait le grub.cfg de l’ESP qu’il faudrait mettre à jour à la place.
$ parted -s /dev/sde unit MiB print free
Number Start End Size File system Name Flags
1 1.00MiB 2.00MiB 1.00MiB bios_grub
2 2.00MiB 1026MiB 1024MiB xfs bls_boot
3 1026MiB 160406MiB 159380MiB lvm
160406MiB 476940MiB 316534MiB Free Space
À faire avec un accès à la console (écran/clavier ou IPMI), au cas où le premier redémarrage se passe mal.
mkdir -p /mnt/newboot
mount /dev/sde4 /mnt/newboot
cp -a /boot/. /mnt/newboot/ # -a conserve droits, liens et contextes SELinux
restorecon -R /mnt/newboot
3. Pointer /etc/fstab sur la nouvelle partition
cp -a /etc/fstab /etc/fstab.bak
blkid -s UUID -o value /dev/sde4 # nouvel UUID
# remplacer l'UUID de la ligne /boot par celui-ci, puis :
systemctl daemon-reload
umount /mnt/newboot
umount /boot && mount /boot
findmnt /boot # doit afficher /dev/sde4
Les entrées de démarrage BLS (/boot/loader/entries/*.conf) donnent des chemins relatifs à la partition /boot. Elles fonctionnent donc telles quelles, et grubenv (le noyau par défaut) a été copié avec le reste.
5. Redémarrer et vérifier
reboot
findmnt /boot # /dev/sde4
df -h /boot # ~2 Go
grubby --default-kernel # toujours le bon noyau
Retour arrière : l’ancienne partition sde2 n’a pas été touchée. Si GRUB tombe sur son invite de secours, on peut la réutiliser à la main :
grub rescue> set prefix=(hd0,gpt2)/grub2
grub rescue> set root=(hd0,gpt2)
grub rescue> insmod normal
grub rescue> normal
Ensuite, on remet l’ancien UUID dans /etc/fstab et on relance grub2-install /dev/sde. Une fois la nouvelle partition validée sur plusieurs redémarrages et mises à jour du noyau, l’ancienne n’est plus qu’un giga perdu, ce qui n’a rien de grave.
Au passage : mettre /data en miroir… quand il manque 4 Mo
La machine avait aussi des disques qui ne servaient à rien. J’en profite pour mettre en miroir /data (931 Go, bases de données, dépôts, sauvegardes) avec LVM RAID1. Sur le papier, c’est une seule commande, sans interruption :
lvconvert --type raid1 -m1 data/lvol0 /dev/sdd
En pratique, elle échoue. Un LV RAID1 a besoin, sur chaque disque, d’une petite zone de métadonnées (rmeta, une extent de 4 Mo) en plus de la copie des données. Or le LV d’origine occupait toutes les extents du premier disque (238 467 sur 238 467), et le second disque est exactement de la même taille. Il manque donc 4 Mo de chaque côté. Comme XFS ne rétrécit pas, impossible d’en libérer.
La solution consiste à faire le chemin dans l’autre sens :
créer sur le nouveau disque un LV une extent plus petit, le formater, et y copier /data à chaud (rsync -aHAXS --numeric-ids, qui conserve liens durs, ACL, xattrs/SELinux et fichiers creux) ;
arrêter les services qui utilisent /data (MariaDB, PostgreSQL, Forgejo…), faire un dernier rsync --delete, renommer les LV pour que le nouveau prenne le nom de l’ancien (le fstab n’a pas besoin de changer), puis redémarrer les services. Comptez un quart d’heure d’interruption ;
garder l’ancien LV un jour comme filet de sécurité, puis le supprimer et lancer lvconvert --type raid1 -m1 vers l’ancien disque, qui a maintenant la place pour sa zone rmeta. La synchronisation se fait en arrière-plan.
Leçon retenue : quand on crée un LV qu’on voudra peut-être mettre en miroir un jour, laisser quelques extents libres (-l 99%FREE plutôt que 100%FREE) évite tout ce détour.
Pour deux autres vieux disques identiques, sans données, c’est beaucoup plus simple. On crée directement un volume miroir et LVM réserve lui-même la place des métadonnées :
vgcreate bulk /dev/sda /dev/sdb
lvcreate --type raid1 -m1 -l 100%FREE -n lvol0 bulk
mkfs.xfs /dev/bulk/lvol0
# dans /etc/fstab, nofail : un vieux disque qui lâche ne doit pas bloquer le démarrage
/dev/bulk/lvol0 /bulk xfs defaults,nofail 0 0
Surveiller les miroirs avec Prometheus
Un miroir dont personne ne remarque qu’il a perdu un disque n’apporte pas grand-chose. Par défaut, LVM se contente d’écrire un avertissement dans les journaux, et node_exporter ne sait rien du RAID LVM (son collecteur mdadm ne couvre que md). J’ai donc ajouté un petit script, lancé toutes les deux minutes par un timer systemd, qui écrit l’état des LV RAID dans le répertoire du collecteur textfile de node_exporter (--collector.textfile.directory) :
On obtient des métriques du genre lvm_raid_healthy (0 dès que lv_health_status n’est plus vide, par exemple partial quand un disque manque), lvm_raid_sync_percent et lvm_raid_mismatch_count. Le fichier est écrit dans un fichier temporaire puis renommé avec mv, pour qu’un scrape ne tombe jamais sur un fichier à moitié écrit. Quatre alertes s’appuient dessus :
- alert: LvmRaidDegraded # un disque manque ou lâche
expr: lvm_raid_healthy == 0
for: 5m
- alert: LvmRaidNotInSync # resynchronisation qui ne finit pas
expr: lvm_raid_sync_percent < 100
for: 12h
- alert: LvmRaidMismatches # le dernier scrub a trouvé des différences
expr: lvm_raid_mismatch_count > 0
- alert: LvmRaidMetricsStale # le script lui-même ne tourne plus
expr: time() - lvm_raid_textfile_last_run_seconds > 900
Côté matériel, des disques WD de 1, 2 et 3 To attendent sur l’étagère. Le jour où une alerte se déclenche : on remplace le disque, puis pvcreate, vgextend et lvconvert --repair, et LVM reconstruit le miroir à chaud.
En résumé
Un /boot de 1 Go, c’est juste dès qu’un pilote embarque de gros firmwares dans l’initramfs. Vérifiez avec lsinitrd.
Déplacer /boot vers une nouvelle partition est plus simple et moins risqué que d’agrandir l’existante. En BIOS/GPT, il ne faut pas oublier grub2-install.
LVM RAID1 a besoin d’une extent libre par disque pour ses métadonnées. Un LV en 100%FREE sur deux disques identiques ne peut pas être converti sur place.
Un miroir sans alerte, c’est une panne qui attend son heure. Le collecteur textfile de node_exporter se prête très bien à ce genre de petit ajout.
Ce billet ouvre une série de notes d’administration système (tag « carnet d’admin »). English version