<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Informatique - Quelques pensées</title>
	<atom:link href="https://blog.neurozone.fr/category/informatique/feed/" rel="self" type="application/rss+xml" />
	<link>https://blog.neurozone.fr/category/informatique/</link>
	<description>Terres dévastées</description>
	<lastBuildDate>Fri, 06 Nov 2026 19:05:23 +0000</lastBuildDate>
	<language>fr-FR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2024/03/cropped-2.png?fit=32%2C32&#038;ssl=1</url>
	<title>Informatique - Quelques pensées</title>
	<link>https://blog.neurozone.fr/category/informatique/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">154219366</site>	<item>
		<title>Protection du système de fichiers avec systemd : durcissement, partie 2</title>
		<link>https://blog.neurozone.fr/2026/11/14/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp/</link>
					<comments>https://blog.neurozone.fr/2026/11/14/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Sat, 14 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[durcissement]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[Rocky Linux]]></category>
		<category><![CDATA[sécurité]]></category>
		<category><![CDATA[systemd]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=4907</guid>

					<description><![CDATA[<p>Partie 2 de la série sur le durcissement de systemd : les directives qui décident quels fichiers un service peut lire, écrire ou voir, testées sur Rocky Linux 10.2.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/14/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp/">Protection du système de fichiers avec systemd : durcissement, partie 2</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">La protection du système de fichiers par systemd décide quels fichiers un service peut lire, écrire ou même voir. Cet article est la partie 2 d&rsquo;une série de huit, « Durcissement systemd, directive par directive ». J&rsquo;y traite les directives qui façonnent l&rsquo;arborescence d&rsquo;un service : <code>ProtectSystem</code>, <code>ProtectHome</code>, <code>PrivateTmp</code>, <code>ReadWritePaths</code> et leurs cousines.</p>



<p class="wp-block-paragraph">Quand un service est compromis, ou simplement bogué, c&rsquo;est dans l&rsquo;arborescence que les dégâts se produisent. Un service qui ne peut pas écrire dans <code>/etc</code> ne peut pas réécrire sa propre configuration, et un service qui ne voit pas <code>/home</code> ne peut pas lire vos clés SSH. La protection du système de fichiers coûte quelques lignes dans un fichier drop-in. Mais chaque ligne peut aussi bloquer une écriture légitime. Pour chaque directive, j&rsquo;explique donc ce qu&rsquo;elle fait, ce qu&rsquo;elle peut casser et comment elle touche ma propre pile. Aucune expérience de systemd n&rsquo;est nécessaire : chaque étape vient avec la commande complète et un commentaire.</p>



<p class="wp-block-paragraph"><strong>Banc d&rsquo;essai.</strong> Chaque sortie de cet article vient d&rsquo;une vraie exécution sur <strong>Rocky Linux 10.2 avec systemd 257</strong>, dans une machine virtuelle WSL2 (noyau 6.18.54.1-microsoft-standard-WSL2, exécution en root). SELinux y est désactivé, et quelques fonctions du noyau diffèrent d&rsquo;un serveur classique. Je le signale chaque fois que cela compte. Mes fichiers et répertoires de test commencent par <code>_sd2_</code>, ce qui permet de les retrouver et de les supprimer facilement. Quand je coupe une ligne d&rsquo;une sortie, je la remplace par <code>[...]</code>, et je marque ce que je n&rsquo;ai pas exécuté comme <em>non testé</em>.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?ssl=1"><img data-recalc-dims="1" fetchpriority="high" decoding="async" width="629" height="278" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=629%2C278&#038;ssl=1" alt="Protection du système de fichiers avec systemd : ProtectSystem true, full et strict, et les exceptions inscriptibles" class="wp-image-4904" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=300%2C133&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=1024%2C452&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=768%2C339&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=1536%2C679&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=900%2C398&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-1.png?resize=1280%2C566&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Les trois niveaux de ProtectSystem, et les moyens d&rsquo;ouvrir une exception inscriptible.</figcaption></figure>



<h2 class="wp-block-heading">Avant de commencer</h2>



<p class="wp-block-paragraph">Lisez d&rsquo;abord cette courte liste. Elle vous évite de durcir un service de production du premier coup.</p>



<ul class="wp-block-list"><li><strong>Une machine de test.</strong> Utilisez une machine virtuelle ou un serveur de rechange qui fait tourner systemd. Je n&rsquo;ai testé que sur Rocky Linux 10.2. Les directives appartiennent à systemd lui-même, donc les autres distributions devraient se comporter pareil, mais je ne l&rsquo;ai pas vérifié.</li><li>**Un shell avec <code>sudo</code>.** Chaque commande qui modifie le système commence par <code>sudo</code>. Mon banc tournait en root, donc mes sorties viennent de commandes root.</li><li><strong>Un service de démonstration.</strong> Le labo plus bas crée <code>demo-hardening-fs.service</code>, un service inoffensif qui essaie seulement de créer des fichiers vides. Vous ne touchez jamais à un vrai service.</li><li><strong>Un retour en arrière.</strong> Chaque changement vit dans un fichier drop-in. <code>systemctl revert</code> le supprime, et le labo se termine par un nettoyage.</li><li><strong>Un coup d&rsquo;œil à votre version.</strong> Lancez <code>systemctl --version</code>. Je n&rsquo;ai pu tester que la version 257 : si votre systemd rejette une directive, lisez son entrée dans la page de manuel <code>systemd.exec</code>.</li></ul>



<h2 class="wp-block-heading">Un petit glossaire</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Terme</th><th>Ce que cela veut dire ici</th></tr></thead><tbody><tr><td>Espace de noms de montage</td><td>Une copie privée de la liste des systèmes de fichiers montés. Un changement fait à l&rsquo;intérieur n&rsquo;apparaît pas sur l&rsquo;hôte.</td></tr><tr><td>Lecture seule</td><td>Un chemin où la lecture marche et où l&rsquo;écriture échoue avec le message « Read-only file system ».</td></tr><tr><td>tmpfs</td><td>Un système de fichiers gardé en mémoire. Il démarre vide et disparaît quand on le démonte.</td></tr><tr><td>Montage lié (bind mount)</td><td>Le même répertoire rendu visible à un second endroit de l&rsquo;arborescence.</td></tr><tr><td>Systèmes de fichiers d&rsquo;API</td><td><code>/proc</code>, <code>/sys</code> et <code>/dev</code>, fournis par le noyau. Un service en a besoin pour tourner.</td></tr><tr><td>Drop-in</td><td>Un petit fichier <code>.conf</code> dans un répertoire <code>.d</code> qui modifie une unité sans toucher au fichier d&rsquo;origine.</td></tr><tr><td>Directive</td><td>Un réglage d&rsquo;un fichier d&rsquo;unité, comme <code>ProtectSystem=strict</code>.</td></tr><tr><td>Unité transitoire</td><td>Une unité temporaire que <code>systemd-run</code> crée à la volée. Elle disparaît quand elle s&rsquo;arrête.</td></tr><tr><td>Utilisateur du service</td><td>Le compte qui exécute le service, défini par <code>User=</code> dans l&rsquo;unité.</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">En un coup d&rsquo;œil</h2>



<p class="wp-block-paragraph">Ce tableau est la carte de la protection du système de fichiers dans cet article. La dernière colonne vient de mes notes de homelab : elle dit où la directive protège déjà, ou a déjà cassé, un de mes services.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Directive</th><th>Ce qu&rsquo;elle fait</th><th>Ce qu&rsquo;elle peut casser</th><th>Dans ma pile</th></tr></thead><tbody><tr><td><code>ProtectSystem=</code></td><td>Monte <code>/usr</code>, puis <code>/etc</code>, puis toute l&rsquo;arborescence en lecture seule</td><td>Toute écriture hors de vos exceptions</td><td>Kavita, Navidrome, mktxp, mox et Etherpad tournent avec <code>strict</code></td></tr><tr><td><code>ProtectHome=</code></td><td>Cache ou fige <code>/home</code>, <code>/root</code> et <code>/run/user</code></td><td>Les programmes qui gardent caches ou clés dans leur répertoire personnel</td><td>Kavita utilise <code>yes</code> ; Oxidized a eu besoin de <code>read-only</code></td></tr><tr><td><code>PrivateTmp=</code></td><td>Donne au service son propre <code>/tmp</code> et <code>/var/tmp</code></td><td>Les services qui partagent des fichiers par <code>/tmp</code></td><td>Kavita ouvre plutôt le <code>/tmp</code> de l&rsquo;hôte</td></tr><tr><td><code>PrivateMounts=</code></td><td>Place le service dans son propre espace de noms de montage</td><td>Les montages que le service fait pour d&rsquo;autres</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>ReadWritePaths=</code></td><td>Ouvre des exceptions inscriptibles</td><td>Un chemin absent arrête le service</td><td>Le correctif le plus fréquent de mes notes</td></tr><tr><td><code>ReadOnlyPaths=</code></td><td>Fige un chemin dans une zone inscriptible</td><td>Une écriture à cet endroit échoue</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>InaccessiblePaths=</code></td><td>Cache complètement un chemin</td><td>Toute lecture à cet endroit échoue</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>ExecPaths=</code> et <code>NoExecPaths=</code></td><td>Autorisent ou interdisent d&rsquo;exécuter des programmes depuis un chemin</td><td>Les binaires d&rsquo;un chemin interdit (erreur 203)</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>BindPaths=</code> et <code>BindReadOnlyPaths=</code></td><td>Montrent un répertoire à un second endroit</td><td>Une mauvaise source ou destination</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>TemporaryFileSystem=</code></td><td>Remplace un répertoire par un tmpfs vide</td><td>Tout ce que ce répertoire contenait</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>StateDirectory=</code> et cie</td><td>Créent des répertoires gérés avec le bon propriétaire</td><td>Des surprises de propriétaire et de mode</td><td>Navidrome liste aujourd&rsquo;hui ces chemins à la main</td></tr><tr><td><code>RestrictFileSystems=</code></td><td>N&rsquo;autorise que certains types de systèmes de fichiers</td><td>Demande une fonction du noyau (BPF LSM)</td><td>Mes notes n&rsquo;en parlent pas</td></tr><tr><td><code>RootDirectory=</code>, <code>RootImage=</code></td><td>Lancent le service dans un chroot ou une image disque</td><td>Tout ce que le service s&rsquo;attend à trouver</td><td>Mes notes n&rsquo;en parlent pas</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Protection du système de fichiers : comment un service obtient sa propre vue</h2>



<p class="wp-block-paragraph">Quand systemd démarre un service, il peut d&rsquo;abord lui donner un espace de noms de montage privé. En clair, le service reçoit sa propre copie de la liste des montages. Systemd modifie ensuite cette copie : il remonte certains chemins en lecture seule, en cache d&rsquo;autres et ajoute des répertoires temporaires privés. L&rsquo;hôte ne change pas. Seuls les processus de ce service voient la différence.</p>



<p class="wp-block-paragraph">Trois conséquences comptent pour un débutant. D&rsquo;abord, les règles s&rsquo;appliquent à tous les processus du service, root compris. Mon banc tournait en root, et les écritures ont quand même été refusées. Ensuite, une règle n&rsquo;est pas une permission : le fichier reste inscriptible pour tous les autres processus de la machine. Enfin, un accès bloqué dit rarement « Permission denied ». Dans mes essais, il disait « Read-only file system » ou « No such file or directory », donc lisez ces messages avec cela en tête.</p>



<p class="wp-block-paragraph">La méthode est toujours la même. On ferme d&rsquo;abord une grande zone, puis on ouvre les quelques exceptions dont le service a besoin. <code>ProtectSystem</code>, <code>ProtectHome</code> et <code>PrivateTmp</code> ferment. Les directives de chemins et la famille <code>*Directory=</code> ouvrent. La suite de l&rsquo;article suit cet ordre.</p>



<h2 class="wp-block-heading">ProtectSystem : trois niveaux de lecture seule</h2>



<p class="wp-block-paragraph"><code>ProtectSystem=</code> est le cœur de la protection du système de fichiers, et il prend quatre valeurs. <code>no</code> est la valeur par défaut et ne change rien. <code>yes</code> (aussi écrit <code>true</code>) monte <code>/usr</code> et les répertoires du chargeur de démarrage en lecture seule. <code>full</code> ajoute <code>/etc</code>. <code>strict</code> monte toute l&rsquo;arborescence en lecture seule, sauf <code>/dev</code>, <code>/proc</code> et <code>/sys</code>. Les exceptions inscriptibles viennent de <code>ReadWritePaths=</code>, des réglages <code>*Directory=</code> et de <code>PrivateTmp=</code>.</p>



<p class="wp-block-paragraph">J&rsquo;ai lancé une sonde par valeur. Elle essaie de créer un fichier vide à quatre endroits. L&rsquo;option <code>-P</code> de <code>systemd-run</code> affiche la sortie dans votre terminal, donc vous voyez le résultat tout de suite. Voici la commande pour <code>strict</code> :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Lancer une commande dans une unité temporaire qui a ProtectSystem=strict
# -P (--pipe) affiche la sortie ici, --wait attend la fin, -G (--collect) supprime l'unité ensuite
sudo systemd-run --quiet --pipe --wait --collect -p ProtectSystem=strict \
  sh -c 'for d in /usr /etc /var/tmp /tmp; do
    if err=$(touch "$d/_sd2_test" 2&gt;&amp;1); then echo "$d: write OK"
    else echo "$d: write refused -&gt; ${err##*: }"; fi
  done'
# Avec ProtectSystem=no, les fichiers de test existent vraiment sur l'hôte : on les supprime
sudo rm -f /usr/_sd2_test /etc/_sd2_test /var/tmp/_sd2_test /tmp/_sd2_test</pre>



<p class="wp-block-paragraph">Et voici sa sortie :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">/usr: write refused -&gt; Read-only file system
/etc: write refused -&gt; Read-only file system
/var/tmp: write refused -&gt; Read-only file system
/tmp: write refused -&gt; Read-only file system</pre>



<p class="wp-block-paragraph">J&rsquo;ai lancé la même sonde pour les autres valeurs et pour deux combinaisons. Ce tableau résume les résultats, et chaque cellule est une vraie réponse :</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Valeur</th><th><code>/usr</code></th><th><code>/etc</code></th><th><code>/var/tmp</code></th><th><code>/tmp</code></th></tr></thead><tbody><tr><td><code>no</code></td><td>écriture OK</td><td>écriture OK</td><td>écriture OK</td><td>écriture OK</td></tr><tr><td><code>yes</code></td><td>refusée</td><td>écriture OK</td><td>écriture OK</td><td>écriture OK</td></tr><tr><td><code>full</code></td><td>refusée</td><td>refusée</td><td>écriture OK</td><td>écriture OK</td></tr><tr><td><code>strict</code></td><td>refusée</td><td>refusée</td><td>refusée</td><td>refusée</td></tr><tr><td><code>strict</code> + <code>PrivateTmp=yes</code></td><td>refusée</td><td>refusée</td><td>écriture OK</td><td>écriture OK</td></tr><tr><td><code>strict</code> + <code>ReadWritePaths=/etc</code></td><td>refusée</td><td>écriture OK</td><td>refusée</td><td>refusée</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Deux leçons ressortent de ce tableau. D&rsquo;abord, <code>strict</code> fige aussi <code>/tmp</code> et <code>/var/tmp</code>, et beaucoup de programmes ne démarrent pas sans répertoire temporaire. Donc <code>strict</code> va en général avec <code>PrivateTmp=yes</code>. Ensuite, une exception de <code>ReadWritePaths=</code> n&rsquo;ouvre que le chemin nommé : avec <code>/etc</code> ouvert, <code>/tmp</code> est resté en lecture seule.</p>



<h3 class="wp-block-heading">Une surprise : /home et /root restent inscriptibles sous strict</h3>



<p class="wp-block-paragraph">Le manuel décrit <code>strict</code> comme une arborescence en lecture seule, hors systèmes de fichiers d&rsquo;API. Mon banc le contredit pour deux répertoires. J&rsquo;ai lancé une seconde sonde :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo systemd-run --quiet --pipe --wait --collect -p ProtectSystem=strict \
  sh -c 'for d in /home /root /opt /srv /var/lib; do
    if err=$(touch "$d/_sd2_diag" 2&gt;&amp;1); then echo "$d: write OK"
    else echo "$d: write refused -&gt; ${err##*: }"; fi
  done'
sudo rm -f /home/_sd2_diag /root/_sd2_diag</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">/home: write OK
/root: write OK
/opt: write refused -&gt; Read-only file system
/srv: write refused -&gt; Read-only file system
/var/lib: write refused -&gt; Read-only file system</pre>



<p class="wp-block-paragraph">Dans le service, <code>findmnt</code> montre la différence : <code>/home</code> est un montage séparé en lecture-écriture, alors que le montage qui contient <code>/etc</code> est en lecture seule.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Quel montage contient /home et /etc dans le service, et avec quelles options ?
sudo systemd-run --quiet --pipe --wait --collect -p ProtectSystem=strict findmnt -T /home -o TARGET,FSTYPE,OPTIONS
sudo systemd-run --quiet --pipe --wait --collect -p ProtectSystem=strict findmnt -T /etc -o TARGET,FSTYPE,OPTIONS</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">TARGET FSTYPE OPTIONS
/home  ext4   rw,relatime,discard,errors=remount-ro,data=ordered
TARGET FSTYPE OPTIONS
/      ext4   ro,relatime,discard,errors=remount-ro,data=ordered</pre>



<p class="wp-block-paragraph">Je ne sais pas si cela vient de WSL2, de ce build de systemd ou de la façon dont le système de fichiers racine est monté, et je n&rsquo;ai pas creusé plus loin. La règle pratique est simple : ne comptez jamais sur <code>strict</code> pour protéger les répertoires personnels. Ajoutez <code>ProtectHome=</code> et vérifiez le résultat sur votre propre serveur avec la sonde ci-dessus.</p>



<h3 class="wp-block-heading">Quelle valeur choisir</h3>



<p class="wp-block-paragraph">Pour un service de longue durée, partez de <code>strict</code> et ajoutez les exceptions une par une. Mes notes montrent ce que cela donne en pratique. L&rsquo;exporteur mktxp essayait d&rsquo;écrire un fichier de configuration par défaut dans son répertoire de configuration, et <code>strict</code> l&rsquo;en empêchait jusqu&rsquo;à ce que j&rsquo;ajoute ce répertoire à <code>ReadWritePaths=</code>. Navidrome avait un problème plus discret. Son répertoire de sauvegarde manquait dans <code>ReadWritePaths=</code>, donc sa propre tâche de sauvegarde échouait chaque nuit avec « unable to open database file ». Personne ne l&rsquo;a remarqué pendant environ une semaine, et chaque archive nocturne pesait 122 octets. La leçon : après avoir ajouté <code>strict</code>, lisez pendant quelques jours le journal du service à la recherche de « Read-only file system ».</p>



<h2 class="wp-block-heading">ProtectHome : cacher, figer ou remplacer /home</h2>



<p class="wp-block-paragraph"><code>ProtectHome=</code> étend la protection du système de fichiers aux données personnelles. Il agit sur <code>/home</code>, <code>/root</code> et <code>/run/user</code>. Avec <code>yes</code> (ou <code>true</code>), les trois répertoires paraissent vides et rien de ce qu&rsquo;ils contiennent n&rsquo;est accessible. Avec <code>read-only</code>, ils restent visibles mais figés. Avec <code>tmpfs</code>, le service voit à la place des systèmes de fichiers temporaires vides, et <code>BindPaths=</code> peut ramener des répertoires choisis.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="629" height="263" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=629%2C263&#038;ssl=1" alt="Protection du système de fichiers et répertoires personnels : ProtectHome true, read-only et tmpfs" class="wp-image-4905" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=300%2C126&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=1024%2C429&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=768%2C321&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=1536%2C643&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=900%2C377&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-2.png?resize=1280%2C536&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Ce qu&rsquo;un service voit dans /home, /root et /run/user pour chaque valeur de ProtectHome.</figcaption></figure>



<p class="wp-block-paragraph">J&rsquo;ai créé un faux répertoire personnel avec un fichier, puis lancé une sonde par valeur. Changez la valeur après <code>ProtectHome=</code> pour la répéter :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Préparer un faux répertoire personnel avec un fichier
sudo mkdir -p /home/_sd2_user &amp;&amp; echo hello | sudo tee /home/_sd2_user/hello.txt &gt; /dev/null
# Changer la valeur après ProtectHome= : no, yes, read-only, tmpfs
sudo systemd-run --quiet --pipe --wait --collect -p ProtectHome=yes \
  sh -c 'echo "ls -A /home:"; ls -A /home 2&gt;&amp;1
         echo "cat hello.txt:"; cat /home/_sd2_user/hello.txt 2&gt;&amp;1
         if err=$(touch /home/_sd2_user/new.txt 2&gt;&amp;1); then echo "write: OK"
         else echo "write: refused -&gt; ${err##*: }"; fi'
# Supprimer le faux répertoire personnel
sudo rm -rf /home/_sd2_user</pre>



<p class="wp-block-paragraph">Avec <code>yes</code>, le service ne voit rien :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ls -A /home:
cat hello.txt:
cat: /home/_sd2_user/hello.txt: No such file or directory
write: refused -&gt; No such file or directory</pre>



<p class="wp-block-paragraph">Avec <code>read-only</code>, le service lit le fichier mais ne peut pas écrire. Une ligne qui nomme le vrai répertoire personnel de mon compte est coupée :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ls -A /home:
[...]
_sd2_user
cat hello.txt:
hello
write: refused -&gt; Read-only file system</pre>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Valeur</th><th>Liste du répertoire</th><th>Lecture du fichier</th><th>Écriture</th></tr></thead><tbody><tr><td><code>no</code></td><td>visible</td><td>OK</td><td>OK</td></tr><tr><td><code>yes</code></td><td>vide</td><td>No such file or directory</td><td>No such file or directory</td></tr><tr><td><code>read-only</code></td><td>visible</td><td>OK</td><td>Read-only file system</td></tr><tr><td><code>tmpfs</code></td><td>vide</td><td>No such file or directory</td><td>No such file or directory</td></tr><tr><td><code>yes</code> + <code>ReadWritePaths=</code> sur le sous-répertoire</td><td>vide</td><td>No such file or directory</td><td>No such file or directory</td></tr><tr><td><code>read-only</code> + <code>ReadWritePaths=</code> sur le sous-répertoire</td><td>visible</td><td>OK</td><td>OK</td></tr><tr><td><code>tmpfs</code> + <code>BindPaths=</code> sur le sous-répertoire</td><td>seulement ce sous-répertoire</td><td>OK</td><td>OK</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Les trois dernières lignes sont celles à retenir. <code>ReadWritePaths=</code> ne peut pas rouvrir un chemin sous <code>ProtectHome=yes</code>. Il marche sous <code>read-only</code>, et <code>tmpfs</code> avec <code>BindPaths=</code> marche aussi. Mes notes décrivent le même piège sur un vrai service : Oxidized, qui sauvegarde les configurations d&rsquo;équipements réseau, ne pouvait pas utiliser son propre répertoire personnel sous <code>ProtectHome=true</code>, bien que <code>ReadWritePaths=</code> le liste. Passer à <code>read-only</code> a réglé le problème. Sur mon serveur, l&rsquo;erreur était « permission denied » ; sur le banc, c&rsquo;était « No such file or directory ». Les deux signifient que le chemin est hors d&rsquo;atteinte. Mon banc tournait en root, ce qui peut expliquer la différence de message.</p>



<h3 class="wp-block-heading">Nommer le sous-répertoire, pas /home</h3>



<p class="wp-block-paragraph">Un second piège : <code>ReadWritePaths=</code> demande le sous-répertoire exact. J&rsquo;ai essayé le parent d&rsquo;abord, puis le sous-répertoire, tous deux avec <code>ProtectHome=read-only</code>. La sonde essaie d&rsquo;écrire aux deux endroits :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Deux essais avec ProtectHome=read-only : ReadWritePaths sur /home lui-même, puis sur un sous-répertoire
sudo mkdir -p /home/_sd2_sub
for rw in /home /home/_sd2_sub; do
  echo "ReadWritePaths=$rw"
  sudo systemd-run --quiet --pipe --wait --collect -p ProtectHome=read-only -p ReadWritePaths=$rw \
    sh -c 'for d in /home /home/_sd2_sub; do
      if err=$(touch "$d/_sd2_p" 2&gt;&amp;1); then echo "  $d: write OK"
      else echo "  $d: write refused -&gt; ${err##*: }"; fi
    done'
done
sudo rm -rf /home/_sd2_sub /home/_sd2_p</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ReadWritePaths=/home
  /home: write refused -&gt; Read-only file system
  /home/_sd2_sub: write refused -&gt; Read-only file system
ReadWritePaths=/home/_sd2_sub
  /home: write refused -&gt; Read-only file system
  /home/_sd2_sub: write OK</pre>



<p class="wp-block-paragraph">Lister <code>/home</code> n&rsquo;a rien ouvert, pas même le sous-répertoire qu&rsquo;il contient. Lister le sous-répertoire l&rsquo;a ouvert, et <code>/home</code> est resté en lecture seule.</p>



<h3 class="wp-block-heading">Quand le programme veut son répertoire personnel</h3>



<p class="wp-block-paragraph">Beaucoup de programmes écrivent des caches ou des clés sous <code>$HOME</code>. Si <code>ProtectHome=yes</code> le cache, ils échouent de façon déroutante. Deux cas réels de mes notes : Kavita n&rsquo;arrivait pas à enregistrer son premier utilisateur, car le magasin de clés .NET utilise par défaut un dossier du répertoire personnel du compte de service. L&rsquo;outil de build <code>pnpm</code> d&rsquo;Etherpad ne trouvait pas non plus de répertoire personnel inscriptible sous <code>strict</code>. Dans les deux cas, je n&rsquo;ai pas élargi le bac à sable. J&rsquo;ai défini <code>Environment=HOME=</code> vers un répertoire déjà inscriptible, dans le dossier de l&rsquo;application. Cela garde la protection et satisfait le programme.</p>



<h2 class="wp-block-heading">PrivateTmp et PrivateMounts : des espaces privés</h2>



<p class="wp-block-paragraph"><code>PrivateTmp=yes</code> donne au service son propre <code>/tmp</code> et <code>/var/tmp</code>. Systemd les crée au démarrage du service et les supprime à son arrêt. Les autres services, et vous, ne voient pas leur contenu. Cela bloque toute une classe d&rsquo;attaques qui reposent sur des noms de fichiers prévisibles dans <code>/tmp</code>. J&rsquo;ai lancé la même commande sans, puis avec. Un fichier nommé <code>_sd2_host_file</code> existait déjà dans le <code>/tmp</code> de l&rsquo;hôte :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">touch /tmp/_sd2_host_file
# Même commande deux fois : sans, puis avec PrivateTmp=yes
sudo systemd-run --quiet --pipe --wait --collect \
  sh -c 'echo "files named _sd2_* seen in /tmp:"; ls -A /tmp | grep _sd2_; touch /tmp/_sd2_in_unit &amp;&amp; echo "created /tmp/_sd2_in_unit"'
# Supprimer le fichier que ce premier essai a créé sur l'hôte
sudo rm -f /tmp/_sd2_in_unit
sudo systemd-run --quiet --pipe --wait --collect -p PrivateTmp=yes \
  sh -c 'echo "files named _sd2_* seen in /tmp:"; ls -A /tmp | grep _sd2_; touch /tmp/_sd2_in_unit &amp;&amp; echo "created /tmp/_sd2_in_unit"'
# Que voit l'hôte ensuite ?
ls -A /tmp | grep _sd2_
sudo rm -f /tmp/_sd2_host_file /tmp/_sd2_in_unit</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">files named _sd2_* seen in /tmp:
_sd2_host_file
created /tmp/_sd2_in_unit
files named _sd2_* seen in /tmp:
created /tmp/_sd2_in_unit
_sd2_host_file</pre>



<p class="wp-block-paragraph">Sans <code>PrivateTmp</code>, le service voit le fichier de l&rsquo;hôte. Avec lui, le service voit un <code>/tmp</code> vide, crée son propre fichier et ne laisse rien derrière lui. La dernière ligne vient de l&rsquo;hôte : il a toujours son fichier d&rsquo;origine et rien d&rsquo;autre. Rappel : <code>strict</code> met <code>/tmp</code> en lecture seule, mais <code>PrivateTmp=yes</code> en redonne un privé et inscriptible.</p>



<p class="wp-block-paragraph">Une troisième valeur existe dans les versions récentes, <code>PrivateTmp=disconnected</code>. J&rsquo;ai seulement testé si elle est acceptée. Via <code>systemd-run -p</code>, systemd 257 l&rsquo;a refusée sur mon banc :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># La troisième valeur, via systemd-run
sudo systemd-run --quiet --pipe --wait --collect -p PrivateTmp=disconnected true</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Failed to parse PrivateTmp: disconnected</pre>



<p class="wp-block-paragraph">Écrite dans un fichier d&rsquo;unité, la même valeur a été acceptée et le service one-shot est allé au bout sans se plaindre. Je n&rsquo;ai pas testé ce qu&rsquo;elle change : son comportement est <em>non testé</em>.</p>



<p class="wp-block-paragraph">Dans ma pile, Kavita ouvre le <code>/tmp</code> de l&rsquo;hôte avec <code>ReadWritePaths=</code> au lieu d&rsquo;utiliser <code>PrivateTmp=yes</code>. Un <code>/tmp</code> privé serait plus strict. Je ne l&rsquo;ai pas encore essayé.</p>



<h3 class="wp-block-heading">PrivateMounts</h3>



<p class="wp-block-paragraph"><code>PrivateMounts=yes</code> place le service dans son propre espace de noms de montage et ne fait rien d&rsquo;autre. Les montages que le service réalise ne fuient pas vers l&rsquo;hôte. Je l&rsquo;ai testé avec un tmpfs monté depuis l&rsquo;intérieur du service :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo mkdir -p /mnt/_sd2_pm
# Monter un tmpfs dans le service, puis regarder depuis l'hôte
sudo systemd-run --quiet --pipe --wait --collect -p PrivateMounts=yes \
  sh -c 'mount -t tmpfs none /mnt/_sd2_pm &amp;&amp; touch /mnt/_sd2_pm/x &amp;&amp; findmnt -n -o TARGET,FSTYPE /mnt/_sd2_pm'
findmnt /mnt/_sd2_pm || echo "not mounted on the host"
sudo rmdir /mnt/_sd2_pm</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">/mnt/_sd2_pm tmpfs
not mounted on the host</pre>



<p class="wp-block-paragraph">La plupart des directives de cet article activent d&rsquo;elles-mêmes un espace de noms de montage privé, donc il est rare de devoir écrire <code>PrivateMounts=yes</code>. Il compte pour un service qui monte des systèmes de fichiers et ne doit pas perturber l&rsquo;hôte.</p>



<h2 class="wp-block-heading">ReadWritePaths, ReadOnlyPaths et InaccessiblePaths</h2>



<p class="wp-block-paragraph">Ces trois directives vous laissent régler la protection du système de fichiers chemin par chemin. Chacune prend une liste de chemins séparés par des espaces. Vous pouvez les répéter, et les listes s&rsquo;additionnent. <code>ReadWritePaths=</code> rend un chemin à nouveau inscriptible sous <code>ProtectSystem</code>. <code>ReadOnlyPaths=</code> fige un chemin dans une zone par ailleurs inscriptible. <code>InaccessiblePaths=</code> le cache. J&rsquo;ai testé les deux dernières sur un répertoire jetable :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">D=/var/tmp/_sd2_dir
mkdir -p $D &amp;&amp; echo data &gt; $D/file.txt
# Lecture seule : le fichier reste lisible, aucun fichier ne peut être créé, le reste de /var/tmp reste inscriptible
sudo systemd-run --quiet --pipe --wait --collect -p ReadOnlyPaths=$D \
  sh -c 'cat /var/tmp/_sd2_dir/file.txt
         if err=$(touch /var/tmp/_sd2_dir/new 2&gt;&amp;1); then echo "write in dir: OK"
         else echo "write in dir: refused -&gt; ${err##*: }"; fi
         touch /var/tmp/_sd2_other &amp;&amp; echo "write elsewhere in /var/tmp: OK"'
# Inaccessible : le répertoire paraît vide et rien ne peut être lu
sudo systemd-run --quiet --pipe --wait --collect -p InaccessiblePaths=$D \
  sh -c 'ls -A /var/tmp/_sd2_dir 2&gt;&amp;1; echo "---"; cat /var/tmp/_sd2_dir/file.txt 2&gt;&amp;1'
rm -rf $D /var/tmp/_sd2_other</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">data
write in dir: refused -&gt; Read-only file system
write elsewhere in /var/tmp: OK
---
cat: /var/tmp/_sd2_dir/file.txt: No such file or directory</pre>



<p class="wp-block-paragraph">Le premier essai montre <code>ReadOnlyPaths=</code> : le fichier est lisible, un nouveau fichier dans le répertoire est refusé, et le reste de <code>/var/tmp</code> reste inscriptible. Le second montre <code>InaccessiblePaths=</code> : le répertoire paraît vide, puis le fichier ne peut pas être lu. Il existe toujours sur l&rsquo;hôte.</p>



<h3 class="wp-block-heading">Le tiret initial</h3>



<p class="wp-block-paragraph">Un chemin qui n&rsquo;existe pas au démarrage du service arrête le service. Un tiret devant le chemin demande à systemd de l&rsquo;ignorer s&rsquo;il est absent :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Un chemin qui n'existe pas, sans puis avec le tiret initial
sudo systemd-run --quiet --pipe --wait --collect -p ReadWritePaths=/nonexistent_sd2 true; echo "exit code: $?"
sudo systemd-run --quiet --pipe --wait --collect -p ReadWritePaths=-/nonexistent_sd2 true; echo "exit code: $?"</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">exit code: 226
exit code: 0</pre>



<p class="wp-block-paragraph">Le code de sortie 226 est la façon dont systemd signale un échec pendant la construction de l&rsquo;espace de noms. Sans le tiret, le service n&rsquo;a même pas démarré.</p>



<h3 class="wp-block-heading">Trouver le chemin manquant</h3>



<p class="wp-block-paragraph">Dans mes notes, <code>ReadWritePaths=</code> est le correctif le plus fréquent. Kavita a demandé deux ajouts après sa première version. Il écrit sa base SQLite dans un dossier <code>config</code> sous son répertoire de travail, un chemin que je n&rsquo;avais pas listé. Il réécrit aussi <code>wwwroot/index.html</code> à chaque démarrage, ce qui ne produisait qu&rsquo;une erreur sans gravité mais bruyante. Chaque fois, le journal disait « Read-only file system ». Ma routine est donc courte : démarrer le service, lancer <code>journalctl -u UNITÉ | grep -i 'read-only file system'</code>, ajouter le seul chemin qui apparaît, puis recommencer. Je n&rsquo;ai pas lancé cette commande exacte sur le banc, mais elle ne fait que lire le journal.</p>



<h2 class="wp-block-heading">ExecPaths et NoExecPaths : où les programmes peuvent tourner</h2>



<p class="wp-block-paragraph"><code>NoExecPaths=</code> interdit d&rsquo;exécuter des programmes depuis un chemin, et <code>ExecPaths=</code> creuse une exception. Une application web qui accepte des envois de fichiers est un bon cas d&rsquo;usage : rien de ce qui est envoyé ne doit jamais s&rsquo;exécuter. J&rsquo;ai copié <code>true</code> dans un répertoire jetable et essayé de le lancer trois fois :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">D=/var/tmp/_sd2_dir
mkdir -p $D &amp;&amp; cp /usr/bin/true $D/runme
# Lancer la copie de true : sans restriction, puis avec NoExecPaths, puis NoExecPaths sur le parent avec une exception ExecPaths
sudo systemd-run --quiet --pipe --wait --collect $D/runme; echo "no restriction, exit code: $?"
sudo systemd-run --quiet --pipe --wait --collect -p NoExecPaths=$D $D/runme; echo "NoExecPaths, exit code: $?"
sudo systemd-run --quiet --pipe --wait --collect -p NoExecPaths=/var/tmp -p ExecPaths=$D $D/runme; echo "with ExecPaths, exit code: $?"
rm -rf $D</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">no restriction, exit code: 0
NoExecPaths, exit code: 203
with ExecPaths, exit code: 0</pre>



<p class="wp-block-paragraph">Le code de sortie 203 signifie que systemd n&rsquo;a pas pu exécuter le programme. Le dernier essai montre le schéma : interdire une grande zone, puis autoriser le seul sous-répertoire qui doit exécuter des programmes. Mes notes ne parlent pas encore de ces deux directives, donc ce sont des candidates pour les répertoires d&rsquo;envoi de mes applications web.</p>



<h2 class="wp-block-heading">BindPaths, BindReadOnlyPaths et TemporaryFileSystem</h2>



<p class="wp-block-paragraph"><code>BindPaths=</code> montre un répertoire à un second endroit, au format <code>SOURCE:DESTINATION</code>. <code>BindReadOnlyPaths=</code> fait pareil, mais en lecture seule. J&rsquo;ai créé un répertoire avec un fichier et je l&rsquo;ai lié deux fois :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">S=/var/tmp/_sd2_src
mkdir -p $S &amp;&amp; echo hi &gt; $S/hi.txt
# Montrer $S à un second endroit : en écriture, puis en lecture seule
sudo systemd-run --quiet --pipe --wait --collect -p BindPaths=$S:/mnt/_sd2_dst \
  sh -c 'ls -A /mnt/_sd2_dst; if echo new &gt; /mnt/_sd2_dst/new.txt; then echo "write OK"; fi'
# L'écriture est arrivée jusqu'à la source sur l'hôte
ls -A $S
sudo systemd-run --quiet --pipe --wait --collect -p BindReadOnlyPaths=$S:/mnt/_sd2_dst \
  sh -c 'if err=$(touch /mnt/_sd2_dst/x 2&gt;&amp;1); then echo "write OK"; else echo "write refused -&gt; ${err##*: }"; fi'
# Systemd a créé le répertoire de destination sur l'hôte
ls -ld /mnt/_sd2_dst
sudo rmdir /mnt/_sd2_dst</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">hi.txt
write OK
hi.txt
new.txt
write refused -&gt; Read-only file system
drwxr-xr-x 2 root root 4096 Nov  2 15:13 /mnt/_sd2_dst</pre>



<p class="wp-block-paragraph">Le premier essai a listé le fichier et en a écrit un nouveau. Le listing de l&rsquo;hôte juste après montre que <code>new.txt</code> est arrivé dans le répertoire source. La variante en lecture seule a refusé l&rsquo;écriture. Systemd a aussi créé sur l&rsquo;hôte le répertoire de destination vide <code>/mnt/_sd2_dst</code>, et la dernière commande le montre. Supprimez-le vous-même après le test, comme le fait l&rsquo;extrait.</p>



<p class="wp-block-paragraph"><code>TemporaryFileSystem=</code> remplace un répertoire par un tmpfs vide. Combiné à un montage lié, il donne une liste blanche : cacher toute une zone, puis montrer seulement ce dont le service a besoin. Ici <code>/var/tmp</code> est caché, puis un répertoire revient :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">S=/var/tmp/_sd2_src
mkdir -p $S &amp;&amp; echo hi &gt; $S/hi.txt
# Cacher tout /var/tmp ...
sudo systemd-run --quiet --pipe --wait --collect -p TemporaryFileSystem=/var/tmp:ro \
  sh -c 'echo "ls -A /var/tmp:"; ls -A /var/tmp'
# ... puis laisser passer un seul répertoire
sudo systemd-run --quiet --pipe --wait --collect -p TemporaryFileSystem=/var/tmp:ro -p BindReadOnlyPaths=$S \
  sh -c 'echo "ls -A /var/tmp:"; ls -A /var/tmp; cat /var/tmp/_sd2_src/hi.txt'
rm -rf $S</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ls -A /var/tmp:
ls -A /var/tmp:
_sd2_src
hi</pre>



<p class="wp-block-paragraph">Le premier listing est vide, et le second ne montre que <code>_sd2_src</code>, dont le fichier est lisible. C&rsquo;est la forme la plus précise de la protection du système de fichiers, et aussi la plus laborieuse. Je ne l&rsquo;utilise nulle part pour l&rsquo;instant.</p>



<h2 class="wp-block-heading">StateDirectory et les autres directives de répertoire</h2>



<p class="wp-block-paragraph">Cinq directives créent un répertoire géré pour le service et l&rsquo;ouvrent en écriture, même sous <code>ProtectSystem=strict</code>. Systemd définit aussi une variable d&rsquo;environnement avec le chemin.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Directive</th><th>Répertoire</th><th>Variable</th><th>Supprimé à l&rsquo;arrêt ?</th></tr></thead><tbody><tr><td><code>StateDirectory=</code></td><td><code>/var/lib/NOM</code></td><td><code>STATE_DIRECTORY</code></td><td>non</td></tr><tr><td><code>CacheDirectory=</code></td><td><code>/var/cache/NOM</code></td><td><code>CACHE_DIRECTORY</code></td><td>non</td></tr><tr><td><code>LogsDirectory=</code></td><td><code>/var/log/NOM</code></td><td><code>LOGS_DIRECTORY</code></td><td>non</td></tr><tr><td><code>RuntimeDirectory=</code></td><td><code>/run/NOM</code></td><td><code>RUNTIME_DIRECTORY</code></td><td>oui</td></tr><tr><td><code>ConfigurationDirectory=</code></td><td><code>/etc/NOM</code></td><td><code>CONFIGURATION_DIRECTORY</code></td><td>non</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">J&rsquo;ai lancé les cinq d&rsquo;un coup avec l&rsquo;utilisateur <code>nobody</code>, sous <code>strict</code>. La sonde affiche les variables, écrit dans chaque répertoire, puis liste ce qui reste sur l&rsquo;hôte :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">N=sd2-demo
# Les cinq réglages *Directory=, sous ProtectSystem=strict, avec l'utilisateur nobody
sudo systemd-run --quiet --pipe --wait --collect -p ProtectSystem=strict -p User=nobody \
  -p StateDirectory=$N -p CacheDirectory=$N -p LogsDirectory=$N -p RuntimeDirectory=$N -p ConfigurationDirectory=$N \
  sh -c 'env | grep -E "^(STATE|CACHE|LOGS|RUNTIME|CONFIGURATION)_DIRECTORY" | sort
         for d in "$STATE_DIRECTORY" "$CACHE_DIRECTORY" "$LOGS_DIRECTORY" "$RUNTIME_DIRECTORY" "$CONFIGURATION_DIRECTORY"; do
           if err=$(touch "$d/x" 2&gt;&amp;1); then echo "$d: write OK"; else echo "$d: write refused -&gt; ${err##*: }"; fi
         done'
# Que reste-t-il sur l'hôte une fois le service arrêté ? (propriétaire et mode)
for p in /var/lib/$N /var/cache/$N /var/log/$N /run/$N /etc/$N; do stat -c "%n %a %U:%G" $p 2&gt;&amp;1; done</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">CACHE_DIRECTORY=/var/cache/sd2-demo
CONFIGURATION_DIRECTORY=/etc/sd2-demo
LOGS_DIRECTORY=/var/log/sd2-demo
RUNTIME_DIRECTORY=/run/sd2-demo
STATE_DIRECTORY=/var/lib/sd2-demo
/var/lib/sd2-demo: write OK
/var/cache/sd2-demo: write OK
/var/log/sd2-demo: write OK
/run/sd2-demo: write OK
/etc/sd2-demo: write OK
/var/lib/sd2-demo 755 nobody:nobody
/var/cache/sd2-demo 755 nobody:nobody
/var/log/sd2-demo 755 nobody:nobody
stat: cannot statx '/run/sd2-demo': No such file or directory
/etc/sd2-demo 755 nobody:nobody</pre>



<p class="wp-block-paragraph">Les cinq variables existent et les cinq répertoires étaient inscriptibles sous <code>strict</code>. Une fois le service arrêté, quatre répertoires restent et appartiennent à l&rsquo;utilisateur du service. Le répertoire d&rsquo;exécution a disparu, car systemd le supprime à l&rsquo;arrêt. Le mode par défaut est 755, et <code>StateDirectoryMode=</code> le change (les autres directives ont des réglages <code>...DirectoryMode=</code> équivalents) :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># StateDirectoryMode= change le mode (755 par défaut)
sudo rm -rf /var/lib/sd2-demo
sudo systemd-run --quiet --pipe --wait --collect -p User=nobody -p StateDirectory=sd2-demo -p StateDirectoryMode=0700 \
  stat -c "%n %a %U:%G" /var/lib/sd2-demo</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">/var/lib/sd2-demo 700 nobody:nobody</pre>



<p class="wp-block-paragraph">Un résultat diffère de mon attente. D&rsquo;après ma lecture du manuel, <code>ConfigurationDirectory=</code> se distingue des quatre autres pour le propriétaire, et je m&rsquo;attendais à <code>root</code>. Sur mon banc, le répertoire appartenait à <code>nobody</code>, comme les autres. Je l&rsquo;ai lancé seul pour vérifier, d&rsquo;abord avec <code>User=nobody</code>, puis sans aucun <code>User=</code> :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># ConfigurationDirectory= seul : avec User=nobody, puis sans User=
sudo systemd-run --quiet --pipe --wait --collect -p User=nobody -p ConfigurationDirectory=sd2-cfg true
stat -c "%n %a %U:%G" /etc/sd2-cfg; sudo rm -rf /etc/sd2-cfg
sudo systemd-run --quiet --pipe --wait --collect -p ConfigurationDirectory=sd2-cfg true
stat -c "%n %a %U:%G" /etc/sd2-cfg; sudo rm -rf /etc/sd2-cfg</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">/etc/sd2-cfg 755 nobody:nobody
/etc/sd2-cfg 755 root:root</pre>



<p class="wp-block-paragraph">Le répertoire a donc suivi l&rsquo;utilisateur du service sur ce système. Vérifiez-le sur votre version avant de vous y fier. Le manuel décrit aussi <code>RuntimeDirectoryPreserve=</code> pour garder le répertoire d&rsquo;exécution après un arrêt ; je ne l&rsquo;ai pas testé, il est <em>non testé</em>. Nettoyez maintenant les répertoires de test :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Nettoyer les répertoires laissés par les tests
sudo rm -rf /var/lib/sd2-demo /var/cache/sd2-demo /var/log/sd2-demo /run/sd2-demo /etc/sd2-demo</pre>



<p class="wp-block-paragraph">Ces directives rendent la protection du système de fichiers plus facile à maintenir. Elles remplacent les lignes <code>ReadWritePaths=</code> écrites à la main et les tâches <code>mkdir</code> et <code>chown</code> qui vont avec. Navidrome, dans ma pile, liste aujourd&rsquo;hui <code>/var/lib/navidrome</code> et <code>/var/log/navidrome</code> à la main, ce qui en fait un bon candidat pour <code>StateDirectory=</code> et <code>LogsDirectory=</code>.</p>



<h2 class="wp-block-heading">RestrictFileSystems : une liste blanche de types de systèmes de fichiers</h2>



<p class="wp-block-paragraph"><code>RestrictFileSystems=</code> limite les types de systèmes de fichiers qu&rsquo;un service peut utiliser, par exemple <code>ext4 xfs</code> seulement, ou <code>~tmpfs</code> pour interdire tmpfs. Le manuel dit qu&rsquo;elle repose sur une fonction du noyau, le module de sécurité BPF. Mon banc ne semble pas l&rsquo;avoir, donc j&rsquo;ai testé ce qui se passe :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Le module de sécurité BPF est-il actif ? (RestrictFileSystems= en a besoin)
cat /sys/kernel/security/lsm
# Écrire dans un tmpfs (/dev/shm) alors que RestrictFileSystems=~tmpfs interdit tmpfs
sudo systemd-run --quiet --pipe --wait --collect -p RestrictFileSystems=~tmpfs \
  sh -c 'if err=$(touch /dev/shm/_sd2_rfs 2&gt;&amp;1); then echo "write to tmpfs OK"; else echo "write to tmpfs refused -&gt; ${err##*: }"; fi'
sudo rm -f /dev/shm/_sd2_rfs</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cat: /sys/kernel/security/lsm: No such file or directory
write to tmpfs OK</pre>



<p class="wp-block-paragraph">Le fichier qui liste les modules de sécurité actifs n&rsquo;existe même pas sur ce noyau. L&rsquo;écriture dans un tmpfs a réussi alors que j&rsquo;avais interdit tmpfs, et systemd n&rsquo;a levé aucune erreur. La directive n&rsquo;a donc eu aucun effet, en silence. Sur un noyau doté du module BPF, elle devrait fonctionner, mais je n&rsquo;ai pas pu le tester. Avant de vous y fier, lancez <code>cat /sys/kernel/security/lsm</code> sur votre serveur et cherchez <code>bpf</code> dans la liste.</p>



<h2 class="wp-block-heading">RootDirectory, RootImage et MountAPIVFS</h2>



<p class="wp-block-paragraph">Ces directives vont plus loin que le reste de l&rsquo;article : elles lancent le service dans une autre racine. <code>RootDirectory=</code> fonctionne comme un chroot, <code>RootImage=</code> monte une image disque comme racine, et <code>MountAPIVFS=yes</code> y ajoute <code>/proc</code>, <code>/sys</code> et <code>/dev</code>. Un tel service ne voit que ce que vous placez dans la nouvelle racine, en général les bibliothèques et outils dont il a besoin. La protection est forte, mais elle demande une vraie préparation, et je n&rsquo;ai testé aucune des trois. Elles restent <em>non testées</em> ici, et mes services ne les utilisent pas.</p>



<h2 class="wp-block-heading">Un flux de travail sûr pour tester chaque changement</h2>



<p class="wp-block-paragraph">La partie 1 de la série explique la méthode en détail. Voici la version courte appliquée à l&rsquo;arborescence. Vous modifiez l&rsquo;unité par un drop-in, redémarrez, lisez le journal et, si besoin, revenez en arrière :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Ouvrir (ou créer) le drop-in override.conf d'une unité dans votre éditeur ; systemd le recharge à l'enregistrement
sudo systemctl edit myapp.service
# Afficher l'unité et tous ses drop-ins, fusionnés, dans l'ordre où systemd les lit
systemctl cat myapp.service
# Un rechargement ne suffit pas pour un processus en cours : redémarrer le service, puis lire le journal
sudo systemctl restart myapp.service
journalctl -u myapp.service -n 20 --no-pager -o cat
# Annuler : supprimer tous les drop-ins de l'unité
sudo systemctl revert myapp.service</pre>



<p class="wp-block-paragraph">Vous pouvez aussi essayer une directive sans aucun fichier. <code>systemd-run -p</code> démarre une unité temporaire avec les propriétés choisies, et <code>systemctl show -p</code> vous dit ce que systemd a réellement appliqué :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Démarrer une unité temporaire de longue durée avec trois propriétés
sudo systemd-run --quiet --unit=demo-hardening-fs-show --collect \
  -p ProtectSystem=strict -p ReadWritePaths=/var/tmp -p PrivateTmp=yes sleep 30
# Demander à systemd ce qu'il a réellement appliqué
systemctl show demo-hardening-fs-show.service -p ProtectSystem -p ReadWritePaths -p PrivateTmp
sudo systemctl stop demo-hardening-fs-show.service
systemctl is-active demo-hardening-fs-show.service</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="ini">ReadWritePaths=/var/tmp
PrivateTmp=yes
ProtectSystem=strict
inactive</pre>



<p class="wp-block-paragraph"><code>systemctl show</code> affiche les propriétés dans son propre ordre, pas dans celui des options <code>-p</code>. Après <code>systemctl stop</code>, l&rsquo;unité transitoire avait disparu et <code>systemctl is-active</code> répondait « inactive ».</p>



<h2 class="wp-block-heading">Labo pratique : voir l&rsquo;arborescence changer</h2>



<p class="wp-block-paragraph">Ce labo montre la protection du système de fichiers à l&rsquo;œuvre sur un service inoffensif qui essaie seulement de créer un fichier vide à cinq endroits. Vous le lancez huit fois, avec un drop-in différent à chaque fois. Commencez par créer le script et l&rsquo;unité :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># 1. Un script inoffensif : il essaie seulement de créer un fichier vide à cinq endroits
sudo mkdir /home/demo-hardening-dir
sudo tee /usr/local/bin/demo-hardening-fs.sh &gt; /dev/null &lt;&lt;'EOF'
#!/bin/sh
echo "entries in /home: $(ls -A /home | wc -l)"
for d in /etc /home /home/demo-hardening-dir /tmp /var/tmp; do
  if err=$(touch "$d/demo-hardening-test" 2&gt;&amp;1); then echo "$d: write OK"
  else echo "$d: write refused (${err##*: })"; fi
done
EOF
sudo chmod 755 /usr/local/bin/demo-hardening-fs.sh

# 2. Un service one-shot qui lance le script
sudo tee /etc/systemd/system/demo-hardening-fs.service &gt; /dev/null &lt;&lt;'EOF'
[Unit]
Description=Filesystem protection demo (harmless)

[Service]
Type=oneshot
ExecStart=/usr/local/bin/demo-hardening-fs.sh
EOF
sudo systemctl daemon-reload</pre>



<p class="wp-block-paragraph">Définissez ensuite une petite fonction d&rsquo;aide. Elle remplace le drop-in par le texte que vous lui donnez, lance le service et affiche le journal :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># try : remplacer le drop-in par le texte lu sur l'entrée standard, lancer l'unité, afficher les 9 dernières lignes du journal
try() {
  sudo rm -rf /etc/systemd/system/demo-hardening-fs.service.d
  local c; c="$(cat)"
  if [ -n "$c" ]; then
    sudo mkdir -p /etc/systemd/system/demo-hardening-fs.service.d
    printf '%s\n' "$c" | sudo tee /etc/systemd/system/demo-hardening-fs.service.d/override.conf &gt; /dev/null
  fi
  sudo systemctl daemon-reload
  sudo systemctl start demo-hardening-fs.service
  sudo journalctl -u demo-hardening-fs.service -n 9 --no-pager -o cat
  # supprimer les fichiers de test créés par le script
  sudo rm -f /etc/demo-hardening-test /home/demo-hardening-test /home/demo-hardening-dir/demo-hardening-test \
             /tmp/demo-hardening-test /var/tmp/demo-hardening-test
}</pre>



<p class="wp-block-paragraph">Lancez la référence et la première expérience :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># E0 : aucun drop-in, rien n'est restreint
: | try
# E1 : toute l'arborescence en lecture seule
printf '[Service]\nProtectSystem=strict\n' | try</pre>



<p class="wp-block-paragraph">Sans drop-in (E0), les cinq écritures réussissent. Avec <code>ProtectSystem=strict</code> (E1), j&rsquo;ai obtenu ce journal. Trois écritures sur cinq sont refusées, mais <code>/home</code> et le répertoire de démonstration qu&rsquo;il contient restent inscriptibles, comme dans la sonde précédente :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Starting demo-hardening-fs.service - Filesystem protection demo (harmless)...
entries in /home: 2
/etc: write refused (Read-only file system)
/home: write OK
/home/demo-hardening-dir: write OK
/tmp: write refused (Read-only file system)
/var/tmp: write refused (Read-only file system)
demo-hardening-fs.service: Deactivated successfully.
Finished demo-hardening-fs.service - Filesystem protection demo (harmless).</pre>



<p class="wp-block-paragraph">Ajoutez maintenant <code>PrivateTmp=yes</code> (E2), puis ouvrez <code>/etc</code> (E3), et lisez l&rsquo;unité fusionnée :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># E2 : ajouter un /tmp et un /var/tmp privés
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\n' | try
# E3 : ouvrir aussi /etc
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\nReadWritePaths=/etc\n' | try
# L'unité et son drop-in, fusionnés
systemctl cat demo-hardening-fs.service --no-pager</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/systemd/system/demo-hardening-fs.service
[Unit]
Description=Filesystem protection demo (harmless)

[Service]
Type=oneshot
ExecStart=/usr/local/bin/demo-hardening-fs.sh

# /etc/systemd/system/demo-hardening-fs.service.d/override.conf
[Service]
ProtectSystem=strict
PrivateTmp=yes
ReadWritePaths=/etc</pre>



<p class="wp-block-paragraph">La vue fusionnée prouve que systemd a lu votre drop-in. En E2, <code>/tmp</code> et <code>/var/tmp</code> acceptent à nouveau les écritures. En E3, <code>/etc</code> aussi. Voyons maintenant le répertoire personnel, en quatre expériences :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># E4 : cacher /home complètement
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\nProtectHome=yes\n' | try
# E5 : garder /home visible mais en lecture seule
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\nProtectHome=read-only\n' | try
# E6 : /home en lecture seule, avec un sous-répertoire inscriptible
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\nProtectHome=read-only\nReadWritePaths=/home/demo-hardening-dir\n' | try
# E7 : /home caché, avec le même sous-répertoire inscriptible
printf '[Service]\nProtectSystem=strict\nPrivateTmp=yes\nProtectHome=yes\nReadWritePaths=/home/demo-hardening-dir\n' | try</pre>



<p class="wp-block-paragraph">En E4, <code>ProtectHome=yes</code> cache tout, et le répertoire de démonstration est absent :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Starting demo-hardening-fs.service - Filesystem protection demo (harmless)...
entries in /home: 0
/etc: write refused (Read-only file system)
/home: write refused (Read-only file system)
/home/demo-hardening-dir: write refused (No such file or directory)
/tmp: write OK
/var/tmp: write OK
demo-hardening-fs.service: Deactivated successfully.
Finished demo-hardening-fs.service - Filesystem protection demo (harmless).</pre>



<p class="wp-block-paragraph">En E6, <code>read-only</code> plus le sous-répertoire exact est la combinaison qui marche :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Starting demo-hardening-fs.service - Filesystem protection demo (harmless)...
entries in /home: 2
/etc: write refused (Read-only file system)
/home: write refused (Read-only file system)
/home/demo-hardening-dir: write OK
/tmp: write OK
/var/tmp: write OK
demo-hardening-fs.service: Deactivated successfully.
Finished demo-hardening-fs.service - Filesystem protection demo (harmless).</pre>



<p class="wp-block-paragraph">Ce tableau résume les huit essais. Chaque cellule est une réponse des vrais journaux :</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Essai</th><th><code>/etc</code></th><th><code>/home</code></th><th><code>/home/demo-hardening-dir</code></th><th><code>/tmp</code></th><th><code>/var/tmp</code></th></tr></thead><tbody><tr><td>E0 aucun drop-in</td><td>OK</td><td>OK</td><td>OK</td><td>OK</td><td>OK</td></tr><tr><td>E1 <code>ProtectSystem=strict</code></td><td>refusée</td><td>OK</td><td>OK</td><td>refusée</td><td>refusée</td></tr><tr><td>E2 + <code>PrivateTmp=yes</code></td><td>refusée</td><td>OK</td><td>OK</td><td>OK</td><td>OK</td></tr><tr><td>E3 + <code>ReadWritePaths=/etc</code></td><td>OK</td><td>OK</td><td>OK</td><td>OK</td><td>OK</td></tr><tr><td>E4 + <code>ProtectHome=yes</code></td><td>refusée</td><td>refusée</td><td>absent</td><td>OK</td><td>OK</td></tr><tr><td>E5 + <code>ProtectHome=read-only</code></td><td>refusée</td><td>refusée</td><td>refusée</td><td>OK</td><td>OK</td></tr><tr><td>E6 <code>read-only</code> + sous-répertoire inscriptible</td><td>refusée</td><td>refusée</td><td>OK</td><td>OK</td><td>OK</td></tr><tr><td>E7 <code>yes</code> + sous-répertoire inscriptible</td><td>refusée</td><td>refusée</td><td>absent</td><td>OK</td><td>OK</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">La ligne E7 est de nouveau le piège : le sous-répertoire inscriptible est invisible sous <code>ProtectHome=yes</code>. Enfin, annulez tout. La première commande montre ce que <code>systemctl revert</code> supprime :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># Annuler le drop-in avec systemctl revert, puis supprimer tout ce que le labo a créé
sudo systemctl revert demo-hardening-fs.service
sudo rm -f /etc/systemd/system/demo-hardening-fs.service /usr/local/bin/demo-hardening-fs.sh
sudo rmdir /home/demo-hardening-dir
sudo systemctl daemon-reload
ls /etc/systemd/system | grep demo-hardening-fs || echo "no demo-hardening-fs unit file left"</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Removed '/etc/systemd/system/demo-hardening-fs.service.d/override.conf'.
Removed '/etc/systemd/system/demo-hardening-fs.service.d'.
no demo-hardening-fs unit file left</pre>



<p class="wp-block-paragraph">La dernière ligne confirme qu&rsquo;aucun fichier d&rsquo;unité ne reste. J&rsquo;ai aussi vérifié que le script de démonstration, le répertoire de démonstration et les fichiers de test dans <code>/etc</code>, <code>/home</code>, <code>/tmp</code> et <code>/var/tmp</code> avaient disparu.</p>



<h2 class="wp-block-heading">Un drop-in complet pour une application web typique</h2>



<p class="wp-block-paragraph">Assemblons les morceaux. Ce drop-in convient à un service qui garde ses données dans <code>/var/lib</code>, écrit un journal et enregistre une sauvegarde dans un répertoire supplémentaire. J&rsquo;ai testé chacune de ses directives séparément, et <code>ProtectSystem</code>, <code>PrivateTmp</code> et <code>ProtectHome</code> ensemble dans le labo ci-dessus. Je n&rsquo;ai pas lancé ce fichier exact sur une vraie application : adaptez donc les noms et testez-le comme dans la section sur le flux de travail.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="ini">[Service]
# Rendre toute l'arborescence en lecture seule, puis ouvrir les exceptions ci-dessous
ProtectSystem=strict
# Cacher /home, /root et /run/user
ProtectHome=yes
# /tmp et /var/tmp privés, supprimés à l'arrêt du service
PrivateTmp=yes
# systemd crée /var/lib/myapp, le donne à l'utilisateur du service et le garde inscriptible
StateDirectory=myapp
# Idem pour /var/log/myapp
LogsDirectory=myapp
# Un chemin inscriptible de plus ; le tiret initial ignore le chemin s'il n'existe pas encore
ReadWritePaths=-/srv/backup/myapp</pre>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?ssl=1"><img data-recalc-dims="1" decoding="async" width="629" height="256" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=629%2C256&#038;ssl=1" alt="Protection du système de fichiers dans une unité : lecture seule avec quatre exceptions voulues" class="wp-image-4906" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=300%2C122&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=1024%2C417&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=768%2C313&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=1536%2C625&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=900%2C366&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp-3.png?resize=1280%2C521&amp;ssl=1 1280w" sizes="(max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une hiérarchie en lecture seule où chaque chemin inscriptible est une décision voulue.</figcaption></figure>



<p class="wp-block-paragraph">Chaque chemin qui reste inscriptible dans cette unité est une décision écrite dans le fichier. C&rsquo;est tout l&rsquo;intérêt de la protection du système de fichiers : l&rsquo;unité documente ce que le service peut toucher.</p>



<h2 class="wp-block-heading">Erreurs courantes</h2>



<p class="wp-block-paragraph">La plupart des échecs de la protection du système de fichiers viennent de six erreurs. Je les ai toutes faites au moins une fois.</p>



<h3 class="wp-block-heading">Erreur 1 : un chemin inscriptible qui n&rsquo;existe pas encore</h3>



<p class="wp-block-paragraph">Le service refuse de démarrer avec le code de sortie 226. Créez d&rsquo;abord le répertoire, utilisez <code>StateDirectory=</code> ou ajoutez le tiret initial.</p>



<h3 class="wp-block-heading">Erreur 2 : ReadWritePaths sous ProtectHome=yes</h3>



<p class="wp-block-paragraph">L&rsquo;exception est invisible, sans message. Utilisez <code>ProtectHome=read-only</code> avec le sous-répertoire exact, ou <code>tmpfs</code> avec <code>BindPaths=</code>.</p>



<h3 class="wp-block-heading">Erreur 3 : strict sans PrivateTmp</h3>



<p class="wp-block-paragraph"><code>/tmp</code> passe en lecture seule et le programme échoue à son premier fichier temporaire. Ajoutez <code>PrivateTmp=yes</code>.</p>



<h3 class="wp-block-heading">Erreur 4 : se fier à strict pour les répertoires personnels</h3>



<p class="wp-block-paragraph">Sur mon banc, <code>/home</code> et <code>/root</code> sont restés inscriptibles sous <code>strict</code>. Ajoutez <code>ProtectHome=</code> et mesurez.</p>



<h3 class="wp-block-heading">Erreur 5 : ouvrir tout un répertoire parent</h3>



<p class="wp-block-paragraph"><code>ReadWritePaths=/var</code> ouvre bien plus que ce dont le service a besoin. Nommez le seul sous-répertoire, ou laissez un réglage <code>*Directory=</code> le créer.</p>



<h3 class="wp-block-heading">Erreur 6 : oublier de redémarrer</h3>



<p class="wp-block-paragraph">Un rechargement ne change pas un processus en cours. Redémarrez le service, puis testez son vrai travail et pas seulement son démarrage.</p>



<h2 class="wp-block-heading">Une liste de contrôle à suivre</h2>



<p class="wp-block-paragraph">Suivez ces étapes chaque fois que vous ajoutez la protection du système de fichiers à un service.</p>



<ol class="wp-block-list"><li>Lancez <code>systemctl cat UNITÉ</code> et notez où le service écrit aujourd&rsquo;hui.</li><li>Ajoutez <code>ProtectSystem=strict</code> et <code>PrivateTmp=yes</code> dans un drop-in.</li><li>Ajoutez <code>ProtectHome=yes</code>, ou <code>read-only</code> si le service a besoin d&rsquo;un chemin sous <code>/home</code>.</li><li>Remplacez les répertoires faits à la main par <code>StateDirectory=</code>, <code>CacheDirectory=</code> et <code>LogsDirectory=</code>.</li><li>Ajoutez <code>ReadWritePaths=</code> pour chaque chemin restant, avec un tiret pour les chemins facultatifs.</li><li>Redémarrez le service et exercez son vrai travail, sauvegardes et tâches planifiées comprises.</li><li>Lisez <code>journalctl -u UNITÉ</code> à la recherche de « Read-only file system » et reprenez à l&rsquo;étape 5.</li><li>Gardez le drop-in sous gestion de versions, pour que la décision reste documentée.</li></ol>



<h2 class="wp-block-heading">Ce que j&rsquo;en retiens</h2>



<ul class="wp-block-list"><li><strong>La protection du système de fichiers est une liste de décisions.</strong> Chaque chemin inscriptible de l&rsquo;unité est une décision, et l&rsquo;unité la documente.</li><li><strong>Fermer d&rsquo;abord, ouvrir ensuite.</strong> <code>strict</code> plus quelques exceptions se relit plus facilement qu&rsquo;une longue liste d&rsquo;interdictions.</li><li><strong>Les erreurs ont un autre visage.</strong> « Read-only file system » et « No such file or directory » signalent le plus souvent une règle du bac à sable, pas une permission.</li><li><strong>Les exceptions demandent des chemins exacts.</strong> Le parent d&rsquo;un chemin n&rsquo;ouvre pas ce chemin, et <code>ProtectHome=yes</code> ne se rouvre pas.</li><li><strong>Laissez systemd créer les répertoires.</strong> Les réglages <code>*Directory=</code> donnent le bon propriétaire et le bon mode.</li><li><strong>Ne faites pas confiance aveuglément à une directive.</strong> Plusieurs résultats diffèrent ici de mon attente : <code>/home</code> sous <code>strict</code>, le propriétaire de <code>ConfigurationDirectory=</code>, <code>PrivateTmp=disconnected</code> via <code>systemd-run</code> et <code>RestrictFileSystems=</code>. Mesurez sur votre propre système.</li><li><strong>Surveillez les échecs silencieux.</strong> Une sauvegarde qui ne peut pas écrire échoue sans aucune alarme, comme l&rsquo;ont montré les archives de 122 octets.</li></ul>



<h2 class="wp-block-heading">Sources</h2>



<ul class="wp-block-list"><li>systemd.exec(5) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html">page de manuel freedesktop.org</a></li><li>systemd-run(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd-run.html">page de manuel freedesktop.org</a></li><li>systemctl(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemctl.html">page de manuel freedesktop.org</a></li><li>journalctl(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/journalctl.html">page de manuel freedesktop.org</a></li><li>Mes notes de homelab : les entrées Kavita, Navidrome, mktxp, Oxidized, Etherpad et mox de mon wiki (privé).</li></ul>



<h2 class="wp-block-heading">Plus tôt dans la série</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=4897">Durcir systemd, directive par directive : mesurer avec systemd-analyze security</a></li></ul>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li>&lt;a href= »https://blog.neurozone.fr/?p=2045&Prime;&gt;Pas à pas : installer, configurer et vérifier un serveur mail avec mox&lt;/a&gt;</li><li>&lt;a href= »https://blog.neurozone.fr/?p=2930&Prime;&gt;ZDI-CAN-33367 : quand une copie évitée écrit dans un fichier en lecture seule&lt;/a&gt;</li><li>&lt;a href= »https://blog.neurozone.fr/?p=2047&Prime;&gt;Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur&lt;/a&gt;</li></ul>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/14/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp/">Protection du système de fichiers avec systemd : durcissement, partie 2</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/14/protection-du-systeme-de-fichiers-systemd-protectsystem-protecthome-privatetmp/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">4907</post-id>	</item>
		<item>
		<title>Pas à pas : mettre à jour Pi-hole et vérifier qu&#8217;il bloque encore</title>
		<link>https://blog.neurozone.fr/2026/11/13/pas-a-pas-mettre-a-jour-pi-hole-tester-blocage/</link>
					<comments>https://blog.neurozone.fr/2026/11/13/pas-a-pas-mettre-a-jour-pi-hole-tester-blocage/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Fri, 13 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[Debian]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[pas à pas]]></category>
		<category><![CDATA[Pi-hole]]></category>
		<category><![CDATA[Raspberry Pi]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=4951</guid>

					<description><![CDATA[<p>Mettre à jour Pi-hole en six étapes : sauvegarde Teleporter, pihole -up, puis test du blocage. Les commandes que j'utilise sur mon réseau domestique.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/13/pas-a-pas-mettre-a-jour-pi-hole-tester-blocage/">Pas à pas : mettre à jour Pi-hole et vérifier qu&rsquo;il bloque encore</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Pour mettre à jour Pi-hole sur mon réseau domestique, je lance une seule commande, mais je ne lui fais jamais confiance seule. Dans ce pas à pas, je décris comment mettre à jour Pi-hole en six étapes. L&rsquo;étape qui compte le plus vérifie que le blocage fonctionne encore, et pas seulement que les noms se résolvent.</p>



<div class="quick-answer" style="background:#eef3fc;border-left:5px solid #4c7bd9;border-radius:4px;padding:16px 20px;margin:24px 0;color:#1f2937">
<p style="margin:0 0 8px"><strong>Réponse rapide</strong> — mettre à jour Pi-hole et prouver qu&rsquo;il bloque encore.</p>
<pre style="margin:0 0 8px;overflow:auto"><code>pihole -v                                   # versions avant
pihole-FTL --teleporter                     # sauvegarde de la configuration
PIHOLE_SKIP_OS_CHECK=true pihole -up        # mise à jour
dig +short doubleclick.net @192.0.2.19      # doit afficher 0.0.0.0 (bloqué)</code></pre>
<p style="margin:0;font-size:0.9em">Les détails, les explications et les vérifications suivent plus bas.</p>
</div>



<p class="wp-block-paragraph">Mon Pi-hole tourne sur un Raspberry Pi avec Debian 12 et sert de seul serveur DNS de la maison. Par conséquent, chaque redémarrage du service DNS provoque une petite coupure. Mon rôle Ansible ne gère pas les versions de Pi-hole, donc je fais la mise à jour à la main. J&rsquo;envoie les commandes avec une commande Ansible ad hoc depuis ma machine de contrôle, mais je montre ici les commandes elles-mêmes.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="227" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=629%2C227&#038;ssl=1" alt="Mettre à jour Pi-hole en cinq étapes : versions, sauvegarde Teleporter, mise à jour, test du blocage, comparaison" class="wp-image-4950" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=300%2C108&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=1024%2C369&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=768%2C277&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=1536%2C554&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=900%2C324&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/mettre-a-jour-pi-hole-cinq-etapes_fr.png?resize=1280%2C461&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Les cinq contrôles autour d&rsquo;une commande. L&rsquo;étape 4 est celle qui a détecté un vrai problème.</figcaption></figure>



<h2 class="wp-block-heading">Avant de commencer</h2>



<ul class="wp-block-list"><li>Un shell sur la machine Pi-hole avec les droits root.</li><li>L&rsquo;adresse de votre Pi-hole et celle de votre routeur. Dans cet article, ce sont 192.0.2.19 et 192.0.2.1.</li><li>Quelques minutes de libres. Dans mes notes, le DNS du réseau local redémarre pendant quelques secondes, et le blocage des publicités peut s&rsquo;arrêter pendant environ cinq minutes (voir l&rsquo;étape 4).</li><li>Les notes de version de Pi-hole. La page officielle de mise à jour demande de les lire pour chaque composant avant de mettre à jour, surtout pour une version majeure.</li></ul>



<h2 class="wp-block-heading">Comment mettre à jour Pi-hole, étape par étape</h2>



<h3 class="wp-block-heading">Étape 1 : vérifier les versions</h3>



<p class="wp-block-paragraph">D&rsquo;abord, je liste ce que j&rsquo;utilise maintenant. La sortie donne les versions de Core, de Web et de FTL.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">pihole -v</pre>



<h3 class="wp-block-heading">Étape 2 : sauvegarder la configuration</h3>



<p class="wp-block-paragraph">Ensuite, je sauvegarde deux fois : avec Teleporter, l&rsquo;export propre à Pi-hole, et avec une simple copie du fichier TOML. Je travaille dans un dossier daté, pour que les deux restent ensemble.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">mkdir -p /root/pre-update-$(date +%Y%m%d) &amp;&amp; cd $_   # un dossier daté, puis on y entre
pihole-FTL --teleporter                                # l'archive Teleporter
cp -a /etc/pihole/pihole.toml pihole.toml.bak          # une copie du fichier de configuration
ls -la                                                 # vérifier que les deux sont là</pre>



<p class="wp-block-paragraph">La copie du fichier TOML sert encore à l&rsquo;étape 5.</p>



<h3 class="wp-block-heading">Étape 3 : lancer la mise à jour</h3>



<p class="wp-block-paragraph">Vient maintenant la mise à jour elle-même. Je garde les 20 dernières lignes de sa sortie, j&rsquo;affiche à nouveau les versions, et je demande à systemd si le service DNS tourne.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PIHOLE_SKIP_OS_CHECK=true pihole -up 2&gt;&amp;1 | tail -20   # mise à jour, on garde les 20 dernières lignes
pihole -v                                              # versions après la mise à jour
systemctl is-active pihole-FTL                         # doit afficher : active</pre>



<p class="wp-block-paragraph">Mes notes utilisent PIHOLE_SKIP_OS_CHECK=true, qui saute le contrôle du système d&rsquo;exploitation par l&rsquo;installeur. La page de mise à jour de Pi-hole ne le mentionne pas, donc testez-le sur votre système avant de le copier.</p>



<h3 class="wp-block-heading">Étape 4 : tester le blocage, pas seulement la résolution</h3>



<p class="wp-block-paragraph">Cette étape est obligatoire pour moi. Le 29 octobre 2026, FTL, le moteur DNS de Pi-hole, a pris de vitesse le remplacement de la base gravity. Le journal affichait <code>no such table: main.gravity</code> dans <code>/var/log/pihole/FTL.log</code>, et Pi-hole n&rsquo;a rien bloqué pendant environ cinq minutes. Cela s&rsquo;est arrêté seulement quand FTL a écrit son propre message : <code>Gravity database has been replaced, reloading now</code>.</p>



<p class="wp-block-paragraph">Pendant ces minutes, les noms se résolvaient toujours. Donc un simple test de résolution m&rsquo;aurait dit que tout allait bien.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">dig +short doubleclick.net @192.0.2.19   # doit afficher 0.0.0.0 (bloqué)
dig +short example.com @192.0.2.19       # doit afficher une vraie adresse
dig +short doubleclick.net @192.0.2.1    # via le routeur : doit aussi afficher 0.0.0.0</pre>



<p class="wp-block-paragraph">La troisième requête passe par le routeur. Sur mon réseau, le routeur transmet ses requêtes à Pi-hole, donc une réponse bloquée à cet endroit prouve tout le chemin.</p>



<p class="wp-block-paragraph">Si le blocage ne fonctionne pas après cinq minutes, je recharge la configuration DNS, puis je lis le journal de FTL.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">pihole reloaddns</pre>



<h3 class="wp-block-heading">Étape 5 : vérifier que la configuration n&rsquo;a pas changé</h3>



<p class="wp-block-paragraph">Une mise à jour peut toucher la configuration. Je compare donc ma sauvegarde avec le fichier en place, en écartant les commentaires et les lignes qui contiennent des empreintes de mot de passe.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">diff &lt;(grep -vE '^\s*#|pwhash|password|totp' /root/pre-update-*/pihole.toml.bak) \
     &lt;(grep -vE '^\s*#|pwhash|password|totp' /etc/pihole/pihole.toml)</pre>



<p class="wp-block-paragraph">Aucune sortie signifie aucune différence.</p>



<h3 class="wp-block-heading">Étape 6 : synchroniser le mot de passe d&rsquo;administration s&rsquo;il a changé</h3>



<p class="wp-block-paragraph">Si le mot de passe d&rsquo;administration a changé pour une raison quelconque, je mets à jour mon gestionnaire de secrets le jour même. En août 2026, je l&rsquo;ai oublié une fois, et chaque déploiement de mon rôle Ansible a échoué jusqu&rsquo;à la correction.</p>



<h2 class="wp-block-heading">Vérifier que tout a fonctionné</h2>



<ul class="wp-block-list"><li><code>pihole -v</code> affiche les nouvelles versions.</li><li><code>systemctl is-active pihole-FTL</code> affiche <code>active</code>.</li><li>La première et la troisième requête <code>dig</code> affichent <code>0.0.0.0</code>, et la deuxième affiche une vraie adresse.</li><li>Le <code>diff</code> n&rsquo;affiche rien.</li></ul>



<h2 class="wp-block-heading">Si quelque chose se passe mal</h2>



<ul class="wp-block-list"><li>Le blocage ne fonctionne pas après cinq minutes : lancez <code>pihole reloaddns</code>, puis lisez <code>/var/log/pihole/FTL.log</code>.</li><li>Vous voulez revenir en arrière : <code>pihole -up</code> ne peut pas réinstaller une version précédente. À la place, restaurez l&rsquo;archive Teleporter sur une installation neuve avec <code>pihole-FTL --teleporter &lt;zip&gt;</code>.</li></ul>



<h2 class="wp-block-heading">Ce que j&rsquo;en retiens</h2>



<ul class="wp-block-list"><li>Je sauvegarde deux fois avant de mettre à jour Pi-hole, parce qu&rsquo;il n&rsquo;existe pas de retour en arrière.</li><li>Je teste le blocage, parce que la résolution seule a caché un trou de cinq minutes.</li><li>Je compare la configuration, parce que je veux savoir ce que la mise à jour a changé.</li></ul>



<h2 class="wp-block-heading">Sources</h2>



<ul class="wp-block-list"><li><a href="https://docs.pi-hole.net/main/update/">Documentation Pi-hole : mise à jour (en anglais)</a></li><li><a href="https://docs.pi-hole.net/ftldns/configfile/">Documentation Pi-hole : configuration de FTL, pihole-FTL &#8211;config (en anglais)</a></li><li>Mon propre manuel de procédure, dernier passage le 29 octobre 2026.</li></ul>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=2043">Pas à pas : installer et configurer WireGuard entre un serveur et la maison</a></li><li><a href="https://blog.neurozone.fr/?p=1419">CrowdSec 1.8 sur Debian : réinstaller depuis le dépôt officiel, piège du postinst et vérifications</a></li><li><a href="https://blog.neurozone.fr/?p=2044">Pas à pas : les réglages MariaDB qui comptent vraiment, et comment les vérifier</a></li></ul>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/13/pas-a-pas-mettre-a-jour-pi-hole-tester-blocage/">Pas à pas : mettre à jour Pi-hole et vérifier qu&rsquo;il bloque encore</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/13/pas-a-pas-mettre-a-jour-pi-hole-tester-blocage/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">4951</post-id>	</item>
		<item>
		<title>Dans les fichiers d&#8217;une base de données (4/3+1) : SQLite</title>
		<link>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/</link>
					<comments>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Thu, 12 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[SQLite]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5455</guid>

					<description><![CDATA[<p>Les fichiers SQLite lus octet par octet sur une vraie base : en-tête, pages d'arbre B, enregistrements, WAL et VACUUM, mesurés sur Rocky Linux 10.2.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/">Dans les fichiers d&rsquo;une base de données (4/3+1) : SQLite</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">SQLite est l&rsquo;intrus de cette série : pas de serveur, pas de répertoire de données, pas de processus d&rsquo;arrière-plan. Les fichiers SQLite tiennent dans un seul fichier ordinaire que l&rsquo;on peut lire avec un vidage hexadécimal. Cet article prolonge les trois premiers, <a href="https://blog.neurozone.fr/?p=1787">InnoDB</a>, <a href="https://blog.neurozone.fr/?p=1788">PostgreSQL</a> et <a href="https://blog.neurozone.fr/?p=1789">Oracle avec les trois côte à côte</a>. Le tableau final ne remplace rien : il ajoute une quatrième colonne à la comparaison.</p>



<p class="wp-block-paragraph">J&rsquo;ai rencontré SQLite dans d&rsquo;autres outils, par exemple ProxySQL, qui garde sa configuration dans une base SQLite. Alors j&rsquo;ai voulu voir les octets. Tout ce qui suit vient d&rsquo;une base de laboratoire : <em>testé sur Rocky Linux 10.2, SQLite 3.46.1, WSL2</em>. Je prévois aussi une série plus détaillée en sept parties, &#171;&#160;SQLite from the inside&#160;&#187;, pour 2027. Cet article reste court et suit le même plan que les trois autres.</p>



<h2 class="wp-block-heading">Un fichier, jusqu&rsquo;à trois voisins</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="328" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=629%2C328&#038;ssl=1" alt="Les fichiers SQLite : le fichier principal et ses voisins journal, WAL et mémoire partagée" class="wp-image-5450" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=300%2C157&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1024%2C535&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=768%2C401&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1536%2C802&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=900%2C470&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/fichiers-sqlite-journal-wal-voisins_fr.png?resize=1280%2C668&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Une base, c&rsquo;est un fichier. Le journal, le WAL et le shm n&rsquo;existent que lorsque SQLite en a besoin.</figcaption></figure>



<p class="wp-block-paragraph">Une base SQLite est un seul fichier, qu&rsquo;un programme ouvre directement. Dans le mode par défaut, un deuxième fichier n&rsquo;apparaît que pendant une transaction d&rsquo;écriture. C&rsquo;est le journal de retour arrière, et il garde la version d&rsquo;origine de chaque page que la transaction modifie.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-rw-r--r--. 1 root root 8192 Nov  2 21:21 demo.db
-rw-r--r--. 1 root root 4616 Nov  2 21:21 demo.db-journal</pre>



<p class="wp-block-paragraph">Le journal fait 4 616 octets. Son en-tête l&rsquo;explique : un secteur de 512 octets, puis un enregistrement de page de 4 + 4 096 + 4 octets (numéro de page, page, somme de contrôle). Au COMMIT, le journal a disparu. Pendant la transaction, ses huit premiers octets, là où se trouve la chaîne magique, valaient encore zéro. Je n&rsquo;ai pas testé pourquoi, donc je rapporte seulement ce que j&rsquo;ai vu.</p>



<p class="wp-block-paragraph">En mode WAL, deux autres fichiers apparaissent : le fichier <code>-wal</code>, où s&rsquo;ajoutent les nouvelles versions de pages, et le fichier <code>-shm</code>, un index de 32 Kio en mémoire partagée. Contrairement aux autres modes de journal, le mode WAL est enregistré dans la base : il survit à un redémarrage. Les deux fichiers disparaissent à la fermeture propre de la dernière connexion, comme le dit la documentation.</p>



<h2 class="wp-block-heading">L&rsquo;en-tête de 100 octets</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="361" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=629%2C361&#038;ssl=1" alt="L'en-tête de 100 octets d'un fichier de base SQLite avec les valeurs lues dans le laboratoire" class="wp-image-5451" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=300%2C172&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1024%2C587&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=768%2C440&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1536%2C881&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=900%2C516&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-en-tete-100-octets_fr.png?resize=1280%2C734&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">L&rsquo;en-tête, champ par champ, avec les valeurs lues dans la base du laboratoire.</figcaption></figure>



<p class="wp-block-paragraph">Chaque fichier de base SQLite commence par un en-tête de 100 octets. Les 16 premiers octets sont le texte <code>SQLite format 3</code> et un octet nul. Viennent ensuite la taille de page, le compteur de changements, la taille en pages, la liste libre, le cookie de schéma, l&rsquo;encodage du texte et la version de la bibliothèque qui a écrit le fichier. Tous les nombres sont en gros-boutiste.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ xxd -l 32 demo.db
00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300  SQLite format 3.
00000010: 1000 0101 0040 2020 0000 0002 0000 0002  .....@  ........</pre>



<p class="wp-block-paragraph">J&rsquo;ai décodé les champs avec quelques lignes de Python, et le client en ligne de commande est d&rsquo;accord.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ sqlite3 demo.db ".dbinfo"
database page size:  4096
file change counter: 2
database page count: 2
freelist page count: 0
schema cookie:       1
text encoding:       1 (utf8)
[...]
software version:    3046001</pre>



<p class="wp-block-paragraph">Deux champs sont à ta disposition. <code>PRAGMA user_version</code> et <code>PRAGMA application_id</code> écrivent directement dans les octets 60 à 71 : après avoir réglé 42 et 1234567890, les octets valent <code>0000 002a</code> et <code>4996 02d2</code>. Beaucoup d&rsquo;outils s&rsquo;en servent pour marquer le format d&rsquo;un fichier.</p>



<h2 class="wp-block-heading">Pages et arbres B dans les fichiers SQLite</h2>



<p class="wp-block-paragraph">Après l&rsquo;en-tête, le fichier est une suite de pages de même taille, ici 4 096 octets. La page 1 contient l&rsquo;en-tête et la table de schéma, <code>sqlite_schema</code>. Chaque table est un arbre B classé par son rowid, et chaque index est un autre arbre B. Chaque arbre a une page racine, dont le numéro est dans <code>sqlite_schema</code>. Ce numéro n&rsquo;a jamais changé pendant mes tests, même quand l&rsquo;arbre grandissait. La table virtuelle <code>dbstat</code>, compilée dans le paquet Rocky, compte les pages de chaque arbre.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">name|pagetype|pages|cells|payload|unused
customers|internal|1|21|0|3917
customers|leaf|22|2000|77781|2282
idx_email|internal|1|13|316|3677
idx_email|leaf|14|1987|48449|2822
sqlite_schema|leaf|1|2|186|3794</pre>



<p class="wp-block-paragraph">Avec 2 000 lignes, la table a besoin d&rsquo;une page interne et de 22 feuilles. L&rsquo;index est un arbre à part, avec une page interne et 14 feuilles. Dans un index, les cellules internes portent aussi des entrées : 13 + 1 987 donnent les 2 000 clés. Une page interne de table ne porte que des clés et des pointeurs vers les enfants.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="297" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=629%2C297&#038;ssl=1" alt="Deux arbres B SQLite, une table et un index, et une chaîne de débordement" class="wp-image-5453" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=300%2C142&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1024%2C484&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=768%2C363&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1536%2C725&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=900%2C425&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-arbres-b-et-debordement_fr.png?resize=1280%2C604&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">La forme des arbres mesurée avec dbstat, et la règle de débordement vérifiée avec la documentation.</figcaption></figure>



<p class="wp-block-paragraph">Une table déclarée <code>WITHOUT ROWID</code> est stockée dans un seul arbre, classé par sa clé primaire. Une table normale avec une clé texte a besoin de deux arbres, la table et un index automatique.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">type|name|tbl_name|rootpage
table|kv|kv|45                      -- WITHOUT ROWID: one tree
table|kv2|kv2|46                    -- rowid table with a TEXT primary key: the table...
index|sqlite_autoindex_kv2_1|kv2|47 -- ...and a second tree for the key</pre>



<p class="wp-block-paragraph">Les grandes valeurs quittent la page. Avec une valeur de 20 000 caractères, la feuille a gardé 3 637 octets et quatre pages de débordement ont porté le reste, 4 092 octets chacune. La documentation donne la règle, avec U = 4 096 et une charge P = 20 005 : X = U &#8211; 35 = 4 061 et M = 489. Comme P dépasse X, la partie locale vaut K = M + ((P &#8211; M) % (U &#8211; 4)) = 3 637. C&rsquo;est exactement ce que rapporte <code>dbstat</code>.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">name|pageno|pagetype|ncell|payload|unused|mx_payload
notes|40|leaf|1|3637|441|20005
notes|41|overflow|0|4092|0|0
notes|42|overflow|0|4092|0|0
notes|43|overflow|0|4092|0|0
notes|44|overflow|0|4092|0|0</pre>



<h2 class="wp-block-heading">Anatomie d&rsquo;une page</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="349" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=629%2C349&#038;ssl=1" alt="Anatomie d'une page feuille de table SQLite : en-tête, pointeurs, espace libre et cellules" class="wp-image-5452" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=300%2C167&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1024%2C569&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=768%2C427&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1536%2C853&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=900%2C500&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-anatomie-page-feuille_fr.png?resize=1280%2C711&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">La page 2 de la base du laboratoire. Les pointeurs croissent depuis le début, les cellules depuis la fin.</figcaption></figure>



<p class="wp-block-paragraph">Une page d&rsquo;arbre B a un en-tête de 8 octets sur une feuille (12 sur une page interne), puis un tableau de pointeurs de 2 octets vers les cellules. Les cellules elles-mêmes sont écrites depuis la fin de la page, donc l&rsquo;espace libre se trouve au milieu. Sur la page 2 du laboratoire, le premier octet est <code>0d</code> (une feuille de table), il y a 2 cellules, et le contenu commence au décalage 4 038.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ xxd -s 4096 -l 16 demo.db
00001000: 0d00 0000 020f c600 0fe1 0fc6 0000 0000  ................
$ xxd -s 8128 -l 64 demo.db        # the end of page 2, where the cells live
00001fc0: 0000 0000 0000 1902 0500 132b 1142 6f62  ...........+.Bob
00001fd0: 626f 6240 6578 616d 706c 652e 6e65 7444  bob@example.netD
00001fe0: 451d 0105 0017 2f11 416c 6963 6561 6c69  E...../.Aliceali
00001ff0: 6365 4065 7861 6d70 6c65 2e6e 6574 4652  ce@example.netFR</pre>



<p class="wp-block-paragraph">L&rsquo;espace libéré dans la zone de contenu devient un bloc libre, une petite liste chaînée dans la page. Les trous de 1 à 3 octets sont seulement comptés comme octets fragmentés dans l&rsquo;en-tête.</p>



<h2 class="wp-block-heading">Un enregistrement, octet par octet</h2>



<p class="wp-block-paragraph">Une cellule contient une taille de données, un rowid et un enregistrement. Les tailles et les rowids sont des entiers variables : 1 à 9 octets, 7 bits à la fois, et le bit de poids fort signifie qu&rsquo;un octet suit. L&rsquo;enregistrement commence par la taille de son en-tête, puis un type par colonne. Un type de 13 ou plus et impair désigne du texte, et la longueur vaut (type &#8211; 13) / 2. Le type 0 désigne NULL. Ici la colonne <code>id</code> est le rowid : SQLite stocke NULL et garde la valeur dans la clé de la cellule.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">1d 01 05 00 17 2f 11 41 6c 69 63 65 61 6c 69 63 65 40 65 78 61 6d 70 6c 65 2e 6e 65 74 46 52
|  |  |  |  |  |  '----- 'Alice' 'alice@example.net' 'FR' (5 + 17 + 2 bytes)
|  |  |  |  |  '-- serial type 17: text of 2 bytes
|  |  |  |  '----- serial type 47: text of 17 bytes
|  |  |  '-------- serial type 23: text of 5 bytes
|  |  '----------- serial type 0: NULL (id is the rowid)
|  '-------------- record header: 5 bytes
'----------------- rowid 1, payload of 29 bytes (1d is the payload size, 01 the rowid)</pre>



<p class="wp-block-paragraph"><code>SELECT rowid, id, name, email, country FROM customers</code> renvoie <code>1|1|Alice|alice@example.net|FR</code> : le décodage est juste.</p>



<h2 class="wp-block-heading">UPDATE et DELETE sur le disque</h2>



<p class="wp-block-paragraph">Rien dans les fichiers SQLite ne fonctionne comme un stock d&rsquo;annulation. SQLite modifie la page elle-même, et il garde l&rsquo;ancienne version dans le journal ou le WAL. Chaque changement validé incrémente le compteur de changements dans l&rsquo;en-tête. Un UPDATE de même taille a réécrit la valeur sur place, avec le même nombre de cellules et le même espace libre, mais le compteur est passé de 7 à 8.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-- page 3 is the first leaf of customers
before          change counter 7   page 3: ncell 102, payload 3655, unused 25    page_count 39
same-size UPDATE        counter 8   page 3: ncell 102, payload 3655, unused 25
larger UPDATE           counter 9   page 3: ncell  89, payload 3242, unused 490   page_count 40</pre>



<p class="wp-block-paragraph">L&rsquo;UPDATE plus grand a fait davantage. La page était presque pleine, donc elle a dû se diviser : la page 3 est passée de 102 à 89 cellules, le fichier a gagné une page et la racine un pointeur. En bref, une ligne qui grossit peut coûter une page.</p>



<p class="wp-block-paragraph">Un DELETE libère la cellule et transforme son espace en bloc libre. Le paquet Rocky est compilé avec <code>SECURE_DELETE</code>, donc <code>PRAGMA secure_delete</code> renvoie 1 et les octets libérés sont mis à zéro. Avec le réglage désactivé, deux occurrences de l&rsquo;adresse e-mail sont restées dans le fichier.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">$ sqlite3 del.db "PRAGMA secure_delete;"
1
-- page 3 header after DELETE FROM customers WHERE id=5:
cells 101 | first freeblock offset 3921 | fragmented bytes 0
freeblock at 3921: next 0, size 36 bytes
its bytes: 00 00 00 24 00 00 00 00 00 00 00 00 [...] 00 00 00 00     &lt;- zeroed
$ grep -a -c "user5@example.net" del.db       # secure_delete on
0
$ sqlite3 del2.db "PRAGMA secure_delete=OFF; DELETE FROM customers WHERE id=5;"
$ grep -a -c "user5@example.net" del2.db      # secure_delete off
2</pre>



<h2 class="wp-block-heading">Pourquoi les fichiers SQLite ne rétrécissent pas</h2>



<p class="wp-block-paragraph">Quand une page entière se vide, elle rejoint la liste libre, que décrivent les octets 32 à 39 de l&rsquo;en-tête. Le fichier garde sa taille. Après un DELETE de 1 800 lignes, 31 pages sur 39 étaient libres, et le fichier faisait toujours 159 744 octets. <code>VACUUM</code> l&rsquo;a reconstruit en 32 768 octets.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">before DELETE         page_count 39   freelist_count 0    size 159744
DELETE ... id &gt; 200   page_count 39   freelist_count 31   size 159744   (header bytes 32-39: 0000 0006 0000 001f)
after VACUUM          page_count 8    freelist_count 0    size 32768</pre>



<p class="wp-block-paragraph">Avec <code>auto_vacuum</code> en mode incrémental, on peut rendre des pages par étapes. L&rsquo;octet 52 de l&rsquo;en-tête contient alors la plus grande page racine, et l&rsquo;octet 64 passe à 1. La page 2 devient une table de pointeurs, faite d&rsquo;entrées de 5 octets : un type et une page parente. <code>dbstat</code> saute cette page.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PRAGMA auto_vacuum=INCREMENTAL; VACUUM;    -&gt; header byte 52 = 4, byte 64 = 1
DELETE ... id &gt; 200                         page_count 40  freelist_count 31  size 163840
PRAGMA incremental_vacuum(5);               page_count 35  freelist_count 26  size 143360
PRAGMA incremental_vacuum;                  page_count  9  freelist_count  0  size  36864
page 2 (pointer map) starts: 01 00000000 | 01 00000000 | 05 00000003 | 05 00000003 | ...</pre>



<p class="wp-block-paragraph">Un type de page est documenté mais non testé ici : la page d&rsquo;octet verrou, qui n&rsquo;existe que si le fichier dépasse 1 Gio.</p>



<h2 class="wp-block-heading">Le WAL</h2>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="241" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=629%2C241&#038;ssl=1" alt="Comment le WAL de SQLite ajoute des trames et comment un point de contrôle les copie" class="wp-image-5454" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=300%2C115&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1024%2C393&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=768%2C294&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1536%2C589&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=900%2C345&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sqlite-wal-ajout-et-point-de-controle_fr.png?resize=1280%2C491&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Trois trames mesurées dans le WAL, puis des points de contrôle PASSIVE et TRUNCATE.</figcaption></figure>



<p class="wp-block-paragraph">En mode WAL, les fichiers SQLite changent dans un autre ordre : un écrivain ajoute d&rsquo;abord des trames au fichier <code>-wal</code>. Chaque trame est un en-tête de 24 octets suivi d&rsquo;une page. Une trame dont le champ de taille n&rsquo;est pas nul est un commit. Les lecteurs utilisent le dernier commit et l&rsquo;index shm pour trouver la bonne version de chaque page.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">-rw-r--r--. 1 root root 159744 Nov  2 21:23 wal.db
-rw-r--r--. 1 root root  32768 Nov  2 21:23 wal.db-shm
-rw-r--r--. 1 root root  12392 Nov  2 21:23 wal.db-wal</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">WAL magic       : 0x377f0682
format version  : 3007000
page size       : 4096
checkpoint seq  : 0
file size       : 12392 bytes = 32 + 3 frames x (24 + 4096)
  frame 1: page 24, commit-size-after 0 (not a commit frame)
  frame 2: page 39, commit-size-after 39
  frame 3: page 3, commit-size-after 39</pre>



<p class="wp-block-paragraph">Un point de contrôle copie les trames dans le fichier principal. Un point de contrôle PASSIVE l&rsquo;a fait sans toucher au fichier WAL, alors que TRUNCATE l&rsquo;a vidé. J&rsquo;ai désactivé le point de contrôle automatique pour garder les trames visibles ; par défaut, il se déclenche à 1 000 pages.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PRAGMA wal_checkpoint(PASSIVE);     -&gt; 0|3|3     (busy, frames in the WAL, frames copied)  -wal still 12392 bytes
PRAGMA wal_checkpoint(TRUNCATE);    -&gt; 0|0|0                                              -wal now 0 bytes
last connection closes              -&gt; demo.db-wal and demo.db-shm are gone</pre>



<h2 class="wp-block-heading">Les quatre moteurs côte à côte</h2>



<p class="wp-block-paragraph">Voici comment les fichiers SQLite se comparent aux trois premières parties, avec les valeurs par défaut.</p>



<figure class="wp-block-table"><table><thead><tr><th></th><th>InnoDB</th><th>PostgreSQL</th><th>Oracle</th><th>SQLite</th></tr></thead><tbody><tr><td>Où vivent les données</td><td>fichiers de tablespace, un par table par défaut</td><td>un fichier par table ou index, en segments</td><td>datafiles dans des tablespaces</td><td>un seul fichier pour tout</td></tr><tr><td>Unité d&rsquo;E/S</td><td>page de 16 Ko</td><td>page de 8 Ko</td><td>bloc, 8 Ko par défaut</td><td>page, 4 096 octets ici</td></tr><tr><td>Format d&rsquo;une ligne</td><td>index clusterisé : la table est l&rsquo;arbre de la clé primaire</td><td>tas, les index y pointent</td><td>bloc avec un répertoire de lignes et un ROWID</td><td>arbre B classé par rowid, ou par clé pour WITHOUT ROWID</td></tr><tr><td>Anciennes versions</td><td>journaux d&rsquo;annulation, à part</td><td>dans la table, nettoyées par VACUUM</td><td>tablespace d&rsquo;annulation</td><td>aucune : le journal ou le WAL les garde jusqu&rsquo;au commit</td></tr><tr><td>Sécurité en cas de panne</td><td>journal de reprise et doublewrite buffer</td><td>WAL et écriture de pages complètes</td><td>journaux de reprise</td><td>journal de retour arrière ou WAL</td></tr><tr><td>Récupérer de la place</td><td>reconstruire la table</td><td>VACUUM, VACUUM FULL</td><td>shrink, move</td><td>VACUUM, incremental_vacuum</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Le plus proche cousin de SQLite ici est PostgreSQL pour le WAL, et InnoDB pour l&rsquo;arbre. La grande différence est qu&rsquo;aucun processus serveur ne se tient entre ton programme et le fichier : le format du fichier et les verrous de fichier du système portent les garanties.</p>



<h2 class="wp-block-heading">Sources</h2>



<ul class="wp-block-list"><li><a href="https://www.sqlite.org/fileformat2.html">SQLite documentation: database file format</a></li><li><a href="https://www.sqlite.org/wal.html">SQLite documentation: write-ahead logging</a></li><li><a href="https://www.sqlite.org/pragma.html">SQLite documentation: PRAGMA statements</a></li><li><a href="https://www.sqlite.org/dbstat.html">SQLite documentation: the dbstat virtual table</a></li><li><a href="https://www.sqlite.org/lang_vacuum.html">SQLite documentation: VACUUM</a></li><li>Les sorties enregistrées viennent de mon propre laboratoire : Rocky Linux 10.2, SQLite 3.46.1, WSL2.</li></ul>



<p class="wp-block-paragraph"><em>Cet article fait partie d&rsquo;une série de notes d&rsquo;admin système (étiquette &#171;&#160;carnet d&rsquo;admin&#160;&#187;).</em><br>Série &#171;&#160;Dans les fichiers d&rsquo;une base de données&#160;&#187;, partie 4/3+1. · <a href="https://blog.neurozone.fr/?p=1789">← Article précédent</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1787">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a></li><li><a href="https://blog.neurozone.fr/?p=1788">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a></li><li><a href="https://blog.neurozone.fr/?p=1789">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a></li></ul>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/">Dans les fichiers d&rsquo;une base de données (4/3+1) : SQLite</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/12/dans-les-fichiers-d-une-base-de-donnees-sqlite/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5455</post-id>	</item>
		<item>
		<title>Pas à pas : fail2ban bien configuré, avec bannissements progressifs</title>
		<link>https://blog.neurozone.fr/2026/11/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progressifs/</link>
					<comments>https://blog.neurozone.fr/2026/11/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progressifs/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Wed, 11 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[fail2ban]]></category>
		<category><![CDATA[firewalld]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[pas à pas]]></category>
		<category><![CDATA[Rocky Linux]]></category>
		<category><![CDATA[sécurité]]></category>
		<category><![CDATA[SSH]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=2065</guid>

					<description><![CDATA[<p>Configurer fail2ban sur Rocky Linux : jail.local ligne par ligne, seuils, bannissements progressifs, jail SSH, récidivistes, filtre maison.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progressifs/">Pas à pas : fail2ban bien configuré, avec bannissements progressifs</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">fail2ban est l&rsquo;un des outils les plus anciens et les plus répandus de la boîte à outils de l&rsquo;administrateur : il lit les journaux, compte les échecs d&rsquo;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&rsquo;arrêtent personne, réglages écrits dans un fichier écrasé à la mise à jour, jail SSH qui ne voit aucune ligne parce qu&rsquo;elle lit le mauvais journal, ou administrateur banni de son propre serveur.</p>



<p class="wp-block-paragraph">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.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="285" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=629%2C285&#038;ssl=1" alt="Échelle des bannissements fail2ban de 1 h à 30 jours, avec la prison recidive et les réglages importants" class="wp-image-4577" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=300%2C136&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=1024%2C464&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=768%2C348&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=1536%2C697&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=900%2C408&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progres_schema_fr.png?resize=1280%2C580&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Un bannissement qui grandit à chaque récidive, plafonné à 30 jours.</figcaption></figure>



<h2 class="wp-block-heading">Trois notions</h2>



<figure class="wp-block-table"><table><thead><tr><th>Notion</th><th>Ce que c&rsquo;est</th><th>Où</th></tr></thead><tbody><tr><td>filtre</td><td>les expressions régulières qui reconnaissent un échec dans un journal</td><td><code>filter.d/*.conf</code></td></tr><tr><td>action</td><td>ce qui est fait pour bannir et débannir (règle firewalld, nftables, courriel…)</td><td><code>action.d/*.conf</code></td></tr><tr><td>jail</td><td>l&rsquo;association d&rsquo;un filtre, d&rsquo;une source de journaux, d&rsquo;une action et de seuils</td><td><code>jail.local</code>, <code>jail.d/*.local</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">fail2ban fournit des dizaines de filtres et d&rsquo;actions prêts à l&#8217;emploi. Le travail consiste surtout à écrire les jails : quoi surveiller, avec quels seuils, et comment bannir.</p>



<h2 class="wp-block-heading">Étape 1 : installer, et ne jamais toucher jail.conf</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">dnf install epel-release
dnf install fail2ban fail2ban-firewalld
systemctl enable --now fail2ban</pre>



<p class="wp-block-paragraph">Le paquet <code>fail2ban-firewalld</code> installe un fichier qui fait de firewalld l&rsquo;action par défaut. Les fichiers <code>jail.conf</code> et <code>fail2ban.conf</code> appartiennent au paquet et seront remplacés à la prochaine mise à jour : tous vos réglages vont dans les fichiers <code>.local</code>, qui les surchargent clé par clé. Un <code>jail.local</code> peut donc ne contenir que les quelques lignes qui diffèrent.</p>



<h2 class="wp-block-heading">Étape 2 : le service lui-même</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/fail2ban/fail2ban.local
[DEFINITION]
logtarget  = /var/log/fail2ban/fail2ban.log
dbfile     = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 35d</pre>



<ul class="wp-block-list"><li><strong>logtarget</strong> : 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 ;</li><li><strong>dbfile</strong> : la base SQLite où fail2ban garde les bannissements en cours et leur historique. C&rsquo;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 ;</li><li><strong>dbpurgeage</strong> : la durée de conservation de l&rsquo;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&rsquo;appuie de toute façon sur le fichier journal.</li></ul>



<p class="wp-block-paragraph">Créez le répertoire et le fichier avant de redémarrer : <code>install -d /var/log/fail2ban && touch /var/log/fail2ban/fail2ban.log</code>. Pensez aussi à adapter la configuration logrotate du paquet à ce nouveau chemin.</p>



<h2 class="wp-block-heading">Étape 3 : jail.local, ligne par ligne</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/fail2ban/jail.local
[DEFAULT]
ignoreip  = 127.0.0.1/8 ::1 203.0.113.25 203.0.113.40 198.51.100.10
bantime   = 1h
findtime  = 10m
maxretry  = 5
bantime.increment = true
bantime.formula   = ban.Time * (1&lt;&lt;(ban.Count if ban.Count&lt;20 else 20)) * banFactor
bantime.maxtime   = 720h
banaction          = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

[sshd]
enabled  = true
backend  = systemd
maxretry = 5
bantime  = 2h</pre>



<h3 class="wp-block-heading">Ne pas se bannir soi-même</h3>



<p class="wp-block-paragraph"><strong>ignoreip</strong> liste les adresses jamais bannies : la boucle locale, l&rsquo;adresse publique de chacun de vos serveurs, et celle de votre accès d&rsquo;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.</p>



<h3 class="wp-block-heading">Les seuils</h3>



<p class="wp-block-paragraph"><strong>findtime</strong> et <strong>maxretry</strong> : 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&rsquo;essais. Pour SSH, si seule l&rsquo;authentification par clé est autorisée, un utilisateur légitime n&rsquo;échoue presque jamais et on peut descendre à 3.</p>



<h3 class="wp-block-heading">Les bannissements progressifs</h3>



<p class="wp-block-paragraph"><strong>bantime.increment</strong> active l&rsquo;allongement automatique. La formule multiplie la durée de base par 2 à la puissance du nombre de bannissements précédents de cette adresse (<code>1&lt;&lt;n</code> vaut 2<sup>n</sup>), plafonné à 2<sup>20</sup> pour éviter les dépassements, puis par <code>banFactor</code> (1 par défaut). <strong>bantime.maxtime</strong> limite le résultat à 30 jours :</p>



<figure class="wp-block-table"><table><thead><tr><th>Bannissement n°</th><th>Durée (bantime 1 h)</th><th>Durée (jail sshd, 2 h)</th></tr></thead><tbody><tr><td>1</td><td>1 h</td><td>2 h</td></tr><tr><td>2</td><td>2 h</td><td>4 h</td></tr><tr><td>3</td><td>4 h</td><td>8 h</td></tr><tr><td>5</td><td>16 h</td><td>32 h</td></tr><tr><td>8</td><td>128 h (5 j)</td><td>256 h (10 j)</td></tr><tr><td>10 et plus</td><td>plafond : 30 j</td><td>plafond : 30 j</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">L&rsquo;effet est exactement celui qu&rsquo;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&rsquo;une dizaine de passages.</p>



<h3 class="wp-block-heading">L&rsquo;action</h3>



<p class="wp-block-paragraph"><strong>banaction = firewallcmd-rich-rules</strong> : chaque bannissement devient une règle riche firewalld qui rejette l&rsquo;adresse sur le port de la jail. <strong>banaction_allports</strong> est l&rsquo;action utilisée par les jails qui bannissent sur tous les ports (la récidive). Sur un serveur sans firewalld, utilisez <code>nftables-multiport</code> et <code>nftables-allports</code>.</p>



<h3 class="wp-block-heading">La jail SSH</h3>



<p class="wp-block-paragraph"><strong>backend = systemd</strong> 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&rsquo;unité <code>sshd.service</code>. Une seconde subtilité : les versions récentes d&rsquo;OpenSSH écrivent une partie de leurs messages sous le nom <code>sshd-session</code>. Si <code>fail2ban-client status sshd</code> reste à zéro alors que des échecs apparaissent dans <code>journalctl</code>, vérifiez que le filtre de votre version de fail2ban connaît ce nom (<code>journalmatch</code>).</p>



<h2 class="wp-block-heading">Étape 4 : punir les récidivistes</h2>



<p class="wp-block-paragraph">La jail <code>recidive</code> lit le propre journal de fail2ban et compte les lignes « Ban ». Une adresse bannie plusieurs fois, quelle que soit la jail, est alors bannie <strong>sur tous les ports</strong>, pour plus longtemps. Quatre paliers complètent bien les bannissements progressifs :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="ini">[recidive1]
enabled   = true
filter    = recidive
logpath   = /var/log/fail2ban/fail2ban.log
banaction = %(banaction_allports)s
findtime  = 1d
maxretry  = 4
bantime   = 3h

[recidive2]
enabled   = true
filter    = recidive
logpath   = /var/log/fail2ban/fail2ban.log
banaction = %(banaction_allports)s
findtime  = 1w
maxretry  = 7
bantime   = 1d

[recidive3]
enabled   = true
filter    = recidive
logpath   = /var/log/fail2ban/fail2ban.log
banaction = %(banaction_allports)s
findtime  = 30d
maxretry  = 10
bantime   = 1w

[recidive4]
enabled   = true
filter    = recidive
logpath   = /var/log/fail2ban/fail2ban.log
banaction = %(banaction_allports)s
findtime  = 180d
maxretry  = 20
bantime   = 30d</pre>



<p class="wp-block-paragraph">Chaque palier a sa propre fenêtre : une adresse bannie 4 fois en une journée l&rsquo;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&rsquo;existe pas encore, d&rsquo;où sa création à l&rsquo;étape 2.</p>



<h2 class="wp-block-heading">Étape 5 : une jail pour sa propre application</h2>



<p class="wp-block-paragraph">Pour protéger une application qui n&rsquo;a pas de filtre fourni, écrivez-en un. Prenons une application qui journalise en JSON :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/fail2ban/filter.d/monappli.conf
[Definition]
failregex = ^.*&quot;msg&quot;:&quot;login failed&quot;.*&quot;remote_ip&quot;:&quot;&lt;HOST&gt;&quot;.*$
ignoreregex =
datepattern = &quot;time&quot;:&quot;%%Y-%%m-%%dT%%H:%%M:%%S</pre>



<ul class="wp-block-list"><li><strong>&lt;HOST&gt;</strong> : la partie qui capture l&rsquo;adresse à bannir ; fail2ban la remplace par une expression qui reconnaît IPv4 et IPv6 ;</li><li><strong>datepattern</strong> : comment lire la date de la ligne. fail2ban ignore les lignes plus vieilles que <code>findtime</code>, il doit donc comprendre la date. Les <code>%%</code> sont des <code>%</code> échappés pour le format du fichier ;</li><li><strong>ignoreregex</strong> : des lignes à ne jamais compter, même si elles correspondent.</li></ul>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/fail2ban/jail.d/monappli.local
[monappli]
enabled  = true
filter   = monappli
logpath  = /var/log/monappli/app.log
port     = 2022,8443
maxretry = 5</pre>



<p class="wp-block-paragraph"><strong>port</strong> limite le bannissement aux ports de l&rsquo;application : l&rsquo;adresse fautive garde l&rsquo;accès au reste, à moins de récidiver.</p>



<h2 class="wp-block-heading">Étape 6 : tester un filtre avant de l&rsquo;activer</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">fail2ban-regex /var/log/monappli/app.log /etc/fail2ban/filter.d/monappli.conf
#   Failregex: 12 total
#   Lines: 4821 lines, 0 ignored, 12 matched, 4809 missed
fail2ban-regex --print-all-matched /var/log/monappli/app.log /etc/fail2ban/filter.d/monappli.conf</pre>



<p class="wp-block-paragraph">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&rsquo;est pas reconnue, aucune ligne ne sera jamais comptée en production. Pour SSH avec le backend systemd : <code>fail2ban-regex systemd-journal sshd</code>.</p>



<h2 class="wp-block-heading">Étape 7 : vérifier et exploiter</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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&#x27;urgence
firewall-cmd --list-rich-rules | grep -c reject</pre>



<p class="wp-block-paragraph">Faites le test pour de vrai, depuis une adresse qui n&rsquo;est pas dans <code>ignoreip</code> (une connexion mobile, par exemple) : cinq mots de passe faux, puis vérifiez que la sixième tentative est refusée et que l&rsquo;adresse apparaît dans le statut de la jail.</p>


<!-- nz-enrich:fail2ban-real -->


<h3 class="wp-block-heading">À quoi ça ressemble après quelques mois</h3>



<p class="wp-block-paragraph">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&#8217;empilent derrière elle :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># 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</pre>



<ul class="wp-block-list"><li><strong>Total failed: 6</strong> depuis le redémarrage du matin. Les connexions par mot de passe étant désactivées, presque personne n&rsquo;arrive même jusqu&rsquo;à un échec : la plupart des robots abandonnent dès l&rsquo;échange de clés. Les compteurs repartent de zéro avec le service ; l&rsquo;historique est dans la base.</li><li><strong>Journal matches</strong> cite à la fois <code>sshd</code> et <code>sshd-session</code> : depuis OpenSSH 9.8, les messages d&rsquo;authentification viennent du processus <code>sshd-session</code>. Un filtre qui ne regarde que <code>_COMM=sshd</code> ne compte plus rien, sans le dire, sur un système récent.</li><li>Les quatre jails de <strong>récidive</strong> 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.</li></ul>



<p class="wp-block-paragraph">Les bannissements progressifs se lisent dans la base SQLite de fail2ban, qui garde chaque adresse avec son nombre de bannissements :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 \
    &quot;select jail, bancount, count(*) from bips group by jail, bancount&quot;
recidive3|1|1
sshd|1|21
sshd|3|1
sshd|7|1
sshd|10|1

# sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 &quot;select max(bantime) from bips where jail=&#x27;sshd&#x27;&quot;
2592000</pre>



<p class="wp-block-paragraph">Vingt et une adresses ont été bannies une fois et ne sont jamais revenues : c&rsquo;est l&rsquo;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&rsquo;à atteindre <code>bantime.maxtime = 720h</code> : 2 592 000 secondes, trente jours, le plafond. C&rsquo;est tout l&rsquo;intérêt de la progression : la première faute coûte peu, les obstinés finissent exclus un mois, sans liste manuelle à entretenir.</p>



<h2 class="wp-block-heading">Les pannes fréquentes</h2>



<h3 class="wp-block-heading">La jail ne compte rien</h3>



<p class="wp-block-paragraph">Mauvais backend (fichier au lieu de systemd), mauvais chemin de journal, ou date non reconnue. <code>fail2ban-client status &lt;jail&gt;</code> montre la source lue ; <code>fail2ban-regex</code> dit si les lignes correspondent.</p>



<h3 class="wp-block-heading">Les adresses sont bannies mais continuent de passer</h3>



<p class="wp-block-paragraph">L&rsquo;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&rsquo;est l&rsquo;adresse du mandataire qui est bannie. Derrière un mandataire, l&rsquo;application doit journaliser l&rsquo;adresse réelle du client (<code>X-Forwarded-For</code>), et le bannissement doit avoir lieu sur la machine qui reçoit la connexion.</p>



<h3 class="wp-block-heading">Une adresse bloquée alors que fail2ban ne la bannit pas</h3>



<p class="wp-block-paragraph">Si CrowdSec tourne aussi, il a peut-être pris sa propre décision : <code>cscli decisions list --ip …</code>. 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.</p>



<h3 class="wp-block-heading">fail2ban met longtemps à démarrer</h3>



<p class="wp-block-paragraph">À chaque démarrage, il rétablit les bannissements en cours depuis sa base et relit la fin des journaux. Avec des milliers d&rsquo;entrées et des règles riches firewalld, cela peut prendre une minute. Une action nftables par ensemble (<code>nftables-allports</code>) est bien plus rapide quand le nombre de bannissements grossit.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de tutoriels d&rsquo;administration système (tag « pas à pas »).</em><br>Série « Pas à pas », tutoriel 11/77. · <a href="https://blog.neurozone.fr/?p=2047">← Tutoriel précédent</a> · <span class="link-upcoming">Tutoriel suivant → (à paraître le 16/11)</span> · <a href="https://hecate.neurozone.fr/?p=620">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=2046">Installer CrowdSec sans jamais se bannir</a></li><li><a href="https://blog.neurozone.fr/?p=2047">Suricata en IPS avec NFQUEUE</a></li><li><a href="https://blog.neurozone.fr/?p=1419">CrowdSec 1.8 sur Debian : réinstaller proprement</a></li></ul>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progressifs/">Pas à pas : fail2ban bien configuré, avec bannissements progressifs</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/11/pas-a-pas-fail2ban-bien-configure-avec-bannissements-progressifs/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2065</post-id>	</item>
		<item>
		<title>Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</title>
		<link>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/</link>
					<comments>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Tue, 10 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5533</guid>

					<description><![CDATA[<p>Séquences PostgreSQL et colonnes identity en 18.6 : serial ou identity, ALWAYS ou BY DEFAULT, trous après rollback, CACHE et trou après kill -9.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/">Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><strong>En bref :</strong> j&rsquo;ai fait un <code>kill -9</code> sur les processus postgres : une colonne identity PostgreSQL est passée de l&rsquo;id 9 à 37, parce que les séquences PostgreSQL journalisent 32 valeurs d&rsquo;avance dans le WAL. Rollbacks et insertions en échec brûlent aussi des valeurs, et une colonne <code>BY DEFAULT</code> ignore les id explicites. Préférer <code>GENERATED ... AS IDENTITY</code> à <code>serial</code>, et ne jamais compter sur des id continus.</p>



<p class="wp-block-paragraph"><em>PostgreSQL 18.6, cluster jetable sous Rocky Linux 10.2. Partie 1 sur 5 (PostgreSQL, MariaDB, Oracle, SQL Server, résumé). Toutes les sorties viennent de mes propres tests.</em></p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="164" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=629%2C164&#038;ssl=1" alt="Séquences PostgreSQL après kill -9 : id 1 à 9, puis un trou de 27 valeurs, puis 37" class="wp-image-5532" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=300%2C78&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1024%2C267&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=768%2C201&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1536%2C401&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=900%2C235&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes.png?resize=1280%2C334&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Un kill -9 sur PostgreSQL 18.6 : 27 valeurs réservées et jamais distribuées.</figcaption></figure>



<h2 class="wp-block-heading">Trois façons d&rsquo;obtenir une valeur générée</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">CREATE TABLE t_ident  (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, v text);
CREATE TABLE t_serial (id serial PRIMARY KEY, v text);
CREATE SEQUENCE s1;
CREATE TABLE t_seq    (id int PRIMARY KEY DEFAULT nextval('s1'), v text);</pre>



<p class="wp-block-paragraph">Chaque <code>INSERT ... RETURNING id</code> renvoie 1 sur une table neuve. Le catalogue montre la différence :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"> column_name |            column_default            | is_identity | identity_generation
-------------+--------------------------------------+-------------+---------------------
 id          |                                      | YES         | ALWAYS
 id          | nextval('s1'::regclass)              | NO          |
 id          | nextval('t_serial_id_seq'::regclass) | NO          |</pre>



<p class="wp-block-paragraph">Une colonne identity n&rsquo;a pas d&rsquo;expression par défaut ; la séquence est attachée à la colonne (<code>pg_get_serial_sequence('t_ident','id')</code> renvoie <code>public.t_ident_id_seq</code>). <code>serial</code> est un raccourci : colonne entière plus défaut appelant <code>nextval</code>.</p>



<h2 class="wp-block-heading">ALWAYS ou BY DEFAULT</h2>



<p class="wp-block-paragraph">Avec <code>GENERATED BY DEFAULT</code>, une valeur explicite est acceptée mais ne fait pas avancer le compteur :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">INSERT INTO t_by(id, v) VALUES (2, 'explicit 2');         -- ok
INSERT INTO t_by(v) VALUES ('default 1') RETURNING id;    -- 1
INSERT INTO t_by(v) VALUES ('default 2') RETURNING id;    -- ERROR: duplicate key ... (id)=(2) already exists
INSERT INTO t_by(v) VALUES ('default 3') RETURNING id;    -- 3</pre>



<p class="wp-block-paragraph">L&rsquo;insertion en échec a brûlé la valeur 2 du compteur. Avec <code>GENERATED ALWAYS</code>, une valeur explicite est refusée sauf demande expresse :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml">ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.</pre>



<p class="wp-block-paragraph">Après <code>OVERRIDING SYSTEM VALUE VALUES (50, ...)</code>, l&rsquo;insertion par défaut suivante a renvoyé 1 : le compteur est indépendant des données.</p>



<h2 class="wp-block-heading">Rollback et échecs consomment des valeurs</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">BEGIN; INSERT INTO t_ident(v) VALUES ('rolled back') RETURNING id;   -- 2
ROLLBACK;
INSERT INTO t_ident(v) VALUES ('after rollback') RETURNING id;       -- 3</pre>



<p class="wp-block-paragraph">Idem avec une violation d&rsquo;unicité : id 1, erreur sur le doublon, puis 3. Un compteur sans trou demande une autre conception (table de compteur verrouillée), au prix de la sérialisation des écritures.</p>



<h2 class="wp-block-heading">Concurrence : l&rsquo;ordre des id n&rsquo;est pas l&rsquo;ordre des commits</h2>



<p class="wp-block-paragraph">La session A insère en premier et valide 3 secondes après B ; la session B insère en second et valide aussitôt :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">A got id 1
B got id 2
B: committed 08:18:48.652
A: committed 08:18:51.154</pre>



<p class="wp-block-paragraph">La ligne de B est visible avant celle de A, bien que son id soit plus grand. Ne pas utiliser « max(id) déjà vu » comme curseur de réplication ou de polling.</p>



<h2 class="wp-block-heading">CACHE est par session</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">CREATE SEQUENCE s_cache CACHE 10;
same session twice : 1|2
new session        : 11
another new session: 21</pre>



<p class="wp-block-paragraph">Avec les séquences PostgreSQL, chaque session réserve un bloc de 10. Les valeurs de sessions différentes s&rsquo;entrelacent et des trous apparaissent dès qu&rsquo;une session se termine.</p>



<h2 class="wp-block-heading">Limites et CYCLE</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ERROR:  nextval: reached maximum value of sequence "s_small" (3)</pre>



<p class="wp-block-paragraph">Après <code>ALTER SEQUENCE s_small CYCLE</code>, la valeur suivante est 1. Une identity sur <code>smallint</code> s&rsquo;arrête à 32767 avec la même erreur sur <code>t_small_id_seq</code>. Les identity <code>integer</code> s&rsquo;arrêtent à 2147483647 : prendre <code>bigint</code> pour les tables à longue vie.</p>



<h2 class="wp-block-heading">Redémarrage et crash</h2>



<p class="wp-block-paragraph">Trois lignes insérées, la dernière supprimée, redémarrage propre :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sequence before the restart: 3|30|t     (last_value | log_cnt | is_called)
sequence after the restart:  3|30|t
new id = 4</pre>



<p class="wp-block-paragraph">Puis cinq lignes de plus (dernier id 9), <code>kill -9</code> de tous les processus postgres, redémarrage :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sequence before the crash: 9|27|t
sequence after the crash:  36|0|t
new id = 37
ids now: 1,2,4,5,6,7,8,9,37</pre>



<p class="wp-block-paragraph"><code>log_cnt</code> est le nombre de valeurs encore couvertes par le dernier enregistrement WAL. PostgreSQL écrit un enregistrement WAL pour 32 valeurs ; après la récupération sur crash, il repart de la fin de la plage réservée (9 + 27 = 36), donc l&rsquo;id suivant est 37. Un arrêt propre écrit l&rsquo;état exact, sans trou.</p>



<h2 class="wp-block-heading">Où lire l&rsquo;état des séquences PostgreSQL</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="sql">SELECT * FROM pg_sequences;                -- départ, incrément, max, cache_size, cycle, last_value
SELECT last_value, log_cnt, is_called FROM t_ident_id_seq;
SELECT attrelid::regclass, attname, attidentity FROM pg_attribute WHERE attidentity &lt;&gt; '';  -- 'a' ALWAYS, 'd' BY DEFAULT</pre>



<h2 class="wp-block-heading">Documentation officielle</h2>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/" rel="noopener">PostgreSQL</a>, site du projet</p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/sql-createsequence.html" rel="noopener">CREATE SEQUENCE</a>, documentation PostgreSQL 18</p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/functions-sequence.html" rel="noopener">Sequence manipulation functions</a>, <code>nextval</code>, <code>currval</code>, <code>setval</code></p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/sql-createtable.html" rel="noopener">CREATE TABLE</a>, <code>GENERATED ... AS IDENTITY</code> et <code>serial</code></p>



<p class="wp-block-paragraph"><a href="https://www.postgresql.org/docs/18/view-pg-sequences.html" rel="noopener">pg_sequences</a>, la vue système utilisée plus haut</p>



<p class="wp-block-paragraph">*Non testé ici : réplication logique des séquences, <code>pg_upgrade</code>, tables partitionnées, <code>ALTER SEQUENCE ... RESTART</code> en cours d&rsquo;usage.*</p>



<h2 class="wp-block-heading">Toutes les parties de la série</h2>



<p class="wp-block-paragraph"><strong>1/5 PostgreSQL</strong></p>



<p class="wp-block-paragraph"><span class="link-upcoming">2/5 MariaDB (à paraître le 17/11)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">3/5 Oracle (à paraître le 24/11)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">4/5 SQL Server (à paraître le 1/12)</span></p>



<p class="wp-block-paragraph"><span class="link-upcoming">5/5 TL;DR : les quatre moteurs comparés (à paraître le 8/12)</span></p>



<h2 class="wp-block-heading">Articles liés</h2>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1787">Dans les fichiers d&rsquo;une base de données (1/3) : InnoDB, de MySQL à MariaDB</a></p>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1788">Dans les fichiers d&rsquo;une base de données (2/3) : PostgreSQL</a></p>



<p class="wp-block-paragraph"><a href="https://blog.neurozone.fr/?p=1789">Dans les fichiers d&rsquo;une base de données (3/3) : Oracle, et les trois côte à côte</a></p>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/">Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/10/sequences-postgresql-colonnes-identity-trous-rollbacks-crashs-testes/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5533</post-id>	</item>
		<item>
		<title>Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur</title>
		<link>https://blog.neurozone.fr/2026/11/09/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-serveur/</link>
					<comments>https://blog.neurozone.fr/2026/11/09/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-serveur/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Mon, 09 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[firewalld]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[IPS]]></category>
		<category><![CDATA[pas à pas]]></category>
		<category><![CDATA[Rocky Linux]]></category>
		<category><![CDATA[sécurité]]></category>
		<category><![CDATA[Suricata]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=2047</guid>

					<description><![CDATA[<p>Installer Suricata en IPS sur Rocky Linux avec NFQUEUE : règles, trafic redirigé, boucle locale exclue, cartes réseau, faux positifs, alertes vers le SIEM.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/09/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-serveur/">Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Suricata est un moteur de détection d&rsquo;intrusion réseau : il examine les paquets qui entrent et sortent d&rsquo;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&rsquo;arrive jamais à destination.</p>



<p class="wp-block-paragraph">Ce tutoriel installe Suricata en IPS sur un serveur Rocky Linux exposé à Internet, avec NFQUEUE. C&rsquo;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&rsquo;ai rencontrées, dont une qui bloquait des connexions locales en silence pendant des semaines.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="307" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=629%2C307&#038;ssl=1" alt="Schéma du chemin d'un paquet via firewalld, NFQUEUE et Suricata, avec les quatre réglages clés et la leçon du loopback" class="wp-image-4575" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=300%2C147&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=1024%2C500&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=768%2C375&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=1536%2C750&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=900%2C440&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-se_schema_fr.png?resize=1280%2C625&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Le chemin d&rsquo;un paquet et les réglages qui évitent les mauvaises surprises.</figcaption></figure>



<h2 class="wp-block-heading">IDS ou IPS</h2>



<figure class="wp-block-table"><table><thead><tr><th>Mode</th><th>Fonctionnement</th><th>Risque</th></tr></thead><tbody><tr><td>IDS (af-packet)</td><td>Suricata reçoit une <em>copie</em> des paquets et alerte</td><td>aucun pour le trafic : au pire, des alertes manquées</td></tr><tr><td>IPS (NFQUEUE)</td><td>le noyau <em>confie</em> chaque paquet à Suricata, qui l&rsquo;accepte ou le jette</td><td>Suricata est sur le chemin : une erreur bloque le trafic</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Commencez en IDS si vous découvrez l&rsquo;outil. Passez en IPS quand vous savez lire ses alertes et que vous avez éliminé les faux positifs les plus bruyants.</p>



<h2 class="wp-block-heading">Étape 1 : installer</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">dnf install epel-release
dnf install suricata
suricata --build-info | grep -E &#x27;NFQueue|AF_PACKET&#x27;   # les deux doivent être « yes »</pre>



<p class="wp-block-paragraph">Deux modules noyau sont nécessaires pour NFQUEUE : <code>nfnetlink_queue</code> (la file elle-même) et <code>xt_NFQUEUE</code> (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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">modprobe nfnetlink_queue
modprobe xt_NFQUEUE
printf &#x27;nfnetlink_queue\nxt_NFQUEUE\n&#x27; &gt; /etc/modules-load.d/nfqueue.conf</pre>



<h2 class="wp-block-heading">Étape 2 : suricata.yaml, l&rsquo;essentiel</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml"># /etc/suricata/suricata.yaml (extraits)
vars:
  address-groups:
    HOME_NET: &quot;[203.0.113.25/32,2001:db8::25/128]&quot;
    EXTERNAL_NET: &quot;!$HOME_NET&quot;

nfq:
  mode: accept
  fail-open: yes

flow-timeouts:
  tcp:
    established: 7200

stream:
  memcap: 256mb
  midstream: true
  reassembly:
    memcap: 512mb
    depth: 1mb

outputs:
  - eve-log:
      enabled: yes
      filename: eve.json
      types: [alert, drop, anomaly, stats]</pre>



<ul class="wp-block-list"><li><strong>HOME_NET</strong> : les adresses que vous protégez. Beaucoup de règles en dépendent (« trafic venant de l&rsquo;extérieur vers HOME_NET ») ; une valeur fausse donne à la fois des faux positifs et des attaques manquées ;</li><li><strong>nfq.mode: accept</strong> : Suricata rend son verdict (accepter ou jeter) et le paquet poursuit ensuite son chemin normal dans le pare-feu ;</li><li><strong>fail-open: yes</strong> : si la file déborde parce que Suricata ne suit pas, les paquets passent au lieu d&rsquo;être jetés. Sur un serveur seul, mieux vaut laisser passer quelques paquets non inspectés que couper le service ;</li><li><strong>flow-timeouts.tcp.established: 7200</strong> : le point qui m&rsquo;a coûté le plus cher, détaillé plus bas. Par défaut, Suricata oublie une connexion TCP inactive au bout d&rsquo;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 ;</li><li><strong>midstream: true</strong> : Suricata accepte de prendre en compte une connexion dont il n&rsquo;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 ;</li><li><strong>memcap</strong> : les plafonds de mémoire pour le suivi des flux et la reconstitution des données. Trop bas, Suricata abandonne l&rsquo;inspection des connexions sous charge ; surveillez les compteurs <code>memcap</code> dans les statistiques ;</li><li><strong>eve.json</strong> : le journal structuré, une ligne JSON par événement. C&rsquo;est ce fichier qu&rsquo;on envoie à un SIEM.</li></ul>



<h2 class="wp-block-heading">Étape 3 : les règles</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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</pre>



<p class="wp-block-paragraph"><code>suricata-update</code> assemble toutes les règles dans un seul fichier, en appliquant vos fichiers de modification. Deux d&rsquo;entre eux sont essentiels en IPS :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># /etc/suricata/drop.conf : ces familles de règles bloquent au lieu d&#x27;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</pre>



<p class="wp-block-paragraph">Par défaut, toutes les règles <strong>alertent</strong> seulement. <code>drop.conf</code> 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. <code>disable.conf</code> retire les règles inutiles, ce qui économise mémoire et processeur.</p>



<p class="wp-block-paragraph">Planifiez une mise à jour quotidienne par un timer systemd qui lance <code>suricata-update</code>, puis demande à Suricata de recharger ses règles sans redémarrer (<code>suricatasc -c reload-rules</code>).</p>



<h2 class="wp-block-heading">Étape 4 : envoyer le trafic à Suricata</h2>



<p class="wp-block-paragraph">Le service lit la file 0 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># /etc/systemd/system/suricata.service.d/ips.conf
[Service]
ExecStart=
ExecStart=/usr/sbin/suricata -c /etc/suricata/suricata.yaml -q 0 --pidfile /var/run/suricata.pid</pre>



<p class="wp-block-paragraph">Puis les règles de pare-feu, avec firewalld en mode « direct » :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># 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</pre>



<ul class="wp-block-list"><li><strong>&#8211;queue-bypass</strong> : 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&rsquo;écoute la file. C&rsquo;est la ligne qui évite d&rsquo;avoir à appeler la console de secours de l&rsquo;hébergeur ;</li><li><strong>la boucle locale exclue</strong> : la leçon la plus coûteuse de ce tutoriel, détaillée ci-dessous ;</li><li><strong>l&rsquo;ordre</strong> : les règles directes de priorité -1 passent avant celles de priorité 0.</li></ul>



<h2 class="wp-block-heading">La leçon de la boucle locale</h2>



<p class="wp-block-paragraph">Pendant six semaines, un service de mon serveur (un gestionnaire de tâches qui parle à Redis sur <code>127.0.0.1</code>) s&rsquo;est figé à intervalles irréguliers, toujours exactement 31 minutes et 40 secondes, avant de repartir. Les journaux de l&rsquo;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&rsquo;appartenaient plus à aucune connexion connue de Suricata, qui les jetait ; il fallait attendre que TCP abandonne et se reconnecte.</p>



<p class="wp-block-paragraph">Le diagnostic est venu d&rsquo;<code>eve.json</code> : chaque paquet jeté y apparaît avec sa raison.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">jq -c &#x27;select(.event_type==&quot;drop&quot;) | {timestamp, src_ip, dest_ip, dest_port, reason: .drop.reason}&#x27; /var/log/suricata/eve.json | tail</pre>



<p class="wp-block-paragraph">Trois corrections, toutes nécessaires : exclure la boucle locale (le trafic entre processus d&rsquo;une même machine n&rsquo;a rien à faire dans un IPS), allonger le délai TCP à deux heures, et activer <code>midstream</code>. Retenez le symptôme : une connexion longue qui se fige un temps fixe et reproductible, sans erreur nulle part, c&rsquo;est presque toujours un équipement intermédiaire (pare-feu, IPS, traduction d&rsquo;adresses) qui a oublié la connexion.</p>



<h2 class="wp-block-heading">Le piège des cartes réseau</h2>



<p class="wp-block-paragraph">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&rsquo;en trouve faussée. Désactivez ces optimisations sur l&rsquo;interface surveillée, de façon permanente, par NetworkManager plutôt que par un simple <code>ethtool -K</code> qui serait annulé à la reconnexion suivante :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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 &#x27;generic-receive|large-receive&#x27;</pre>



<h2 class="wp-block-heading">Étape 5 : démarrer et vérifier</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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&#x27;alerte 2100498
jq -c &#x27;select(.event_type==&quot;alert&quot;) | .alert.signature&#x27; /var/log/suricata/eve.json | tail -3</pre>



<p class="wp-block-paragraph">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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">jq -c &#x27;select(.event_type==&quot;stats&quot;) | .stats | {drop: .ips.blocked, memcap: .tcp.ssn_memcap_drop, kernel: .capture}&#x27; /var/log/suricata/eve.json | tail -1</pre>


<!-- nz-enrich:suricata-real -->


<h2 class="wp-block-heading">Les mêmes compteurs sur un serveur en production</h2>



<p class="wp-block-paragraph">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. <code>suricatasc</code> donne les compteurs en direct, sans attendre la prochaine ligne de statistiques dans eve.json :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># suricatasc -c iface-list
{&quot;message&quot;: {&quot;count&quot;: 1, &quot;ifaces&quot;: [&quot;NFQ#0&quot;]}, &quot;return&quot;: &quot;OK&quot;}

# suricatasc -c ruleset-stats
[{&quot;id&quot;: 0, &quot;rules_loaded&quot;: 53325, &quot;rules_failed&quot;: 0, &quot;rules_skipped&quot;: 0}]

# suricatasc -c dump-counters | jq &#x27;.message | {uptime, ips, alerts: .detect.alert}&#x27;
uptime        4544 s
ips.accepted  2 076 277
ips.blocked   771
  drop_reason.stream_error    769
  drop_reason.applayer_error  2
  drop_reason.rules           0
alerts        23</pre>



<ul class="wp-block-list"><li><strong>NFQ#0</strong> : 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.</li><li><strong>53 325 règles chargées, 0 en échec</strong> : un <em>rules_failed</em> non nul signifie le plus souvent qu&rsquo;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.</li><li><strong>771 paquets bloqués, 769 pour stream_error, 0 par les règles</strong> : c&rsquo;est le tableau normal d&rsquo;un serveur web. Les rejets par règle sont rares ; la plupart des paquets jetés n&rsquo;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&rsquo;un coup, regardez les événements <code>drop</code> dans eve.json : la leçon de la boucle locale, plus haut, a commencé par exactement ce genre de pic.</li><li><strong>23 alertes</strong> 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&rsquo;exploitation, minage…) grâce à <code>drop.conf</code> : pendant cette heure, aucune ne s&rsquo;est déclenchée. Une règle de blocage qui ne se déclenche jamais, c&rsquo;est l&rsquo;état attendu, pas le signe d&rsquo;une panne.</li></ul>



<h2 class="wp-block-heading">Faire taire un faux positif</h2>



<p class="wp-block-paragraph">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 <code>threshold.config</code>, puis désactivation de la règle par son numéro dans <code>disable.conf</code>. Ne modifiez jamais le fichier de règles directement : la prochaine mise à jour l&rsquo;écraserait.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># /etc/suricata/threshold.config
suppress gen_id 1, sig_id 2210045, track by_src, ip 203.0.113.40</pre>



<h2 class="wp-block-heading">Envoyer les alertes au SIEM</h2>



<p class="wp-block-paragraph"><code>eve.json</code> est lisible par Wazuh, Elastic, Graylog et la plupart des SIEM. Avec un agent Wazuh, quelques lignes suffisent (format <code>json</code>, 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&rsquo;une connexion SSH ou d&rsquo;un changement de fichier. Pensez à la rotation : <code>eve.json</code> grossit vite, et logrotate doit envoyer à Suricata le signal de rouvrir le fichier.</p>



<h2 class="wp-block-heading">Mettre à jour Suricata</h2>



<p class="wp-block-paragraph">Après chaque mise à jour du paquet, relancez <code>suricata -T</code> avant le redémarrage : une option renommée ou une règle incompatible empêche le démarrage, et grâce à <code>--queue-bypass</code> le trafic passera alors <em>sans inspection</em>, sans que rien ne casse visiblement. C&rsquo;est le revers de la sécurité de fonctionnement : surveillez l&rsquo;état du service, pas seulement le trafic.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de tutoriels d&rsquo;administration système (tag « pas à pas »).</em><br>Série « Pas à pas », tutoriel 10/77. · <a href="https://blog.neurozone.fr/?p=2046">← Tutoriel précédent</a> · <a href="https://blog.neurozone.fr/?p=2065">Tutoriel suivant →</a> · <a href="https://hecate.neurozone.fr/?p=599">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=2046">Installer CrowdSec sans jamais se bannir</a></li><li><a href="https://blog.neurozone.fr/?p=1419">CrowdSec 1.8 sur Debian : réinstaller proprement</a></li><li><a href="https://blog.neurozone.fr/?p=2975">Sous le capot des failles d&rsquo;octobre 2026</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/11/09/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-serveur/">Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/09/pas-a-pas-suricata-en-ips-sur-rocky-linux-sans-casser-son-serveur/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2047</post-id>	</item>
		<item>
		<title>Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</title>
		<link>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/</link>
					<comments>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Sun, 08 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[InnoDB]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[série : Dans les fichiers d'une base de données]]></category>
		<category><![CDATA[stockage]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5572</guid>

					<description><![CDATA[<p>Ce que contient une table LanceDB sur disque : 265 versions, 13,4 Gio, un fragment vivant. Manifestes et transactions décodés sur une vraie table.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/">Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><strong>J&rsquo;ai lancé un script en lecture seule sur ma propre machine pour regarder à l&rsquo;intérieur d&rsquo;une table LanceDB, et un assistant IA (Claude, d&rsquo;Anthropic) m&rsquo;a aidée à écrire ce script et à rédiger cet article.</strong> Les chiffres ci-dessous sont la sortie réelle de ce lancement, avec LanceDB 0.41.0 et pyarrow 26.0.1. J&rsquo;ai remplacé le nom de la table et les chemins. C&rsquo;est le premier article d&rsquo;une série sur LanceDB vu par une DBA.</p>



<h2 class="wp-block-heading">Réponse rapide</h2>



<p class="wp-block-paragraph">Une table LanceDB est un répertoire, et rien d&rsquo;autre : des fichiers de données dans <code>data/</code>, un manifeste par version dans <code>_versions/</code>, un fichier de transaction par validation dans <code>_transactions/</code>, et un répertoire <code>_indices/</code> dès qu&rsquo;on construit un index. Ma table contient 1 130 595 lignes de vecteurs à 1024 dimensions et pèse 13,4 Gio sur disque, mais sa version courante, la 265, ne référence qu&rsquo;un seul fichier de données de 5,8 Gio. Les 7,6 Gio restants environ sont répartis dans 259 anciens fichiers de données dont aucune version courante n&rsquo;a besoin, et ils restent sur disque tant que personne ne nettoie les anciennes versions. Chaque ajout crée un nouveau fichier de données et une nouvelle version, c&rsquo;est ainsi qu&rsquo;une table atteint 265 versions et 260 fichiers de données, et la dernière version a fusionné 226 fragments en un sans supprimer les anciens fichiers.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="224" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=629%2C224&#038;ssl=1" alt="Table LanceDB sur disque : la version 265 réécrit 226 fragments en un, 5,8 Gio lus sur 13,4 Gio dans data/" class="wp-image-5571" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?w=1800&amp;ssl=1 1800w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=300%2C107&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1024%2C364&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=768%2C273&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1536%2C546&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=900%2C320&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/table-lancedb-sur-disque-versions-manifestes-fragments.png?resize=1280%2C455&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Versions 263 à 265 et part de data/ que lit la version courante.</figcaption></figure>



<h2 class="wp-block-heading">Ce que contient le répertoire d&rsquo;une table LanceDB</h2>



<p class="wp-block-paragraph">La première partie de mon script parcourt le répertoire de la table LanceDB, <code>docs_index.lance</code>, et n&rsquo;a pas besoin de LanceDB. L&rsquo;arborescence contient trois sous-répertoires. La <a href="https://lance.org/format/table/layout/" rel="noopener">documentation de l&rsquo;agencement des fichiers</a> en liste d&rsquo;autres, dont deux, <code>_indices/</code> et <code>_deletions/</code>, sont absents de ma table.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Répertoire</th><th>Ce que dit la documentation</th><th>Ce que j&rsquo;ai trouvé</th></tr></thead><tbody><tr><td><code>data/</code></td><td>fichiers de données (<code>*.lance</code>)</td><td>260 fichiers, 13,4 Gio, médiane 21,6 Mio, le plus gros 5,8 Gio</td></tr><tr><td><code>_versions/</code></td><td>manifestes (<code>*.manifest</code>) et un <code>latest_version_hint.json</code> optionnel</td><td>265 manifestes, 2,4 Mio au total, plus un fichier d&rsquo;indice de 15 octets</td></tr><tr><td><code>_transactions/</code></td><td>fichiers de transaction (<code>*.txn</code>)</td><td>265 fichiers, 56,7 Kio au total, médiane 132 octets</td></tr><tr><td><code>_indices/</code></td><td>un dossier par index</td><td>absent</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">La table compte 791 fichiers en tout. Les statistiques donnent <code>num_indices: 0</code> et <code>num_deleted_rows: 0</code>, ce qui correspond à l&rsquo;absence de ces deux répertoires : je n&rsquo;ai jamais construit d&rsquo;index et jamais supprimé de ligne.</p>



<h2 class="wp-block-heading">Une version, c&rsquo;est un manifeste et une transaction</h2>



<p class="wp-block-paragraph">Le manifeste est le cœur du format. La <a href="https://lance.org/format/table/" rel="noopener">documentation</a> le décrit comme la description immuable d&rsquo;une version unique, avec le schéma, la liste des fragments, le numéro de version, un horodatage et une référence à la transaction qui l&rsquo;a créée. Ma table a 265 manifestes pour 265 versions, donc exactement un manifeste par version, et 265 fichiers de transaction, donc chaque validation en a laissé un.</p>



<p class="wp-block-paragraph">Les noms portent de l&rsquo;information. Un fichier de transaction s&rsquo;appelle <code>{read_version}-{uuid}.txn</code>, où le nombre est la version à partir de laquelle la transaction a été construite, et le plus gros de ma table est <code>263-45522501-…txn</code>, de 20,1 Kio. Les manifestes déroutent au début, mais la logique est simple : le fichier de la version 265 est <code>18446744073709551350.manifest</code>. Ce nombre est 18446744073709551615, le plus grand entier non signé sur 64 bits, moins 265, et la version 1 est <code>18446744073709551614.manifest</code>. Les noms décroissent donc quand les versions augmentent. Les pages de documentation que j&rsquo;ai lues n&rsquo;expliquent pas ce schéma, donc ma lecture n&rsquo;est qu&rsquo;une hypothèse : la version la plus récente arrive en premier dans un listing de répertoire. Le fichier <code>latest_version_hint.json</code> fait 15 octets. La documentation parle d&rsquo;une « simple optimisation », destinée à accélérer la recherche de la dernière version sur les stockages où lister un répertoire coûte cher.</p>



<h2 class="wp-block-heading">Chaque ajout crée un fragment</h2>



<p class="wp-block-paragraph">Les cinq dernières versions montrent comment la table a grossi. <code>list_versions</code> a renvoyé ces lignes, que j&rsquo;ai réduites aux champs intéressants et alignées en tableau :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">version  timestamp                    rows       data files
261      2026-11-03 22:55:19.615921   1,123,032  224
262      2026-11-03 22:56:55.260015   1,127,160  225
263      2026-11-03 22:58:15.392524   1,130,595  226
264      2026-11-03 22:59:22.144490   1,130,595  226
265      2026-11-03 22:59:22.177593   1,130,595  1</pre>



<p class="wp-block-paragraph">De la 261 à la 263, chaque version ajoute un fichier de données et quelques milliers de lignes : 4 128 lignes pour le premier ajout et 3 435 pour le second. C&rsquo;est l&rsquo;<a href="https://lance.org/format/table/transaction/" rel="noopener">opération Append</a> de la documentation, qui ajoute de nouveaux fragments sans modifier les données existantes. De petits ajouts produisent donc de petits fichiers de données, et une table alimentée par petits lots accumule les fragments : la mienne en avait 226, et le fichier de données médian fait 21,6 Mio.</p>



<p class="wp-block-paragraph">Les versions 264 et 265 sont séparées de 33 millisecondes, et le nombre de lignes ne change pas. J&rsquo;ai décodé leurs transactions avec les définitions protobuf publiées dans le dépôt Lance. La version 264 est une transaction <code>reserve_fragments</code> de 46 octets : elle réserve un identifiant de fragment, et le <code>max_fragment_id</code> du manifeste passe de 258 à 259 alors que les 226 fragments restent les mêmes. La version 265 est une transaction <code>rewrite</code>, le compactage : elle remplace les 226 anciens fragments par un nouveau fragment, d&rsquo;identifiant 259, qui contient les 1 130 595 lignes. La documentation décrit une Rewrite comme une opération qui « réorganise les données sans modification sémantique », et c&rsquo;est ce que montre le fichier. Les versions 21 et 33 de la même table contiennent des réécritures plus anciennes, de 21 fragments en un et de 6 en un, et la première version est un <code>overwrite</code> de 329 octets.</p>



<h2 class="wp-block-heading">La version courante d&rsquo;une table LanceDB est plus petite que la table</h2>



<p class="wp-block-paragraph">Les statistiques de la version 265 donnent la taille de ce dont la table a réellement besoin :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml">total_bytes: 6,203,819,954   num_rows: 1,130,595   num_deleted_rows: 0
num_indices: 0               num_fragments: 1      num_small_fragments: 0</pre>



<p class="wp-block-paragraph">Cela fait 5,8 Gio, soit environ 5 487 octets par ligne. La colonne de vecteurs contient 1024 flottants de 4 octets, donc 4 096 octets par ligne, et les 1 391 octets restants par ligne contiennent le texte, le titre, le chemin et le numéro de segment. Le répertoire <code>data/</code> pèse 13,4 Gio, ce qui laisse environ 7,6 Gio dans 259 fichiers de données que la version 265 ne référence pas. Ce sont les fragments des versions précédentes, toujours là parce que rien ne les a supprimés. Dans cette table, la fusion des fragments n&rsquo;a pas réduit le répertoire : le nouveau fichier a été ajouté et les anciens sont restés en place, toujours référencés par les anciennes versions.</p>



<h2 class="wp-block-heading">Pourquoi le manifeste d&rsquo;un seul fragment pèse 20,5 Kio</h2>



<p class="wp-block-paragraph">Les manifestes grossissent avec le nombre de fragments : 628 et 736 octets pour les premiers, 20,4 Kio à 226 fragments, soit environ 90 octets par fragment. La version 265 n&rsquo;a qu&rsquo;un fragment et son fichier fait pourtant 21 031 octets, et je l&rsquo;ai décodé pour comprendre pourquoi. Le fichier commence par une longueur sur 4 octets et une copie de la transaction, puis une seconde longueur et le manifeste proprement dit, puis un pied de 16 octets qui se termine par la marque <code>LANC</code>. Le manifeste proprement dit fait 391 octets. Les 20 620 autres octets sont la transaction intégrée, la même réécriture que celle stockée dans <code>263-45522501-…txn</code>, qui liste les 226 fragments qu&rsquo;elle remplace, à 88,6 octets chacun en moyenne. À titre de comparaison, les manifestes des versions 263 et 264 font chacun 20,8 Kio de liste de fragments, plus une petite transaction intégrée de 136 et 50 octets.</p>



<p class="wp-block-paragraph">Les 20 Kio du dernier manifeste ne décrivent donc pas la version courante. Ils enregistrent ce que le compactage a retiré de la table, et ils sont présents deux fois sur disque, dans le manifeste et dans le fichier de transaction. Ce champ s&rsquo;appelle <code>transaction_section</code> dans le protobuf. Je n&rsquo;ai pas trouvé cela décrit dans les pages du format que j&rsquo;ai lues.</p>



<h2 class="wp-block-heading">Ce que je n&rsquo;ai pas testé</h2>



<p class="wp-block-paragraph">Je n&rsquo;ai lancé aucune opération d&rsquo;écriture sur la table d&rsquo;origine, parce que je voulais la laisser intacte : ni compactage, ni nettoyage des anciennes versions, ni index. Le script ne fait que lister des fichiers et appeler <code>count_rows</code>, <code>schema</code>, <code>list_versions</code>, <code>list_indices</code> et <code>stats</code>. J&rsquo;ai décodé les manifestes et les fichiers de transaction uniquement pour les versions 1, 21, 33 et 263 à 265, et uniquement sur une copie de la table. Je n&rsquo;ai pas non plus mesuré de temps de requête, qui dépend de l&rsquo;index et viendra dans un prochain article.</p>



<h2 class="wp-block-heading">Documentation officielle</h2>



<p class="wp-block-paragraph">Les pages <a href="https://lance.org/format/table/" rel="noopener">format de table Lance</a>, <a href="https://lance.org/format/table/layout/" rel="noopener">agencement des fichiers</a>, <a href="https://lance.org/format/table/versioning/" rel="noopener">versions</a> et <a href="https://lance.org/format/table/transaction/" rel="noopener">transactions</a> décrivent ce que j&rsquo;ai mesuré, et les définitions protobuf <a href="https://github.com/lance-format/lance/blob/main/protos/table.proto" rel="noopener">table.proto</a> et <a href="https://github.com/lance-format/lance/blob/main/protos/transaction.proto" rel="noopener">transaction.proto</a> donnent les noms de champs. Le <a href="https://lancedb.com/" rel="noopener">projet LanceDB</a> documente l&rsquo;API Python derrière le script.</p>


<p>L’article <a href="https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/">Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/08/table-lancedb-sur-disque-versions-manifestes-fragments/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5572</post-id>	</item>
		<item>
		<title>Durcir systemd, directive par directive : mesurer avec systemd-analyze security</title>
		<link>https://blog.neurozone.fr/2026/11/07/systemd-analyze-security-mesurer-avant-de-durcir/</link>
					<comments>https://blog.neurozone.fr/2026/11/07/systemd-analyze-security-mesurer-avant-de-durcir/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Sat, 07 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<category><![CDATA[durcissement]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[hardening]]></category>
		<category><![CDATA[Rocky Linux]]></category>
		<category><![CDATA[sécurité]]></category>
		<category><![CDATA[systemd]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=4897</guid>

					<description><![CDATA[<p>Partie 1 d'une série sur le durcissement de systemd : lire systemd-analyze security, écrire un drop-in, tester une directive à la fois et l'annuler, avec un lab sur un service inoffensif.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/07/systemd-analyze-security-mesurer-avant-de-durcir/">Durcir systemd, directive par directive : mesurer avec systemd-analyze security</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><code>systemd-analyze security</code> est la commande que je lance avant de modifier quoi que ce soit dans une unité systemd. Elle attribue à chaque service une note d&rsquo;exposition, liste les options de sandboxing que l&rsquo;unité n&rsquo;utilise pas encore et indique par où commencer. Cet article est la partie 1 d&rsquo;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.</p>



<p class="wp-block-paragraph">Les parties suivantes passent en revue les directives, une famille à la fois. Pour chaque directive, j&rsquo;explique ce qu&rsquo;elle fait, quels services de ma stack elle touche et ce qu&rsquo;elle peut casser. Cette première partie reste sur la méthode, parce que toutes les suivantes s&rsquo;appuient dessus. Aucune expérience de systemd n&rsquo;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.</p>



<p class="wp-block-paragraph"><strong>Banc de test.</strong> Toutes les sorties de cet article viennent d&rsquo;exécutions réelles sur <strong>Rocky Linux 10.2 avec systemd 257</strong>, 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&rsquo;un serveur classique. Je le signale à chaque endroit où cela compte. Quand je coupe des lignes d&rsquo;une sortie, je les remplace par <code>[...]</code>, et je marque <em>non testées</em> les commandes que je n&rsquo;ai pas lancées.</p>



<h2 class="wp-block-heading">Avant de commencer</h2>



<p class="wp-block-paragraph">Lisez d&rsquo;abord cette courte liste. Elle vous évite l&rsquo;erreur classique : durcir un service de production du premier coup.</p>



<ul class="wp-block-list"><li><strong>Une machine de test.</strong> Utilisez une machine virtuelle ou un serveur de rechange sous systemd. J&rsquo;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&rsquo;autres distributions, mais je ne l&rsquo;ai pas testé.</li><li><strong>Un shell avec les droits <code>sudo</code>.</strong> Chaque commande qui modifie le système commence par <code>sudo</code>.</li><li><strong>Un service inoffensif pour vos premiers essais.</strong> Le lab ci-dessous crée un service de démonstration nommé <code>demo-hardening-sandbox.service</code> : vous ne touchez donc à aucun vrai service.</li><li><strong>Un moyen de tout annuler.</strong> 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&rsquo;il a créé.</li><li><strong>Un systemd récent.</strong> Plusieurs options de systemd-analyze security sont apparues dans la version 250 : vérifiez donc la vôtre avec <code>systemctl --version</code>.</li></ul>



<h2 class="wp-block-heading">Un court glossaire</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Terme</th><th>Sens dans cet article</th></tr></thead><tbody><tr><td>Unité</td><td>Un fichier qui décrit ce que systemd gère. Une unité de type <em>service</em> se termine par <code>.service</code>.</td></tr><tr><td>Service</td><td>Une unité qui lance un programme : un démon qui reste actif, ou une commande qui s&rsquo;exécute une fois puis se termine.</td></tr><tr><td>Drop-in</td><td>Un petit fichier <code>.conf</code> placé dans un répertoire <code>.d</code>, qui modifie une unité sans toucher au fichier d&rsquo;origine.</td></tr><tr><td>Directive</td><td>Un réglage d&rsquo;un fichier d&rsquo;unité, par exemple <code>ProtectSystem=strict</code>.</td></tr><tr><td>Sandboxing</td><td>Les directives qui limitent ce qu&rsquo;un service peut voir et faire.</td></tr><tr><td>Namespace</td><td>Une fonction du noyau qui donne à un processus sa propre vue du système, par exemple ses propres points de montage. Plusieurs directives s&rsquo;appuient dessus.</td></tr><tr><td>Capability</td><td>Une part des pouvoirs de root, par exemple ouvrir un port bas, que le noyau peut accorder séparément.</td></tr><tr><td>Appel système</td><td>Une requête d&rsquo;un programme au noyau, par exemple ouvrir un fichier.</td></tr><tr><td>Niveau d&rsquo;exposition</td><td>La note que <code>systemd-analyze security</code> calcule pour une unité.</td></tr><tr><td>Unité transitoire</td><td>Une unité temporaire que <code>systemd-run</code> crée à la volée ; elle disparaît quand elle s&rsquo;arrête.</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">En un coup d&rsquo;œil</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Étape</th><th>Commande</th><th>Ce qu&rsquo;elle vous apprend</th></tr></thead><tbody><tr><td>Noter tous les services actifs</td><td><code>systemd-analyze security</code></td><td>Une ligne par service chargé, avec un niveau d&rsquo;exposition</td></tr><tr><td>Détailler un service</td><td><code>systemd-analyze security UNITÉ</code></td><td>Chaque réglage, appliqué (✓) ou non (✗), avec son exposition</td></tr><tr><td>Contrôler un fichier avant de le déployer</td><td><code>systemd-analyze verify FICHIER</code> puis <code>security --offline=true FICHIER</code></td><td>Les erreurs de syntaxe, puis la revue hors ligne</td></tr><tr><td>Changer une directive</td><td><code>systemctl edit UNITÉ</code></td><td>Un fichier drop-in, rechargé à l&rsquo;enregistrement</td></tr><tr><td>Expérimenter sans modifier de fichier</td><td><code>systemd-run -p DIRECTIVE=VALEUR ...</code></td><td>Une unité temporaire avec les propriétés de votre choix</td></tr><tr><td>Lire un échec</td><td><code>systemctl status UNITÉ</code> et <code>journalctl -u UNITÉ</code></td><td>Le code de sortie, puis les messages du service lui-même</td></tr><tr><td>Revenir en arrière</td><td><code>systemctl revert UNITÉ</code></td><td>Supprime les drop-ins de l&rsquo;unité</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Pourquoi durcir les services avec systemd</h2>



<p class="wp-block-paragraph">Un service qui n&rsquo;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&rsquo;exposition du système, et recommande d&rsquo;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 <strong>9,6 et l&rsquo;étiquette UNSAFE</strong>, avec 75 réglages non appliqués et 4 appliqués (vous le mesurerez vous-même dans le lab).</p>



<p class="wp-block-paragraph">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&rsquo;être retenues. D&rsquo;abord, beaucoup d&rsquo;options sont désactivées sans bruit sur les systèmes où le mécanisme sous-jacent n&rsquo;existe pas. Ensuite, certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.</p>



<h2 class="wp-block-heading">Comment une unité et ses drop-ins s&rsquo;assemblent</h2>



<p class="wp-block-paragraph">Un drop-in est la façon la plus sûre de modifier un service. Au lieu d&rsquo;é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&#8217;emporte.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="285" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=629%2C285&#038;ssl=1" alt="Superposition des drop-ins d&#x27;une unité systemd : fichier du paquet, drop-ins d&#x27;exécution et votre drop-in fusionnés en unité effective" class="wp-image-4894" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=300%2C136&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=1024%2C464&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=768%2C348&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=1536%2C697&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=900%2C408&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-1.png?resize=1280%2C580&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Quel fichier l&#x27;emporte quand une unité systemd et ses drop-ins sont fusionnés.</figcaption></figure>



<h3 class="wp-block-heading">Où vivent les drop-ins et lequel gagne</h3>



<p class="wp-block-paragraph">Pour une unité nommée <code>foo.service</code>, systemd cherche un répertoire <code>foo.service.d/</code>. Il ne lit que les fichiers qui se terminent par <code>.conf</code> et les fusionne par ordre alphanumérique après avoir lu le fichier principal de l&rsquo;unité. Chaque drop-in doit contenir son propre en-tête de section, par exemple <code>[Service]</code>.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Emplacement</th><th>Rôle</th><th>Priorité</th></tr></thead><tbody><tr><td><code>/usr/lib/systemd/system/</code></td><td>Unités et drop-ins fournis par les paquets</td><td>La plus basse</td></tr><tr><td><code>/run/systemd/system/</code></td><td>Changements d&rsquo;exécution, perdus au redémarrage</td><td>Intermédiaire</td></tr><tr><td><code>/etc/systemd/system/</code></td><td>Vos propres unités et drop-ins</td><td>La plus haute</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Le manuel ajoute trois règles. Les drop-ins de <code>/etc/</code> l&#8217;emportent sur ceux de <code>/run/</code>, qui l&#8217;emportent sur ceux de <code>/usr/lib/</code>. Les drop-ins l&#8217;emportent sur les fichiers d&rsquo;unité, où qu&rsquo;ils se trouvent. À niveau égal, les fichiers de noms différents s&rsquo;appliquent dans l&rsquo;ordre lexicographique. Cette dernière règle explique pourquoi je donne à mes fichiers un nom qui indique leur rôle, comme <code>hardening.conf</code>.</p>



<h3 class="wp-block-heading">Créer, vérifier et annuler un drop-in</h3>



<p class="wp-block-paragraph">Le lab ci-dessous lance les vraies commandes dans l&rsquo;ordre, avec leur sortie : <code>systemctl edit</code> pour créer un drop-in, <code>systemctl cat</code> pour voir ce que systemd lit, <code>systemctl show</code> pour voir ce qu&rsquo;il a chargé, et <code>systemctl revert</code> pour l&rsquo;annuler. Un point mérite l&rsquo;attention. <code>systemctl edit</code> recharge la configuration à votre place, et un rechargement conserve l&rsquo;état d&rsquo;exécution. Un service déjà lancé garde donc son ancien sandbox jusqu&rsquo;à son redémarrage. L&rsquo;erreur 4 du lab le démontre.</p>



<h2 class="wp-block-heading">Lire la sortie de systemd-analyze security</h2>



<p class="wp-block-paragraph">L&rsquo;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.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="293" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=629%2C293&#038;ssl=1" alt="Lire le tableau de systemd-analyze security : lignes non appliquées et appliquées, note d&#x27;exposition de 0,0 à 10,0 et ce que la note ignore" class="wp-image-4896" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=300%2C140&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=1024%2C476&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=768%2C357&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=1536%2C714&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=900%2C419&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-3.png?resize=1280%2C595&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">Lire la sortie de systemd-analyze security, ligne par ligne.</figcaption></figure>



<h3 class="wp-block-heading">Un service ou tout le système</h3>



<p class="wp-block-paragraph">Sans nom d&rsquo;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&rsquo;unité, il affiche une analyse détaillée de chacune. Voici le tableau de tout le système, coupé après quatre lignes :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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    😐
[...]</pre>



<p class="wp-block-paragraph">Les colonnes sont l&rsquo;unité, son niveau d&rsquo;exposition, une étiquette (le « predicate ») et un visage. Sur le banc de test, j&rsquo;ai vu les étiquettes MEDIUM, EXPOSED et UNSAFE ; le manuel ne les définit pas. Fiez-vous au nombre, et voyez l&rsquo;étiquette comme un repère visuel rapide.</p>



<h3 class="wp-block-heading">Le tableau, ligne par ligne</h3>



<p class="wp-block-paragraph">Pour un service, la sortie a trois colonnes : <code>NAME</code>, <code>DESCRIPTION</code> et <code>EXPOSURE</code>. Chaque ligne commence par une marque : ✗ signifie que le réglage n&rsquo;est pas appliqué, ✓ qu&rsquo;il l&rsquo;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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager demo-hardening-sandbox.service | head -n 6</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">  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</pre>



<p class="wp-block-paragraph">Lisez-le comme une liste de tâches. Chaque ligne ✗ est une protection que vous pouvez encore ajouter, et le nombre à droite indique l&rsquo;exposition qu&rsquo;elle retirerait. Les lignes sans marque et sans nombre ne s&rsquo;appliquent pas à cette unité : l&rsquo;outil vous dit par exemple que <code>RemoveIPC=</code> n&rsquo;a pas d&rsquo;importance quand le service tourne en root. Pour ne garder que le travail restant, filtrez la sortie :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager demo-hardening-sandbox.service | grep '✗' | head -n 4</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">✗ 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</pre>



<h3 class="wp-block-heading">Ce que la note signifie, et ce qu&rsquo;elle ne signifie pas</h3>



<p class="wp-block-paragraph">Le manuel décrit le niveau d&rsquo;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 :</p>



<ul class="wp-block-list"><li>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.</li><li>Une note élevée ne veut pas dire que le service est vulnérable, et une note basse ne prouve pas qu&rsquo;il est sûr. Elle indique seulement que le service pourrait profiter de réglages supplémentaires.</li><li>Beaucoup de réglages peuvent être annulés individuellement par d&rsquo;autres réglages. L&rsquo;outil tient compte de certaines combinaisons, pas de toutes.</li><li>Les réglages s&rsquo;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&rsquo;accès IPC.</li></ul>



<p class="wp-block-paragraph">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&rsquo;un changement a produit l&rsquo;effet voulu.</p>



<h3 class="wp-block-heading">Les options à connaître</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Option</th><th>Ce qu&rsquo;elle fait</th><th>Testée ici</th></tr></thead><tbody><tr><td><code>--offline=BOOL</code></td><td>Passe en revue des fichiers d&rsquo;unité sans interroger le gestionnaire en cours d&rsquo;exécution (ajoutée en version 250)</td><td>Oui : fonctionne avec un chemin de fichier et avec un nom d&rsquo;unité</td></tr><tr><td><code>--threshold=NOMBRE</code></td><td>Renvoie une erreur quand le niveau global dépasse la valeur (ajoutée en 250)</td><td>Oui : code de sortie 1 au-dessus du seuil, 0 en dessous</td></tr><tr><td><code>--json=MODE</code></td><td>Affiche le tableau en JSON : <code>off</code>, <code>pretty</code> ou <code>short</code></td><td>Oui : <code>short</code></td></tr><tr><td><code>--security-policy=CHEMIN</code></td><td>Applique vos propres exigences en JSON ; un poids de 0 signifie « ne pas contrôler » (ajoutée en 250)</td><td>Non testée</td></tr><tr><td><code>--profile=CHEMIN</code></td><td>Avec <code>--offline</code>, prend en compte un profil portable (ajoutée en 250)</td><td>Non testée</td></tr><tr><td><code>--no-pager</code></td><td>Affiche directement, sans pagineur</td><td>Oui</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">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&rsquo;étape 3 du lab). J&rsquo;ai lancé deux fois <code>--offline</code>, sur le chemin du fichier puis sur le nom de l&rsquo;unité :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --offline=true --no-pager /etc/systemd/system/demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --offline=true --no-pager demo-hardening-sandbox.service | tail -n 1</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁</pre>



<p class="wp-block-paragraph">J&rsquo;ai ensuite lancé <code>--threshold</code> deux fois pour vérifier le code de sortie :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --threshold=3 --no-pager demo-hardening-sandbox.service | tail -n 1; echo "exit=${PIPESTATUS[0]}"
systemd-analyze security --threshold=100 --no-pager demo-hardening-sandbox.service | tail -n 1; echo "exit=${PIPESTATUS[0]}"</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">→ 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</pre>



<p class="wp-block-paragraph">Le mode JSON donne un objet par ligne, avec les champs <code>set</code>, <code>name</code>, <code>json_field</code>, <code>description</code> et <code>exposure</code>. Voici les 330 premiers caractères :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --json=short --no-pager demo-hardening-sandbox.service | head -c 330</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="json">[{"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"
[...]</pre>



<p class="wp-block-paragraph">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 <code>--offline</code> renvoie une erreur au-dessus de ce seuil. Le seuil par défaut ne se déclenche donc jamais. Si vous utilisez <code>--threshold</code> dans un script, passez une valeur explicite et testez les deux côtés du seuil, comme je l&rsquo;ai fait ci-dessus.</p>



<h2 class="wp-block-heading">Contrôler une unité avant de la déployer</h2>



<p class="wp-block-paragraph">Deux commandes vous aident avant qu&rsquo;un fichier n&rsquo;entre dans <code>/etc/systemd/system/</code>. <code>systemd-analyze verify</code> détecte les sections et directives inconnues, les dépendances manquantes, les pages de manuel citées dans <code>Documentation=</code> qui n&rsquo;existent pas, et les commandes de <code>ExecStart=</code> introuvables ou non exécutables. Ensuite, le mode hors ligne de systemd-analyze security (<code>--offline=true</code>) passe le fichier en revue sans interroger le gestionnaire en cours d&rsquo;exécution. Voici une exécution propre sur l&rsquo;unité du lab :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze verify /etc/systemd/system/demo-hardening-sandbox.service; echo "exit=$?"</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">exit=0</pre>



<p class="wp-block-paragraph">L&rsquo;absence de message et le code 0 signifient que <code>verify</code> n&rsquo;a rien à signaler. Cependant, le manuel ne donne aucun exemple de revue hors ligne d&rsquo;un chemin de fichier. Cela fonctionne sur mon banc de test, mais essayez-le sur une copie avant de vous y fier.</p>



<h2 class="wp-block-heading">Une boucle de durcissement sûre</h2>



<p class="wp-block-paragraph">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&rsquo;é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.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="278" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=629%2C278&#038;ssl=1" alt="Boucle de durcissement d&#x27;un service systemd : mesurer, changer une directive, redémarrer, lire le résultat, tester le vrai travail, garder ou annuler" class="wp-image-4895" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=300%2C133&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=1024%2C452&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=768%2C339&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=1536%2C679&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=900%2C398&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/systemd-analyze-security-mesurer-avant-de-durcir-2.png?resize=1280%2C566&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">La boucle de durcissement : une directive à la fois, puis la garder ou l&#x27;annuler.</figcaption></figure>



<ol class="wp-block-list"><li><strong>Mesurer.</strong> Lancez <code>systemd-analyze security UNITÉ</code> et notez les lignes ✗ que vous comptez corriger.</li><li><strong>Changer une directive</strong> dans un drop-in avec <code>sudo systemctl edit UNITÉ</code>.</li><li><strong>Redémarrer</strong> avec <code>sudo systemctl restart UNITÉ</code>. Un simple rechargement ne donne pas son nouveau sandbox à un service déjà lancé.</li><li><strong>Lire le résultat</strong> avec <code>systemctl status UNITÉ</code> et <code>journalctl -u UNITÉ</code>.</li><li><strong>Tester le vrai travail :</strong> se connecter, envoyer un fichier, lancer la sauvegarde, envoyer un courrier. Un service peut démarrer et échouer plus tard.</li><li><strong>Garder ou annuler.</strong> Gardez le drop-in dans votre gestion de configuration, ou supprimez-le, lancez <code>sudo systemctl daemon-reload</code> et redémarrez.</li></ol>



<p class="wp-block-paragraph">Les deux dernières étapes comptent le plus. Un sandbox peut laisser un service démarrer alors que l&rsquo;une de ses tâches échoue plus tard, par exemple parce qu&rsquo;elle a besoin d&rsquo;un chemin désormais en lecture seule. C&rsquo;est pourquoi la boucle se termine par un test du vrai travail, et non par un démarrage réussi.</p>



<h3 class="wp-block-heading">Lire un échec</h3>



<p class="wp-block-paragraph">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 <code>ProtectSystem=strict</code> et tente de créer un fichier dans <code>/etc</code>. 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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo systemctl start demo-hardening-fail.service
systemctl status --no-pager -n 5 demo-hardening-fail.service</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">× 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
[...]</pre>



<p class="wp-block-paragraph">Le journal montre le message du programme lui-même. <code>-u</code> sélectionne l&rsquo;unité, <code>-o cat</code> n&rsquo;affiche que le message et <code>-n 6</code> limite le nombre de lignes :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">journalctl -u demo-hardening-fail.service -o cat --no-pager -n 6</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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).</pre>



<p class="wp-block-paragraph">Le message dit <em>Read-only file system</em> : la directive responsable est donc <code>ProtectSystem=</code>. La solution est d&rsquo;autoriser le chemin dont le programme a vraiment besoin avec <code>ReadWritePaths=</code>, que la partie 2 de la série détaille. Les messages de systemd restent en anglais sur le banc de test.</p>



<h2 class="wp-block-heading">Lab pratique : voir trois directives à l&rsquo;œuvre</h2>



<p class="wp-block-paragraph">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 <code>---</code> dans les sorties sont des libellés que mon script de test a affichés entre les commandes, en anglais.</p>



<h3 class="wp-block-heading">Étape 1 : créer le service de démonstration et le lancer sans sandbox</h3>



<p class="wp-block-paragraph">Créez le fichier <code>/etc/systemd/system/demo-hardening-sandbox.service</code> avec votre éditeur, par exemple <code>sudo nano /etc/systemd/system/demo-hardening-sandbox.service</code>. Les lignes qui commencent par <code>#</code> sont des commentaires. Mettez toujours un commentaire sur sa propre ligne, jamais après un réglage.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="ini"># 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 &amp;&amp; rm /etc/demo-hardening-test &amp;&amp; echo "etc : écriture ok" || echo "etc : écriture refusée"; touch /tmp/demo-hardening-marker; echo "terminé"'</pre>



<p class="wp-block-paragraph">Demandez maintenant à systemd de lire le nouveau fichier, affichez-le, lancez le service et lisez sa sortie. Un service <code>oneshot</code> s&rsquo;exécute une fois, et <code>systemctl start</code> attend sa fin :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo systemctl daemon-reload
systemctl cat demo-hardening-sandbox.service
sudo systemctl start demo-hardening-sandbox.service
journalctl -u demo-hardening-sandbox.service -o cat --no-pager
ls -l /tmp/demo-hardening-marker | awk '{print $1, $3, $NF}'</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="ini">[...]
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</pre>



<p class="wp-block-paragraph"><code>systemctl cat</code> affiche le fichier précédé d&rsquo;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&rsquo;ai coupé ces anciennes lignes avec <code>[...]</code>. Lisez le résultat. Le service a vu une entrée dans <code>/home</code>, il a pu écrire dans <code>/etc</code> et il a créé son fichier témoin dans le vrai <code>/tmp</code>. Rien n&rsquo;est encore protégé. Mesurez le point de départ :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -c '✗'
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -c '✓'</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">→ Overall exposure level for demo-hardening-sandbox.service: 9.6 UNSAFE 😨
75
4</pre>



<p class="wp-block-paragraph">Regardez enfin les trois lignes que l&rsquo;étape suivante va corriger :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -E 'ProtectSystem|ProtectHome|PrivateTmp'</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">✗ 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</pre>



<h3 class="wp-block-heading">Étape 2 : ajouter le drop-in</h3>



<p class="wp-block-paragraph">La méthode habituelle est l&rsquo;éditeur interactif : <code>sudo systemctl edit demo-hardening-sandbox.service</code> ouvre un nouveau fichier nommé <code>override.conf</code> (non testé ici, car il demande un terminal). Pour que le lab reste reproductible, utilisez la forme non interactive. <code>--stdin</code> lit le contenu sur l&rsquo;entrée standard et <code>--drop-in=hardening</code> choisit le nom du fichier :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo systemctl edit --stdin --drop-in=hardening demo-hardening-sandbox.service &lt;&lt;'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</pre>



<p class="wp-block-paragraph">Chaque ligne de ce fichier fait une chose. <code>[Service]</code> est l&rsquo;en-tête de section obligatoire. <code>ProtectSystem=strict</code> monte toute la hiérarchie du système de fichiers en lecture seule, sauf <code>/dev</code>, <code>/proc</code> et <code>/sys</code>. <code>ProtectHome=true</code> rend <code>/home</code>, <code>/root</code> et <code>/run/user</code> inaccessibles et vides. <code>PrivateTmp=true</code> donne au service son propre <code>/tmp</code> et <code>/var/tmp</code>, supprimés à son arrêt. Systemd répond avec le chemin du nouveau fichier :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Successfully installed edited file '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.</pre>



<p class="wp-block-paragraph">Vérifiez maintenant ce que systemd lit et ce qu&rsquo;il a chargé. La sortie de <code>systemctl cat</code> commence par le fichier principal de l&rsquo;unité ; je coupe cette partie :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemctl cat demo-hardening-sandbox.service
systemctl show --property=ProtectSystem --property=ProtectHome --property=PrivateTmp demo-hardening-sandbox.service</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="ini">[...]
# /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</pre>



<p class="wp-block-paragraph"><code>systemctl show</code> affiche <code>yes</code> là où le fichier dit <code>true</code> : 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&rsquo;avant sont le fichier. Relancez le service et comparez avec l&rsquo;étape 1 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">rm -f /tmp/demo-hardening-marker
sudo systemctl start demo-hardening-sandbox.service
journalctl -u demo-hardening-sandbox.service -n 7 -o cat --no-pager
ls -l /tmp/demo-hardening-marker</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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</pre>



<p class="wp-block-paragraph">Trois choses ont changé, et elles correspondent au manuel. Le service voit maintenant <strong>0</strong> entrée dans <code>/home</code>. Son écriture dans <code>/etc</code> échoue avec <em>Read-only file system</em>. Et le fichier témoin qu&rsquo;il a créé n&rsquo;est pas visible depuis l&rsquo;hôte, car le service a utilisé un <code>/tmp</code> privé.</p>



<h3 class="wp-block-heading">Étape 3 : mesurer à nouveau</h3>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemd-analyze security --no-pager demo-hardening-sandbox.service | tail -n 1
systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -E 'ProtectSystem|ProtectHome|PrivateTmp'</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">→ 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                               </pre>



<p class="wp-block-paragraph">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&rsquo;autres expositions, comme l&rsquo;utilisateur sous lequel il tourne et ses capabilities. Les parties 3 et 4 de la série s&rsquo;en occupent.</p>



<h3 class="wp-block-heading">Étape 4 : essayer une directive à la fois avec systemd-run</h3>



<p class="wp-block-paragraph"><code>systemd-run</code> lance une commande comme unité transitoire, et <code>-p</code> fixe une propriété dans le même format que <code>systemctl set-property</code>. Les options ci-dessous sont celles du manuel : <code>--quiet</code> masque le message de démarrage, <code>--pipe</code> relie la commande à l&rsquo;entrée et à la sortie de votre terminal, <code>--wait</code> attend la fin et <code>--collect</code> décharge l&rsquo;unité ensuite, même après un échec. (Le manuel documente aussi <code>-t</code> pour un shell interactif ; je ne l&rsquo;ai pas testé.) Le manuel ne donne aucun exemple de sandboxing pour <code>systemd-run</code> : les résultats ci-dessous sont donc mes propres tests. Les lignes <code>echo</code> affichent les libellés que vous voyez dans la sortie, et chaque <code>ls</code> contrôle ensuite le vrai système de fichiers :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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&gt;&amp;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&gt;&amp;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&gt;&amp;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&gt;&amp;1
echo "--- without PrivateTmp, same test"
$RUN /bin/touch /tmp/demo-hardening-t7; echo "service exit=$?"; ls /tmp/demo-hardening-t7 2&gt;&amp;1; rm -f /tmp/demo-hardening-t7</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">--- 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</pre>



<p class="wp-block-paragraph">Ce tableau lit les résultats comme le manuel les décrit :</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Directive</th><th>Ce que mon test a montré</th><th>Manuel</th></tr></thead><tbody><tr><td><code>ProtectSystem=true</code></td><td>L&rsquo;écriture dans <code>/etc</code> fonctionne, l&rsquo;écriture dans <code>/usr</code> est refusée</td><td><code>/usr</code> et les répertoires du chargeur de démarrage en lecture seule</td></tr><tr><td><code>ProtectSystem=full</code></td><td>L&rsquo;écriture dans <code>/etc</code> est refusée</td><td><code>/etc</code> en lecture seule également</td></tr><tr><td><code>ProtectSystem=strict</code></td><td>L&rsquo;écriture dans <code>/etc</code> est refusée</td><td>Toute la hiérarchie en lecture seule, sauf <code>/dev</code>, <code>/proc</code>, <code>/sys</code></td></tr><tr><td><code>ProtectHome=true</code></td><td>0 entrée vue, et <code>/home</code> affiche le mode <code>d---------</code></td><td>Répertoires inaccessibles et vides</td></tr><tr><td><code>PrivateTmp=true</code></td><td>Le fichier créé par le service est absent sur l&rsquo;hôte</td><td><code>/tmp</code> et <code>/var/tmp</code> non partagés avec les processus extérieurs</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Ces résultats confirment le manuel sur mon banc de test. Ils viennent d&rsquo;un noyau WSL2 : confirmez-les sur votre propre serveur avant de vous appuyer sur une directive en production.</p>



<h3 class="wp-block-heading">Étape 5 : annuler et nettoyer</h3>



<p class="wp-block-paragraph"><code>systemctl revert</code> supprime les drop-ins d&rsquo;une unité. Il laisse en place un fichier d&rsquo;unité qui n&rsquo;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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">sudo systemctl revert demo-hardening-sandbox.service
sudo rm -f /etc/systemd/system/demo-hardening-sandbox.service
sudo rm -f /tmp/demo-hardening-marker
sudo systemctl daemon-reload</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">Removed '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.
Removed '/etc/systemd/system/demo-hardening-sandbox.service.d'.</pre>



<p class="wp-block-paragraph">La première commande a affiché les deux lignes ci-dessus. Après le revert, le fichier d&rsquo;unité était toujours là et le répertoire <code>.d</code> avait disparu. Après le nettoyage, j&rsquo;ai vérifié qu&rsquo;il ne restait aucune unité de démonstration, aucun fichier dans <code>/tmp</code>, <code>/etc</code> ou <code>/usr</code>, et aucune unité de démonstration chargée (tous les compteurs à 0).</p>



<h2 class="wp-block-heading">Erreurs courantes</h2>



<p class="wp-block-paragraph">Chaque erreur ci-dessous s&rsquo;est produite sur le banc de test, avec le message réel. Je les ai reproduites sur une unité jetable nommée <code>demo-hardening-mistakes.service</code> (un service <code>oneshot</code> qui lance <code>/bin/true</code>) et sur un service <code>sleep</code>, puis j&rsquo;ai supprimé les deux.</p>



<h3 class="wp-block-heading">Erreur 1 : un drop-in sans en-tête de section</h3>



<p class="wp-block-paragraph">Le drop-in <code>a-no-header.conf</code> ne contenait que <code>ProtectSystem=strict</code>. Systemd a ignoré la ligne, et la valeur chargée est restée <code>no</code> :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i a-no-header | sort -u</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/a-no-header.conf:1: Assignment outside of section. Ignoring.</pre>



<h3 class="wp-block-heading">Erreur 2 : un drop-in sans le suffixe .conf</h3>



<p class="wp-block-paragraph">Un fichier nommé <code>b-wrong-suffix.txt</code> n&rsquo;est jamais lu. <code>systemctl cat</code> ne liste que le fichier principal, et la valeur reste <code>no</code> :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemctl cat demo-hardening-mistakes.service | grep '^#'
systemctl show --property=ProtectSystem demo-hardening-mistakes.service</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># /etc/systemd/system/demo-hardening-mistakes.service
ProtectSystem=no</pre>



<h3 class="wp-block-heading">Erreur 3 : un commentaire sur la même ligne qu&rsquo;un réglage</h3>



<p class="wp-block-paragraph">J&rsquo;ai écrit <code>ProtectSystem=strict # make everything read-only</code> sur une seule ligne. Systemd a lu tout le texte comme la valeur, l&rsquo;a rejeté et a gardé la valeur par défaut :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i c-inline-comment | sort -u</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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</pre>



<h3 class="wp-block-heading">Erreur 4 : un rechargement sans redémarrage</h3>



<p class="wp-block-paragraph">Un service <code>sleep</code> tournait déjà. J&rsquo;ai ajouté <code>ProtectSystem=strict</code> puis lancé <code>daemon-reload</code>. <code>systemctl show</code> a répondu <code>strict</code>, 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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">PID=$(systemctl show --property=MainPID --value demo-hardening-sleep.service)
readlink /proc/1/ns/mnt
readlink /proc/$PID/ns/mnt</pre>



<p class="wp-block-paragraph">J&rsquo;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 :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">--- 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]</pre>



<h3 class="wp-block-heading">Autres erreurs à éviter</h3>



<ul class="wp-block-list"><li><strong>Modifier le fichier du paquet dans <code>/usr/lib/systemd/system/</code>.</strong> Utilisez plutôt un drop-in dans <code>/etc/systemd/system/</code> ; le manuel donne la priorité à <code>/etc/</code> sur <code>/usr/lib/</code>.</li><li><strong>Juger un changement sur « le service démarre ».</strong> Testez le vrai travail, comme dans la boucle ci-dessus.</li><li><strong>Changer plusieurs directives à la fois.</strong> Vous ne saurez pas laquelle a cassé le service.</li><li><strong>Viser une note de 0.</strong> Le manuel précise qu&rsquo;une note élevée ne signifie pas qu&rsquo;un service est vulnérable. Servez-vous de la note de systemd-analyze security pour prioriser.</li><li><strong>Utiliser des directives de namespace de montage dans un service utilisateur.</strong> Le manuel avertit que certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.</li></ul>



<h2 class="wp-block-heading">Une checklist à suivre</h2>



<ol class="wp-block-list"><li>Travaillez d&rsquo;abord sur une machine de test ou sur un service inoffensif.</li><li>Lancez <code>systemd-analyze security UNITÉ</code> et notez la note et les lignes ✗.</li><li>Écrivez une directive dans un drop-in sous <code>/etc/systemd/system/UNITÉ.d/</code>, dans un fichier <code>.conf</code> avec un en-tête <code>[Service]</code> et des commentaires sur leur propre ligne.</li><li>Lancez <code>systemctl cat UNITÉ</code> pour vérifier que systemd lit votre fichier.</li><li>Lancez <code>sudo systemctl restart UNITÉ</code> ; un simple rechargement ne suffit pas pour un service déjà lancé.</li><li>Lisez <code>systemctl status UNITÉ</code> et <code>journalctl -u UNITÉ</code>.</li><li>Testez le vrai travail du service, pas seulement son démarrage.</li><li>Mesurez à nouveau avec <code>systemd-analyze security UNITÉ</code>.</li><li>Gardez le drop-in dans votre gestion de configuration, ou supprimez-le avec <code>systemctl revert</code> puis redémarrez.</li><li>Recommencez avec la directive suivante.</li></ol>



<h2 class="wp-block-heading">Les familles de directives des prochaines parties</h2>



<p class="wp-block-paragraph">Les prochaines parties prennent chaque famille à tour de rôle. Chaque nom de directive ci-dessous est documenté dans la page de manuel <code>systemd.exec</code> ; <code>IPAddressAllow=</code> et <code>IPAddressDeny=</code> le sont dans <code>systemd.resource-control</code>.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Partie</th><th>Famille</th><th>Directives couvertes</th><th>Question à laquelle elle répond</th></tr></thead><tbody><tr><td>2</td><td>Système de fichiers</td><td><code>ProtectSystem</code>, <code>ProtectHome</code>, <code>PrivateTmp</code>, <code>ReadWritePaths</code>, <code>ReadOnlyPaths</code>, <code>InaccessiblePaths</code>, <code>TemporaryFileSystem</code>, <code>BindPaths</code></td><td>Que peut lire et écrire le service ?</td></tr><tr><td>3</td><td>Privilèges</td><td><code>NoNewPrivileges</code>, <code>User</code>, <code>DynamicUser</code>, <code>CapabilityBoundingSet</code>, <code>AmbientCapabilities</code>, <code>SecureBits</code>, <code>RestrictSUIDSGID</code></td><td>Que peut devenir le processus ?</td></tr><tr><td>4</td><td>Noyau et périphériques</td><td><code>ProtectKernelTunables</code>, <code>ProtectKernelModules</code>, <code>ProtectKernelLogs</code>, <code>ProtectClock</code>, <code>ProtectControlGroups</code>, <code>PrivateDevices</code>, <code>ProtectProc</code>, <code>ProcSubset</code></td><td>Que peut-il modifier dans le noyau ?</td></tr><tr><td>5</td><td>Namespaces et réseau</td><td><code>PrivateNetwork</code>, <code>PrivateUsers</code>, <code>RestrictNamespaces</code>, <code>RestrictAddressFamilies</code>, <code>IPAddressAllow</code>, <code>IPAddressDeny</code></td><td>Avec qui peut-il communiquer ?</td></tr><tr><td>6</td><td>Appels système</td><td><code>SystemCallFilter</code>, <code>SystemCallArchitectures</code>, <code>SystemCallErrorNumber</code></td><td>Quelles requêtes peut-il envoyer au noyau ?</td></tr><tr><td>7</td><td>Mémoire et divers</td><td><code>MemoryDenyWriteExecute</code>, <code>LockPersonality</code>, <code>RestrictRealtime</code>, <code>UMask</code>, <code>ProtectHostname</code>, <code>RemoveIPC</code>, <code>KeyringMode</code></td><td>Quels tours de passe-passe lui restent possibles ?</td></tr><tr><td>8</td><td>Toute la stack</td><td>Une matrice de chaque directive face à chaque service</td><td>Que dois-je appliquer où ?</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Ma stack, et par où commencer</h2>



<p class="wp-block-paragraph">Le durcissement demande un modèle de menace, même simple. Je pars du principe qu&rsquo;un attaquant a déjà pris le contrôle du processus d&rsquo;un service, et je me demande ce que ce processus pourrait faire ensuite : lire des secrets, écrire un mécanisme de persistance, joindre d&rsquo;autres services ou obtenir plus de privilèges. Chaque directive retire l&rsquo;une de ces options.</p>



<p class="wp-block-paragraph">Mes serveurs font tourner une stack auto-hébergée typique, que les parties suivantes couvriront service par service :</p>



<ul class="wp-block-list"><li>un reverse proxy devant chaque application publique, avec son bouncer CrowdSec ;</li><li>des applications PHP-FPM, ainsi que des applications Node.js, Python, Go et .NET (un serveur multimédia et un serveur de bibliothèque) ;</li><li>MariaDB, PostgreSQL et Valkey, avec pgBackRest pour les sauvegardes PostgreSQL ;</li><li>un serveur de messagerie, un serveur SFTP et WebDAV, et une messagerie d&rsquo;équipe ;</li><li>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 ;</li><li>un VPN et une console d&rsquo;administration web.</li></ul>



<p class="wp-block-paragraph">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&rsquo;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.</p>



<h2 class="wp-block-heading">Ce que je retiens</h2>



<ul class="wp-block-list"><li><strong>Mesurez avant de changer.</strong> systemd-analyze security donne une note et une liste de tâches, sans rien modifier du service.</li><li><strong>Utilisez des drop-ins.</strong> Ils résistent mieux aux mises à jour de paquets qu&rsquo;une modification du fichier du paquet, et en supprimer un suffit pour revenir en arrière.</li><li><strong>Redémarrez après un changement.</strong> Un simple rechargement laisse un processus déjà lancé dans son ancien sandbox.</li><li><strong>Changez une directive à la fois</strong> et testez le vrai travail du service.</li><li><strong>Voyez la note comme une boussole.</strong> Elle ne compte que ce que systemd restreint lui-même.</li><li><strong>Testez les affirmations.</strong> Chaque résultat de cet article vient d&rsquo;une exécution réelle ; lancez le lab vous-même et comparez.</li></ul>



<h2 class="wp-block-heading">Sources</h2>



<ul class="wp-block-list"><li>systemd-analyze(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html">page de manuel freedesktop.org</a></li><li>systemd.unit(5) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html">page de manuel freedesktop.org</a></li><li>systemd.exec(5) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html">page de manuel freedesktop.org</a></li><li>systemd.service(5) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html">page de manuel freedesktop.org</a></li><li>systemd.resource-control(5) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html">page de manuel freedesktop.org</a></li><li>systemctl(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemctl.html">page de manuel freedesktop.org</a></li><li>systemd-run(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd-run.html">page de manuel freedesktop.org</a></li><li>journalctl(1) : <a href="https://www.freedesktop.org/software/systemd/man/latest/journalctl.html">page de manuel freedesktop.org</a></li></ul>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=2975">Sous le capot des failles d’octobre 2026 : un octet de trop, un pointeur oublié, une page partagée</a></li><li><a href="https://blog.neurozone.fr/?p=2930">ZDI-CAN-33367 : quand une copie évitée écrit dans un fichier en lecture seule</a></li><li><a href="https://blog.neurozone.fr/?p=1419">CrowdSec 1.8 sur Debian (1/3) : quitter le paquet de la distribution</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/11/07/systemd-analyze-security-mesurer-avant-de-durcir/">Durcir systemd, directive par directive : mesurer avec systemd-analyze security</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/07/systemd-analyze-security-mesurer-avant-de-durcir/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">4897</post-id>	</item>
		<item>
		<title>Pas à pas : installer CrowdSec, bloquer au pare-feu et ne jamais se bannir</title>
		<link>https://blog.neurozone.fr/2026/11/06/crowdsec-installer-bloquer-pare-feu-bouncer/</link>
					<comments>https://blog.neurozone.fr/2026/11/06/crowdsec-installer-bloquer-pare-feu-bouncer/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Fri, 06 Nov 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[CrowdSec]]></category>
		<category><![CDATA[Français]]></category>
		<category><![CDATA[nftables]]></category>
		<category><![CDATA[pas à pas]]></category>
		<category><![CDATA[Rocky Linux]]></category>
		<category><![CDATA[sécurité]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=2046</guid>

					<description><![CDATA[<p>Installer CrowdSec 1.8 sur Rocky Linux : collections, journaux, liste d'autorisation pour ne jamais se bannir, bouncer pare-feu, liste communautaire.</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/06/crowdsec-installer-bloquer-pare-feu-bouncer/">Pas à pas : installer CrowdSec, bloquer au pare-feu et ne jamais se bannir</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">CrowdSec est souvent présenté comme « un fail2ban moderne ». C&rsquo;est vrai en partie : lui aussi lit les journaux et bannit les adresses agressives. Mais il ajoute deux choses que fail2ban n&rsquo;a pas. D&rsquo;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&rsquo;elle voit, et reçoit en échange une liste d&rsquo;adresses déjà repérées ailleurs, bloquées avant même qu&rsquo;elles ne vous atteignent.</p>



<p class="wp-block-paragraph">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&rsquo;on ne le croit.</p>



<figure class="wp-block-image size-full"><a href="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?ssl=1"><img data-recalc-dims="1" loading="lazy" decoding="async" width="629" height="300" src="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=629%2C300&#038;ssl=1" alt="Schéma du parcours CrowdSec : journaux, agent, API locale, bouncer pare-feu, liste communautaire et liste blanche" class="wp-image-4573" srcset="https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?w=1720&amp;ssl=1 1720w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=300%2C143&amp;ssl=1 300w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=1024%2C488&amp;ssl=1 1024w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=768%2C366&amp;ssl=1 768w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=1536%2C732&amp;ssl=1 1536w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=900%2C429&amp;ssl=1 900w, https://i0.wp.com/blog.neurozone.fr/wp-content/uploads/2026/11/pas-a-pas-installer-et-configurer-crowdsec-avec-son-bouncer-_schema_fr.png?resize=1280%2C610&amp;ssl=1 1280w" sizes="auto, (max-width: 629px) 100vw, 629px" /></a><figcaption class="wp-element-caption">De la ligne de journal au blocage, et comment ne jamais se bannir soi-même.</figcaption></figure>



<h2 class="wp-block-heading">Les pièces du puzzle</h2>



<figure class="wp-block-table"><table><thead><tr><th>Élément</th><th>Rôle</th></tr></thead><tbody><tr><td>l&rsquo;agent (<code>crowdsec</code>)</td><td>lit les journaux, les découpe (parseurs), les compare à des scénarios, émet des alertes</td></tr><tr><td>l&rsquo;API locale (LAPI)</td><td>stocke les alertes et transforme celles qui le méritent en <em>décisions</em> (bannir telle adresse pendant tant de temps)</td></tr><tr><td>les bouncers</td><td>appliquent les décisions : pare-feu (nftables), serveur web, application</td></tr><tr><td>l&rsquo;API centrale (CAPI)</td><td>reçoit vos signalements et vous renvoie une liste de blocage communautaire</td></tr><tr><td>le Hub</td><td>le catalogue de collections, parseurs, scénarios, installés par <code>cscli</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Point important : <strong>l&rsquo;agent ne bloque rien</strong>. Sans bouncer, CrowdSec détecte et prend des décisions, mais personne ne les applique. C&rsquo;est l&rsquo;erreur la plus fréquente lors d&rsquo;une première installation.</p>



<h2 class="wp-block-heading">Étape 1 : installer</h2>



<p class="wp-block-paragraph">CrowdSec publie ses paquets dans un dépôt packagecloud. Déclarez-le dans <code>/etc/yum.repos.d/</code> avec sa clé GPG, puis :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">dnf install crowdsec crowdsec-firewall-bouncer-nftables
systemctl enable --now crowdsec
cscli version</pre>



<p class="wp-block-paragraph">À l&rsquo;installation, le paquet détecte les services présents (sshd, nginx…) et installe les collections correspondantes. Vérifiez ce qu&rsquo;il a choisi plutôt que de lui faire confiance : <code>cscli collections list</code>.</p>



<p class="wp-block-paragraph">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&rsquo;exécuter avant que l&rsquo;API locale soit prête et échouer à s&rsquo;enregistrer. Installez l&rsquo;agent, attendez qu&rsquo;il démarre, puis le bouncer.</p>



<h2 class="wp-block-heading">Étape 2 : les collections</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cscli collections install crowdsecurity/linux crowdsecurity/sshd crowdsecurity/nginx
cscli collections list
systemctl reload crowdsec</pre>



<p class="wp-block-paragraph">Une collection regroupe des parseurs (qui savent lire un format de journal) et des scénarios (qui reconnaissent un comportement). <code>crowdsecurity/nginx</code> apporte ainsi le parseur des journaux nginx et une dizaine de scénarios : balayage de chemins, sondes de fichiers sensibles, recherche de pages d&rsquo;administration, robots agressifs. Les collections se mettent à jour avec <code>cscli hub update && cscli hub upgrade</code>, à planifier chaque jour.</p>



<h2 class="wp-block-heading">Étape 3 : dire à CrowdSec quoi lire</h2>



<p class="wp-block-paragraph">La configuration d&rsquo;acquisition liste les sources et leur type, qui détermine le parseur utilisé :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml"># /etc/crowdsec/acquis.yaml
filenames:
  - /var/log/secure
labels:
  type: syslog
---
filenames:
  - /var/log/messages
labels:
  type: syslog
---
# /etc/crowdsec/acquis.d/nginx.yaml
filenames:
  - /var/log/nginx/*.log
labels:
  type: nginx</pre>



<ul class="wp-block-list"><li><strong>labels.type</strong> : <code>syslog</code> pour les journaux système (sshd y écrit sur Rocky Linux), <code>nginx</code> pour les journaux d&rsquo;accès et d&rsquo;erreur ;</li><li><strong>une source, une seule fois</strong> : le piège classique est de lister les journaux nginx à la fois dans <code>acquis.yaml</code> et dans <code>acquis.d/nginx.yaml</code>. Chaque ligne est alors lue deux fois, chaque compteur double, et les bannissements tombent deux fois plus vite. Même chose avec une source <code>journalctl</code> ajoutée par-dessus un fichier qui contient déjà les mêmes lignes ;</li><li><strong>les jokers</strong> (<code>*.log</code>) suivent automatiquement les nouveaux sites.</li></ul>



<p class="wp-block-paragraph">Pour vérifier que les lignes sont bien lues et comprises :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cscli metrics show acquisition parsers
# « Lines read » augmente, « Lines parsed » aussi ; beaucoup de « unparsed » = mauvais type</pre>



<h2 class="wp-block-heading">Étape 4 : ne jamais se bannir soi-même</h2>



<p class="wp-block-paragraph">C&rsquo;est la partie à ne pas sauter. CrowdSec n&rsquo;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&rsquo;est votre propre serveur que le pare-feu rejette. J&rsquo;ai vécu les deux : un serveur qui s&rsquo;est banni lui-même pour « sondage HTTP », et un autre qui a banni l&rsquo;adresse de mon poste d&rsquo;administration pendant que je vérifiais des clés SSH.</p>



<p class="wp-block-paragraph">La parade est une liste blanche locale, en parseur d&rsquo;enrichissement :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml"># /etc/crowdsec/parsers/s02-enrich/local-whitelist.yaml
name: local/whitelists
description: &quot;Adresses de confiance, jamais bannies&quot;
whitelist:
  reason: &quot;mes serveurs et mon adresse d&#x27;administration&quot;
  ip:
    - &quot;203.0.113.25&quot;
    - &quot;203.0.113.40&quot;
    - &quot;2001:db8::25&quot;
    - &quot;198.51.100.10&quot;</pre>



<p class="wp-block-paragraph">Mettez-y les adresses publiques de chacun de vos serveurs (IPv4 et IPv6), celle de votre accès d&rsquo;administration, et déployez la même liste <strong>sur toutes vos machines</strong> : une infrastructure personnelle où chaque serveur peut bannir les autres finit toujours par le faire. Une liste blanche s&rsquo;applique avant les scénarios, donc aucune alerte n&rsquo;est créée. Elle ne supprime pas une décision déjà prise : <code>cscli decisions delete --ip …</code> s&rsquo;en charge.</p>



<h2 class="wp-block-heading">Étape 5 : appliquer les décisions au pare-feu</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cscli bouncers add firewall-bouncer     # affiche une clé à copier dans la configuration du bouncer</pre>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml"># /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml (extrait)
mode: nftables
update_frequency: 10s
api_url: http://127.0.0.1:8081/
api_key: &lt;clé générée par cscli bouncers add&gt;
disable_ipv6: false
deny_action: DROP
supported_decisions_types:
  - ban
nftables:
  ipv4:
    enabled: true
    table: crowdsec
  ipv6:
    enabled: true
    table: crowdsec6</pre>



<ul class="wp-block-list"><li><strong>mode: nftables</strong> : 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&rsquo;entrées ;</li><li><strong>update_frequency</strong> : toutes les 10 secondes, le bouncer récupère les nouvelles décisions ;</li><li><strong>deny_action: DROP</strong> : le paquet est jeté sans réponse, l&rsquo;attaquant n&rsquo;apprend rien ;</li><li><strong>IPv6</strong> : à activer explicitement ; un attaquant qui dispose des deux familles d&rsquo;adresses passera sinon par celle qui n&rsquo;est pas filtrée.</li></ul>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">systemctl enable --now crowdsec-firewall-bouncer
cscli bouncers list                    # « Last API pull » récent
nft list set ip crowdsec crowdsec-blacklists-cscli | head</pre>



<h2 class="wp-block-heading">Étape 6 : la liste communautaire</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cscli capi register
cscli console enroll &lt;clé de la console&gt;   # facultatif : tableau de bord web
cscli capi status
systemctl restart crowdsec</pre>



<p class="wp-block-paragraph">Une fois inscrit, l&rsquo;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&rsquo;environ quinze mille adresses, renouvelée régulièrement. C&rsquo;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. <code>cscli decisions list --origin CAPI | wc -l</code> donne la taille de la liste reçue.</p>



<h2 class="wp-block-heading">Simuler avant de bannir</h2>



<p class="wp-block-paragraph">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&rsquo;est le moyen de voir ce que CrowdSec <em>aurait</em> bloqué pendant une semaine, avant de lui confier la porte.</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">cscli simulation enable crowdsecurity/http-crawl-non_statics
cscli simulation status
cscli alerts list --since 24h</pre>



<h2 class="wp-block-heading">Exploiter au quotidien</h2>



<pre class="EnlighterJSRAW" data-enlighter-language="shell">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 &#x27;manuel&#x27;
cscli metrics                           # sources, parseurs, scénarios, bouncers</pre>



<p class="wp-block-paragraph">La durée de bannissement par défaut est de quatre heures ; un profil (<code>profiles.yaml</code>) permet de l&rsquo;allonger pour les récidivistes ou de bannir une plage entière plutôt qu&rsquo;une adresse.</p>


<!-- nz-enrich:crowdsec-real -->


<h2 class="wp-block-heading">Une heure d&rsquo;un vrai serveur</h2>



<p class="wp-block-paragraph">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&rsquo;abord, ce que l&rsquo;agent a lu :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># cscli metrics show acquisition          (extrait)
| Source                                      | Lines read | Lines parsed | Lines unparsed | Poured to bucket | Whitelisted |
| appsec:appsec                               | 136        | 136          | -              | 136              | -           |
| file:/var/log/messages                      | 1.31k      | -            | 1.31k          | -                | -           |
| file:/var/log/nginx/access.blog...log       | 716        | 716          | -              | 42               | 528         |
| file:/var/log/nginx/access.log              | 8.03k      | 8.03k        | -              | 4.29k            | 1.76k       |</pre>



<ul class="wp-block-list"><li><strong>Lines parsed</strong> égal à <strong>Lines read</strong> sur les journaux web : l&rsquo;analyseur nginx comprend chaque ligne. Si un journal affiche tout en <em>unparsed</em>, la collection manque ou le format du journal a été personnalisé.</li><li><strong>/var/log/messages : 1,31k non analysées</strong> : c&rsquo;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&rsquo;authentification sont dans /var/log/secure, lu par sa propre entrée : 3 020 lignes lues, 39 analysées, et c&rsquo;est bien le but, puisque seules les connexions échouées et événements semblables intéressent CrowdSec.</li><li><strong>Whitelisted</strong> : les requêtes venant de mes propres adresses et des sondes de supervision, ignorées volontairement (étape 4).</li><li><strong>Poured to bucket</strong> : 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.</li></ul>



<p class="wp-block-paragraph">Puis le côté application des décisions, la seule preuve que la protection est réelle :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="shell"># 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</pre>



<p class="wp-block-paragraph">En 75 minutes, 447 paquets ont été jetés avant d&rsquo;atteindre le moindre service : 387 venaient d&rsquo;adresses de la liste communautaire, qui n&rsquo;avaient jamais touché ce serveur, et 60 des 8 adresses bannies par mon propre serveur. La colonne <em>Last API pull</em>, 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&rsquo;appliquer une liste figée.</p>



<h2 class="wp-block-heading">Allonger les bannissements : profiles.yaml</h2>



<p class="wp-block-paragraph">Le profil décide de ce qu&rsquo;une alerte devient. Celui par défaut bannit l&rsquo;adresse quatre heures. On peut le rendre progressif, comme avec fail2ban, en s&rsquo;appuyant sur le nombre de décisions déjà prises contre cette adresse :</p>



<pre class="EnlighterJSRAW" data-enlighter-language="yaml"># /etc/crowdsec/profiles.yaml
name: default_ip_remediation
filters:
  - Alert.Remediation == true &amp;&amp; Alert.GetScope() == &quot;Ip&quot;
decisions:
  - type: ban
    duration: 4h
duration_expr: Sprintf(&#x27;%dh&#x27;, (GetDecisionsCount(Alert.GetValue()) + 1) * 4)
on_success: break</pre>



<ul class="wp-block-list"><li><strong>filters</strong> : le profil ne s&rsquo;applique qu&rsquo;aux alertes qui demandent une sanction et visent une adresse IP ;</li><li><strong>duration_expr</strong> : 4 heures la première fois, 8 la deuxième, 12 la troisième… Le calcul utilise l&rsquo;historique de l&rsquo;API locale, il survit donc aux redémarrages ;</li><li><strong>on_success: break</strong> : les profils suivants ne sont pas évalués. L&rsquo;ordre des profils dans le fichier compte.</li></ul>



<h2 class="wp-block-heading">Les pannes fréquentes</h2>



<h3 class="wp-block-heading">Rien n&rsquo;est jamais banni</h3>



<p class="wp-block-paragraph">Vérifiez dans l&rsquo;ordre : <code>cscli metrics show acquisition</code> (les lignes sont-elles lues ?), <code>cscli metrics show parsers</code> (sont-elles comprises ?), <code>cscli alerts list</code> (des scénarios se déclenchent-ils ?), <code>cscli bouncers list</code> (le bouncer récupère-t-il les décisions ?). La panne se trouve presque toujours à la première étape qui reste à zéro.</p>



<h3 class="wp-block-heading">Le bouncer ne démarre pas, « unauthorized »</h3>



<p class="wp-block-paragraph">La clé du bouncer ne correspond à aucune clé de l&rsquo;API locale, typiquement après une réinstallation qui a vidé la base. Supprimez l&rsquo;entrée (<code>cscli bouncers delete</code>), recréez-la et recopiez la clé.</p>



<h3 class="wp-block-heading">L&rsquo;API locale écoute sur un port déjà pris</h3>



<p class="wp-block-paragraph">Par défaut, l&rsquo;API locale écoute sur 8080, port très demandé. Changez-le dans <code>config.yaml</code> (<code>api.server.listen_uri</code>) <strong>et</strong> dans <code>local_api_credentials.yaml</code>, puis dans la configuration de chaque bouncer. Oublier l&rsquo;un des trois produit un agent qui démarre mais dont les outils ne parlent plus à personne.</p>



<h3 class="wp-block-heading">Les parseurs ne comprennent pas les journaux SSH</h3>



<p class="wp-block-paragraph">Les versions récentes d&rsquo;OpenSSH journalisent sous le nom <code>sshd-session</code> au lieu de <code>sshd</code>. Si les parseurs ne suivent pas, les échecs d&rsquo;authentification ne sont plus reconnus du tout. Un <code>cscli hub upgrade</code> règle généralement la question ; vérifiez avec <code>cscli explain --file /var/log/secure --type syslog</code>, qui montre, ligne par ligne, quel parseur l&rsquo;a acceptée ou rejetée.</p>



<h2 class="wp-block-heading">Mettre à jour sans surprise</h2>



<ul class="wp-block-list"><li>mettez à jour l&rsquo;agent, puis vérifiez <code>cscli metrics</code> et la liste des bouncers avant de toucher aux bouncers ;</li><li>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 ;</li><li>après une mise à jour du hub, un <code>cscli hub upgrade</code> peut remplacer un scénario que vous aviez modifié à la main. Les modifications locales vont dans des fichiers à vous, jamais dans ceux du hub.</li></ul>



<h2 class="wp-block-heading">Et fail2ban ?</h2>



<p class="wp-block-paragraph">Les deux cohabitent sans problème, et c&rsquo;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 (<code>fail2ban-client status</code> et <code>cscli decisions list</code>).</p>



<h2 class="wp-block-heading">Pour aller plus loin</h2>



<p class="wp-block-paragraph">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&rsquo;est l&rsquo;objet d&rsquo;un prochain tutoriel de cette série, avec OpenResty.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Ce billet fait partie d&rsquo;une série de tutoriels d&rsquo;administration système (tag « pas à pas »).</em><br>Série « Pas à pas », tutoriel 9/77. · <a href="https://blog.neurozone.fr/?p=2045">← Tutoriel précédent</a> · <a href="https://blog.neurozone.fr/?p=2047">Tutoriel suivant →</a> · <a href="https://hecate.neurozone.fr/?p=598">English version</a></p>



<h2 class="wp-block-heading">Articles liés</h2>



<ul class="wp-block-list"><li><a href="https://blog.neurozone.fr/?p=1419">CrowdSec 1.8 sur Debian : réinstaller proprement</a></li><li><a href="https://blog.neurozone.fr/?p=2886">La faille Brotli CVE-2026-111497</a></li><li><a href="https://blog.neurozone.fr/?p=2975">Sous le capot des failles d&rsquo;octobre 2026</a></li></ul>

<p>L’article <a href="https://blog.neurozone.fr/2026/11/06/crowdsec-installer-bloquer-pare-feu-bouncer/">Pas à pas : installer CrowdSec, bloquer au pare-feu et ne jamais se bannir</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/06/crowdsec-installer-bloquer-pare-feu-bouncer/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2046</post-id>	</item>
		<item>
		<title>Mon blog technique</title>
		<link>https://blog.neurozone.fr/2026/11/05/mon-blog-technique/</link>
					<comments>https://blog.neurozone.fr/2026/11/05/mon-blog-technique/#respond</comments>
		
		<dc:creator><![CDATA[Hécate Raven]]></dc:creator>
		<pubDate>Thu, 05 Nov 2026 09:22:46 +0000</pubDate>
				<category><![CDATA[Informatique]]></category>
		<category><![CDATA[carnet d'admin]]></category>
		<guid isPermaLink="false">https://blog.neurozone.fr/?p=5548</guid>

					<description><![CDATA[<p>Est situé ici, en anglais : https://hecate.neurozone.fr</p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/05/mon-blog-technique/">Mon blog technique</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Est situé ici, en anglais : <a href="https://hecate.neurozone.fr ">https://hecate.neurozone.fr </a></p>
<p>L’article <a href="https://blog.neurozone.fr/2026/11/05/mon-blog-technique/">Mon blog technique</a> est apparu en premier sur <a href="https://blog.neurozone.fr">Quelques pensées</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.neurozone.fr/2026/11/05/mon-blog-technique/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5548</post-id>	</item>
	</channel>
</rss>
