
🌐 Aussi en: English · Deutsch · Español
Deep Dive de la série autour de l’Ansible Tour. Niveau : administration Linux avancée. Le Tour explique les rôles ; les deep dives expliquent la technique en dessous – comment elle fonctionne, pourquoi elle est là, ce que cette installation en fait exactement, comment l’exploiter au quotidien et quelles autres options il y aurait eu.
nftables a remplacé iptables dans le noyau il y a des années, mais la plupart des guides traduisent encore les anciennes règles iptables ligne par ligne, puis s’arrêtent. Cela fonctionne jusqu’à ce qu’un second programme écrive lui aussi des règles – et sur un hôte K3s, plusieurs le font. Cet article explique le modèle de données de nftables avec les détails qui comptent en pratique, comment le rôle common_firewall construit une table pour l’hôte, fail2ban, la détection de scans et le filtre SSH par pays, ce que K3s place à côté, et quatre bugs des derniers mois qui ont chacun enseigné l’un de ces détails à la dure. La migration elle-même est décrite dans un article précédent, le filtre par pays dans un autre ; celui-ci montre comment tout s’assemble.
🧭 Comment nftables décide
Tables, chaînes, hooks
Avec iptables, les tables (filter, nat, mangle) et leurs chaînes étaient fixes. Avec nftables, une table n’est qu’un espace de noms que l’on crée soi-même, rattaché à une famille d’adresses : ip, ip6, inet (les deux), arp, bridge, netdev. Une chaîne ne voit des paquets que si c’est une chaîne de base – attachée à un hook netfilter (prerouting, input, forward, output, postrouting) avec une priorité et une politique. Les chaînes ordinaires sans hook ne sont atteintes que par jump ou goto.
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
...
}
}Priorités : plusieurs tables sur un même hook
Autant de chaînes de base que l’on veut, issues d’autant de tables que l’on veut, peuvent s’accrocher au même hook. Le noyau les exécute dans l’ordre de leur priorité – la plus petite d’abord : raw vaut −300, le suivi de connexions tourne à −200, mangle −150, dstnat −100, filter 0, srcnat 100. Deux faits faciles à manquer en découlent :
- Un accept n’est définitif que pour sa propre chaîne. Si notre chaîne
inputaccepte un paquet, la chaîne de base suivante sur le même hook – par exemple celle de K3s – le voit quand même et peut le rejeter. Seul un drop termine le parcours du paquet partout. - La position d’une chaîne par rapport à conntrack compte. Une chaîne à −300 tourne avant le suivi de connexions et ne peut pas savoir si un paquet est une réponse. Une chaîne à −150 tourne après et peut utiliser
ct state. L’un des bugs ci-dessous vient exactement de cette différence.
Sets et maps
Les sets sont des collections typées (ipv4_addr, ipv6_addr, numéros de port, concaténations) stockées dans le noyau et consultées en temps constant. Avec flags interval ils contiennent des plages, avec flags timeout chaque élément expire tout seul, et avec flags dynamic la règle elle-même peut ajouter des éléments depuis le chemin du paquet (add @set, update @set). C’est ce qui a rendu ipset inutile : une liste de bannissement avec expiration à 24 heures tient en trois lignes.
set banned4 {
type ipv4_addr
flags timeout
timeout 1d
}
...
ip saddr @banned4 dropRemplacement atomique – et pourquoi « flush ruleset » est dangereux
nft -f fichier applique tout le fichier en une seule transaction : soit tout est chargé, soit rien, et il n’y a aucun instant sans règles. Beaucoup de configurations d’exemple commencent par flush ruleset. Sur un hôte où un seul programme écrit des règles, c’est sans problème. Sur un hôte où kube-proxy, flannel et le contrôleur de NetworkPolicy de Kubernetes ont leurs propres tables, cela efface aussi leurs règles – ils ne s’en aperçoivent qu’à leur prochaine synchronisation, quelques secondes ou minutes plus tard.
iptables-nft et iptables-legacy
La plupart des composants Kubernetes parlent encore iptables. Sur les distributions actuelles, la commande iptables est en réalité iptables-nft : même syntaxe, mais elle écrit dans des tables nftables nommées ip filter, ip nat, etc., avec une couche de compatibilité pour les anciens modules de correspondance. L’autre backend, iptables-legacy, écrit dans les anciennes structures x_tables du noyau. Les deux peuvent être actifs en même temps – et alors ni nft list ruleset ni iptables-legacy -L ne montrent l’image complète.
🛡️ Pourquoi il est là
- Un seul fichier lisible pour IPv4 et IPv6 (
table inet), versionné dans Git, rendu depuis un modèle. - Drop par défaut dans les deux sens. En entrée seulement le web, SSH et ce dont K3s a besoin ; en sortie seulement DNS, NTP, HTTP(S), l’envoi de mails et le trafic propre au cluster.
- Des listes de bannissement dans le noyau avec expiration pour fail2ban, la détection de scans et la liste SSH par pays – aucun démon sur le chemin des paquets.
🔧 Ce que fait ce dépôt
Une table à nous
Le rôle rend fw.nft.j2 dans /etc/sysconfig/nftables.conf – le fichier que l’unité nftables d’AlmaLinux lit réellement (pas /etc/nftables.conf). Le fichier commence ainsi :
table inet filter
delete table inet filter
table inet filter {
...
}La première ligne crée la table si elle n’existe pas (pour que le delete n’échoue pas au premier démarrage), la deuxième la supprime avec tout son contenu, puis elle est reconstruite – le tout en une transaction. Seule notre table est remplacée ; celles de K3s restent intactes. Un drop-in systemd délimite l’unité de la même façon : ExecReload charge le fichier, ExecStop exécute nft delete table inet filter au lieu du flush par défaut de la distribution. Avant que le fichier ne remplace le fichier actif, Ansible valide le candidat rendu avec nft -c -f ; une erreur de syntaxe n’atteint jamais le pare-feu.
La chaîne input, dans l’ordre
- Loopback, established/related, rejet de invalid ; rejet des paquets XMAS et NULL, reset sur SYN+ACK pour les nouvelles connexions, limitation des RST.
- Les listes de bannissement d’abord :
banned4/6(fail2ban, 24 h) etscanban4/6(scanners de ports, 1 h) – avant tout accept, pour qu’une adresse bannie n’atteigne rien. - Exceptions K3s : les pods peuvent joindre l’API server et le kubelet (6443, 10250), le HTTP(S) de l’hôte pour l’auto-test de cert-manager, et sur les hôtes Nextcloud MariaDB et Redis sur l’hôte.
- Rejet des plages source privées et réservées venant de l’extérieur, autorisation de l’ICMP utile et des types ICMPv6 sans lesquels IPv6 ne fonctionne pas.
- Web sur 80/443 (pas d’UDP 443 – le contrôleur ingress ne gère pas HTTP/3), SSH sur 10022 seulement depuis la liste de pays.
- Détection de scans de ports : ce qui arrive encore ici va tomber sur le drop de la politique – un port fermé. Une source qui envoie plus de 30 SYN par minute vers des ports fermés est ajoutée à
scanban4par la règle elle-même. - Ligne de journal limitée, drop de la politique.
tcp flags & (fin|syn|rst|ack) == syn tcp dport != 22 \
update @portscan4 { ip saddr limit rate over 30/minute burst 30 packets } \
add @scanban4 { ip saddr } dropfail2ban ne peut pas faire cela : il ne lit que des journaux, et le journal des rejets est volontairement limité à deux lignes par minute. Le port 22 est exclu pour qu’un -p 10022 oublié quelques fois n’enferme jamais l’administrateur dehors.
fail2ban écrit dans les mêmes sets
fail2ban n’a pas de table à lui. Son action tient en deux lignes :
actionban = nft add element inet filter <nftset> { <ip> timeout <bantime>s }
actionunban = nft delete element inet filter <nftset> { <ip> } 2>/dev/null; trueChaque bannissement porte sa propre expiration et expire dans le noyau même si fail2ban ne tourne pas. Sur le blog, au moment de l’écriture, banned4 contenait 22 adresses et la liste SSH ssh_geo4 7 498 plages d’adresses allemandes (avec flags interval et auto-merge, reconstruite chaque semaine à partir des données RIPE). Depuis le 19 octobre 2026, la même liste protège aussi la connexion WordPress : ssh-geo-update l’écrit une seconde fois sous forme de fichier geo nginx, et nginx dans le pod du blog répond 403 à /wp-login.php et /wp-admin/ pour tout ce qui est en dehors. Depuis le 20 octobre 2026, la même liste va aussi, sur chaque serveur, à l’ingress controller, qui répond 403 sur /grafana en dehors.
Ce que K3s écrit à côté
Sur le blog, nft list tables montre sept tables. Seule inet filter est à nous (49 règles). Les autres viennent de K3s via iptables-nft : ip filter avec environ 190 règles – KUBE-SERVICES, KUBE-NODEPORTS, KUBE-PROXY-FIREWALL de kube-proxy et, pour le contrôleur de NetworkPolicy, KUBE-ROUTER-*, KUBE-NWPLCY-* et une chaîne KUBE-POD-FW-* par pod –, ip nat avec environ 120 (DNAT des Services, masquerading de flannel) et de petites tables ip6. Notre chaîne forward n’a donc qu’à laisser passer le réseau des pods ; savoir si un pod peut parler à un autre se décide dans les chaînes de K3s sur le même hook.
🐛 Quatre bugs, quatre détails
1. « flush ruleset » cassait les Services
La première version du modèle commençait par flush ruleset. Chaque rechargement du pare-feu effaçait aussi les règles de kube-proxy et de flannel ; Nextcloud perdait pendant 30 à 90 secondes sa connexion à MariaDB et Redis via la ClusterIP, jusqu’à la resynchronisation de kube-proxy. La correction est le delete limité à notre table montré plus haut.
2. Un rechargement débannissait tout le monde en silence
Remplacer notre table remplace aussi les sets de bannissement – vides. fail2ban ne remarquait rien et gardait les adresses comme « bannies » dans sa propre base, pendant que le pare-feu les laissait de nouveau entrer. Découvert le 30 septembre 2026 : après une exécution du playbook, 15 bannissements sur 17 sur l’un des serveurs Nextcloud et 17 sur 24 sur l’autre n’existaient plus que dans fail2ban. Le handler du pare-feu redémarre désormais fail2ban juste après chaque rechargement (systemctl try-restart fail2ban), et fail2ban réapplique tous les bannissements encore actifs depuis sa base.
3. La chaîne NodePort rejetait les réponses à notre propre trafic
kube-proxy rend chaque NodePort (30000–32767) joignable sur chaque adresse locale – y compris la publique –, et ce trafic est réécrit par DNAT vers un pod puis passe par la chaîne forward, pas par input. Les exportateurs étaient joignables depuis Internet. La première correction fut une chaîne en priorité raw (−300) qui rejetait tout ce qui visait cette plage sur l’adresse publique. Cela fonctionnait – et rejetait aussi environ 4 % des réponses aux connexions sortantes des pods : flannel masque le trafic des pods avec --random-fully sur toute la plage de ports, donc certaines connexions recevaient un port source entre 30000 et 32767, et leur SYN-ACK était jeté avant que conntrack ne puisse le reconnaître comme réponse. Symptôme pendant deux mois : des timeouts sporadiques vers GitHub et les magasins d’applications depuis chaque hôte K3s. La chaîne tourne maintenant à −150, après conntrack, et ne rejette que ct state new.
chain prerouting {
type filter hook prerouting priority -150; policy accept;
ip daddr <public-ipv4> tcp dport 30000-32767 ct state new drop
ip6 daddr <public-ipv6> tcp dport 30000-32767 ct state new drop
}4. Le moteur de politiques qui n’appliquait rien, et un blocage de modules trop large
Pendant deux mois, Calico a tourné à côté de K3s comme moteur supposé de NetworkPolicy. Un test deny-all le 19 octobre 2026 a montré qu’il n’avait jamais rien intercepté : Felix, le composant de Calico, ne gère que les interfaces cali* – celles de son propre CNI –, alors que Flannel dans K3s crée des veth*. Toute l’application avait toujours été assurée par le contrôleur de politiques intégré à K3s (les chaînes KUBE-NWPLCY-*). Calico a été retiré ; le blog, qui utilisait jusque-là iptables-legacy, est passé à nftables uniquement, et les modules de tables legacy ont été bloqués par install … /bin/false dans modprobe.d. La première version de ce blocage incluait aussi ip_tables et ip6_tables – au redémarrage suivant, le contrôleur de politiques de K3s a paniqué, car iptables-nft utilise encore l’ancienne correspondance icmp via la couche de compatibilité, qui a besoin de ces modules. Le blog a répondu 502 pendant environ cinq minutes. Seuls les modules de tables iptable_* et ip6table_* sont désormais bloqués.
🧰 Exploitation au quotidien
# notre table seulement (le jeu complet contient ~300 règles de K3s)
nft list table inet filter
# listes de bannissement : qui, et pour combien de temps encore
nft list set inet filter banned4
nft list set inet filter scanban4
# une adresse est-elle dans la liste SSH ?
nft get element inet filter ssh_geo4 '{ 203.0.113.7 }'
# lever un bannissement (l'entrée de fail2ban : fail2ban-client unban <ip>)
nft delete element inet filter banned4 '{ 203.0.113.7 }'
# ce que K3s a écrit
nft list tables
iptables-nft-save | grep -c '^-A'
# suivre un paquet à travers toutes les chaînes (ajouter d'abord une règle de trace)
nft add rule inet filter prerouting ip saddr 203.0.113.7 meta nftrace set 1
nft monitor trace
nft flush chain inet filter prerouting # retire la règle de trace - le rôle restaure la chaîne au prochain passageModifications manuelles et prochaine exécution d’Ansible
Les éléments ajoutés à la main dans banned4 restent jusqu’à leur expiration – mais seulement jusqu’au prochain rechargement, qui reconstruit la table. Les règles ajoutées à la main disparaissent à l’exécution suivante, volontairement : le modèle fait foi. Les exceptions permanentes vont dans le modèle ou l’inventaire (par exemple fw_ssh_geo_extra pour une adresse hors de la liste de pays).
Lire le journal des rejets
Les paquets rejetés sont journalisés avec les préfixes nftables DROP IN:, DROP FWD: et DROP OUT:, deux par minute et par chaîne au plus. Depuis octobre 2026, ils sont consultables dans Loki : {job="journal", transport="kernel"} |= "nftables DROP". Une ligne DROP OUT est la plus intéressante – elle signifie que quelque chose sur le serveur a voulu se connecter là où il n’en a pas le droit.
🔭 La suite
- Des compteurs dans Prometheus. Des compteurs nommés sur les règles de rejet et la taille des sets comme métriques montreraient le volume d’attaques et la croissance des listes de bannissement dans le temps.
- Les flowtables pour le chemin forward pourraient décharger les connexions établies des pods – inutile à ce niveau de trafic.
- Moins de règles K3s : kube-proxy a son propre mode nftables ; dès que K3s le livrera par défaut, la couche iptables-nft pourra disparaître.
⚖️ Alternatives – et pourquoi nftables tout court
| Option | Point fort | Pourquoi pas ici |
|---|---|---|
| iptables + ipset | Ce que parlent encore la plupart des composants Kubernetes | Deux outils, des jeux de règles séparés pour IPv4/IPv6, pas de remplacement atomique du fichier entier. |
| firewalld | Zones, runtime vs permanent, CLI agréable | Possède le jeu de règles et le réécrit ; sur les hôtes K3s il est connu pour entrer en conflit avec kube-proxy. Le rôle le supprime. |
| eBPF (Cilium, XDP) | Très rapide, intégration Kubernetes profonde | Un autre modèle d’exploitation et un autre CNI – trop pour trois clusters à un seul nœud. |
| nftables tout court | Un fichier lisible, chargement atomique, sets du noyau, aucun démon | Choisi. |
La vraie leçon des derniers mois n’est pas un détail de syntaxe. C’est qu’un pare-feu sur un hôte Kubernetes n’est jamais seul : chaque hypothèse sur qui écrit quelle règle, et dans quel ordre le noyau les exécute, doit être testée – un test deny-all en a trouvé plus en un après-midi que deux mois de lecture de fichiers de configuration.


