Quelques pensées

Terres dévastées

Vallée de montagne enneigée entre deux sommets rocheux, sous un ciel bleu
Protection du système de fichiers avec systemd : ProtectSystem true, full et strict, et les exceptions inscriptibles

Protection du système de fichiers avec systemd : durcissement, partie 2

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’une série de huit, « Durcissement systemd, directive par directive ». J’y traite les directives qui façonnent l’arborescence d’un service : ProtectSystem, ProtectHome, PrivateTmp, ReadWritePaths et leurs cousines.

Quand un service est compromis, ou simplement bogué, c’est dans l’arborescence que les dégâts se produisent. Un service qui ne peut pas écrire dans /etc ne peut pas réécrire sa propre configuration, et un service qui ne voit pas /home 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’explique donc ce qu’elle fait, ce qu’elle peut casser et comment elle touche ma propre pile. Aucune expérience de systemd n’est nécessaire : chaque étape vient avec la commande complète et un commentaire.

Banc d’essai. Chaque sortie de cet article vient d’une vraie exécution sur Rocky Linux 10.2 avec systemd 257, 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’un serveur classique. Je le signale chaque fois que cela compte. Mes fichiers et répertoires de test commencent par _sd2_, ce qui permet de les retrouver et de les supprimer facilement. Quand je coupe une ligne d’une sortie, je la remplace par [...], et je marque ce que je n’ai pas exécuté comme non testé.

Protection du système de fichiers avec systemd : ProtectSystem true, full et strict, et les exceptions inscriptibles
Les trois niveaux de ProtectSystem, et les moyens d’ouvrir une exception inscriptible.

Avant de commencer

Lisez d’abord cette courte liste. Elle vous évite de durcir un service de production du premier coup.

  • Une machine de test. Utilisez une machine virtuelle ou un serveur de rechange qui fait tourner systemd. Je n’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’ai pas vérifié.
  • **Un shell avec sudo.** Chaque commande qui modifie le système commence par sudo. Mon banc tournait en root, donc mes sorties viennent de commandes root.
  • Un service de démonstration. Le labo plus bas crée demo-hardening-fs.service, un service inoffensif qui essaie seulement de créer des fichiers vides. Vous ne touchez jamais à un vrai service.
  • Un retour en arrière. Chaque changement vit dans un fichier drop-in. systemctl revert le supprime, et le labo se termine par un nettoyage.
  • Un coup d’œil à votre version. Lancez systemctl --version. Je n’ai pu tester que la version 257 : si votre systemd rejette une directive, lisez son entrée dans la page de manuel systemd.exec.

Un petit glossaire

TermeCe que cela veut dire ici
Espace de noms de montageUne copie privée de la liste des systèmes de fichiers montés. Un changement fait à l’intérieur n’apparaît pas sur l’hôte.
Lecture seuleUn chemin où la lecture marche et où l’écriture échoue avec le message « Read-only file system ».
tmpfsUn système de fichiers gardé en mémoire. Il démarre vide et disparaît quand on le démonte.
Montage lié (bind mount)Le même répertoire rendu visible à un second endroit de l’arborescence.
Systèmes de fichiers d’API/proc, /sys et /dev, fournis par le noyau. Un service en a besoin pour tourner.
Drop-inUn petit fichier .conf dans un répertoire .d qui modifie une unité sans toucher au fichier d’origine.
DirectiveUn réglage d’un fichier d’unité, comme ProtectSystem=strict.
Unité transitoireUne unité temporaire que systemd-run crée à la volée. Elle disparaît quand elle s’arrête.
Utilisateur du serviceLe compte qui exécute le service, défini par User= dans l’unité.

En un coup d’œil

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.

DirectiveCe qu’elle faitCe qu’elle peut casserDans ma pile
ProtectSystem=Monte /usr, puis /etc, puis toute l’arborescence en lecture seuleToute écriture hors de vos exceptionsKavita, Navidrome, mktxp, mox et Etherpad tournent avec strict
ProtectHome=Cache ou fige /home, /root et /run/userLes programmes qui gardent caches ou clés dans leur répertoire personnelKavita utilise yes ; Oxidized a eu besoin de read-only
PrivateTmp=Donne au service son propre /tmp et /var/tmpLes services qui partagent des fichiers par /tmpKavita ouvre plutôt le /tmp de l’hôte
PrivateMounts=Place le service dans son propre espace de noms de montageLes montages que le service fait pour d’autresMes notes n’en parlent pas
ReadWritePaths=Ouvre des exceptions inscriptiblesUn chemin absent arrête le serviceLe correctif le plus fréquent de mes notes
ReadOnlyPaths=Fige un chemin dans une zone inscriptibleUne écriture à cet endroit échoueMes notes n’en parlent pas
InaccessiblePaths=Cache complètement un cheminToute lecture à cet endroit échoueMes notes n’en parlent pas
ExecPaths= et NoExecPaths=Autorisent ou interdisent d’exécuter des programmes depuis un cheminLes binaires d’un chemin interdit (erreur 203)Mes notes n’en parlent pas
BindPaths= et BindReadOnlyPaths=Montrent un répertoire à un second endroitUne mauvaise source ou destinationMes notes n’en parlent pas
TemporaryFileSystem=Remplace un répertoire par un tmpfs videTout ce que ce répertoire contenaitMes notes n’en parlent pas
StateDirectory= et cieCréent des répertoires gérés avec le bon propriétaireDes surprises de propriétaire et de modeNavidrome liste aujourd’hui ces chemins à la main
RestrictFileSystems=N’autorise que certains types de systèmes de fichiersDemande une fonction du noyau (BPF LSM)Mes notes n’en parlent pas
RootDirectory=, RootImage=Lancent le service dans un chroot ou une image disqueTout ce que le service s’attend à trouverMes notes n’en parlent pas

Protection du système de fichiers : comment un service obtient sa propre vue

Quand systemd démarre un service, il peut d’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’autres et ajoute des répertoires temporaires privés. L’hôte ne change pas. Seuls les processus de ce service voient la différence.

Trois conséquences comptent pour un débutant. D’abord, les règles s’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’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.

La méthode est toujours la même. On ferme d’abord une grande zone, puis on ouvre les quelques exceptions dont le service a besoin. ProtectSystem, ProtectHome et PrivateTmp ferment. Les directives de chemins et la famille *Directory= ouvrent. La suite de l’article suit cet ordre.

ProtectSystem : trois niveaux de lecture seule

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

J’ai lancé une sonde par valeur. Elle essaie de créer un fichier vide à quatre endroits. L’option -P de systemd-run affiche la sortie dans votre terminal, donc vous voyez le résultat tout de suite. Voici la commande pour strict :

# 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>&1); then echo "$d: write OK"
    else echo "$d: write refused -> ${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

Et voici sa sortie :

/usr: write refused -> Read-only file system
/etc: write refused -> Read-only file system
/var/tmp: write refused -> Read-only file system
/tmp: write refused -> Read-only file system

J’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 :

Valeur/usr/etc/var/tmp/tmp
noécriture OKécriture OKécriture OKécriture OK
yesrefuséeécriture OKécriture OKécriture OK
fullrefuséerefuséeécriture OKécriture OK
strictrefuséerefuséerefuséerefusée
strict + PrivateTmp=yesrefuséerefuséeécriture OKécriture OK
strict + ReadWritePaths=/etcrefuséeécriture OKrefuséerefusée

Deux leçons ressortent de ce tableau. D’abord, strict fige aussi /tmp et /var/tmp, et beaucoup de programmes ne démarrent pas sans répertoire temporaire. Donc strict va en général avec PrivateTmp=yes. Ensuite, une exception de ReadWritePaths= n’ouvre que le chemin nommé : avec /etc ouvert, /tmp est resté en lecture seule.

Une surprise : /home et /root restent inscriptibles sous strict

Le manuel décrit strict comme une arborescence en lecture seule, hors systèmes de fichiers d’API. Mon banc le contredit pour deux répertoires. J’ai lancé une seconde sonde :

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>&1); then echo "$d: write OK"
    else echo "$d: write refused -> ${err##*: }"; fi
  done'
sudo rm -f /home/_sd2_diag /root/_sd2_diag
/home: write OK
/root: write OK
/opt: write refused -> Read-only file system
/srv: write refused -> Read-only file system
/var/lib: write refused -> Read-only file system

Dans le service, findmnt montre la différence : /home est un montage séparé en lecture-écriture, alors que le montage qui contient /etc est en lecture seule.

# 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
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

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’ai pas creusé plus loin. La règle pratique est simple : ne comptez jamais sur strict pour protéger les répertoires personnels. Ajoutez ProtectHome= et vérifiez le résultat sur votre propre serveur avec la sonde ci-dessus.

Quelle valeur choisir

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

ProtectHome : cacher, figer ou remplacer /home

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

Protection du système de fichiers et répertoires personnels : ProtectHome true, read-only et tmpfs
Ce qu’un service voit dans /home, /root et /run/user pour chaque valeur de ProtectHome.

J’ai créé un faux répertoire personnel avec un fichier, puis lancé une sonde par valeur. Changez la valeur après ProtectHome= pour la répéter :

# Préparer un faux répertoire personnel avec un fichier
sudo mkdir -p /home/_sd2_user && echo hello | sudo tee /home/_sd2_user/hello.txt > /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>&1
         echo "cat hello.txt:"; cat /home/_sd2_user/hello.txt 2>&1
         if err=$(touch /home/_sd2_user/new.txt 2>&1); then echo "write: OK"
         else echo "write: refused -> ${err##*: }"; fi'
# Supprimer le faux répertoire personnel
sudo rm -rf /home/_sd2_user

Avec yes, le service ne voit rien :

ls -A /home:
cat hello.txt:
cat: /home/_sd2_user/hello.txt: No such file or directory
write: refused -> No such file or directory

Avec read-only, 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 :

ls -A /home:
[...]
_sd2_user
cat hello.txt:
hello
write: refused -> Read-only file system
ValeurListe du répertoireLecture du fichierÉcriture
novisibleOKOK
yesvideNo such file or directoryNo such file or directory
read-onlyvisibleOKRead-only file system
tmpfsvideNo such file or directoryNo such file or directory
yes + ReadWritePaths= sur le sous-répertoirevideNo such file or directoryNo such file or directory
read-only + ReadWritePaths= sur le sous-répertoirevisibleOKOK
tmpfs + BindPaths= sur le sous-répertoireseulement ce sous-répertoireOKOK

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

Nommer le sous-répertoire, pas /home

Un second piège : ReadWritePaths= demande le sous-répertoire exact. J’ai essayé le parent d’abord, puis le sous-répertoire, tous deux avec ProtectHome=read-only. La sonde essaie d’écrire aux deux endroits :

# 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>&1); then echo "  $d: write OK"
      else echo "  $d: write refused -> ${err##*: }"; fi
    done'
done
sudo rm -rf /home/_sd2_sub /home/_sd2_p
ReadWritePaths=/home
  /home: write refused -> Read-only file system
  /home/_sd2_sub: write refused -> Read-only file system
ReadWritePaths=/home/_sd2_sub
  /home: write refused -> Read-only file system
  /home/_sd2_sub: write OK

Lister /home n’a rien ouvert, pas même le sous-répertoire qu’il contient. Lister le sous-répertoire l’a ouvert, et /home est resté en lecture seule.

Quand le programme veut son répertoire personnel

Beaucoup de programmes écrivent des caches ou des clés sous $HOME. Si ProtectHome=yes le cache, ils échouent de façon déroutante. Deux cas réels de mes notes : Kavita n’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’outil de build pnpm d’Etherpad ne trouvait pas non plus de répertoire personnel inscriptible sous strict. Dans les deux cas, je n’ai pas élargi le bac à sable. J’ai défini Environment=HOME= vers un répertoire déjà inscriptible, dans le dossier de l’application. Cela garde la protection et satisfait le programme.

PrivateTmp et PrivateMounts : des espaces privés

PrivateTmp=yes donne au service son propre /tmp et /var/tmp. 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’attaques qui reposent sur des noms de fichiers prévisibles dans /tmp. J’ai lancé la même commande sans, puis avec. Un fichier nommé _sd2_host_file existait déjà dans le /tmp de l’hôte :

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 && 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 && 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
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

Sans PrivateTmp, le service voit le fichier de l’hôte. Avec lui, le service voit un /tmp vide, crée son propre fichier et ne laisse rien derrière lui. La dernière ligne vient de l’hôte : il a toujours son fichier d’origine et rien d’autre. Rappel : strict met /tmp en lecture seule, mais PrivateTmp=yes en redonne un privé et inscriptible.

Une troisième valeur existe dans les versions récentes, PrivateTmp=disconnected. J’ai seulement testé si elle est acceptée. Via systemd-run -p, systemd 257 l’a refusée sur mon banc :

# La troisième valeur, via systemd-run
sudo systemd-run --quiet --pipe --wait --collect -p PrivateTmp=disconnected true
Failed to parse PrivateTmp: disconnected

Écrite dans un fichier d’unité, la même valeur a été acceptée et le service one-shot est allé au bout sans se plaindre. Je n’ai pas testé ce qu’elle change : son comportement est non testé.

Dans ma pile, Kavita ouvre le /tmp de l’hôte avec ReadWritePaths= au lieu d’utiliser PrivateTmp=yes. Un /tmp privé serait plus strict. Je ne l’ai pas encore essayé.

PrivateMounts

PrivateMounts=yes place le service dans son propre espace de noms de montage et ne fait rien d’autre. Les montages que le service réalise ne fuient pas vers l’hôte. Je l’ai testé avec un tmpfs monté depuis l’intérieur du service :

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 && touch /mnt/_sd2_pm/x && findmnt -n -o TARGET,FSTYPE /mnt/_sd2_pm'
findmnt /mnt/_sd2_pm || echo "not mounted on the host"
sudo rmdir /mnt/_sd2_pm
/mnt/_sd2_pm tmpfs
not mounted on the host

La plupart des directives de cet article activent d’elles-mêmes un espace de noms de montage privé, donc il est rare de devoir écrire PrivateMounts=yes. Il compte pour un service qui monte des systèmes de fichiers et ne doit pas perturber l’hôte.

ReadWritePaths, ReadOnlyPaths et InaccessiblePaths

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’additionnent. ReadWritePaths= rend un chemin à nouveau inscriptible sous ProtectSystem. ReadOnlyPaths= fige un chemin dans une zone par ailleurs inscriptible. InaccessiblePaths= le cache. J’ai testé les deux dernières sur un répertoire jetable :

D=/var/tmp/_sd2_dir
mkdir -p $D && echo data > $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>&1); then echo "write in dir: OK"
         else echo "write in dir: refused -> ${err##*: }"; fi
         touch /var/tmp/_sd2_other && 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>&1; echo "---"; cat /var/tmp/_sd2_dir/file.txt 2>&1'
rm -rf $D /var/tmp/_sd2_other
data
write in dir: refused -> Read-only file system
write elsewhere in /var/tmp: OK
---
cat: /var/tmp/_sd2_dir/file.txt: No such file or directory

Le premier essai montre ReadOnlyPaths= : le fichier est lisible, un nouveau fichier dans le répertoire est refusé, et le reste de /var/tmp reste inscriptible. Le second montre InaccessiblePaths= : le répertoire paraît vide, puis le fichier ne peut pas être lu. Il existe toujours sur l’hôte.

Le tiret initial

Un chemin qui n’existe pas au démarrage du service arrête le service. Un tiret devant le chemin demande à systemd de l’ignorer s’il est absent :

# 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: $?"
exit code: 226
exit code: 0

Le code de sortie 226 est la façon dont systemd signale un échec pendant la construction de l’espace de noms. Sans le tiret, le service n’a même pas démarré.

Trouver le chemin manquant

Dans mes notes, ReadWritePaths= 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 config sous son répertoire de travail, un chemin que je n’avais pas listé. Il réécrit aussi wwwroot/index.html à chaque démarrage, ce qui ne produisait qu’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 journalctl -u UNITÉ | grep -i 'read-only file system', ajouter le seul chemin qui apparaît, puis recommencer. Je n’ai pas lancé cette commande exacte sur le banc, mais elle ne fait que lire le journal.

ExecPaths et NoExecPaths : où les programmes peuvent tourner

NoExecPaths= interdit d’exécuter des programmes depuis un chemin, et ExecPaths= creuse une exception. Une application web qui accepte des envois de fichiers est un bon cas d’usage : rien de ce qui est envoyé ne doit jamais s’exécuter. J’ai copié true dans un répertoire jetable et essayé de le lancer trois fois :

D=/var/tmp/_sd2_dir
mkdir -p $D && 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
no restriction, exit code: 0
NoExecPaths, exit code: 203
with ExecPaths, exit code: 0

Le code de sortie 203 signifie que systemd n’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’envoi de mes applications web.

BindPaths, BindReadOnlyPaths et TemporaryFileSystem

BindPaths= montre un répertoire à un second endroit, au format SOURCE:DESTINATION. BindReadOnlyPaths= fait pareil, mais en lecture seule. J’ai créé un répertoire avec un fichier et je l’ai lié deux fois :

S=/var/tmp/_sd2_src
mkdir -p $S && echo hi > $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 > /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>&1); then echo "write OK"; else echo "write refused -> ${err##*: }"; fi'
# Systemd a créé le répertoire de destination sur l'hôte
ls -ld /mnt/_sd2_dst
sudo rmdir /mnt/_sd2_dst
hi.txt
write OK
hi.txt
new.txt
write refused -> Read-only file system
drwxr-xr-x 2 root root 4096 Nov  2 15:13 /mnt/_sd2_dst

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

TemporaryFileSystem= 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 /var/tmp est caché, puis un répertoire revient :

S=/var/tmp/_sd2_src
mkdir -p $S && echo hi > $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
ls -A /var/tmp:
ls -A /var/tmp:
_sd2_src
hi

Le premier listing est vide, et le second ne montre que _sd2_src, dont le fichier est lisible. C’est la forme la plus précise de la protection du système de fichiers, et aussi la plus laborieuse. Je ne l’utilise nulle part pour l’instant.

StateDirectory et les autres directives de répertoire

Cinq directives créent un répertoire géré pour le service et l’ouvrent en écriture, même sous ProtectSystem=strict. Systemd définit aussi une variable d’environnement avec le chemin.

DirectiveRépertoireVariableSupprimé à l’arrêt ?
StateDirectory=/var/lib/NOMSTATE_DIRECTORYnon
CacheDirectory=/var/cache/NOMCACHE_DIRECTORYnon
LogsDirectory=/var/log/NOMLOGS_DIRECTORYnon
RuntimeDirectory=/run/NOMRUNTIME_DIRECTORYoui
ConfigurationDirectory=/etc/NOMCONFIGURATION_DIRECTORYnon

J’ai lancé les cinq d’un coup avec l’utilisateur nobody, sous strict. La sonde affiche les variables, écrit dans chaque répertoire, puis liste ce qui reste sur l’hôte :

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>&1); then echo "$d: write OK"; else echo "$d: write refused -> ${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>&1; done
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

Les cinq variables existent et les cinq répertoires étaient inscriptibles sous strict. Une fois le service arrêté, quatre répertoires restent et appartiennent à l’utilisateur du service. Le répertoire d’exécution a disparu, car systemd le supprime à l’arrêt. Le mode par défaut est 755, et StateDirectoryMode= le change (les autres directives ont des réglages ...DirectoryMode= équivalents) :

# 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
/var/lib/sd2-demo 700 nobody:nobody

Un résultat diffère de mon attente. D’après ma lecture du manuel, ConfigurationDirectory= se distingue des quatre autres pour le propriétaire, et je m’attendais à root. Sur mon banc, le répertoire appartenait à nobody, comme les autres. Je l’ai lancé seul pour vérifier, d’abord avec User=nobody, puis sans aucun User= :

# 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
/etc/sd2-cfg 755 nobody:nobody
/etc/sd2-cfg 755 root:root

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

# 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

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

RestrictFileSystems : une liste blanche de types de systèmes de fichiers

RestrictFileSystems= limite les types de systèmes de fichiers qu’un service peut utiliser, par exemple ext4 xfs seulement, ou ~tmpfs pour interdire tmpfs. Le manuel dit qu’elle repose sur une fonction du noyau, le module de sécurité BPF. Mon banc ne semble pas l’avoir, donc j’ai testé ce qui se passe :

# 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>&1); then echo "write to tmpfs OK"; else echo "write to tmpfs refused -> ${err##*: }"; fi'
sudo rm -f /dev/shm/_sd2_rfs
cat: /sys/kernel/security/lsm: No such file or directory
write to tmpfs OK

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

RootDirectory, RootImage et MountAPIVFS

Ces directives vont plus loin que le reste de l’article : elles lancent le service dans une autre racine. RootDirectory= fonctionne comme un chroot, RootImage= monte une image disque comme racine, et MountAPIVFS=yes y ajoute /proc, /sys et /dev. 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’ai testé aucune des trois. Elles restent non testées ici, et mes services ne les utilisent pas.

Un flux de travail sûr pour tester chaque changement

La partie 1 de la série explique la méthode en détail. Voici la version courte appliquée à l’arborescence. Vous modifiez l’unité par un drop-in, redémarrez, lisez le journal et, si besoin, revenez en arrière :

# 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

Vous pouvez aussi essayer une directive sans aucun fichier. systemd-run -p démarre une unité temporaire avec les propriétés choisies, et systemctl show -p vous dit ce que systemd a réellement appliqué :

# 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
ReadWritePaths=/var/tmp
PrivateTmp=yes
ProtectSystem=strict
inactive

systemctl show affiche les propriétés dans son propre ordre, pas dans celui des options -p. Après systemctl stop, l’unité transitoire avait disparu et systemctl is-active répondait « inactive ».

Labo pratique : voir l’arborescence changer

Ce labo montre la protection du système de fichiers à l’œ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’unité :

# 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 > /dev/null <<'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>&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 > /dev/null <<'EOF'
[Unit]
Description=Filesystem protection demo (harmless)

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

Définissez ensuite une petite fonction d’aide. Elle remplace le drop-in par le texte que vous lui donnez, lance le service et affiche le journal :

# 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 > /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
}

Lancez la référence et la première expérience :

# E0 : aucun drop-in, rien n'est restreint
: | try
# E1 : toute l'arborescence en lecture seule
printf '[Service]\nProtectSystem=strict\n' | try

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

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).

Ajoutez maintenant PrivateTmp=yes (E2), puis ouvrez /etc (E3), et lisez l’unité fusionnée :

# 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
# /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

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

# 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

En E4, ProtectHome=yes cache tout, et le répertoire de démonstration est absent :

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).

En E6, read-only plus le sous-répertoire exact est la combinaison qui marche :

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).

Ce tableau résume les huit essais. Chaque cellule est une réponse des vrais journaux :

Essai/etc/home/home/demo-hardening-dir/tmp/var/tmp
E0 aucun drop-inOKOKOKOKOK
E1 ProtectSystem=strictrefuséeOKOKrefuséerefusée
E2 + PrivateTmp=yesrefuséeOKOKOKOK
E3 + ReadWritePaths=/etcOKOKOKOKOK
E4 + ProtectHome=yesrefuséerefuséeabsentOKOK
E5 + ProtectHome=read-onlyrefuséerefuséerefuséeOKOK
E6 read-only + sous-répertoire inscriptiblerefuséerefuséeOKOKOK
E7 yes + sous-répertoire inscriptiblerefuséerefuséeabsentOKOK

La ligne E7 est de nouveau le piège : le sous-répertoire inscriptible est invisible sous ProtectHome=yes. Enfin, annulez tout. La première commande montre ce que systemctl revert supprime :

# 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"
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

La dernière ligne confirme qu’aucun fichier d’unité ne reste. J’ai aussi vérifié que le script de démonstration, le répertoire de démonstration et les fichiers de test dans /etc, /home, /tmp et /var/tmp avaient disparu.

Un drop-in complet pour une application web typique

Assemblons les morceaux. Ce drop-in convient à un service qui garde ses données dans /var/lib, écrit un journal et enregistre une sauvegarde dans un répertoire supplémentaire. J’ai testé chacune de ses directives séparément, et ProtectSystem, PrivateTmp et ProtectHome ensemble dans le labo ci-dessus. Je n’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.

[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
Protection du système de fichiers dans une unité : lecture seule avec quatre exceptions voulues
Une hiérarchie en lecture seule où chaque chemin inscriptible est une décision voulue.

Chaque chemin qui reste inscriptible dans cette unité est une décision écrite dans le fichier. C’est tout l’intérêt de la protection du système de fichiers : l’unité documente ce que le service peut toucher.

Erreurs courantes

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.

Erreur 1 : un chemin inscriptible qui n’existe pas encore

Le service refuse de démarrer avec le code de sortie 226. Créez d’abord le répertoire, utilisez StateDirectory= ou ajoutez le tiret initial.

Erreur 2 : ReadWritePaths sous ProtectHome=yes

L’exception est invisible, sans message. Utilisez ProtectHome=read-only avec le sous-répertoire exact, ou tmpfs avec BindPaths=.

Erreur 3 : strict sans PrivateTmp

/tmp passe en lecture seule et le programme échoue à son premier fichier temporaire. Ajoutez PrivateTmp=yes.

Erreur 4 : se fier à strict pour les répertoires personnels

Sur mon banc, /home et /root sont restés inscriptibles sous strict. Ajoutez ProtectHome= et mesurez.

Erreur 5 : ouvrir tout un répertoire parent

ReadWritePaths=/var ouvre bien plus que ce dont le service a besoin. Nommez le seul sous-répertoire, ou laissez un réglage *Directory= le créer.

Erreur 6 : oublier de redémarrer

Un rechargement ne change pas un processus en cours. Redémarrez le service, puis testez son vrai travail et pas seulement son démarrage.

Une liste de contrôle à suivre

Suivez ces étapes chaque fois que vous ajoutez la protection du système de fichiers à un service.

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

Ce que j’en retiens

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

Sources

Plus tôt dans la série

Articles liés

  • <a href= »https://blog.neurozone.fr/?p=2045″>Pas à pas : installer, configurer et vérifier un serveur mail avec mox</a>
  • <a href= »https://blog.neurozone.fr/?p=2930″>ZDI-CAN-33367 : quand une copie évitée écrit dans un fichier en lecture seule</a>
  • <a href= »https://blog.neurozone.fr/?p=2047″>Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur</a>
Mettre à jour Pi-hole en cinq étapes : versions, sauvegarde Teleporter, mise à jour, test du blocage, comparaison

Pas à pas : mettre à jour Pi-hole et vérifier qu’il bloque encore

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’étape qui compte le plus vérifie que le blocage fonctionne encore, et pas seulement que les noms se résolvent.

Réponse rapide — mettre à jour Pi-hole et prouver qu’il bloque encore.

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é)

Les détails, les explications et les vérifications suivent plus bas.

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’envoie les commandes avec une commande Ansible ad hoc depuis ma machine de contrôle, mais je montre ici les commandes elles-mêmes.

Mettre à jour Pi-hole en cinq étapes : versions, sauvegarde Teleporter, mise à jour, test du blocage, comparaison
Les cinq contrôles autour d’une commande. L’étape 4 est celle qui a détecté un vrai problème.

Avant de commencer

  • Un shell sur la machine Pi-hole avec les droits root.
  • L’adresse de votre Pi-hole et celle de votre routeur. Dans cet article, ce sont 192.0.2.19 et 192.0.2.1.
  • 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’arrêter pendant environ cinq minutes (voir l’étape 4).
  • 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.

Comment mettre à jour Pi-hole, étape par étape

Étape 1 : vérifier les versions

D’abord, je liste ce que j’utilise maintenant. La sortie donne les versions de Core, de Web et de FTL.

pihole -v

Étape 2 : sauvegarder la configuration

Ensuite, je sauvegarde deux fois : avec Teleporter, l’export propre à Pi-hole, et avec une simple copie du fichier TOML. Je travaille dans un dossier daté, pour que les deux restent ensemble.

mkdir -p /root/pre-update-$(date +%Y%m%d) && 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à

La copie du fichier TOML sert encore à l’étape 5.

Étape 3 : lancer la mise à jour

Vient maintenant la mise à jour elle-même. Je garde les 20 dernières lignes de sa sortie, j’affiche à nouveau les versions, et je demande à systemd si le service DNS tourne.

PIHOLE_SKIP_OS_CHECK=true pihole -up 2>&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

Mes notes utilisent PIHOLE_SKIP_OS_CHECK=true, qui saute le contrôle du système d’exploitation par l’installeur. La page de mise à jour de Pi-hole ne le mentionne pas, donc testez-le sur votre système avant de le copier.

Étape 4 : tester le blocage, pas seulement la résolution

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 no such table: main.gravity dans /var/log/pihole/FTL.log, et Pi-hole n’a rien bloqué pendant environ cinq minutes. Cela s’est arrêté seulement quand FTL a écrit son propre message : Gravity database has been replaced, reloading now.

Pendant ces minutes, les noms se résolvaient toujours. Donc un simple test de résolution m’aurait dit que tout allait bien.

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

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.

Si le blocage ne fonctionne pas après cinq minutes, je recharge la configuration DNS, puis je lis le journal de FTL.

pihole reloaddns

Étape 5 : vérifier que la configuration n’a pas changé

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.

diff <(grep -vE '^\s*#|pwhash|password|totp' /root/pre-update-*/pihole.toml.bak) \
     <(grep -vE '^\s*#|pwhash|password|totp' /etc/pihole/pihole.toml)

Aucune sortie signifie aucune différence.

Étape 6 : synchroniser le mot de passe d’administration s’il a changé

Si le mot de passe d’administration a changé pour une raison quelconque, je mets à jour mon gestionnaire de secrets le jour même. En août 2026, je l’ai oublié une fois, et chaque déploiement de mon rôle Ansible a échoué jusqu’à la correction.

Vérifier que tout a fonctionné

  • pihole -v affiche les nouvelles versions.
  • systemctl is-active pihole-FTL affiche active.
  • La première et la troisième requête dig affichent 0.0.0.0, et la deuxième affiche une vraie adresse.
  • Le diff n’affiche rien.

Si quelque chose se passe mal

  • Le blocage ne fonctionne pas après cinq minutes : lancez pihole reloaddns, puis lisez /var/log/pihole/FTL.log.
  • Vous voulez revenir en arrière : pihole -up ne peut pas réinstaller une version précédente. À la place, restaurez l’archive Teleporter sur une installation neuve avec pihole-FTL --teleporter <zip>.

Ce que j’en retiens

  • Je sauvegarde deux fois avant de mettre à jour Pi-hole, parce qu’il n’existe pas de retour en arrière.
  • Je teste le blocage, parce que la résolution seule a caché un trou de cinq minutes.
  • Je compare la configuration, parce que je veux savoir ce que la mise à jour a changé.

Sources

Articles liés

Les fichiers SQLite : le fichier principal et ses voisins journal, WAL et mémoire partagée

Dans les fichiers d’une base de données (4/3+1) : SQLite

SQLite est l’intrus de cette série : pas de serveur, pas de répertoire de données, pas de processus d’arrière-plan. Les fichiers SQLite tiennent dans un seul fichier ordinaire que l’on peut lire avec un vidage hexadécimal. Cet article prolonge les trois premiers, InnoDB, PostgreSQL et Oracle avec les trois côte à côte. Le tableau final ne remplace rien : il ajoute une quatrième colonne à la comparaison.

J’ai rencontré SQLite dans d’autres outils, par exemple ProxySQL, qui garde sa configuration dans une base SQLite. Alors j’ai voulu voir les octets. Tout ce qui suit vient d’une base de laboratoire : testé sur Rocky Linux 10.2, SQLite 3.46.1, WSL2. Je prévois aussi une série plus détaillée en sept parties, « SQLite from the inside », pour 2027. Cet article reste court et suit le même plan que les trois autres.

Un fichier, jusqu’à trois voisins

Les fichiers SQLite : le fichier principal et ses voisins journal, WAL et mémoire partagée
Une base, c’est un fichier. Le journal, le WAL et le shm n’existent que lorsque SQLite en a besoin.

Une base SQLite est un seul fichier, qu’un programme ouvre directement. Dans le mode par défaut, un deuxième fichier n’apparaît que pendant une transaction d’écriture. C’est le journal de retour arrière, et il garde la version d’origine de chaque page que la transaction modifie.

-rw-r--r--. 1 root root 8192 Nov  2 21:21 demo.db
-rw-r--r--. 1 root root 4616 Nov  2 21:21 demo.db-journal

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

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

L’en-tête de 100 octets

L'en-tête de 100 octets d'un fichier de base SQLite avec les valeurs lues dans le laboratoire
L’en-tête, champ par champ, avec les valeurs lues dans la base du laboratoire.

Chaque fichier de base SQLite commence par un en-tête de 100 octets. Les 16 premiers octets sont le texte SQLite format 3 et un octet nul. Viennent ensuite la taille de page, le compteur de changements, la taille en pages, la liste libre, le cookie de schéma, l’encodage du texte et la version de la bibliothèque qui a écrit le fichier. Tous les nombres sont en gros-boutiste.

$ 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  .....@  ........

J’ai décodé les champs avec quelques lignes de Python, et le client en ligne de commande est d’accord.

$ 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

Deux champs sont à ta disposition. PRAGMA user_version et PRAGMA application_id écrivent directement dans les octets 60 à 71 : après avoir réglé 42 et 1234567890, les octets valent 0000 002a et 4996 02d2. Beaucoup d’outils s’en servent pour marquer le format d’un fichier.

Pages et arbres B dans les fichiers SQLite

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

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

Avec 2 000 lignes, la table a besoin d’une page interne et de 22 feuilles. L’index est un arbre à part, avec une page interne et 14 feuilles. Dans un index, les cellules internes portent aussi des entrées : 13 + 1 987 donnent les 2 000 clés. Une page interne de table ne porte que des clés et des pointeurs vers les enfants.

Deux arbres B SQLite, une table et un index, et une chaîne de débordement
La forme des arbres mesurée avec dbstat, et la règle de débordement vérifiée avec la documentation.

Une table déclarée WITHOUT ROWID est stockée dans un seul arbre, classé par sa clé primaire. Une table normale avec une clé texte a besoin de deux arbres, la table et un index automatique.

type|name|tbl_name|rootpage
table|kv|kv|45                      -- WITHOUT ROWID: one tree
table|kv2|kv2|46                    -- rowid table with a TEXT primary key: the table...
index|sqlite_autoindex_kv2_1|kv2|47 -- ...and a second tree for the key

Les grandes valeurs quittent la page. Avec une valeur de 20 000 caractères, la feuille a gardé 3 637 octets et quatre pages de débordement ont porté le reste, 4 092 octets chacune. La documentation donne la règle, avec U = 4 096 et une charge P = 20 005 : X = U – 35 = 4 061 et M = 489. Comme P dépasse X, la partie locale vaut K = M + ((P – M) % (U – 4)) = 3 637. C’est exactement ce que rapporte dbstat.

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

Anatomie d’une page

Anatomie d'une page feuille de table SQLite : en-tête, pointeurs, espace libre et cellules
La page 2 de la base du laboratoire. Les pointeurs croissent depuis le début, les cellules depuis la fin.

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

$ 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

L’espace libéré dans la zone de contenu devient un bloc libre, une petite liste chaînée dans la page. Les trous de 1 à 3 octets sont seulement comptés comme octets fragmentés dans l’en-tête.

Un enregistrement, octet par octet

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

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

SELECT rowid, id, name, email, country FROM customers renvoie 1|1|Alice|alice@example.net|FR : le décodage est juste.

UPDATE et DELETE sur le disque

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

-- page 3 is the first leaf of customers
before          change counter 7   page 3: ncell 102, payload 3655, unused 25    page_count 39
same-size UPDATE        counter 8   page 3: ncell 102, payload 3655, unused 25
larger UPDATE           counter 9   page 3: ncell  89, payload 3242, unused 490   page_count 40

L’UPDATE plus grand a fait davantage. La page était presque pleine, donc elle a dû se diviser : la page 3 est passée de 102 à 89 cellules, le fichier a gagné une page et la racine un pointeur. En bref, une ligne qui grossit peut coûter une page.

Un DELETE libère la cellule et transforme son espace en bloc libre. Le paquet Rocky est compilé avec SECURE_DELETE, donc PRAGMA secure_delete renvoie 1 et les octets libérés sont mis à zéro. Avec le réglage désactivé, deux occurrences de l’adresse e-mail sont restées dans le fichier.

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

Pourquoi les fichiers SQLite ne rétrécissent pas

Quand une page entière se vide, elle rejoint la liste libre, que décrivent les octets 32 à 39 de l’en-tête. Le fichier garde sa taille. Après un DELETE de 1 800 lignes, 31 pages sur 39 étaient libres, et le fichier faisait toujours 159 744 octets. VACUUM l’a reconstruit en 32 768 octets.

before DELETE         page_count 39   freelist_count 0    size 159744
DELETE ... id > 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

Avec auto_vacuum en mode incrémental, on peut rendre des pages par étapes. L’octet 52 de l’en-tête contient alors la plus grande page racine, et l’octet 64 passe à 1. La page 2 devient une table de pointeurs, faite d’entrées de 5 octets : un type et une page parente. dbstat saute cette page.

PRAGMA auto_vacuum=INCREMENTAL; VACUUM;    -> header byte 52 = 4, byte 64 = 1
DELETE ... id > 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 | ...

Un type de page est documenté mais non testé ici : la page d’octet verrou, qui n’existe que si le fichier dépasse 1 Gio.

Le WAL

Comment le WAL de SQLite ajoute des trames et comment un point de contrôle les copie
Trois trames mesurées dans le WAL, puis des points de contrôle PASSIVE et TRUNCATE.

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

-rw-r--r--. 1 root root 159744 Nov  2 21:23 wal.db
-rw-r--r--. 1 root root  32768 Nov  2 21:23 wal.db-shm
-rw-r--r--. 1 root root  12392 Nov  2 21:23 wal.db-wal
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

Un point de contrôle copie les trames dans le fichier principal. Un point de contrôle PASSIVE l’a fait sans toucher au fichier WAL, alors que TRUNCATE l’a vidé. J’ai désactivé le point de contrôle automatique pour garder les trames visibles ; par défaut, il se déclenche à 1 000 pages.

PRAGMA wal_checkpoint(PASSIVE);     -> 0|3|3     (busy, frames in the WAL, frames copied)  -wal still 12392 bytes
PRAGMA wal_checkpoint(TRUNCATE);    -> 0|0|0                                              -wal now 0 bytes
last connection closes              -> demo.db-wal and demo.db-shm are gone

Les quatre moteurs côte à côte

Voici comment les fichiers SQLite se comparent aux trois premières parties, avec les valeurs par défaut.

InnoDBPostgreSQLOracleSQLite
Où vivent les donnéesfichiers de tablespace, un par table par défautun fichier par table ou index, en segmentsdatafiles dans des tablespacesun seul fichier pour tout
Unité d’E/Spage de 16 Kopage de 8 Kobloc, 8 Ko par défautpage, 4 096 octets ici
Format d’une ligneindex clusterisé : la table est l’arbre de la clé primairetas, les index y pointentbloc avec un répertoire de lignes et un ROWIDarbre B classé par rowid, ou par clé pour WITHOUT ROWID
Anciennes versionsjournaux d’annulation, à partdans la table, nettoyées par VACUUMtablespace d’annulationaucune : le journal ou le WAL les garde jusqu’au commit
Sécurité en cas de pannejournal de reprise et doublewrite bufferWAL et écriture de pages complètesjournaux de reprisejournal de retour arrière ou WAL
Récupérer de la placereconstruire la tableVACUUM, VACUUM FULLshrink, moveVACUUM, incremental_vacuum

Le plus proche cousin de SQLite ici est PostgreSQL pour le WAL, et InnoDB pour l’arbre. La grande différence est qu’aucun processus serveur ne se tient entre ton programme et le fichier : le format du fichier et les verrous de fichier du système portent les garanties.

Sources

Cet article fait partie d’une série de notes d’admin système (étiquette « carnet d’admin »).
Série « Dans les fichiers d’une base de données », partie 4/3+1. · ← Article précédent

Articles liés

Échelle des bannissements fail2ban de 1 h à 30 jours, avec la prison recidive et les réglages importants

Pas à pas : fail2ban bien configuré, avec bannissements progressifs

fail2ban est l’un des outils les plus anciens et les plus répandus de la boîte à outils de l’administrateur : il lit les journaux, compte les échecs d’authentification et bannit au pare-feu les adresses trop insistantes. Simple sur le papier, il est pourtant souvent mal réglé : bannissements de dix minutes qui n’arrêtent personne, réglages écrits dans un fichier écrasé à la mise à jour, jail SSH qui ne voit aucune ligne parce qu’elle lit le mauvais journal, ou administrateur banni de son propre serveur.

Ce tutoriel part de la configuration réelle de mes serveurs Rocky Linux : bannissements progressifs, quatre niveaux de récidive, une jail pour une application maison, et les vérifications qui permettent de savoir que tout cela fonctionne vraiment.

Échelle des bannissements fail2ban de 1 h à 30 jours, avec la prison recidive et les réglages importants
Un bannissement qui grandit à chaque récidive, plafonné à 30 jours.

Trois notions

NotionCe que c’estOù
filtreles expressions régulières qui reconnaissent un échec dans un journalfilter.d/*.conf
actionce qui est fait pour bannir et débannir (règle firewalld, nftables, courriel…)action.d/*.conf
jaill’association d’un filtre, d’une source de journaux, d’une action et de seuilsjail.local, jail.d/*.local

fail2ban fournit des dizaines de filtres et d’actions prêts à l’emploi. Le travail consiste surtout à écrire les jails : quoi surveiller, avec quels seuils, et comment bannir.

Étape 1 : installer, et ne jamais toucher jail.conf

dnf install epel-release
dnf install fail2ban fail2ban-firewalld
systemctl enable --now fail2ban

Le paquet fail2ban-firewalld installe un fichier qui fait de firewalld l’action par défaut. Les fichiers jail.conf et fail2ban.conf appartiennent au paquet et seront remplacés à la prochaine mise à jour : tous vos réglages vont dans les fichiers .local, qui les surchargent clé par clé. Un jail.local peut donc ne contenir que les quelques lignes qui diffèrent.

Étape 2 : le service lui-même

# /etc/fail2ban/fail2ban.local
[DEFINITION]
logtarget  = /var/log/fail2ban/fail2ban.log
dbfile     = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 35d
  • logtarget : un fichier dédié plutôt que le journal système. Il servira de source à la jail de récidive, et il est plus facile à lire ;
  • dbfile : la base SQLite où fail2ban garde les bannissements en cours et leur historique. C’est elle qui permet de rétablir les bannissements après un redémarrage, et de savoir combien de fois une adresse a déjà été bannie ;
  • dbpurgeage : la durée de conservation de l’historique. Par défaut, un jour seulement : trop court pour que les bannissements progressifs et la récidive à six mois aient une mémoire. 35 jours est un compromis ; la récidive longue s’appuie de toute façon sur le fichier journal.

Créez le répertoire et le fichier avant de redémarrer : install -d /var/log/fail2ban && touch /var/log/fail2ban/fail2ban.log. Pensez aussi à adapter la configuration logrotate du paquet à ce nouveau chemin.

Étape 3 : jail.local, ligne par ligne

# /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<<(ban.Count if ban.Count<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

Ne pas se bannir soi-même

ignoreip liste les adresses jamais bannies : la boucle locale, l’adresse publique de chacun de vos serveurs, et celle de votre accès d’administration. Sans elle, quelques tests de connexion ratés suffisent à vous enfermer dehors, et un serveur qui interroge un autre serveur avec un mauvais mot de passe le fera bannir. Mettez la même liste partout.

Les seuils

findtime et maxretry : 5 échecs en 10 minutes déclenchent un bannissement. Plus bas, on bannit des utilisateurs qui se trompent de mot de passe ; plus haut, on laisse aux robots plus d’essais. Pour SSH, si seule l’authentification par clé est autorisée, un utilisateur légitime n’échoue presque jamais et on peut descendre à 3.

Les bannissements progressifs

bantime.increment active l’allongement automatique. La formule multiplie la durée de base par 2 à la puissance du nombre de bannissements précédents de cette adresse (1<<n vaut 2n), plafonné à 220 pour éviter les dépassements, puis par banFactor (1 par défaut). bantime.maxtime limite le résultat à 30 jours :

Bannissement n°Durée (bantime 1 h)Durée (jail sshd, 2 h)
11 h2 h
22 h4 h
34 h8 h
516 h32 h
8128 h (5 j)256 h (10 j)
10 et plusplafond : 30 jplafond : 30 j

L’effet est exactement celui qu’on veut : une erreur ponctuelle coûte une heure, un robot qui revient tous les jours se retrouve banni pour des semaines au bout d’une dizaine de passages.

L’action

banaction = firewallcmd-rich-rules : chaque bannissement devient une règle riche firewalld qui rejette l’adresse sur le port de la jail. banaction_allports est l’action utilisée par les jails qui bannissent sur tous les ports (la récidive). Sur un serveur sans firewalld, utilisez nftables-multiport et nftables-allports.

La jail SSH

backend = systemd est indispensable sur Rocky Linux : sshd écrit dans le journal systemd, et une jail qui lit un fichier ne verrait rien, sans aucune erreur. La jail lit les entrées de l’unité sshd.service. Une seconde subtilité : les versions récentes d’OpenSSH écrivent une partie de leurs messages sous le nom sshd-session. Si fail2ban-client status sshd reste à zéro alors que des échecs apparaissent dans journalctl, vérifiez que le filtre de votre version de fail2ban connaît ce nom (journalmatch).

Étape 4 : punir les récidivistes

La jail recidive lit le propre journal de fail2ban et compte les lignes « Ban ». Une adresse bannie plusieurs fois, quelle que soit la jail, est alors bannie sur tous les ports, pour plus longtemps. Quatre paliers complètent bien les bannissements progressifs :

[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

Chaque palier a sa propre fenêtre : une adresse bannie 4 fois en une journée l’est pour 3 heures sur tous les ports ; 20 fois en six mois, pour un mois. Les fenêtres longues imposent de garder le journal assez longtemps : réglez la rotation pour conserver au moins six mois, compressés. La jail refuse de démarrer si le fichier journal n’existe pas encore, d’où sa création à l’étape 2.

Étape 5 : une jail pour sa propre application

Pour protéger une application qui n’a pas de filtre fourni, écrivez-en un. Prenons une application qui journalise en JSON :

# /etc/fail2ban/filter.d/monappli.conf
[Definition]
failregex = ^.*"msg":"login failed".*"remote_ip":"<HOST>".*$
ignoreregex =
datepattern = "time":"%%Y-%%m-%%dT%%H:%%M:%%S
  • <HOST> : la partie qui capture l’adresse à bannir ; fail2ban la remplace par une expression qui reconnaît IPv4 et IPv6 ;
  • datepattern : comment lire la date de la ligne. fail2ban ignore les lignes plus vieilles que findtime, il doit donc comprendre la date. Les %% sont des % échappés pour le format du fichier ;
  • ignoreregex : des lignes à ne jamais compter, même si elles correspondent.
# /etc/fail2ban/jail.d/monappli.local
[monappli]
enabled  = true
filter   = monappli
logpath  = /var/log/monappli/app.log
port     = 2022,8443
maxretry = 5

port limite le bannissement aux ports de l’application : l’adresse fautive garde l’accès au reste, à moins de récidiver.

Étape 6 : tester un filtre avant de l’activer

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

Un filtre qui ne trouve rien (« 0 matched ») sur un journal contenant des échecs connus est faux ; un filtre qui trouve trop (des connexions réussies) bannira vos utilisateurs. Vérifiez aussi la ligne « Date template hits » : si la date n’est pas reconnue, aucune ligne ne sera jamais comptée en production. Pour SSH avec le backend systemd : fail2ban-regex systemd-journal sshd.

Étape 7 : vérifier et exploiter

fail2ban-client -t                         # la configuration est-elle valide ?
systemctl restart fail2ban
fail2ban-client status                     # les jails actives
fail2ban-client status sshd                # échecs comptés, adresses bannies
fail2ban-client get sshd banip --with-time # avec la fin de chaque bannissement
fail2ban-client set sshd unbanip 192.0.2.66
fail2ban-client unban --all                # en cas d'urgence
firewall-cmd --list-rich-rules | grep -c reject

Faites le test pour de vrai, depuis une adresse qui n’est pas dans ignoreip (une connexion mobile, par exemple) : cinq mots de passe faux, puis vérifiez que la sixième tentative est refusée et que l’adresse apparaît dans le statut de la jail.

À quoi ça ressemble après quelques mois

Mon serveur web le 2 novembre 2026 (fail2ban 1.1.0). La jail SSH lit le journal systemd, et quatre jails de récidive s’empilent derrière elle :

# fail2ban-client status
|- Number of jail:	5
`- Jail list:	recidive1, recidive2, recidive3, recidive4, sshd

# fail2ban-client status sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	6
|  `- Journal matches:	_SYSTEMD_UNIT=sshd.service + _COMM=sshd + _COMM=sshd-session
`- Actions
   |- Currently banned:	2
   |- Total banned:	2

# for j in recidive1 recidive2 recidive3 recidive4; do ...; done
recidive1: bantime=10800   findtime=86400    maxretry=4
recidive2: bantime=86400   findtime=604800   maxretry=7
recidive3: bantime=604800  findtime=2592000  maxretry=10
recidive4: bantime=2592000 findtime=15552000 maxretry=20
  • Total failed: 6 depuis le redémarrage du matin. Les connexions par mot de passe étant désactivées, presque personne n’arrive même jusqu’à un échec : la plupart des robots abandonnent dès l’échange de clés. Les compteurs repartent de zéro avec le service ; l’historique est dans la base.
  • Journal matches cite à la fois sshd et sshd-session : depuis OpenSSH 9.8, les messages d’authentification viennent du processus sshd-session. Un filtre qui ne regarde que _COMM=sshd ne compte plus rien, sans le dire, sur un système récent.
  • Les quatre jails de récidive lisent le journal de fail2ban lui-même : 4 bannissements en un jour donnent 3 heures, 7 en une semaine une journée, 10 en un mois une semaine, 20 en six mois un mois.

Les bannissements progressifs se lisent dans la base SQLite de fail2ban, qui garde chaque adresse avec son nombre de bannissements :

# sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 \
    "select jail, bancount, count(*) from bips group by jail, bancount"
recidive3|1|1
sshd|1|21
sshd|3|1
sshd|7|1
sshd|10|1

# sqlite3 -readonly /var/lib/fail2ban/fail2ban.sqlite3 "select max(bantime) from bips where jail='sshd'"
2592000

Vingt et une adresses ont été bannies une fois et ne sont jamais revenues : c’est l’essentiel du trafic, des robots opportunistes. Trois sont revenues, dont une dix fois. Pour celle-là, chaque bannissement a doublé la durée du précédent jusqu’à atteindre bantime.maxtime = 720h : 2 592 000 secondes, trente jours, le plafond. C’est tout l’intérêt de la progression : la première faute coûte peu, les obstinés finissent exclus un mois, sans liste manuelle à entretenir.

Les pannes fréquentes

La jail ne compte rien

Mauvais backend (fichier au lieu de systemd), mauvais chemin de journal, ou date non reconnue. fail2ban-client status <jail> montre la source lue ; fail2ban-regex dit si les lignes correspondent.

Les adresses sont bannies mais continuent de passer

L’action ne correspond pas au pare-feu réellement utilisé (une action iptables sur un système nftables pur, par exemple), ou le trafic arrive par un mandataire inverse et c’est l’adresse du mandataire qui est bannie. Derrière un mandataire, l’application doit journaliser l’adresse réelle du client (X-Forwarded-For), et le bannissement doit avoir lieu sur la machine qui reçoit la connexion.

Une adresse bloquée alors que fail2ban ne la bannit pas

Si CrowdSec tourne aussi, il a peut-être pris sa propre décision : cscli decisions list --ip …. Les deux se complètent bien (fail2ban réagit à vos propres journaux, CrowdSec ajoute des scénarios plus riches et une liste communautaire), mais il faut penser à regarder des deux côtés.

fail2ban met longtemps à démarrer

À chaque démarrage, il rétablit les bannissements en cours depuis sa base et relit la fin des journaux. Avec des milliers d’entrées et des règles riches firewalld, cela peut prendre une minute. Une action nftables par ensemble (nftables-allports) est bien plus rapide quand le nombre de bannissements grossit.


Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »).
Série « Pas à pas », tutoriel 11/77. · ← Tutoriel précédent · Tutoriel suivant → (à paraître le 16/11) · English version

Articles liés

Séquences PostgreSQL après kill -9 : id 1 à 9, puis un trou de 27 valeurs, puis 37

Séquences et colonnes identity dans PostgreSQL : trous, rollbacks et crashs, testés

En bref : j’ai fait un kill -9 sur les processus postgres : une colonne identity PostgreSQL est passée de l’id 9 à 37, parce que les séquences PostgreSQL journalisent 32 valeurs d’avance dans le WAL. Rollbacks et insertions en échec brûlent aussi des valeurs, et une colonne BY DEFAULT ignore les id explicites. Préférer GENERATED ... AS IDENTITY à serial, et ne jamais compter sur des id continus.

PostgreSQL 18.6, cluster jetable sous Rocky Linux 10.2. Partie 1 sur 5 (PostgreSQL, MariaDB, Oracle, SQL Server, résumé). Toutes les sorties viennent de mes propres tests.

Séquences PostgreSQL après kill -9 : id 1 à 9, puis un trou de 27 valeurs, puis 37
Un kill -9 sur PostgreSQL 18.6 : 27 valeurs réservées et jamais distribuées.

Trois façons d’obtenir une valeur générée

CREATE TABLE t_ident  (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, v text);
CREATE TABLE t_serial (id serial PRIMARY KEY, v text);
CREATE SEQUENCE s1;
CREATE TABLE t_seq    (id int PRIMARY KEY DEFAULT nextval('s1'), v text);

Chaque INSERT ... RETURNING id renvoie 1 sur une table neuve. Le catalogue montre la différence :

 column_name |            column_default            | is_identity | identity_generation
-------------+--------------------------------------+-------------+---------------------
 id          |                                      | YES         | ALWAYS
 id          | nextval('s1'::regclass)              | NO          |
 id          | nextval('t_serial_id_seq'::regclass) | NO          |

Une colonne identity n’a pas d’expression par défaut ; la séquence est attachée à la colonne (pg_get_serial_sequence('t_ident','id') renvoie public.t_ident_id_seq). serial est un raccourci : colonne entière plus défaut appelant nextval.

ALWAYS ou BY DEFAULT

Avec GENERATED BY DEFAULT, une valeur explicite est acceptée mais ne fait pas avancer le compteur :

INSERT INTO t_by(id, v) VALUES (2, 'explicit 2');         -- ok
INSERT INTO t_by(v) VALUES ('default 1') RETURNING id;    -- 1
INSERT INTO t_by(v) VALUES ('default 2') RETURNING id;    -- ERROR: duplicate key ... (id)=(2) already exists
INSERT INTO t_by(v) VALUES ('default 3') RETURNING id;    -- 3

L’insertion en échec a brûlé la valeur 2 du compteur. Avec GENERATED ALWAYS, une valeur explicite est refusée sauf demande expresse :

ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.

Après OVERRIDING SYSTEM VALUE VALUES (50, ...), l’insertion par défaut suivante a renvoyé 1 : le compteur est indépendant des données.

Rollback et échecs consomment des valeurs

BEGIN; INSERT INTO t_ident(v) VALUES ('rolled back') RETURNING id;   -- 2
ROLLBACK;
INSERT INTO t_ident(v) VALUES ('after rollback') RETURNING id;       -- 3

Idem avec une violation d’unicité : id 1, erreur sur le doublon, puis 3. Un compteur sans trou demande une autre conception (table de compteur verrouillée), au prix de la sérialisation des écritures.

Concurrence : l’ordre des id n’est pas l’ordre des commits

La session A insère en premier et valide 3 secondes après B ; la session B insère en second et valide aussitôt :

A got id 1
B got id 2
B: committed 08:18:48.652
A: committed 08:18:51.154

La ligne de B est visible avant celle de A, bien que son id soit plus grand. Ne pas utiliser « max(id) déjà vu » comme curseur de réplication ou de polling.

CACHE est par session

CREATE SEQUENCE s_cache CACHE 10;
same session twice : 1|2
new session        : 11
another new session: 21

Avec les séquences PostgreSQL, chaque session réserve un bloc de 10. Les valeurs de sessions différentes s’entrelacent et des trous apparaissent dès qu’une session se termine.

Limites et CYCLE

ERROR:  nextval: reached maximum value of sequence "s_small" (3)

Après ALTER SEQUENCE s_small CYCLE, la valeur suivante est 1. Une identity sur smallint s’arrête à 32767 avec la même erreur sur t_small_id_seq. Les identity integer s’arrêtent à 2147483647 : prendre bigint pour les tables à longue vie.

Redémarrage et crash

Trois lignes insérées, la dernière supprimée, redémarrage propre :

sequence before the restart: 3|30|t     (last_value | log_cnt | is_called)
sequence after the restart:  3|30|t
new id = 4

Puis cinq lignes de plus (dernier id 9), kill -9 de tous les processus postgres, redémarrage :

sequence before the crash: 9|27|t
sequence after the crash:  36|0|t
new id = 37
ids now: 1,2,4,5,6,7,8,9,37

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

Où lire l’état des séquences PostgreSQL

SELECT * FROM pg_sequences;                -- départ, incrément, max, cache_size, cycle, last_value
SELECT last_value, log_cnt, is_called FROM t_ident_id_seq;
SELECT attrelid::regclass, attname, attidentity FROM pg_attribute WHERE attidentity <> '';  -- 'a' ALWAYS, 'd' BY DEFAULT

Documentation officielle

PostgreSQL, site du projet

CREATE SEQUENCE, documentation PostgreSQL 18

Sequence manipulation functions, nextval, currval, setval

CREATE TABLE, GENERATED ... AS IDENTITY et serial

pg_sequences, la vue système utilisée plus haut

*Non testé ici : réplication logique des séquences, pg_upgrade, tables partitionnées, ALTER SEQUENCE ... RESTART en cours d’usage.*

Toutes les parties de la série

1/5 PostgreSQL

2/5 MariaDB (à paraître le 17/11)

3/5 Oracle (à paraître le 24/11)

4/5 SQL Server (à paraître le 1/12)

5/5 TL;DR : les quatre moteurs comparés (à paraître le 8/12)

Articles liés

Dans les fichiers d’une base de données (1/3) : InnoDB, de MySQL à MariaDB

Dans les fichiers d’une base de données (2/3) : PostgreSQL

Dans les fichiers d’une base de données (3/3) : Oracle, et les trois côte à côte

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

Pas à pas : Suricata en IPS avec NFQUEUE, sans casser son serveur

Suricata est un moteur de détection d’intrusion réseau : il examine les paquets qui entrent et sortent d’une machine, reconstitue les connexions, comprend les protocoles (HTTP, TLS, DNS, SSH…) et compare le tout à des dizaines de milliers de règles décrivant des attaques connues. En mode IPS, il peut aussi bloquer : un paquet qui correspond à une règle de blocage n’arrive jamais à destination.

Ce tutoriel installe Suricata en IPS sur un serveur Rocky Linux exposé à Internet, avec NFQUEUE. C’est le mode le plus utile sur un serveur seul, et aussi le plus délicat : Suricata se retrouve sur le chemin de chaque paquet. Je décris donc aussi, en détail, les pannes que j’ai rencontrées, dont une qui bloquait des connexions locales en silence pendant des semaines.

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
Le chemin d’un paquet et les réglages qui évitent les mauvaises surprises.

IDS ou IPS

ModeFonctionnementRisque
IDS (af-packet)Suricata reçoit une copie des paquets et alerteaucun pour le trafic : au pire, des alertes manquées
IPS (NFQUEUE)le noyau confie chaque paquet à Suricata, qui l’accepte ou le jetteSuricata est sur le chemin : une erreur bloque le trafic

Commencez en IDS si vous découvrez l’outil. Passez en IPS quand vous savez lire ses alertes et que vous avez éliminé les faux positifs les plus bruyants.

Étape 1 : installer

dnf install epel-release
dnf install suricata
suricata --build-info | grep -E 'NFQueue|AF_PACKET'   # les deux doivent être « yes »

Deux modules noyau sont nécessaires pour NFQUEUE : nfnetlink_queue (la file elle-même) et xt_NFQUEUE (la cible utilisée par les règles de pare-feu). Le second est souvent oublié : la règle échoue alors avec un message peu clair sur un module manquant. Chargez-les et rendez-les permanents :

modprobe nfnetlink_queue
modprobe xt_NFQUEUE
printf 'nfnetlink_queue\nxt_NFQUEUE\n' > /etc/modules-load.d/nfqueue.conf

Étape 2 : suricata.yaml, l’essentiel

# /etc/suricata/suricata.yaml (extraits)
vars:
  address-groups:
    HOME_NET: "[203.0.113.25/32,2001:db8::25/128]"
    EXTERNAL_NET: "!$HOME_NET"

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]
  • HOME_NET : les adresses que vous protégez. Beaucoup de règles en dépendent (« trafic venant de l’extérieur vers HOME_NET ») ; une valeur fausse donne à la fois des faux positifs et des attaques manquées ;
  • nfq.mode: accept : Suricata rend son verdict (accepter ou jeter) et le paquet poursuit ensuite son chemin normal dans le pare-feu ;
  • fail-open: yes : si la file déborde parce que Suricata ne suit pas, les paquets passent au lieu d’être jetés. Sur un serveur seul, mieux vaut laisser passer quelques paquets non inspectés que couper le service ;
  • flow-timeouts.tcp.established: 7200 : le point qui m’a coûté le plus cher, détaillé plus bas. Par défaut, Suricata oublie une connexion TCP inactive au bout d’une heure ; une connexion qui reste ouverte plus longtemps sans trafic voit ses paquets suivants traités comme des paquets « hors connexion », et parfois jetés ;
  • midstream: true : Suricata accepte de prendre en compte une connexion dont il n’a pas vu le début, par exemple après son propre redémarrage. Sans cela, toutes les connexions établies avant le démarrage sont considérées comme invalides ;
  • memcap : les plafonds de mémoire pour le suivi des flux et la reconstitution des données. Trop bas, Suricata abandonne l’inspection des connexions sous charge ; surveillez les compteurs memcap dans les statistiques ;
  • eve.json : le journal structuré, une ligne JSON par événement. C’est ce fichier qu’on envoie à un SIEM.

Étape 3 : les règles

suricata-update                      # télécharge le jeu ET Open (gratuit)
suricata-update list-sources
suricata-update enable-source et/open
ls -l /var/lib/suricata/rules/suricata.rules

suricata-update assemble toutes les règles dans un seul fichier, en appliquant vos fichiers de modification. Deux d’entre eux sont essentiels en IPS :

# /etc/suricata/drop.conf : ces familles de règles bloquent au lieu d'alerter
re:classtype:trojan-activity
re:classtype:command-and-control
re:classtype:domain-c2
re:classtype:exploit-kit
re:classtype:coin-mining

# /etc/suricata/disable.conf : protocoles industriels absents ici
re:dnp3
re:modbus

Par défaut, toutes les règles alertent seulement. drop.conf transforme en blocages celles dont la catégorie ne laisse pas de doute : communication avec un serveur de commande, cheval de Troie, minage de cryptomonnaie. Évitez de tout passer en blocage : une règle générique de « trafic suspect » déclenchée par une application légitime coupe cette application sans prévenir. disable.conf retire les règles inutiles, ce qui économise mémoire et processeur.

Planifiez une mise à jour quotidienne par un timer systemd qui lance suricata-update, puis demande à Suricata de recharger ses règles sans redémarrer (suricatasc -c reload-rules).

Étape 4 : envoyer le trafic à Suricata

Le service lit la file 0 :

# /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

Puis les règles de pare-feu, avec firewalld en mode « direct » :

# 1. la boucle locale ne passe jamais par Suricata (priorité -1 : avant tout)
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT  -1 -i lo -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT -1 -o lo -j ACCEPT
# 2. tout le reste est confié à la file 0 ; --queue-bypass : si Suricata est arrêté, le trafic passe
firewall-cmd --permanent --direct --add-rule ipv4 filter INPUT   0 -j NFQUEUE --queue-num 0 --queue-bypass
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT  0 -j NFQUEUE --queue-num 0 --queue-bypass
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -j NFQUEUE --queue-num 0 --queue-bypass
# mêmes règles en ipv6
firewall-cmd --reload
  • –queue-bypass : sans lui, si Suricata est arrêté ou plante, plus aucun paquet ne passe, SSH compris. Avec lui, le noyau laisse passer le trafic quand personne n’écoute la file. C’est la ligne qui évite d’avoir à appeler la console de secours de l’hébergeur ;
  • la boucle locale exclue : la leçon la plus coûteuse de ce tutoriel, détaillée ci-dessous ;
  • l’ordre : les règles directes de priorité -1 passent avant celles de priorité 0.

La leçon de la boucle locale

Pendant six semaines, un service de mon serveur (un gestionnaire de tâches qui parle à Redis sur 127.0.0.1) s’est figé à intervalles irréguliers, toujours exactement 31 minutes et 40 secondes, avant de repartir. Les journaux de l’application ne montraient rien, ceux de Redis non plus. La cause : Suricata inspectait aussi le trafic de la boucle locale. Une longue connexion inactive dépassait le délai de suivi des flux ; au réveil, ses paquets n’appartenaient plus à aucune connexion connue de Suricata, qui les jetait ; il fallait attendre que TCP abandonne et se reconnecte.

Le diagnostic est venu d’eve.json : chaque paquet jeté y apparaît avec sa raison.

jq -c 'select(.event_type=="drop") | {timestamp, src_ip, dest_ip, dest_port, reason: .drop.reason}' /var/log/suricata/eve.json | tail

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

Le piège des cartes réseau

Les cartes et pilotes modernes regroupent plusieurs paquets en un seul « super-paquet » avant de les remettre au noyau (GRO, LRO). Suricata voit alors des paquets plus gros que la taille réelle du réseau, et sa reconstitution des connexions s’en trouve faussée. Désactivez ces optimisations sur l’interface surveillée, de façon permanente, par NetworkManager plutôt que par un simple ethtool -K qui serait annulé à la reconnexion suivante :

nmcli connection modify eth0 ethtool.feature-gro off ethtool.feature-lro off \
    ethtool.feature-gso off ethtool.feature-tso off
nmcli connection up eth0
ethtool -k eth0 | grep -E 'generic-receive|large-receive'

Étape 5 : démarrer et vérifier

suricata -T -c /etc/suricata/suricata.yaml        # test de configuration et des règles
systemctl enable --now suricata
tail -f /var/log/suricata/suricata.log            # « engine started »
curl -s http://testmynids.org/uid/index.html      # doit déclencher l'alerte 2100498
jq -c 'select(.event_type=="alert") | .alert.signature' /var/log/suricata/eve.json | tail -3

Vérifiez aussi que le trafic normal passe toujours : connexion SSH neuve, site web, envoi de courrier, résolution DNS. Puis surveillez les statistiques pendant quelques jours :

jq -c 'select(.event_type=="stats") | .stats | {drop: .ips.blocked, memcap: .tcp.ssn_memcap_drop, kernel: .capture}' /var/log/suricata/eve.json | tail -1

Les mêmes compteurs sur un serveur en production

Suricata 8.0.8 en mode IPS sur mon serveur web, lu par le socket de contrôle le 2 novembre 2026 après 75 minutes de fonctionnement. suricatasc donne les compteurs en direct, sans attendre la prochaine ligne de statistiques dans eve.json :

# suricatasc -c iface-list
{"message": {"count": 1, "ifaces": ["NFQ#0"]}, "return": "OK"}

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

# suricatasc -c dump-counters | jq '.message | {uptime, ips, alerts: .detect.alert}'
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
  • NFQ#0 : Suricata est branché sur la file netfilter 0, il voit donc les paquets au passage et peut les jeter. En mode IDS, cette liste montrerait une interface réseau.
  • 53 325 règles chargées, 0 en échec : un rules_failed non nul signifie le plus souvent qu’une règle utilise un mot-clé que votre version ne connaît pas, après une mise à jour des règles ou du moteur.
  • 771 paquets bloqués, 769 pour stream_error, 0 par les règles : c’est le tableau normal d’un serveur web. Les rejets par règle sont rares ; la plupart des paquets jetés n’ont aucun sens pour le flux TCP auquel ils prétendent appartenir (retransmissions après la fermeture, numéros de séquence faux, sommes de contrôle invalides). Si ce nombre grimpe d’un coup, regardez les événements drop dans eve.json : la leçon de la boucle locale, plus haut, a commencé par exactement ce genre de pic.
  • 23 alertes en 75 minutes, aucune bloquante : des anomalies de protocole (trop de SYN pour une même poignée de main, protocoles applicatifs incohérents, horodatages invalides) journalisées pour le SIEM. Pourtant, 23 665 des règles chargées sont réglées pour bloquer, par classe (chevaux de Troie, serveurs de commande, kits d’exploitation, minage…) grâce à drop.conf : pendant cette heure, aucune ne s’est déclenchée. Une règle de blocage qui ne se déclenche jamais, c’est l’état attendu, pas le signe d’une panne.

Faire taire un faux positif

Une règle qui se déclenche sur un trafic légitime se neutralise, du plus précis au plus large : seuil ou suppression pour une adresse dans threshold.config, puis désactivation de la règle par son numéro dans disable.conf. Ne modifiez jamais le fichier de règles directement : la prochaine mise à jour l’écraserait.

# /etc/suricata/threshold.config
suppress gen_id 1, sig_id 2210045, track by_src, ip 203.0.113.40

Envoyer les alertes au SIEM

eve.json est lisible par Wazuh, Elastic, Graylog et la plupart des SIEM. Avec un agent Wazuh, quelques lignes suffisent (format json, chemin du fichier) et les alertes de Suricata apparaissent à côté des journaux système, ce qui permet de rapprocher une alerte réseau d’une connexion SSH ou d’un changement de fichier. Pensez à la rotation : eve.json grossit vite, et logrotate doit envoyer à Suricata le signal de rouvrir le fichier.

Mettre à jour Suricata

Après chaque mise à jour du paquet, relancez suricata -T avant le redémarrage : une option renommée ou une règle incompatible empêche le démarrage, et grâce à --queue-bypass le trafic passera alors sans inspection, sans que rien ne casse visiblement. C’est le revers de la sécurité de fonctionnement : surveillez l’état du service, pas seulement le trafic.


Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »).
Série « Pas à pas », tutoriel 10/77. · ← Tutoriel précédent · Tutoriel suivant → · English version

Articles liés

Table LanceDB sur disque : la version 265 réécrit 226 fragments en un, 5,8 Gio lus sur 13,4 Gio dans data/

Table LanceDB sur disque : 265 versions, 13,4 Gio, un seul fragment vivant

J’ai lancé un script en lecture seule sur ma propre machine pour regarder à l’intérieur d’une table LanceDB, et un assistant IA (Claude, d’Anthropic) m’a aidée à écrire ce script et à rédiger cet article. Les chiffres ci-dessous sont la sortie réelle de ce lancement, avec LanceDB 0.41.0 et pyarrow 26.0.1. J’ai remplacé le nom de la table et les chemins. C’est le premier article d’une série sur LanceDB vu par une DBA.

Réponse rapide

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

Table LanceDB sur disque : la version 265 réécrit 226 fragments en un, 5,8 Gio lus sur 13,4 Gio dans data/
Versions 263 à 265 et part de data/ que lit la version courante.

Ce que contient le répertoire d’une table LanceDB

La première partie de mon script parcourt le répertoire de la table LanceDB, docs_index.lance, et n’a pas besoin de LanceDB. L’arborescence contient trois sous-répertoires. La documentation de l’agencement des fichiers en liste d’autres, dont deux, _indices/ et _deletions/, sont absents de ma table.

RépertoireCe que dit la documentationCe que j’ai trouvé
data/fichiers de données (*.lance)260 fichiers, 13,4 Gio, médiane 21,6 Mio, le plus gros 5,8 Gio
_versions/manifestes (*.manifest) et un latest_version_hint.json optionnel265 manifestes, 2,4 Mio au total, plus un fichier d’indice de 15 octets
_transactions/fichiers de transaction (*.txn)265 fichiers, 56,7 Kio au total, médiane 132 octets
_indices/un dossier par indexabsent

La table compte 791 fichiers en tout. Les statistiques donnent num_indices: 0 et num_deleted_rows: 0, ce qui correspond à l’absence de ces deux répertoires : je n’ai jamais construit d’index et jamais supprimé de ligne.

Une version, c’est un manifeste et une transaction

Le manifeste est le cœur du format. La documentation le décrit comme la description immuable d’une version unique, avec le schéma, la liste des fragments, le numéro de version, un horodatage et une référence à la transaction qui l’a créée. Ma table a 265 manifestes pour 265 versions, donc exactement un manifeste par version, et 265 fichiers de transaction, donc chaque validation en a laissé un.

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

Chaque ajout crée un fragment

Les cinq dernières versions montrent comment la table a grossi. list_versions a renvoyé ces lignes, que j’ai réduites aux champs intéressants et alignées en tableau :

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

De la 261 à la 263, chaque version ajoute un fichier de données et quelques milliers de lignes : 4 128 lignes pour le premier ajout et 3 435 pour le second. C’est l’opération Append de la documentation, qui ajoute de nouveaux fragments sans modifier les données existantes. De petits ajouts produisent donc de petits fichiers de données, et une table alimentée par petits lots accumule les fragments : la mienne en avait 226, et le fichier de données médian fait 21,6 Mio.

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

La version courante d’une table LanceDB est plus petite que la table

Les statistiques de la version 265 donnent la taille de ce dont la table a réellement besoin :

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

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

Pourquoi le manifeste d’un seul fragment pèse 20,5 Kio

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

Les 20 Kio du dernier manifeste ne décrivent donc pas la version courante. Ils enregistrent ce que le compactage a retiré de la table, et ils sont présents deux fois sur disque, dans le manifeste et dans le fichier de transaction. Ce champ s’appelle transaction_section dans le protobuf. Je n’ai pas trouvé cela décrit dans les pages du format que j’ai lues.

Ce que je n’ai pas testé

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

Documentation officielle

Les pages format de table Lance, agencement des fichiers, versions et transactions décrivent ce que j’ai mesuré, et les définitions protobuf table.proto et transaction.proto donnent les noms de champs. Le projet LanceDB documente l’API Python derrière le script.

Lire le tableau de systemd-analyze security : lignes non appliquées et appliquées, note d'exposition de 0,0 à 10,0 et ce que la note ignore

Durcir systemd, directive par directive : mesurer avec systemd-analyze security

systemd-analyze security est la commande que je lance avant de modifier quoi que ce soit dans une unité systemd. Elle attribue à chaque service une note d’exposition, liste les options de sandboxing que l’unité n’utilise pas encore et indique par où commencer. Cet article est la partie 1 d’une série de huit sur le durcissement de systemd, directive par directive. Je couvre ici la méthode : comment fonctionnent les drop-ins, comment lire la sortie de systemd-analyze security, comment tester une modification sans risque et quels services traiter en premier.

Les parties suivantes passent en revue les directives, une famille à la fois. Pour chaque directive, j’explique ce qu’elle fait, quels services de ma stack elle touche et ce qu’elle peut casser. Cette première partie reste sur la méthode, parce que toutes les suivantes s’appuient dessus. Aucune expérience de systemd n’est nécessaire : chaque étape vient avec la commande complète, un commentaire sur chaque ligne et un petit lab sur un service de démonstration inoffensif.

Banc de test. Toutes les sorties de cet article viennent d’exécutions réelles sur Rocky Linux 10.2 avec systemd 257, dans une machine virtuelle WSL2 (noyau 6.18.54.1-microsoft-standard-WSL2, session root). SELinux y est désactivé et certaines fonctions du noyau peuvent différer d’un serveur classique. Je le signale à chaque endroit où cela compte. Quand je coupe des lignes d’une sortie, je les remplace par [...], et je marque non testées les commandes que je n’ai pas lancées.

Avant de commencer

Lisez d’abord cette courte liste. Elle vous évite l’erreur classique : durcir un service de production du premier coup.

  • Une machine de test. Utilisez une machine virtuelle ou un serveur de rechange sous systemd. J’ai tout exécuté sur Rocky Linux 10.2 uniquement ; ces commandes appartiennent à systemd lui-même et devraient se comporter de la même façon sur d’autres distributions, mais je ne l’ai pas testé.
  • Un shell avec les droits sudo. Chaque commande qui modifie le système commence par sudo.
  • Un service inoffensif pour vos premiers essais. Le lab ci-dessous crée un service de démonstration nommé demo-hardening-sandbox.service : vous ne touchez donc à aucun vrai service.
  • Un moyen de tout annuler. Chaque changement vit dans un seul fichier drop-in, et supprimer ce fichier annule le changement. Le lab se termine par une étape de nettoyage qui supprime tout ce qu’il a créé.
  • Un systemd récent. Plusieurs options de systemd-analyze security sont apparues dans la version 250 : vérifiez donc la vôtre avec systemctl --version.

Un court glossaire

TermeSens dans cet article
UnitéUn fichier qui décrit ce que systemd gère. Une unité de type service se termine par .service.
ServiceUne unité qui lance un programme : un démon qui reste actif, ou une commande qui s’exécute une fois puis se termine.
Drop-inUn petit fichier .conf placé dans un répertoire .d, qui modifie une unité sans toucher au fichier d’origine.
DirectiveUn réglage d’un fichier d’unité, par exemple ProtectSystem=strict.
SandboxingLes directives qui limitent ce qu’un service peut voir et faire.
NamespaceUne fonction du noyau qui donne à un processus sa propre vue du système, par exemple ses propres points de montage. Plusieurs directives s’appuient dessus.
CapabilityUne part des pouvoirs de root, par exemple ouvrir un port bas, que le noyau peut accorder séparément.
Appel systèmeUne requête d’un programme au noyau, par exemple ouvrir un fichier.
Niveau d’expositionLa note que systemd-analyze security calcule pour une unité.
Unité transitoireUne unité temporaire que systemd-run crée à la volée ; elle disparaît quand elle s’arrête.

En un coup d’œil

ÉtapeCommandeCe qu’elle vous apprend
Noter tous les services actifssystemd-analyze securityUne ligne par service chargé, avec un niveau d’exposition
Détailler un servicesystemd-analyze security UNITÉChaque réglage, appliqué (✓) ou non (✗), avec son exposition
Contrôler un fichier avant de le déployersystemd-analyze verify FICHIER puis security --offline=true FICHIERLes erreurs de syntaxe, puis la revue hors ligne
Changer une directivesystemctl edit UNITÉUn fichier drop-in, rechargé à l’enregistrement
Expérimenter sans modifier de fichiersystemd-run -p DIRECTIVE=VALEUR ...Une unité temporaire avec les propriétés de votre choix
Lire un échecsystemctl status UNITÉ et journalctl -u UNITÉLe code de sortie, puis les messages du service lui-même
Revenir en arrièresystemctl revert UNITÉSupprime les drop-ins de l’unité

Pourquoi durcir les services avec systemd

Un service qui n’a aucune directive de sandboxing tourne sans aucune de ces protections. Le manuel systemd.exec qualifie les options de sandboxing de moyen efficace de limiter l’exposition du système, et recommande d’en activer autant que possible sans empêcher le processus de fonctionner. Mon service de démonstration montre le point de départ : sans aucun sandboxing, systemd-analyze security lui attribue 9,6 et l’étiquette UNSAFE, avec 75 réglages non appliqués et 4 appliqués (vous le mesurerez vous-même dans le lab).

Le sandboxing ne remplace pas les autres couches de défense. En revanche, il en ajoute une qui ne coûte presque rien : quelques lignes dans un drop-in. Deux limites du manuel valent d’être retenues. D’abord, beaucoup d’options sont désactivées sans bruit sur les systèmes où le mécanisme sous-jacent n’existe pas. Ensuite, certaines fonctions de sandboxing ne sont en général pas disponibles dans les services utilisateur.

Comment une unité et ses drop-ins s’assemblent

Un drop-in est la façon la plus sûre de modifier un service. Au lieu d’éditer le fichier installé par un paquet, vous ajoutez un petit fichier à côté, et systemd fusionne les deux. Le schéma montre quel fichier l’emporte.

Superposition des drop-ins d'une unité systemd : fichier du paquet, drop-ins d'exécution et votre drop-in fusionnés en unité effective
Quel fichier l'emporte quand une unité systemd et ses drop-ins sont fusionnés.

Où vivent les drop-ins et lequel gagne

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

EmplacementRôlePriorité
/usr/lib/systemd/system/Unités et drop-ins fournis par les paquetsLa plus basse
/run/systemd/system/Changements d’exécution, perdus au redémarrageIntermédiaire
/etc/systemd/system/Vos propres unités et drop-insLa plus haute

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

Créer, vérifier et annuler un drop-in

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

Lire la sortie de systemd-analyze security

L’outil systemd-analyze security analyse les réglages de sécurité et de sandboxing des unités de service. Il ne démarre rien. Il lit les réglages, puis les compare aux protections que systemd sait appliquer.

Lire le tableau de systemd-analyze security : lignes non appliquées et appliquées, note d'exposition de 0,0 à 10,0 et ce que la note ignore
Lire la sortie de systemd-analyze security, ligne par ligne.

Un service ou tout le système

Sans nom d’unité, systemd-analyze security inspecte tous les services chargés qui tournent durablement et affiche un tableau concis, une ligne par unité. Avec un ou plusieurs noms d’unité, il affiche une analyse détaillée de chacune. Voici le tableau de tout le système, coupé après quatre lignes :

systemd-analyze security --no-pager
UNIT                                 EXPOSURE PREDICATE HAPPY
NetworkManager.service                    7.8 EXPOSED   🙁
auditd.service                            9.4 UNSAFE    😨
cockpit-wsinstance-http.service           5.4 MEDIUM    😐
cockpit.service                           6.2 MEDIUM    😐
[...]

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

Le tableau, ligne par ligne

Pour un service, la sortie a trois colonnes : NAME, DESCRIPTION et EXPOSURE. Chaque ligne commence par une marque : ✗ signifie que le réglage n’est pas appliqué, ✓ qu’il l’est. La dernière ligne donne le résultat global. Voici le début du tableau pour le service de démonstration du lab, avant tout sandboxing :

systemd-analyze security --no-pager demo-hardening-sandbox.service | head -n 6
  NAME                                                        DESCRIPTION                                                             EXPOSURE
✗ RootDirectory=/RootImage=                                   Service runs within the host's root directory                                0.1
  SupplementaryGroups=                                        Service runs as root, option does not matter
  RemoveIPC=                                                  Service runs as root, option does not apply
✗ User=/DynamicUser=                                          Service runs as root user                                                    0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME                         Service processes may change the system clock                                0.2

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

systemd-analyze security --no-pager demo-hardening-sandbox.service | grep '✗' | head -n 4
✗ RootDirectory=/RootImage=                                   Service runs within the host's root directory                                0.1
✗ User=/DynamicUser=                                          Service runs as root user                                                    0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME                         Service processes may change the system clock                                0.2
✗ NoNewPrivileges=                                            Service processes may acquire new privileges                                 0.2

Ce que la note signifie, et ce qu’elle ne signifie pas

Le manuel décrit le niveau d’exposition global comme une estimation de 0,0 à 10,0. Une note élevée signale peu de sandboxing, une note basse des restrictions serrées. Soyez prudent avec ce nombre, car le manuel est explicite sur ses limites :

  • Il ne couvre que les fonctions de sécurité par service que systemd implémente lui-même. Il ignore ce que le programme fait pour se protéger, et il ignore SELinux.
  • Une note élevée ne veut pas dire que le service est vulnérable, et une note basse ne prouve pas qu’il est sûr. Elle indique seulement que le service pourrait profiter de réglages supplémentaires.
  • Beaucoup de réglages peuvent être annulés individuellement par d’autres réglages. L’outil tient compte de certaines combinaisons, pas de toutes.
  • Les réglages s’appliquent aux opérations propres du service. Les requêtes envoyées par IPC, comme D-Bus, peuvent atteindre des services qui ne sont pas restreints de la même façon : vérifiez donc séparément la politique d’accès IPC.

En pratique, traitez la note de systemd-analyze security comme une boussole, pas comme un objectif. En effet, elle aide à repérer les services sans aucune protection et confirme qu’un changement a produit l’effet voulu.

Les options à connaître

OptionCe qu’elle faitTestée ici
--offline=BOOLPasse en revue des fichiers d’unité sans interroger le gestionnaire en cours d’exécution (ajoutée en version 250)Oui : fonctionne avec un chemin de fichier et avec un nom d’unité
--threshold=NOMBRERenvoie une erreur quand le niveau global dépasse la valeur (ajoutée en 250)Oui : code de sortie 1 au-dessus du seuil, 0 en dessous
--json=MODEAffiche le tableau en JSON : off, pretty ou shortOui : short
--security-policy=CHEMINApplique vos propres exigences en JSON ; un poids de 0 signifie « ne pas contrôler » (ajoutée en 250)Non testée
--profile=CHEMINAvec --offline, prend en compte un profil portable (ajoutée en 250)Non testée
--no-pagerAffiche directement, sans pagineurOui

Voici les résultats réels pour le service de démonstration une fois le drop-in du lab en place (sa note est 8,9, comme le montre l’étape 3 du lab). J’ai lancé deux fois --offline, sur le chemin du fichier puis sur le nom de l’unité :

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
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁

J’ai ensuite lancé --threshold deux fois pour vérifier le code de sortie :

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]}"
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=1
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
exit=0

Le mode JSON donne un objet par ligne, avec les champs set, name, json_field, description et exposure. Voici les 330 premiers caractères :

systemd-analyze security --json=short --no-pager demo-hardening-sandbox.service | head -c 330
[{"set":false,"name":"RootDirectory=/RootImage=","json_field":"RootDirectoryOrRootImage","description":"Service runs within the host's root directory","exposure":"0.1"},{"set":null,"name":"SupplementaryGroups=","json_field":"SupplementaryGroups","description":"Service runs as root, option does not matter","exposure":null},{"set"
[...]

Un détail mérite un avertissement. Le manuel dit que le niveau global va de 0,0 à 10,0, mais il documente un seuil par défaut de 100, et précise que --offline renvoie une erreur au-dessus de ce seuil. Le seuil par défaut ne se déclenche donc jamais. Si vous utilisez --threshold dans un script, passez une valeur explicite et testez les deux côtés du seuil, comme je l’ai fait ci-dessus.

Contrôler une unité avant de la déployer

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

systemd-analyze verify /etc/systemd/system/demo-hardening-sandbox.service; echo "exit=$?"
exit=0

L’absence de message et le code 0 signifient que verify n’a rien à signaler. Cependant, le manuel ne donne aucun exemple de revue hors ligne d’un chemin de fichier. Cela fonctionne sur mon banc de test, mais essayez-le sur une copie avant de vous y fier.

Une boucle de durcissement sûre

Le durcissement tourne mal quand vous changez cinq choses à la fois et ne savez plus laquelle a cassé le service. La boucle ci-dessous l’évite. Elle commence par systemd-analyze security, puis ajoute une directive à la fois, et elle se termine par un test du vrai travail du service.

Boucle de durcissement d'un service systemd : mesurer, changer une directive, redémarrer, lire le résultat, tester le vrai travail, garder ou annuler
La boucle de durcissement : une directive à la fois, puis la garder ou l'annuler.
  1. Mesurer. Lancez systemd-analyze security UNITÉ et notez les lignes ✗ que vous comptez corriger.
  2. Changer une directive dans un drop-in avec sudo systemctl edit UNITÉ.
  3. Redémarrer avec sudo systemctl restart UNITÉ. Un simple rechargement ne donne pas son nouveau sandbox à un service déjà lancé.
  4. Lire le résultat avec systemctl status UNITÉ et journalctl -u UNITÉ.
  5. Tester le vrai travail : se connecter, envoyer un fichier, lancer la sauvegarde, envoyer un courrier. Un service peut démarrer et échouer plus tard.
  6. Garder ou annuler. Gardez le drop-in dans votre gestion de configuration, ou supprimez-le, lancez sudo systemctl daemon-reload et redémarrez.

Les deux dernières étapes comptent le plus. Un sandbox peut laisser un service démarrer alors que l’une de ses tâches échoue plus tard, par exemple parce qu’elle a besoin d’un chemin désormais en lecture seule. C’est pourquoi la boucle se termine par un test du vrai travail, et non par un démarrage réussi.

Lire un échec

Quand un sandbox casse un service, le code de sortie et le message du service vous indiquent la directive à assouplir. Voici une unité de démonstration qui tourne avec ProtectSystem=strict et tente de créer un fichier dans /etc. La sortie de status est coupée après le résumé, car les lignes du journal qui suivent portent le nom de ma machine de test :

sudo systemctl start demo-hardening-fail.service
systemctl status --no-pager -n 5 demo-hardening-fail.service
× demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)
     Loaded: loaded (/etc/systemd/system/demo-hardening-fail.service; static)
     Active: failed (Result: exit-code) since Mon 2026-11-02 14:54:40 CET; 35ms ago
 Invocation: 55376c422cb741a69786c3968cc455a2
    Process: 2008 ExecStart=/bin/touch /etc/demo-hardening-fail (code=exited, status=1/FAILURE)
   Main PID: 2008 (code=exited, status=1/FAILURE)
   Mem peak: 1.2M
        CPU: 5ms
[...]

Le journal montre le message du programme lui-même. -u sélectionne l’unité, -o cat n’affiche que le message et -n 6 limite le nombre de lignes :

journalctl -u demo-hardening-fail.service -o cat --no-pager -n 6
Starting demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless)...
/bin/touch: cannot touch '/etc/demo-hardening-fail': Read-only file system
demo-hardening-fail.service: Main process exited, code=exited, status=1/FAILURE
demo-hardening-fail.service: Failed with result 'exit-code'.
Failed to start demo-hardening-fail.service - Demo unit that the sandbox breaks (harmless).

Le message dit Read-only file system : la directive responsable est donc ProtectSystem=. La solution est d’autoriser le chemin dont le programme a vraiment besoin avec ReadWritePaths=, que la partie 2 de la série détaille. Les messages de systemd restent en anglais sur le banc de test.

Lab pratique : voir trois directives à l’œuvre

Ce lab prend environ quinze minutes. Il utilise un service de démonstration qui se contente de lister, tester et créer des fichiers, et il supprime tout à la fin. Chaque sortie ci-dessous est une sortie réelle du banc de test. Les lignes qui commencent par --- dans les sorties sont des libellés que mon script de test a affichés entre les commandes, en anglais.

Étape 1 : créer le service de démonstration et le lancer sans sandbox

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

# Démonstration inoffensive : compte les entrées de /home, teste une écriture dans /etc et crée un fichier dans /tmp.
[Unit]
Description=Service de démonstration du sandboxing (inoffensif)

[Service]
# oneshot : systemd attend la fin de la commande ; l'unité ne reste jamais « active ».
Type=oneshot
# systemd ne lance pas de shell tout seul : la commande démarre donc /bin/sh explicitement.
ExecStart=/bin/sh -c 'echo "entrées dans /home :"; ls -A /home | wc -l; touch /etc/demo-hardening-test && rm /etc/demo-hardening-test && echo "etc : écriture ok" || echo "etc : écriture refusée"; touch /tmp/demo-hardening-marker; echo "terminé"'

Demandez maintenant à systemd de lire le nouveau fichier, affichez-le, lancez le service et lisez sa sortie. Un service oneshot s’exécute une fois, et systemctl start attend sa fin :

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}'
[...]
Starting demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif)...
entrées dans /home :
1
etc : écriture ok
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
-rw-r--r-- root /tmp/demo-hardening-marker

systemctl cat affiche le fichier précédé d’une ligne de commentaire avec son chemin. Le journal garde les exécutions précédentes du service sur le banc de test (celles de la variante anglaise de ce lab) : j’ai coupé ces anciennes lignes avec [...]. Lisez le résultat. Le service a vu une entrée dans /home, il a pu écrire dans /etc et il a créé son fichier témoin dans le vrai /tmp. Rien n’est encore protégé. Mesurez le point de départ :

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 '✓'
→ Overall exposure level for demo-hardening-sandbox.service: 9.6 UNSAFE 😨
75
4

Regardez enfin les trois lignes que l’étape suivante va corriger :

systemd-analyze security --no-pager demo-hardening-sandbox.service | grep -E 'ProtectSystem|ProtectHome|PrivateTmp'
✗ ProtectSystem=                                              Service has full access to the OS file hierarchy                             0.2
✗ ProtectHome=                                                Service has full access to home directories                                  0.2
✗ PrivateTmp=                                                 Service has access to other software's temporary files                       0.2

Étape 2 : ajouter le drop-in

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

sudo systemctl edit --stdin --drop-in=hardening demo-hardening-sandbox.service <<'EOF'
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
EOF

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

Successfully installed edited file '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.

Vérifiez maintenant ce que systemd lit et ce qu’il a chargé. La sortie de systemctl cat commence par le fichier principal de l’unité ; je coupe cette partie :

systemctl cat demo-hardening-sandbox.service
systemctl show --property=ProtectSystem --property=ProtectHome --property=PrivateTmp demo-hardening-sandbox.service
[...]
# /etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf
[Service]
# Tout le système de fichiers devient en lecture seule pour ce service (sauf /dev, /proc et /sys).
ProtectSystem=strict
# /home, /root et /run/user deviennent inaccessibles et vides.
ProtectHome=true
# /tmp et /var/tmp deviennent privés à ce service et sont supprimés à son arrêt.
PrivateTmp=true
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict

systemctl show affiche yes là où le fichier dit true : les deux désignent le même booléen. Les trois dernières lignes sont les valeurs que systemd a chargées, et celles d’avant sont le fichier. Relancez le service et comparez avec l’étape 1 :

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
entrées dans /home :
0
touch: cannot touch '/etc/demo-hardening-test': Read-only file system
etc : écriture refusée
terminé
demo-hardening-sandbox.service: Deactivated successfully.
Finished demo-hardening-sandbox.service - Service de démonstration du sandboxing (inoffensif).
ls: cannot access '/tmp/demo-hardening-marker': No such file or directory

Trois choses ont changé, et elles correspondent au manuel. Le service voit maintenant 0 entrée dans /home. Son écriture dans /etc échoue avec Read-only file system. Et le fichier témoin qu’il a créé n’est pas visible depuis l’hôte, car le service a utilisé un /tmp privé.

Étape 3 : mesurer à nouveau

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'
→ Overall exposure level for demo-hardening-sandbox.service: 8.9 EXPOSED 🙁
✓ ProtectSystem=                                              Service has strict read-only access to the OS file hierarchy
✓ PrivateTmp=                                                 Service has no access to other software's temporary files
✓ ProtectHome=                                                Service has no access to home directories                               

La note de systemd-analyze security est passée de 9,6 UNSAFE à 8,9 EXPOSED, les trois lignes sont passées de ✗ à ✓, et les compteurs sont passés de 75 non appliqués et 4 appliqués à 71 et 8. Trois directives donnent un gain modeste, ce qui est normal : un service root garde beaucoup d’autres expositions, comme l’utilisateur sous lequel il tourne et ses capabilities. Les parties 3 et 4 de la série s’en occupent.

Étape 4 : essayer une directive à la fois avec systemd-run

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

RUN="sudo systemd-run --quiet --pipe --wait --collect"
echo "--- no sandbox: write to /etc"; $RUN /bin/touch /etc/demo-hardening-t1; echo "exit=$?"; ls /etc/demo-hardening-t1; rm -f /etc/demo-hardening-t1
echo "--- ProtectSystem=strict: write to /etc"; $RUN -p ProtectSystem=strict /bin/touch /etc/demo-hardening-t2; echo "exit=$?"; ls /etc/demo-hardening-t2 2>&1
echo "--- ProtectSystem=true: write to /etc, then to /usr"
$RUN -p ProtectSystem=true /bin/touch /etc/demo-hardening-t3; echo "etc exit=$?"; ls /etc/demo-hardening-t3; rm -f /etc/demo-hardening-t3
$RUN -p ProtectSystem=true /bin/touch /usr/demo-hardening-t4; echo "usr exit=$?"; ls /usr/demo-hardening-t4 2>&1
echo "--- ProtectSystem=full: write to /etc"; $RUN -p ProtectSystem=full /bin/touch /etc/demo-hardening-t5; echo "exit=$?"; ls /etc/demo-hardening-t5 2>&1
echo "--- /home entries seen by the service, without and with ProtectHome=true"
$RUN /bin/sh -c 'ls -A /home | wc -l'
$RUN -p ProtectHome=true /bin/sh -c 'ls -A /home | wc -l'
echo "--- ProtectHome=true: ls -ld /home inside the service"; $RUN -p ProtectHome=true /bin/ls -ld /home | awk '{print $1, $3, $4}'
echo "--- PrivateTmp=true: file created in /tmp by the service, seen from the host?"
rm -f /tmp/demo-hardening-t6
$RUN -p PrivateTmp=true /bin/touch /tmp/demo-hardening-t6; echo "service exit=$?"; ls /tmp/demo-hardening-t6 2>&1
echo "--- without PrivateTmp, same test"
$RUN /bin/touch /tmp/demo-hardening-t7; echo "service exit=$?"; ls /tmp/demo-hardening-t7 2>&1; rm -f /tmp/demo-hardening-t7
--- no sandbox: write to /etc
exit=0
/etc/demo-hardening-t1
--- ProtectSystem=strict: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t2': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t2': No such file or directory
--- ProtectSystem=true: write to /etc, then to /usr
etc exit=0
/etc/demo-hardening-t3
/bin/touch: cannot touch '/usr/demo-hardening-t4': Read-only file system
usr exit=1
ls: cannot access '/usr/demo-hardening-t4': No such file or directory
--- ProtectSystem=full: write to /etc
/bin/touch: cannot touch '/etc/demo-hardening-t5': Read-only file system
exit=1
ls: cannot access '/etc/demo-hardening-t5': No such file or directory
--- /home entries seen by the service, without and with ProtectHome=true
1
0
--- ProtectHome=true: ls -ld /home inside the service
d--------- root root
--- PrivateTmp=true: file created in /tmp by the service, seen from the host?
service exit=0
ls: cannot access '/tmp/demo-hardening-t6': No such file or directory
--- without PrivateTmp, same test
service exit=0
/tmp/demo-hardening-t7

Ce tableau lit les résultats comme le manuel les décrit :

DirectiveCe que mon test a montréManuel
ProtectSystem=trueL’écriture dans /etc fonctionne, l’écriture dans /usr est refusée/usr et les répertoires du chargeur de démarrage en lecture seule
ProtectSystem=fullL’écriture dans /etc est refusée/etc en lecture seule également
ProtectSystem=strictL’écriture dans /etc est refuséeToute la hiérarchie en lecture seule, sauf /dev, /proc, /sys
ProtectHome=true0 entrée vue, et /home affiche le mode d---------Répertoires inaccessibles et vides
PrivateTmp=trueLe fichier créé par le service est absent sur l’hôte/tmp et /var/tmp non partagés avec les processus extérieurs

Ces résultats confirment le manuel sur mon banc de test. Ils viennent d’un noyau WSL2 : confirmez-les sur votre propre serveur avant de vous appuyer sur une directive en production.

Étape 5 : annuler et nettoyer

systemctl revert supprime les drop-ins d’une unité. Il laisse en place un fichier d’unité qui n’a pas de version fournie par un paquet, comme notre fichier de démonstration. Supprimez ensuite les fichiers de démonstration et prévenez systemd :

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
Removed '/etc/systemd/system/demo-hardening-sandbox.service.d/hardening.conf'.
Removed '/etc/systemd/system/demo-hardening-sandbox.service.d'.

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

Erreurs courantes

Chaque erreur ci-dessous s’est produite sur le banc de test, avec le message réel. Je les ai reproduites sur une unité jetable nommée demo-hardening-mistakes.service (un service oneshot qui lance /bin/true) et sur un service sleep, puis j’ai supprimé les deux.

Erreur 1 : un drop-in sans en-tête de section

Le drop-in a-no-header.conf ne contenait que ProtectSystem=strict. Systemd a ignoré la ligne, et la valeur chargée est restée no :

systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i a-no-header | sort -u
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/a-no-header.conf:1: Assignment outside of section. Ignoring.

Erreur 2 : un drop-in sans le suffixe .conf

Un fichier nommé b-wrong-suffix.txt n’est jamais lu. systemctl cat ne liste que le fichier principal, et la valeur reste no :

systemctl cat demo-hardening-mistakes.service | grep '^#'
systemctl show --property=ProtectSystem demo-hardening-mistakes.service
# /etc/systemd/system/demo-hardening-mistakes.service
ProtectSystem=no

Erreur 3 : un commentaire sur la même ligne qu’un réglage

J’ai écrit ProtectSystem=strict # make everything read-only sur une seule ligne. Systemd a lu tout le texte comme la valeur, l’a rejeté et a gardé la valeur par défaut :

systemctl show --property=ProtectSystem demo-hardening-mistakes.service
journalctl -b -o cat --no-pager --since "-1min" | grep -i c-inline-comment | sort -u
ProtectSystem=no
/etc/systemd/system/demo-hardening-mistakes.service.d/c-inline-comment.conf:2: Failed to parse ProtectSystem=strict # make everything read-only, ignoring: Invalid argument

Erreur 4 : un rechargement sans redémarrage

Un service sleep tournait déjà. J’ai ajouté ProtectSystem=strict puis lancé daemon-reload. systemctl show a répondu strict, mais le processus a gardé le même PID et le même namespace de montage que le PID 1. Seul le redémarrage lui a donné un nouveau PID et son propre namespace :

PID=$(systemctl show --property=MainPID --value demo-hardening-sleep.service)
readlink /proc/1/ns/mnt
readlink /proc/$PID/ns/mnt

J’ai lancé ces trois lignes avant le changement, après le rechargement et après le redémarrage. Les résultats, avec leurs libellés, sont :

--- mount namespace of PID 1 and of the service
pid1:    mnt:[4026532218]
service: mnt:[4026532218]
--- after drop-in + daemon-reload (no restart)
show ProtectSystem: strict
same PID: 1886 (was 1886)
service: mnt:[4026532218]
--- after systemctl restart
new PID: 1948
pid1:    mnt:[4026532218]
service: mnt:[4026532287]

Autres erreurs à éviter

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

Une checklist à suivre

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

Les familles de directives des prochaines parties

Les prochaines parties prennent chaque famille à tour de rôle. Chaque nom de directive ci-dessous est documenté dans la page de manuel systemd.exec ; IPAddressAllow= et IPAddressDeny= le sont dans systemd.resource-control.

PartieFamilleDirectives couvertesQuestion à laquelle elle répond
2Système de fichiersProtectSystem, ProtectHome, PrivateTmp, ReadWritePaths, ReadOnlyPaths, InaccessiblePaths, TemporaryFileSystem, BindPathsQue peut lire et écrire le service ?
3PrivilègesNoNewPrivileges, User, DynamicUser, CapabilityBoundingSet, AmbientCapabilities, SecureBits, RestrictSUIDSGIDQue peut devenir le processus ?
4Noyau et périphériquesProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectClock, ProtectControlGroups, PrivateDevices, ProtectProc, ProcSubsetQue peut-il modifier dans le noyau ?
5Namespaces et réseauPrivateNetwork, PrivateUsers, RestrictNamespaces, RestrictAddressFamilies, IPAddressAllow, IPAddressDenyAvec qui peut-il communiquer ?
6Appels systèmeSystemCallFilter, SystemCallArchitectures, SystemCallErrorNumberQuelles requêtes peut-il envoyer au noyau ?
7Mémoire et diversMemoryDenyWriteExecute, LockPersonality, RestrictRealtime, UMask, ProtectHostname, RemoveIPC, KeyringModeQuels tours de passe-passe lui restent possibles ?
8Toute la stackUne matrice de chaque directive face à chaque serviceQue dois-je appliquer où ?

Ma stack, et par où commencer

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

Mes serveurs font tourner une stack auto-hébergée typique, que les parties suivantes couvriront service par service :

  • un reverse proxy devant chaque application publique, avec son bouncer CrowdSec ;
  • des applications PHP-FPM, ainsi que des applications Node.js, Python, Go et .NET (un serveur multimédia et un serveur de bibliothèque) ;
  • MariaDB, PostgreSQL et Valkey, avec pgBackRest pour les sauvegardes PostgreSQL ;
  • un serveur de messagerie, un serveur SFTP et WebDAV, et une messagerie d’équipe ;
  • des agents de sécurité et de supervision : un agent SIEM, une protection contre la force brute, un IDS et des exporters de métriques ;
  • un VPN et une console d’administration web.

Je commence par les services qui réunissent trois critères : ils sont joignables depuis Internet, directement ou derrière le reverse proxy ; des tiers les ont écrits ; et ils n’ont aucune politique SELinux propre. Ce sont les applications web. Les bases de données et les agents viennent après, car le réseau ne peut pas les joindre directement.

Ce que je retiens

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

Sources

Articles liés

Schéma du parcours CrowdSec : journaux, agent, API locale, bouncer pare-feu, liste communautaire et liste blanche

Pas à pas : installer CrowdSec, bloquer au pare-feu et ne jamais se bannir

CrowdSec est souvent présenté comme « un fail2ban moderne ». C’est vrai en partie : lui aussi lit les journaux et bannit les adresses agressives. Mais il ajoute deux choses que fail2ban n’a pas. D’abord des scénarios plus riches, qui reconnaissent un balayage de chemins web, une attaque par dictionnaire répartie ou une exploitation de faille connue, au lieu de simplement compter des lignes. Ensuite une intelligence collective : chaque installation signale les attaques qu’elle voit, et reçoit en échange une liste d’adresses déjà repérées ailleurs, bloquées avant même qu’elles ne vous atteignent.

Ce tutoriel installe CrowdSec 1.8 sur Rocky Linux, le branche sur SSH et nginx, ajoute le blocage au pare-feu, et surtout explique comment ne pas se bannir soi-même, ce qui arrive plus vite qu’on ne le croit.

Schéma du parcours CrowdSec : journaux, agent, API locale, bouncer pare-feu, liste communautaire et liste blanche
De la ligne de journal au blocage, et comment ne jamais se bannir soi-même.

Les pièces du puzzle

ÉlémentRôle
l’agent (crowdsec)lit les journaux, les découpe (parseurs), les compare à des scénarios, émet des alertes
l’API locale (LAPI)stocke les alertes et transforme celles qui le méritent en décisions (bannir telle adresse pendant tant de temps)
les bouncersappliquent les décisions : pare-feu (nftables), serveur web, application
l’API centrale (CAPI)reçoit vos signalements et vous renvoie une liste de blocage communautaire
le Huble catalogue de collections, parseurs, scénarios, installés par cscli

Point important : l’agent ne bloque rien. Sans bouncer, CrowdSec détecte et prend des décisions, mais personne ne les applique. C’est l’erreur la plus fréquente lors d’une première installation.

Étape 1 : installer

CrowdSec publie ses paquets dans un dépôt packagecloud. Déclarez-le dans /etc/yum.repos.d/ avec sa clé GPG, puis :

dnf install crowdsec crowdsec-firewall-bouncer-nftables
systemctl enable --now crowdsec
cscli version

À l’installation, le paquet détecte les services présents (sshd, nginx…) et installe les collections correspondantes. Vérifiez ce qu’il a choisi plutôt que de lui faire confiance : cscli collections list.

Une précaution sur Debian : si vous installez CrowdSec et le bouncer dans la même commande sur une machine neuve, le script post-installation du bouncer peut s’exécuter avant que l’API locale soit prête et échouer à s’enregistrer. Installez l’agent, attendez qu’il démarre, puis le bouncer.

Étape 2 : les collections

cscli collections install crowdsecurity/linux crowdsecurity/sshd crowdsecurity/nginx
cscli collections list
systemctl reload crowdsec

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

Étape 3 : dire à CrowdSec quoi lire

La configuration d’acquisition liste les sources et leur type, qui détermine le parseur utilisé :

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

Pour vérifier que les lignes sont bien lues et comprises :

cscli metrics show acquisition parsers
# « Lines read » augmente, « Lines parsed » aussi ; beaucoup de « unparsed » = mauvais type

Étape 4 : ne jamais se bannir soi-même

C’est la partie à ne pas sauter. CrowdSec n’a aucune raison de faire confiance à vos propres adresses : un script de supervision qui reçoit une série de 403, des tests de connexion SSH ratés, un serveur qui interroge ses propres noms publics… et c’est votre propre serveur que le pare-feu rejette. J’ai vécu les deux : un serveur qui s’est banni lui-même pour « sondage HTTP », et un autre qui a banni l’adresse de mon poste d’administration pendant que je vérifiais des clés SSH.

La parade est une liste blanche locale, en parseur d’enrichissement :

# /etc/crowdsec/parsers/s02-enrich/local-whitelist.yaml
name: local/whitelists
description: "Adresses de confiance, jamais bannies"
whitelist:
  reason: "mes serveurs et mon adresse d'administration"
  ip:
    - "203.0.113.25"
    - "203.0.113.40"
    - "2001:db8::25"
    - "198.51.100.10"

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

Étape 5 : appliquer les décisions au pare-feu

cscli bouncers add firewall-bouncer     # affiche une clé à copier dans la configuration du bouncer
# /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml (extrait)
mode: nftables
update_frequency: 10s
api_url: http://127.0.0.1:8081/
api_key: <clé générée par cscli bouncers add>
disable_ipv6: false
deny_action: DROP
supported_decisions_types:
  - ban
nftables:
  ipv4:
    enabled: true
    table: crowdsec
  ipv6:
    enabled: true
    table: crowdsec6
  • mode: nftables : le bouncer crée ses propres tables et ensembles nftables, indépendants de firewalld. Les adresses bannies sont dans un ensemble, ce qui reste rapide même avec des dizaines de milliers d’entrées ;
  • update_frequency : toutes les 10 secondes, le bouncer récupère les nouvelles décisions ;
  • deny_action: DROP : le paquet est jeté sans réponse, l’attaquant n’apprend rien ;
  • IPv6 : à activer explicitement ; un attaquant qui dispose des deux familles d’adresses passera sinon par celle qui n’est pas filtrée.
systemctl enable --now crowdsec-firewall-bouncer
cscli bouncers list                    # « Last API pull » récent
nft list set ip crowdsec crowdsec-blacklists-cscli | head

Étape 6 : la liste communautaire

cscli capi register
cscli console enroll <clé de la console>   # facultatif : tableau de bord web
cscli capi status
systemctl restart crowdsec

Une fois inscrit, l’agent envoie les alertes de ses scénarios (adresse et scénario, jamais le contenu des journaux) et reçoit une liste de blocage d’environ quinze mille adresses, renouvelée régulièrement. C’est elle qui fait la plus grande différence au quotidien : la majorité des robots qui balaient Internet sont bloqués dès leur premier paquet. cscli decisions list --origin CAPI | wc -l donne la taille de la liste reçue.

Simuler avant de bannir

Un nouveau scénario, ou un nouveau serveur, peut être placé en simulation : les alertes et décisions sont enregistrées mais marquées « simulées », et aucun bouncer ne les applique. C’est le moyen de voir ce que CrowdSec aurait bloqué pendant une semaine, avant de lui confier la porte.

cscli simulation enable crowdsecurity/http-crawl-non_statics
cscli simulation status
cscli alerts list --since 24h

Exploiter au quotidien

cscli alerts list --since 24h           # ce qui a été détecté
cscli decisions list                    # qui est banni, pour combien de temps
cscli decisions delete --ip 192.0.2.66   # lever un bannissement
cscli decisions add --ip 192.0.2.99 --duration 72h --reason 'manuel'
cscli metrics                           # sources, parseurs, scénarios, bouncers

La durée de bannissement par défaut est de quatre heures ; un profil (profiles.yaml) permet de l’allonger pour les récidivistes ou de bannir une plage entière plutôt qu’une adresse.

Une heure d’un vrai serveur

Voici les chiffres de mon serveur web le 2 novembre 2026, 75 minutes après un redémarrage (CrowdSec 1.8.2, bouncer nftables 0.0.36). D’abord, ce que l’agent a lu :

# 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       |
  • Lines parsed égal à Lines read sur les journaux web : l’analyseur nginx comprend chaque ligne. Si un journal affiche tout en unparsed, la collection manque ou le format du journal a été personnalisé.
  • /var/log/messages : 1,31k non analysées : c’est attendu. La plupart des lignes du journal système ne sont pas des événements de sécurité et aucun analyseur ne les revendique. Les événements SSH et d’authentification sont dans /var/log/secure, lu par sa propre entrée : 3 020 lignes lues, 39 analysées, et c’est bien le but, puisque seules les connexions échouées et événements semblables intéressent CrowdSec.
  • Whitelisted : les requêtes venant de mes propres adresses et des sondes de supervision, ignorées volontairement (étape 4).
  • Poured to bucket : les lignes qui ont alimenté un scénario. La plupart ne deviennent jamais une alerte ; un compteur ne déborde que si le comportement se répète.

Puis le côté application des décisions, la seule preuve que la protection est réelle :

# cscli bouncers list
 crowdsec-firewall-bouncer  127.0.0.1  ✔️  2026-11-02T10:21:35+01:00  v0.0.36
 openresty-appsec           127.0.0.1  ✔️  2026-11-02T10:21:37+01:00  v1.2.3

# nft list set ip crowdsec crowdsec-blacklists-CAPI      → 14 752 adresses IPv4
# nft list set ip6 crowdsec6 crowdsec6-blacklists-CAPI   → 475 adresses IPv6
# nft list set ip crowdsec crowdsec-blacklists-crowdsec  → 8 décisions locales

# nft list counters table ip crowdsec
counter processed                     packets 1286809
counter crowdsec-blacklists-CAPI      packets 387   → jetés
counter crowdsec-blacklists-crowdsec  packets 60    → jetés

En 75 minutes, 447 paquets ont été jetés avant d’atteindre le moindre service : 387 venaient d’adresses de la liste communautaire, qui n’avaient jamais touché ce serveur, et 60 des 8 adresses bannies par mon propre serveur. La colonne Last API pull, vieille de quelques secondes pour les deux bouncers, est la première chose à regarder quand les bannissements semblent sans effet : un bouncer qui ne tire plus continue d’appliquer une liste figée.

Allonger les bannissements : profiles.yaml

Le profil décide de ce qu’une alerte devient. Celui par défaut bannit l’adresse quatre heures. On peut le rendre progressif, comme avec fail2ban, en s’appuyant sur le nombre de décisions déjà prises contre cette adresse :

# /etc/crowdsec/profiles.yaml
name: default_ip_remediation
filters:
  - Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
  - type: ban
    duration: 4h
duration_expr: Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)
on_success: break
  • filters : le profil ne s’applique qu’aux alertes qui demandent une sanction et visent une adresse IP ;
  • duration_expr : 4 heures la première fois, 8 la deuxième, 12 la troisième… Le calcul utilise l’historique de l’API locale, il survit donc aux redémarrages ;
  • on_success: break : les profils suivants ne sont pas évalués. L’ordre des profils dans le fichier compte.

Les pannes fréquentes

Rien n’est jamais banni

Vérifiez dans l’ordre : cscli metrics show acquisition (les lignes sont-elles lues ?), cscli metrics show parsers (sont-elles comprises ?), cscli alerts list (des scénarios se déclenchent-ils ?), cscli bouncers list (le bouncer récupère-t-il les décisions ?). La panne se trouve presque toujours à la première étape qui reste à zéro.

Le bouncer ne démarre pas, « unauthorized »

La clé du bouncer ne correspond à aucune clé de l’API locale, typiquement après une réinstallation qui a vidé la base. Supprimez l’entrée (cscli bouncers delete), recréez-la et recopiez la clé.

L’API locale écoute sur un port déjà pris

Par défaut, l’API locale écoute sur 8080, port très demandé. Changez-le dans config.yaml (api.server.listen_uri) et dans local_api_credentials.yaml, puis dans la configuration de chaque bouncer. Oublier l’un des trois produit un agent qui démarre mais dont les outils ne parlent plus à personne.

Les parseurs ne comprennent pas les journaux SSH

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

Mettre à jour sans surprise

  • mettez à jour l’agent, puis vérifiez cscli metrics et la liste des bouncers avant de toucher aux bouncers ;
  • lisez les notes de version : la version 1.8 a par exemple changé la façon dont certains champs sont exposés aux scénarios ;
  • après une mise à jour du hub, un cscli hub upgrade peut remplacer un scénario que vous aviez modifié à la main. Les modifications locales vont dans des fichiers à vous, jamais dans ceux du hub.

Et fail2ban ?

Les deux cohabitent sans problème, et c’est ce que je fais : fail2ban réagit vite à mes propres journaux avec des bannissements progressifs, CrowdSec ajoute les scénarios web et la liste communautaire. Seul inconvénient : quand une adresse reste bloquée, il faut regarder des deux côtés (fail2ban-client status et cscli decisions list).

Pour aller plus loin

CrowdSec sait aussi inspecter le contenu des requêtes HTTP grâce à son moteur AppSec, et bloquer les attaques directement dans le serveur web. C’est l’objet d’un prochain tutoriel de cette série, avec OpenResty.


Ce billet fait partie d’une série de tutoriels d’administration système (tag « pas à pas »).
Série « Pas à pas », tutoriel 9/77. · ← Tutoriel précédent · Tutoriel suivant → · English version

Articles liés

Mon blog technique

Est situé ici, en anglais : https://hecate.neurozone.fr

Page 1 of 33

Propulsé par WordPress & Thème par Anders Norén