
🌐 Aussi en: English · Deutsch · Español
Partie 5 de la visite Ansible. La partie 1 a expliqué le cadre, la partie 2 le serveur et la plateforme, les parties 3 et 4 le blog et Nextcloud. Cette partie rassemble chaque rôle de sécurité au même endroit, pour les deux piles. Elle décrit la version v3.3.0.
Defense in Depth a décrit l’idée : neuf couches entre Internet et les données, chacune rattrapant ce que la précédente a laissé passer. Cet article disait quelles sont les couches. Celui-ci montre comment elles sont construites – rôle par rôle, avec les handlers, les entrées déclarées, les templates et les erreurs qui les ont façonnées. Neuf rôles, de l’extérieur vers l’intérieur :
| Couche | Rôle | Protège contre |
|---|---|---|
| Réseau | common_firewall | Tout ce qui n’est pas explicitement autorisé, les scans de ports, une porte dérobée via Kubernetes |
| Connexion | common_ssh | Le devinement de mots de passe, une cryptographie faible, des sessions d’administration qui en peuvent trop |
| Réaction | common_fail2ban | Les tentatives répétées – devinement de connexion, scanners, chemins d’attaque connus |
| Système d’exploitation | common_cis_hardening | Les fonctions inutilisées, les réglages par défaut faibles, les fuites d’informations système |
| Confinement | common_selinux | Un processus compromis qui atteint des fichiers qui ne le regardent pas |
| Détection | common_auditd, common_aide_refresh, common_rkhunter, common_clamav | Des changements qui ne devraient jamais arriver, des rootkits, des logiciels malveillants dans les uploads – remarqués, signalés |
Deux autres couches ont été traitées ailleurs et ne sont que résumées à la fin : le durcissement réseau du noyau (common_sysctl, partie 2) et le durcissement des pods eux-mêmes (parties 3 et 4). Et une a son propre article : l’alerte de connexion SSH.
Une remarque sur le niveau de détail : le code est public et la série l’explique, donc un attaquant peut le lire aussi. Ce n’est pas grave – la protection doit venir des clés, des couches et des alertes, pas du secret. Mais je reste vague ici sur les seuils exacts. Ils sont dans le dépôt pour qui en a besoin.
🧱 common_firewall : une table, une liste d’autorisations et quelques pièges
Le pare-feu est nftables, le successeur d’iptables dans le noyau Linux. Si vous connaissez iptables, les différences qui comptent ici sont : un seul outil (nft) pour IPv4 et IPv6 au lieu de deux jeux de règles ; des tables et des chaînes que l’on crée soi-même et que l’on accroche à un point du chemin des paquets dans le noyau (un hook, avec une priorité) ; des sets d’adresses intégrés avec expiration automatique, au lieu de l’outil séparé ipset ; et – le plus important pour l’automatisation – un fichier de règles est chargé comme une seule transaction atomique : les anciennes règles restent actives jusqu’à ce que les nouvelles soient entièrement en place. La migration depuis iptables est un article plus ancien.
Le rôle, pas à pas
- Installer nftables.
- DHCP ou statique ? Le rôle demande à NetworkManager comment l’interface montante obtient ses adresses et range la réponse dans deux facts (la partie 1 l’a pris comme exemple de « détecter plutôt que déclarer »). Ce n’est que là où DHCP est utilisé que le template génère les règles DHCP – sans elles, le renouvellement du bail échoue et l’adresse est perdue.
- Le filtre SSH par pays, si l’hôte a
fw_ssh_geo_countriesdéfini (les trois serveurs n’autorisent SSH que depuis un seul pays – l’histoire est ici). Un script télécharge les plages d’adresses de ce pays auprès du registre régional et les écrit dans un fichier nftables ; un timer systemd le rafraîchit chaque semaine. Ce bloc s’exécute volontairement avant l’écriture du jeu de règles : le jeu de règles inclut le fichier, et si le tout premier téléchargement échoue, le play s’arrête avant qu’existe la moindre règle qui pourrait bloquer SSH. L’ensemble est un seulblockavec un seulwhen, et un bloc miroir supprime tout quand le filtre est désactivé. - Le jeu de règles, de
fw.nft.j2vers/etc/sysconfig/nftables.conf– le fichier que le service d’AlmaLinux lit réellement, pas le/etc/nftables.confque citent beaucoup de guides. Avecvalidate: nft -c -f %s: Ansible écrit le fichier rendu à un emplacement temporaire, lance la vérification de syntaxe dessus et ne remplace le vrai fichier que si elle réussit. Une faute de frappe n’atteint jamais la configuration du serveur. - Un drop-in systemd qui change le rechargement et l’arrêt. On y revient plus bas – c’est la ligne la plus importante du rôle.
- Activer nftables, le démarrer – sauf à la toute première exécution.
- Nettoyer : un ancien script iptables d’avant ce rôle (le blog a tourné un temps avec deux pare-feu superposés, et l’ancien rejetait les renouvellements DHCP), le
/etc/nftables.confinutilisé, et firewalld, qui est masqué plutôt que supprimé – supprimer le paquet supprimerait aussi nftables et fail2ban, car ils dépendent les uns des autres dans l’empaquetage.
Jamais de « flush ruleset »
Le service nftables fourni recharge avec flush ruleset – supprimer toutes les règles du noyau – puis charge le fichier. Sur un nœud Kubernetes, c’est un piège. kube-proxy, Flannel et le contrôleur de network policies intégré à K3s écrivent aussi leurs propres règles dans nftables, dans leurs propres tables. Un flush les supprime aussi, et pendant 30 à 90 secondes, jusqu’à ce qu’ils le remarquent et les réécrivent, les Services Kubernetes ne fonctionnent plus – Nextcloud ne joignait plus sa base de données après chaque rechargement du pare-feu. Le jeu de règles ne remplace donc que sa propre table, et le drop-in apprend à systemd à faire de même :
# fw.nft.j2, first lines
table inet filter
delete table inet filter
table inet filter {
...
}
# /etc/systemd/system/nftables.service.d/10-own-table-only.conf
[Service]
ExecReload=
ExecReload=/sbin/nft -f /etc/sysconfig/nftables.conf
ExecStop=
ExecStop=/sbin/nft delete table inet filterLa première ligne paraît étrange : pourquoi déclarer une table pour la supprimer aussitôt ? Parce que delete échoue si la table n’existe pas – au démarrage d’un système neuf. La déclarer d’abord rend la suppression toujours sûre, et comme tout le fichier est une seule transaction, il n’y a aucun instant sans pare-feu. Dans le drop-in, le ExecReload= vide efface la commande fournie avant de définir la nouvelle, la même astuce qu’avec le timer de mise à jour de la partie 2.
Ce que fait le jeu de règles
Le template est long, mais sa structure est simple. Chaque chaîne a policy drop : ce qui n’est pas explicitement autorisé est rejeté – trafic sortant compris.
- Input : loopback ; les paquets appartenant à une connexion existante (
ct state established,related– le suivi de connexions se souvient de ce qui a été autorisé) ; puis rejet des déchets (état invalide, paquets aux combinaisons de drapeaux impossibles, réponses de négociation falsifiées, inondations de RST) ; puis les sets de bannissement ; puis les exceptions dont le cluster a besoin (les pods qui joignent le serveur d’API, et sur les hôtes Nextcloud la base de données et Redis de l’hôte – l’exemplestackde la partie 1) ; puis le trafic web, SSH (depuis la liste de pays) et les types ICMP dont IPv4 et surtout IPv6 ont besoin pour fonctionner. Enfin, avant le rejet par défaut, une ligne de journal à débit limité. - Sets de bannissement :
banned4/banned6pour fail2ban, avec un délai pour que les entrées expirent d’elles-mêmes, etscanban4/scanban6pour le détecteur de scans de ports. - Détection des scans de ports, dans le pare-feu : un paquet qui atteint la fin de la chaîne input va être rejeté – un port fermé. La règle compte ces tentatives de connexion par adresse source dans un set dynamique ; une source qui en dépasse quelques dizaines par minute atterrit pour une heure dans
scanbanet est alors bloquée sur tous les ports. Une personne qui se trompe de port en envoie une poignée ; un scanner en envoie des centaines. Le port SSH par défaut n’est pas compté, si bien qu’oublier quelques fois le port personnalisé ne bloque jamais l’administrateur. fail2ban ne pourrait pas faire cela – il ne lit que des journaux, et le journal des rejets est volontairement limité en débit. - Prerouting : ferme une porte dérobée que Kubernetes ouvre. kube-proxy relaie la plage NodePort sur chaque adresse locale, l’adresse publique comprise, et ce trafic passe par la chaîne forward, pas par input. Les exporteurs de métriques des parties 3 et 4 étaient ainsi joignables depuis Internet. Cette chaîne rejette les nouvelles connexions vers cette plage sur les adresses publiques. Sa priorité est choisie précisément – après le suivi de connexions, avant que Kubernetes ne réécrive la destination – et cette précision a une histoire : elle s’exécutait d’abord plus tôt, avant le suivi de connexions, et rejetait aussi les réponses aux connexions sortantes des pods chaque fois que leur port source choisi au hasard tombait dans cette plage. Environ 4 pour cent des connexions sortantes des pods ont expiré pendant deux mois, jusqu’à ce qu’un test de réception d’un nouveau serveur le découvre.
- Forward : le trafic des pods dans les deux sens ; output : DNS, NTP, HTTP(S), soumission de mails, DHCP si nécessaire, le réseau des pods et le serveur d’API – rien d’autre ne quitte le serveur.
La première exécution : pourquoi le serveur redémarre
Sur un serveur neuf, SSH écoute encore sur le port 22 et aucun pare-feu n’est actif. Le rôle SSH (plus bas) déplace SSH sur son nouveau port, ce rôle écrit le pare-feu. Activer les deux à chaud couperait la connexion qu’utilise Ansible : la connexion existante n’est pas encore dans le suivi de connexions, ne correspond à aucune règle, et ses paquets sont rejetés en silence – le play reste simplement bloqué. À la première exécution, le rôle ne démarre donc pas nftables et se termine par un redémarrage :
- name: Firewall | First run only – reboot to activate the new SSH port and firewall together
ansible.builtin.include_tasks:
file: first_run_reboot.yml
apply:
tags: [common_firewall]
when: ssh_first_runDans first_run_reboot.yml : envoyer systemctl reboot par la connexion actuelle sur le port 22 avec async: 1, poll: 0 (tirer et oublier – la connexion va mourir), basculer ansible_port sur le nouveau port avec set_fact, abandonner l’ancienne connexion avec meta: reset_connection, et wait_for_connection jusqu’à ce que le serveur réponde sur le nouveau port. Le play continue ensuite là-bas. Trois détails d’Ansible rendent cela possible :
include_tasks, pasimport_tasks:meta: reset_connectionignorewhen. Dans un fichier importé statiquement avec une condition, il s’exécutait à chaque exécution et affichait un avertissement. Un include dynamique évalue d’abord sa condition et ne charge le fichier qu’ensuite.apply: tags: les tags d’un include dynamique n’atteignent pas les tâches qu’il contient.applyles transmet, si bien que l’include fonctionne aussi avec--tags common_firewall.- Le fact
ssh_first_runvient du rôle SSH, qui s’est exécuté juste avant : les valeurs deset_factrestent disponibles pour tous les rôles suivants sur cet hôte. Il est vrai quand le play s’est connecté sur le port 22.
Une chose reste manuelle, et le play affiche un rappel : le nouveau port doit être inscrit dans le vars.yml de l’hôte avant l’exécution suivante. Une version antérieure tentait le redémarrage par le nouveau port – et échouait aussitôt, car les variables de tâche s’appliquent aussi à la connexion qui envoie le redémarrage. L’ordre actuel (redémarrage par l’ancien port, puis bascule) en est né.
Handlers : recharger, puis restaurer les bannissements
- name: Reload nftables
ansible.builtin.service:
name: nftables
state: reloaded
- name: Re-apply fail2ban bans after the nftables reload
ansible.builtin.command: systemctl try-restart fail2ban
listen: Reload nftablesRemplacer la table remplace aussi les sets de bannissement – vides. fail2ban ne le remarquait pas et gardait ces adresses comme « bannies » pendant que le pare-feu les laissait de nouveau entrer : après une exécution du playbook, la plupart des bannissements sur deux serveurs n’existaient plus que dans la mémoire de fail2ban. Le second handler s’abonne à la même notification avec listen et s’exécute après le premier, parce que les handlers s’exécutent dans l’ordre où ils sont définis. Un redémarrage fait réappliquer par fail2ban chaque bannissement actif depuis sa base de données. try-restart ne fait rien là où fail2ban ne tourne pas.
Mise à jour (19/10/2026) : nftables uniquement, sur chaque hôte. Le blog avait encore les paquets EPEL iptables-legacy et iptables-services, si bien que iptables était le backend legacy et que K3s gardait ses règles dans des tables legacy, invisibles pour nft list ruleset. common_firewall retire désormais ces paquets, et common_cis_hardening bloque les modules de tables legacy (iptable_*, ip6table_*). Pas ip_tables lui-même : iptables-nft y prend la correspondance ICMP – le bloquer aussi a fait planter le contrôleur de policies de K3s après un redémarrage et mis le blog hors service pendant cinq minutes.
🔑 common_ssh : la porte, et ce qu’on peut faire une fois à l’intérieur
Port, SELinux et la configuration
SSH passe sur un port non standard. Ce n’est pas de la sécurité en soi – un scanner le trouve –, mais cela élimine presque tout le bruit automatisé visant le port 22, ce qui garde les journaux et fail2ban pertinents. SELinux n’autorise sshd à écouter que sur les ports étiquetés ssh_port_t, donc le rôle lit une fois la liste des étiquettes, puis soit ajoute l’étiquette (semanage port -a), soit modifie une étiquette existante (-m) – deux tâches aux conditions opposées sur la même sortie enregistrée, l’une avec un regex_search qui vérifie si le port est déjà listé sous le bon type.
Le fichier de configuration est strict : clés uniquement, pas de mots de passe ni de connexion keyboard-interactive, seul root peut se connecter (aucun autre compte n’a besoin de SSH), très peu de tentatives et de sessions par connexion, un temps d’authentification court, pas de redirection de ports, de X11 ni de tunnels, et une courte liste d’algorithmes modernes d’échange de clés, de chiffrement et de MAC. LogLevel VERBOSE journalise l’empreinte de la clé utilisée pour chaque connexion.
La première valeur l’emporte
Une leçon tirée de deux serveurs. sshd_config inclut /etc/ssh/sshd_config.d/*.conf tout en haut, et pour sshd, c’est la première valeur d’un mot-clé qui l’emporte. L’image de l’hébergeur pour les serveurs Nextcloud y dépose un 50-cloud-init.conf qui dit PasswordAuthentication yes – et il l’emportait en silence sur le « no » du fichier principal. Le 50-redhat.conf d’AlmaLinux faisait de même avec la redirection X11 sur les trois serveurs. La solution est un drop-in nommé 00-hardening.conf : il est trié en premier, donc ses valeurs l’emportent quoi qu’une image ajoute plus tard.
Le bac à sable autour de sshd
Un drop-in systemd restreint le service sshd – et tout ce qu’il lance hérite des restrictions, chaque session SSH et chaque tâche Ansible comprises. C’est à la fois le but et le risque :
[Service]
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictRealtime=yes
LockPersonality=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINKPersonne qui se connecte par SSH ne peut charger un module du noyau ni changer un paramètre du noyau – y compris un attaquant avec une clé volée. Les changements légitimes passent par systemd-run, que la partie 2 a expliqué. Le commentaire au-dessus du drop-in liste ce qui a été essayé puis retiré, et chaque retrait est une leçon :
AF_NETLINKdoit être autorisé : sans lui,nft,ipetsséchouent dans chaque session SSH – y compris quand on essaie de réparer le pare-feu.- Pas de
RestrictSUIDSGID: il bloquait la pose de bits setuid même pour root, et les installations de paquets qui livrent de tels fichiers échouaient. - Pas de
PrivateTmp: le/tmpprivé de sshd est hérité par chaque session, si bien que les fichiers déposés dans/tmppar SSH étaient invisibles pour cron et les autres services, et chaque redémarrage de sshd le détruisait sous les sessions en cours –dnfa échoué deux fois ainsi.
La règle générale que pose le rôle : le bac à sable par service a sa place sur des services qui ne lancent pas de sessions. Le drop-in de MariaDB dans la partie 4 est l’exemple de la version complète.
Redémarrer le serveur par lequel on est connecté
La partie la plus délicate du rôle est sa chaîne de handlers. Redémarrer sshd tue la connexion par laquelle passe Ansible. Les handlers, dans l’ordre de leur définition (qui est leur ordre d’exécution) :
| Handler | Ce qu’il fait |
|---|---|
Validate sshd config | sshd -t – une configuration cassée arrête le play avant que sshd soit redémarré avec elle |
Reload systemd units for sshd | relire le drop-in ; sans cela, systemd redémarrerait sshd à partir de son ancienne unité en cache |
Reload sshd | le redémarrage – avec async: 30, poll: 0 : le lancer et ne pas attendre une réponse qui ne peut jamais arriver par un canal mourant |
Reset connection after sshd restart | meta: reset_connection : jeter la connexion SSH en cache, désormais morte |
Confirm sshd is accepting connections again | wait_for_connection : se reconnecter ; si sshd n’est pas revenu, le play échoue ici – bruyamment, alors qu’on a sans doute encore une autre session ouverte |
C’est volontairement un redémarrage et non un rechargement : systemd n’applique les réglages de bac à sable qu’au lancement d’un processus. Un rechargement enverrait un signal au sshd en cours, et les changements du drop-in ne prendraient jamais effet. Avant l’ajout d’async, la tâche de redémarrage mourait avec « Module result deserialization failed » – la réponse du module n’arrivait jamais, parce que sa connexion avait disparu.
Une limite est documentée dans le rôle : le sshd_config principal ne notifie que le handler de validation. Un changement de ce seul fichier est vérifié, mais ne devient actif qu’au prochain redémarrage de sshd – par un changement de drop-in ou un reboot. Les drop-ins, qui portent les réglages critiques pour la sécurité, redémarrent sshd eux-mêmes.
Le rôle se termine par l’alerte de connexion (STEP 8), qui a son propre article, une bannière d’avertissement et un /etc/issue remplacé qui ne dit plus à personne, à l’invite de connexion, quel système et quelle version du noyau tournent ici.
🚫 common_fail2ban : réagir à ce que disent les journaux
fail2ban lit des fichiers journaux, reconnaît des attaques avec des expressions régulières (filtres), les compte par adresse et, après trop d’occurrences dans une fenêtre de temps, exécute une action – ici : placer l’adresse dans le set de bannissement du pare-feu. Sept jails combinent un filtre, un journal et des limites :
| Jail | Surveille | Réagit à |
|---|---|---|
sshd | le journal du système | les connexions SSH échouées |
nginx-k3s-wp-login | le journal de l’Ingress | les connexions WordPress échouées |
nginx-k3s-nc-login | le journal de l’Ingress | les connexions Nextcloud échouées |
nginx-k3s-grafana-login | le journal de l’Ingress | les connexions Grafana échouées (plus strict) |
nginx-k3s-scanner | le journal de l’Ingress | des rafales de requêtes typiques des scanners de vulnérabilités |
nginx-k3s-bad-paths | le journal de l’Ingress | des chemins qui ne sont jamais légitimes ici – /.env, /.git/, des web shells, des outils d’administration qui n’existent pas – avec une tolérance quasi nulle et un long bannissement |
recidive | le propre journal de fail2ban | les adresses bannies encore et encore : un bannissement bien plus long sur tous les ports |
Les jails HTTP lisent le journal du contrôleur Ingress directement sur l’hôte – nginx tourne dans un pod, mais son journal atterrit dans /var/log/pods/, dans un format avec un préfixe containerd dont les filtres personnalisés tiennent compte. Cela crée un problème à part : le nom du fichier journal change à chaque redémarrage du conteneur, et fail2ban ne résout le chemin qu’au démarrage d’une jail. Après un reboot, fail2ban démarrait avant le conteneur Ingress et lisait ensuite un fichier mort – les jails HTTP n’ont rien banni pendant des jours. Un petit script lancé par un timer systemd recharge maintenant ces jails dès que l’ensemble des fichiers journaux change ; son état vit dans /run, si bien que la première vérification après chaque démarrage recharge toujours.
L’action de bannissement – et un bug trouvé en écrivant cet article
[Definition]
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; true
[Init]
nftset = banned4
[Init?family=inet6]
nftset = banned6L’action écrit l’adresse directement dans le set du pare-feu, avec la durée du bannissement comme délai de l’élément – nftables la retire de lui-même ; le débannissement n’est que du nettoyage. Les deux sections [Init] choisissent le set selon la famille d’adresses. Cette partie est nouvelle : en relisant le rôle pour cet article, j’ai remarqué que les jails passaient nftset=banned4 pour chaque adresse. Chaque bannissement IPv6 tentait donc de mettre une adresse IPv6 dans un set IPv4, et nft refusait avec « Address family for hostname not supported » – sur les trois serveurs. fail2ban journalisait « Ban », le pare-feu laissait entrer l’adresse, banned6 restait vide. La correction a été testée sur une copie de la configuration en production (fail2ban-client -t et -d, et la commande nft en mode vérification) avant d’entrer dans le dépôt. Le détecteur de scans de ports propre au pare-feu n’a jamais été touché – il gère lui-même les deux familles. La leçon pour les exploitants : une ligne de journal qui dit « Ban » ne prouve pas que quoi que ce soit a été bloqué. Regardez le set.
Deux détails plus petits. Le propre journal de fail2ban doit exister avant son premier démarrage, parce que la jail recidive le lit et que fail2ban vérifie tous les journaux avant de créer le sien – un serveur neuf a échoué exactement ainsi. Le rôle le crée avec state: touch et modification_time: preserve, pour que la tâche ne soit pas « changed » à chaque exécution. Et l’unique handler redémarre fail2ban une fois à la fin du play, quel que soit le nombre de fichiers modifiés – ce qui réapplique aussi tous les bannissements actifs.
Ajout du 19/10/2026 : la connexion WordPress se trouve désormais derrière la même liste de pays que SSH. nginx dans le pod du blog répond 403 à /wp-login.php et /wp-admin/ pour les adresses hors d’Allemagne – avant l’exécution de PHP, si bien que les tentatives de force brute venues de l’étranger n’atteignent ni WordPress ni fail2ban. ssh-geo-update écrit les mêmes blocs RIPE sous forme de fichier geo nginx et recharge nginx après sa mise à jour hebdomadaire ; admin-ajax.php et admin-post.php restent ouverts car des fonctions pour les visiteurs les utilisent. Se connecter depuis l’étranger demande un VPN avec sortie en Allemagne ou une entrée dans fw_ssh_geo_extra.
🛡️ common_cis_hardening : le benchmark, appliqué avec discernement
Le CIS Benchmark est une longue liste de contrôle pour durcir un système d’exploitation. Le passer sur ces serveurs a produit plus d’une centaine de constats. Ce rôle applique ceux qui sont sûrs – et son en-tête commence par une liste de ce qu’il ne fait volontairement pas, chaque point avec sa raison. Cette liste est la partie la plus utile pour quiconque exploite une installation semblable :
- Changements PAM (qualité des mots de passe, verrouillage après échecs) : PAM n’a jamais été sous l’outil de gestion que suppose le benchmark ; l’activer réécrit la pile d’authentification, et une erreur à cet endroit bloque toute connexion locale. SSH fonctionne de toute façon uniquement par clés.
- Un mot de passe pour le chargeur d’amorçage : protège contre un accès physique, qu’un VPS n’a pas, et complique la console de secours de l’hébergeur.
- Une politique cryptographique personnalisée : change les algorithmes TLS et SSH pour tout le système et pourrait casser des choses difficiles à tester.
- Transfert IP et filtrage du chemin retour : requis par Kubernetes, voir la partie 2.
Ce qu’il applique, chaque point étant un petit groupe de tâches :
- AIDE, un contrôleur d’intégrité des fichiers : il tient une base de sommes de contrôle et d’attributs des fichiers système et signale chaque différence. Le rôle l’installe, exclut les répertoires de journaux des pods (ils changent à chaque démarrage de pod et ne sont pas une cible de falsification), construit une fois la première base (
stat→when: not ... exists, viasystemd-runparce que le bac à sable SSH masque des parties du système de fichiers) et planifie deux tâches cron : la vérification à minuit, qui n’envoie un mail que si elle trouve quelque chose, et un renouvellement de la référence à 04:00 – après les mises à jour nocturnes et la fenêtre de redémarrage, pour qu’une mise à jour du noyau installée la nuit n’apparaisse pas le minuit suivant comme des milliers de « fichiers modifiés ». Cette fausse alerte est arrivée une fois, avec exactement une telle mise à jour du noyau. - Les modules du noyau pour d’anciens systèmes de fichiers et le stockage USB sont bloqués (
install <module> /bin/false). - journald garde les journaux de façon persistante et compressée, avec une taille maximale ; un handler le redémarre quand ce fichier change.
- Pas de core dumps : le vidage mémoire d’un processus privilégié peut contenir des mots de passe et des jetons de session.
- cron et at : des droits restrictifs, et seul root peut les utiliser.
- sudo : un pseudo-terminal, un fichier journal et pas d’identifiants en cache – écrit avec
validate: "visudo -cf %s". C’est le filet de sécurité le plus important du rôle : un fichier sudoers cassé peut rendre sudo inutilisable, et la validation garantit qu’une version cassée ne remplace jamais la vraie. - L’utilisateur par défaut de l’image : l’image de l’hébergeur pour les serveurs Nextcloud crée un utilisateur avec sudo sans mot de passe. Le rôle retire cette ligne sudo (là encore avec la validation
visudo) et ne donne à l’utilisateur aucun shell de connexion. Il ne peut pas supprimer l’utilisateur : le même identifiant numérique est utilisé par un processus dans un pod, si bien que l’hôte affiche un processus en cours qui lui « appartient ».getentlit d’abord la base des utilisateurs, et(getent_passwd | default({})).get('almalinux') is not noneest une manière sûre de demander « cet utilisateur existe-t-il ? » sans erreur s’il n’existe pas. - Services et paquets inutilisés : Bluetooth, le démon rsync (mais pas le client rsync, dont les sauvegardes ont besoin – une version antérieure supprimait tout le paquet et les cassait), la pile cliente NFS/Samba qu’apporte l’image cloud, et cloud-init après la mise en service.
- Options de montage :
/tmp,/var/tmpet/dev/shmavecnodev,nosuid,noexec– rien ne peut y être exécuté.ansible.posix.mountavecstate: mountedécrit la ligne dans/etc/fstabet remonte immédiatement ;presentn’écrirait que le fichier. C’est pourquoi l’installateur de K3s de la partie 2 est lancé avecsh /tmp/k3s-install.shau lieu d’être exécuté.
🏷️ common_selinux : des étiquettes qui confinent une intrusion
SELinux donne à chaque fichier et à chaque processus un type, et la politique dit quels types de processus peuvent toucher quels types de fichiers. Un processus de serveur web tourne sous un type et peut lire le contenu web, mais pas, disons, des clés SSH – même s’il tourne en root et même si les droits des fichiers le permettraient. Pour les conteneurs, la règle est simple : les processus des conteneurs tournent en container_t et ne peuvent écrire que des fichiers de type container_file_t. Un conteneur qui s’échappe de son propre système de fichiers trouve un hôte où presque rien ne porte cette étiquette.
Découvert en écrivant cette partie (19/10/2026) : pendant des semaines, cette règle n’était vraie ici qu’à moitié. SELinux était en mode enforcing et les répertoires portaient container_file_t – mais K3s démarre son runtime de conteneurs sans prise en charge de SELinux tant que sa configuration ne contient pas selinux: true, et elle ne le contenait pas. Chaque pod tournait en spc_t ou unconfined_service_t, confiné par rien. Un coup d’œil à ps -eZ l’a montré ; aucune vérification ne l’avait jamais demandé. common_k3s installe désormais la politique k3s-selinux, active l’option et redémarre une fois les workloads. Depuis, chaque processus applicatif tourne en container_t avec ses propres catégories MCS – plus aucun pod ne tourne en spc_t. La leçon pour toute installation SELinux : vérifier le domaine des processus en cours, pas seulement le mode et les étiquettes.
C’est aussi pourquoi l’ordre du playbook compte (partie 1) : les répertoires de l’hôte montés dans les pods doivent porter container_file_t avant que les pods démarrent. Ce rôle les crée – avec les propriétaires qu’attendent les conteneurs, d’après le profil de la pile – et les étiquette :
- name: SELinux | Read the local file-context rules
ansible.builtin.command: semanage fcontext -l -C
register: semanage_fc_local
changed_when: false
- name: SELinux | Label the HostPath directories as container_file_t
ansible.builtin.command: >
semanage fcontext -a -t container_file_t "{{ item.path }}(/.*)?"
loop: "{{ selinux_profiles[stack] }}"
when: >-
(item.path ~ '(/.*)?') not in
(semanage_fc_local.stdout_lines | select | map('split') | map('first') | list)semanage fcontext -a ajoute une règle à la politique – « ce chemin et tout ce qu’il contient est container_file_t » – qui survit à un réétiquetage complet du système ; restorecon -R applique ensuite la règle aux fichiers déjà existants, et les nouveaux fichiers en héritent. La logique « lire d’abord » a une histoire récente : une mise à jour des outils SELinux a cessé d’échouer sur une règle existante et affichait à la place « already defined, modifying instead » sur la sortie normale – l’ancienne vérification cherchait l’erreur et signalait « changed » à chaque exécution. Désormais, le rôle lit les règles locales (-C), prend la première colonne de chaque ligne avec map('split') | map('first') et n’ajoute que ce qui manque.
Les argument_specs du rôle montrent une fonctionnalité bonne à connaître : stack est déclaré avec choices: [wordpress, nextcloud] – une faute de frappe dans la variable de play du playbook échoue avant la première tâche. Le rôle met aussi SELinux en mode enforcing avec le module d’ansible.posix (l’ancien nom intégré ne fonctionne plus que par redirection), et il est honnête sur un réglage : un booléen dont le commentaire prétendait autrefois qu’il autorisait les connexions sortantes. Ce n’est pas le cas – les connexions sortantes fonctionnent sans lui –, et savoir si quelque chose en a encore besoin est une question ouverte, notée plutôt que devinée.
🔎 Détection : auditd, AIDE, rkhunter, ClamAV
Les couches précédentes essaient de garder un attaquant dehors. Ces quatre-là supposent que quelqu’un est entré – ou que quelque chose a changé qui n’aurait pas dû – et veillent à ce que ce soit remarqué. Leur règle commune : silence quand tout va bien, un mail quand ce n’est pas le cas. Un détecteur qui envoie un mail chaque nuit vous habitue à l’ignorer.
common_auditd : le journal d’audit du noyau, et quatre clés qui envoient des mails
auditd enregistre ce qu’on demande au noyau de surveiller. Le fichier de règles surveille les écritures dans les fichiers d’utilisateurs et de groupes, la configuration de sudo, la configuration SSH, cron, la configuration réseau, la configuration de l’audit elle-même et, pour K3s, la configuration, les jetons, les certificats et le répertoire d’auto-déploiement ; et il enregistre chaque exécution des outils de chargement de modules et de sudo. Chaque règle a une clé, si bien que ausearch -k <key> retrouve les événements plus tard. Un handler compile les règles dans le noyau avec augenrules --load quand le fichier change.
auditd ne fait que journaliser. Un petit script, toutes les dix minutes via cron, transforme quatre clés en mails : le répertoire d’auto-déploiement de K3s (K3s applique tout ce qui y est écrit – l’endroit parfait pour une porte dérobée dans le cluster, et ce dépôt n’y écrit jamais), le jeton du cluster, les certificats du cluster et la base des utilisateurs. Un point de reprise par clé garantit que chaque événement est signalé exactement une fois. Les clés bruyantes – sudo, modules, configuration SSH, cron – ne sont volontairement pas envoyées par mail : elles se déclenchent lors de l’administration normale et des exécutions d’Ansible, et restent disponibles pour les investigations.
Mise à jour (19/10/2026) : chaque exécution d’AIDE produisait une série de refus SELinux, car AIDE appelle statfs() sur le /boot/efi en vfat bien qu’il soit exclu. Un module de politique local d’une seule règle, aide_dosfs (allow aide_t dosfs_t:filesystem getattr;), construit avec checkmodule/semodule_package et chargé seulement quand sa source change, y a mis fin – les hôtes ne signalent plus aucun AVC.
common_aide_refresh : le dernier rôle de chaque exécution
Chaque exécution du playbook modifie des fichiers qu’AIDE surveille – une mise à jour de paquet suffit. Sans référence fraîche, la vérification de minuit suivante signale les propres changements de l’exécution comme une intrusion. Le tout dernier rôle de chaque playbook renouvelle donc la référence, mais seulement si quelque chose a réellement changé :
- name: AIDE refresh | Compare with the baseline and write the new database (one scan)
ansible.builtin.command: systemd-run --wait --pipe --quiet --collect -- aide --update
register: aide_update
changed_when: false
failed_when: false
- name: AIDE refresh | Activate the new baseline (differences were found)
ansible.builtin.command: mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
changed_when: true
when:
- aide_update.rc is defined
- aide_update.rc in range(1, 8)
- aide_db_new.stat.existsLe code de sortie d’AIDE est un masque de bits : 1 signifie que des fichiers ont été ajoutés, 2 supprimés, 4 modifiés – donc 1 à 7 signifie « différences trouvées et enregistrées », ce qui est normal juste après une exécution du playbook. 0 signifie identique (la nouvelle base est jetée), et 14 et plus est une erreur (signalée, référence laissée intacte). range(1, 8) est du Jinja pour 1 à 7 – la borne finale est exclue. failed_when: false est essentiel : la première version traitait un code non nul comme un échec et interrompait le play avant que la nouvelle base puisse être activée. Seul le mv signale « changed », si bien qu’une référence renouvelée apparaît exactement une fois dans le récapitulatif. Et un seul scan fait à la fois la comparaison et la nouvelle base – l’ancienne version scannait tout deux fois.
Il y a ici un compromis qu’un exploitant doit comprendre : renouveler la référence accepte toutes les différences actuelles – y compris celles qui n’ont pas été causées par le playbook. C’est la vérification de minuit avant l’exécution qui les attrape, et c’est pourquoi ses mails comptent.
common_rkhunter : des rootkits, chaque nuit
rkhunter compare les binaires du système à une base enregistrée de leurs propriétés et cherche des traces de rootkits connus. Le rôle définit ses options avec lineinfile dans une boucle sur des paires clé/valeur, construit la base (--propupd) et planifie une vérification nocturne qui ne signale que des avertissements. Deux détails :
- Un petit plugin dnf, écrit par le rôle dans le répertoire des plugins de dnf, exécute
--propupdaprès chaque transaction de paquets – mises à jour automatiques nocturnes comme manuelles. Sans lui, chaque mise à jour donnerait un avertissement « fichier modifié » la nuit suivante. - Le paquet livre son propre script cron quotidien, donc chaque serveur faisait deux scans par nuit. Le supprimer seul ne tiendrait pas – la mise à jour suivante du paquet le remettrait. Une macro rpm d’une ligne,
%_netsharedpath, dit à rpm de ne plus jamais installer ce chemin.
common_clamav : des logiciels malveillants dans les fichiers des autres
Un scan ClamAV nocturne de ce que les utilisateurs envoient – les médias du blog, les données utilisateur de Nextcloud – avec une faible priorité CPU et disque, qui n’envoie un mail que s’il trouve quelque chose et ne supprime jamais rien de lui-même. La preuve qu’il trouve vraiment quelque chose est un article plus ancien. Le rôle choisit ses réglages dans un profil, comme common_selinux, et contient un joli morceau de Jinja : la liste des extensions de médias à ignorer est transformée, lettre par lettre, en motif insensible à la casse – jpg devient [jJ][pP][gG] – parce que le scanner n’a pas d’option pour des exclusions insensibles à la casse. Sur Nextcloud, les aperçus générés sont ignorés (ils sont produits à partir de fichiers qui sont de toute façon scannés, et avec eux un scan complet prenait seize heures), et le scan tourne à 04:30, après la fenêtre de redémarrage – plus tôt, le redémarrage le tuait chaque nuit de mise à jour.
📦 Les couches des autres parties, d’un coup d’œil
| Couche | Où | En bref |
|---|---|---|
| Durcissement réseau du noyau | partie 2, common_sysctl | pas de redirections ICMP, pas de routage par la source, SYN cookies, ASLR, ptrace restreint, journal du noyau réservé à root, RFC 1337, fichiers protégés dans /tmp |
| Durcissement des pods | parties 3 et 4 | toutes les capabilities retirées et seulement les nécessaires rendues, pas d’élévation de privilèges, systèmes de fichiers racine en lecture seule, utilisateurs non root si possible, pas de jetons de service account |
| NetworkPolicies | parties 3 et 4 | base de données et cache seulement depuis l’application ; Collabora coupé des deux |
| Politique d’images | parties 3 et 4 | seuls les dépôts d’images approuvés peuvent tourner |
| Pod Security Standards | parties 3 et 4 | avertir et auditer, pas imposer – les répertoires de l’hôte seraient rejetés |
| Journal d’audit du cluster | partie 2 | qui a fait quoi dans Kubernetes, les secrets journalisés seulement en métadonnées |
| Alerte de connexion | article dédié | un mail pour chaque connexion SSH interactive |
🛠️ En exploitation : la sécurité au quotidien
Regarder
# fail2ban: which jails, how many bans
fail2ban-client status
fail2ban-client status nginx-k3s-bad-paths
# What is really blocked right now (fail2ban and the port-scan detector)
nft list set inet filter banned4
nft list set inet filter banned6
nft list set inet filter scanban4
# Audit events of one key
ausearch -k k3s_manifests -i
# The whole firewall as it is loaded
nft list table inet filterLes mails de cette partie
| Signification | Que faire | |
|---|---|---|
| Alerte de connexion SSH | Quelqu’un s’est connecté de façon interactive | Si ce n’était pas vous : agir maintenant – vérifier last -i, changer les clés |
| AIDE a trouvé des changements d’intégrité | Des fichiers système diffèrent de la référence | Lire la liste. Après un changement planifié : attendu. Sinon : enquêter avant que la prochaine exécution du playbook renouvelle la référence |
| Événement auditd (k3s_manifests, token, tls, identity) | Quelque chose qui ne devrait jamais changer a changé | Traiter comme un incident jusqu’à explication |
| Avertissements rkhunter | Un binaire ou un réglage paraît suspect | Souvent un changement de paquet – comparer avec les mises à jour récentes ; sinon enquêter |
| ClamAV a trouvé quelque chose | Un fichier des uploads ou des données utilisateur correspond à une signature | Regarder le fichier et qui l’a envoyé ; ClamAV n’a rien supprimé |
Quand vous vous êtes enfermé dehors
- Votre propre adresse bannie :
fail2ban-client set <jail> unbanip <address>depuis une autre connexion, ou attendre l’expiration du bannissement. Pour le bannissement de scan de ports :nft delete element inet filter scanban4 { <address> }. Ne testez jamais des chemins d’attaque depuis la machine d’où vous administrez – je me suis banni moi-même deux fois. - Hors du pays autorisé : ajoutez votre adresse à
fw_ssh_geo_extraet lancez le rôle de pare-feu depuis la zone autorisée – ou utilisez la console web de l’hébergeur. - SSH ne revient pas après un changement : la console web de l’hébergeur avec le mot de passe root. C’est pourquoi la partie 2 dit : testez la console avant d’en avoir besoin.
🧭 La suite
La partie 6 clôt la visite avec la supervision : les exporteurs, Prometheus, Grafana et ses alertes, et le chien de garde Monit – ce que chacun surveille et ce que signifient leurs mails.

