Moderniser iptables après 10 ans… sans casser Docker !

Licorne sysadmin réorganisant un ancien classeur de règles firewall en chemins SSH, Docker et blacklist IP

Bonjour à tous, aujourd’hui on va parler du firewall iptables, et de vieux articles qui ont plutôt bien vieilli (et d’un quatrième encore tout frais), et d’une petite modification d’interface réseau qui aurait pu mettre le serveur dans le noir. Bref, un sujet parfaitement raisonnable à traiter un soir en SSH.

Pour rappel, ou pour les nouveaux qui nous rejoignent (coucou 👋) le serveur qui héberge ce site est directement exposé dans un datacenter OVH. Il porte plusieurs services web derrière un Nginx Proxy Manager, lui-même dans Docker (cf. ma série sur Docker), sans WAF ou CDN externe pour cacher la m… poussière sous le tapis. Les ports 22, 80 et 443 doivent rester accessibles depuis Internet. Et toute la politique du firewall doit donc fonctionner pour de vrai, dans l’environnement « à peine agressif d’Internet », pas seulement avoir l’air impressionnante dans un article du blog.

La leçon de cette migration tient en une phrase : un firewall n’est pas une liste de règles, c’est le chemin réel suivi par les paquets. Et ce chemin avait beaucoup changé depuis 2017.

Quatre articles, quatre couches de défense du firewall iptables

Ce chantier est le fils spirituel de quatre articles du blog.

D’abord, dans mon focus sur Fail2ban, j’expliquais comment calmer les brute-force à partir des logs. Le principe reste excellent : Fail2ban ne prétend pas « arrêter Internet », il réduit le bruit et bannit un comportement observé localement. En revanche, les valeurs par défaut, les noms des jails et surtout l’action de bannissement ont évolué. ( Et mon sympathique maxretry = 42 de l’exemple de 2016 mérite aussi de rester une blague, pas un standard de durcissement).

Puis, dans la configuration avancée du firewall iptables, j’avais ajouté SYNPROXY, hashlimit, connlimit, filtrage des flags TCP, l’anti-spoofing, blocage de scanners et quelques réglages noyau. Les concepts importants sont toujours là, mais compiler iptables 1.6.1 à la main, filtrer sur eth0, rejeter tout ICMP ou appliquer SYNPROXY sans tenir compte de Docker relève désormais de l’archéologie informatique.

En 2022, la blacklist AbuseIPDB avec iptables et ipset avait déjà trouvé le bon outil (10 000 adresses dans 10 000 règles séquentielles seraient une horreur, alors qu’un ipset est conçu pour cet usage). Le swap entre deux sets était également la bonne solution. Le script restait néanmoins sur IPv4, mettait la clé API dans le code (boouuh, pas bien), validait peu la réponse et laissait trop de scénarios d’échec au hasard. Le commentaire dans lequel une vraie clé a fini publiée aura fourni une démonstration grandeur nature du problème, rotation du secret en bonus (comme au taf quoi…😂) ! On apprend aussi grâce aux lecteurs, merci à vous !

Enfin, mon article récent sur CrowdSec ajoutait une défense comportementale et collaborative. CrowdSec, Fail2ban et AbuseIPDB ne sont pas trois produits qui se battent pour le même tabouret : Fail2ban réagit aux logs locaux, CrowdSec enrichit cette logique avec ses scénarios et ses décisions communautaires, AbuseIPDB apporte une autre source de réputation.

Je vous propose de faire une passe de « dépoussièrage » et de mise à jour des règles de firewall iptables pour revenir à l’état de l’art en 2026.

Conntrack et SYNPROXY : la protection qui coupait SSH

Conntrack est la mémoire du firewall : il associe les paquets à une connexion et permet, par exemple, d’accepter le trafic ESTABLISHED sans réévaluer toute la politique à chaque segment TCP. SYNPROXY va plus loin en répondant lui-même au début de la poignée de main TCP, avec des SYN cookies, avant d’allouer l’état normal de la connexion. Sur le papier, c’est séduisant contre un SYN flood.

Le premier audit du Codex a pourtant trouvé un piège. Mes anciennes règles visaient eth0, tandis que l’interface publique s’appelle désormais eno1 (ok boomer…). Remplacer simplement le nom réveillait une règle NOTRACK sur 22, 80 et 443. Or si SSH termine dans INPUT, mon traffic web est tramis à Docker et traverse FORWARD et DOCKER-USER (et le web IPv6 passe ici par un listener local). La documentation Docker confirme que les ports publiés suivent ses propres chaînes. Un SYN web rendu UNTRACKED pouvait donc ne jamais rencontrer le SYNPROXY prévu ailleurs.

Nous (oui, nous : moi et Codex ; je ne parle pas encore de moi au pluriel) avons d’abord corrigé cette incohérence en limitant SYNPROXY à SSH. La configuration passait les tests immédiats… puis mes sessions SSH se sont mises à tomber après quelques secondes. Un retour aux anciennes règles a restauré la stabilité. Le suspect principal était le sysctl sur nf_conntrack_tcp_loose = 0, réglage global exigé par la recette SYNPROXY pour que l’ACK final arrive en état INVALID. Si la documentation Netfilter impose bien la séquence NOTRACK, SYNPROXY, puis DROP INVALID ; elle précise que désormais les noyaux Linux absorbent bien mieux les SYN floods depuis Linux 4.4. L’incident et le rollback étaient suffisamment clairs pour choisir et SYNPROXY et NOTRACK ont donc disparu de configuration iptables. Conntrack reprend son comportement normal, tandis que les SYN cookies restent le filet de secours du noyau :

# /etc/sysctl.d/customnetwork.conf
net.netfilter.nf_conntrack_tcp_loose=1
net.ipv4.tcp_syncookies=1
net.ipv4.tcp_timestamps=1
net.netfilter.nf_conntrack_max=200000

Dans INPUT, le trafic déjà établi est ensuite accepté avant les contrôles qui concernent les nouvelles connexions, et les paquets réellement invalides sont rejetés :

-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP

Une protection sophistiquée qui rend l’administration instable n’est pas une protection. C’est votre prochain incident de prod avec un joli nom.

Hashlimit et connlimit : encaisser sans bannir tout Internet

hashlimit limite un débit en maintenant des compteurs par source ou groupe de sources. Il ne cherche pas à savoir si quelqu’un est « méchant » : il évite simplement qu’un volume anormal de SYN consomme toutes les ressources. connlimit, lui, compte les connexions simultanées et pose un plafond. Ces deux extensions sont décrites dans la documentation iptables-extensions. Ce sont des protections de capacité, pas des moteurs de détection.

Sur ce serveur, les ports 22, 80 et 443 acceptent beaucoup de profils légitimes derrière du NAT. Le seuil reste donc volontairement permissif : 200 SYN par seconde avec un burst à 1 000, groupés par /24 en IPv4 et /64 en IPv6. En IPv4, la règle ressemble à ceci :

-A PREROUTING -i eno1 -p tcp --syn \
  -m multiport --dports 22,80,443 \
  -m hashlimit --hashlimit-above 200/sec --hashlimit-burst 1000 \
  --hashlimit-mode srcip --hashlimit-srcmask 24 \
  --hashlimit-name syn4 --hashlimit-htable-size 1048576 \
  -j DROP

Elle vit dans raw/PREROUTING pour intervenir tôt. La variante IPv6 utilise le même seuil avec –hashlimit-srcmask 64. Ce réglage constitue une bonne protection « anti-bourrins », mais pas une défense magique contre un DDoS distribué de dix mille sources sous le seuil restent dix mille sources (je vous laisse regarder openbullet d’ailleurs).

Pour SSH, le plafond de 100 connexions par adresse complète (le MaxStartups 10:30:100 de sshd) ne porte que sur les nouveaux SYN. Sans –syn, chaque paquet d’une connexion déjà établie pourrait repasser par un contrôle qui n’a rien à faire là :

-A INPUT -i eno1 -p tcp --syn --dport 22 \
  -m connlimit --connlimit-above 100 \
  --connlimit-mask 32 --connlimit-saddr \
  -j REJECT --reject-with icmp-port-unreachable

Enfin, si le volume devient réellement important, il est déjà trop tard pour demander au serveur de sauver seul sa liaison. Jetez donc un oeil à l’Edge Network Firewall d’OVHcloud. Il reste actuellement limité à IPv4 et peut être indisponible sur certaines gammes Eco mais considérez-le comme une couche complémentaire, pas comme une excuse pour abandonner le firewall local.

Fail2ban et CrowdSec : bannir ce que les logs ont réellement vu

Fail2ban lit les logs, regroupe les échecs d’authentification et crée un bannissement local. CrowdSec analyse des scénarios plus larges et son bouncer applique aussi les décisions communautaires. Ils interviennent donc après observation d’un comportement, contrairement au hashlimit qui ne regarde que le débit.

L’audit a découvert un détail amusant : sur Debian 13, mes commandes iptables utilisent le backend noyau nf_tables via iptables-nft, mais Fail2ban avait choisi son action native nftables et créé une table séparée pendant l’installation. Ce n’était pas « deux noyaux firewall », mais bien deux plans d’administration. Parfait pour chercher à trois heures du matin qui bloque quoi. Codex a forcé Fail2ban à reconstruire ses chaînes avec ses actions iptables officielles :

# /etc/fail2ban/jail.d/99-iptables.local
[DEFAULT]
banaction = iptables-multiport
banaction_allports = iptables-allports

Après redémarrage, sa chaîne SSH dynamique est revenue avec les bannissements persistés. CrowdSec est resté sur son firewall bouncer iptables/ipset. Le début d’INPUT devient ainsi lisible : Fail2ban pour SSH en IPv4, CrowdSec ensuite, puis AbuseIPDB. Les fichiers de boot ne figent pas ces données temporaires ; chaque service recrée ce qu’il possède.

-A INPUT -p tcp -m multiport --dports 22 -j f2b-sshd
-A INPUT -j CROWDSEC_CHAIN
-A INPUT -m set --match-set abuseipdb_ipv4 src -j DROP

AbuseIPDB et ipset : remplacer 10 000 règles d’un seul coup

Un ipset est une structure du noyau optimisée pour tester l’appartenance d’une adresse à un ensemble. Une seule règle iptables peut donc consulter près de 10 000 IP sans parcourir 10 000 règles l’une après l’autre. C’était déjà la bonne idée du script de 2022 ; il fallait surtout fiabiliser tout ce qui l’entourait.

La documentation AbuseIPDB permet d’obtenir une blacklist texte mixte IPv4/IPv6, avec un seuil de confiance et une limite. Le nouvel updater effectue une seule requête, sépare les familles localement, refuse les adresses non globales, déduplique, vérifie un nombre minimal d’entrées et conserve la dernière génération valide au moindre doute. La clé reste dans un fichier root en 0600 et arrive à curl par son entrée standard, jamais dans sa ligne de commande.

La mise à jour ne vide jamais le set actif. Elle remplit deux sets temporaires puis utilise ipset swap, opération documentée par Netfilter pour échanger instantanément deux ensembles compatibles :

ipset create abuseipdb_ipv4_new hash:ip family inet -exist
awk '{ print "add abuseipdb_ipv4_new " $0 }' ipv4.txt | ipset restore
ipset swap abuseipdb_ipv4 abuseipdb_ipv4_new
ipset destroy abuseipdb_ipv4_new

La même séquence est exécutée pour IPv6. Chaque famille est atomique ; si le téléchargement ou la validation échoue avant le swap, la liste en production ne bouge pas. Un mode –dry-run télécharge et valide sans aucune mutation, tandis que –ensure-rules reconstruit seulement les objets firewall.

Le placement compte autant que la liste. Dans INPUT, AbuseIPDB vient juste après CrowdSec. Dans DOCKER-USER, son test est inséré en première position, avant les règles d’acceptation créées par Docker. C’est le point prévu par Docker pour filtrer les paquets routés vers des ports publiés :

-A INPUT -m set --match-set abuseipdb_ipv4 src -j DROP
-A DOCKER-USER -m set --match-set abuseipdb_ipv4 src -j DROP

Je n’ai pas ajouté de politique générale fail-closed dans DOCKER-USER : cette machine reste aussi une sandbox et je veux pouvoir publier temporairement un port sans réécrire le firewall. La contrepartie est simple : chaque docker run -p ou bloc ports: devient explicitement une décision d’exposition Internet (don’t do this in prod).

Enfin, Un service systemd crée d’abord les sets et les règles, après le firewall, Docker et CrowdSec. Un timer lance ensuite l’actualisation deux fois par jour. Ce découpage évite surtout de mettre dans rules.v4 une règle qui ferait échouer iptables-restore au démarrage si son ipset n’existait pas encore.

Lors du contrôle final, mes sets contenaient 9 971 IPv4 et 29 IPv6 au score demandé. Avant le premier chargement, pensez quand même à contrôler l’adresse de votre session d’administration. Se bannir soi-même reste une méthode de test extrêmement convaincante, néanmois. Le nouveau script update_abuseipdbblocklist.sh est dispo ci-dessous.

xt_recent : laisser le site ouvert et faire disparaître le scanner

xt_recent, également documenté dans iptables-extensions, tient une petite liste d’adresses récemment observées. Ici, une source qui tente certains ports (fermés sur mon serveur) Telnet, SMTP, SMB, bases de données ou RDP, entre en quarantaine pendant 24 heures. Ce n’est pas une analyse comportementale subtile ; c’est plutôt le videur qui vous a vu essayer d’entrer par la porte de service.

L’ordre choisi est important. Une connexion web ou SSH déjà suivie par conntrack est acceptée avant la quarantaine. La source peut donc continuer sa lecture d’article du blog en session HTTP/2 actuelle, mais son nmap et ses nouvelles connexions ne voient plus rien :

-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

-A INPUT -m recent --rcheck --seconds 86400 \
  --name portscan --rsource -j DROP
-A INPUT -m recent --remove --name portscan --rsource
-A INPUT -p tcp -m multiport --dports 23,25,445,1433,5432,3389 \
  -m recent --set --name portscan --rsource -j DROP

La règle –remove nettoie une entrée expirée lorsque le contrôle précédent ne la considère plus active. La variante IPv6 utilise une liste séparée, portscan6.

Cette mécanique a une limite assumée : un attaquant capable de forger une adresse source (coucou Scapy) peut provoquer la quarantaine temporaire d’une « innocente victime » (si une telle chose existe sur les Z’internets…). Je ne proposerais pas cette règle comme modèle pour un frontal critique. Sur ce serveur personnel, où ce scénario ne s’est pas matérialisé en dix ans, j’accepte le compromis pour sortir quelques bots débiles (et n’oubliez pas, ralentir et embêter un attaquant c’est déjà la moitié du travail de faite).

Anti-spoofing : filtrer les bogons sans avaler Docker

L’anti-spoofing rejette sur l’interface Internet les sources qui ne devraient jamais en provenir : loopback, réseaux privés, documentation, benchmarking, multicast ou plages réservées. pour ceux qui ont oublié, ces préfixes viennent des registres IANA, pas d’une liste trouvée au hasard sur un forum de 2009 hein.

Le détail essentiel est -i eno1. Un paquet en 172.16.0.0/12 reçu depuis Internet est suspect (un peu plus même) ; un paquet du même espace privé reçu depuis un bridge Docker est parfaitement normal. En limitant le DROP à l’interface publique, le réseau partagé de Nginx Proxy Manager continue de fonctionner :

-A PREROUTING -i eno1 -s 10.0.0.0/8 -j DROP
-A PREROUTING -i eno1 -s 172.16.0.0/12 -j DROP
-A PREROUTING -i eno1 -s 192.168.0.0/16 -j DROP

Le reverse-path filtering documenté par le noyau complète cette logique sur ce serveur mono-interface : Linux vérifie qu’il saurait répondre à la source par un chemin cohérent. Là encore, je l’applique explicitement à l’interface publique au lieu de compter sur une valeur globale ambiguë :

net.ipv4.conf.default.rp_filter=1
net.ipv4.conf.all.rp_filter=1
net.ipv4.conf.eno1.rp_filter=1

Sur une architecture asymétrique, multi-WAN ou avec du routage avancé, ce choix doit être revu. Une bonne règle de sécurité est aussi une règle dont on sait expliquer le domaine de validité.

ICMP : filtrer sans casser le réseau

ICMP ne sert pas seulement à répondre au ping. Il transporte des erreurs nécessaires au routage et à la découverte du MTU. En IPv6, Neighbor Discovery, Router Discovery et Packet Too Big participent directement au fonctionnement du réseau ; la RFC 4890 explique d’ailleurs pourquoi un blocage aveugle est une mauvaise idée.

Mon ancien DROP de tout ICMP IPv4 a donc été remplacé par des autorisations ciblées. Les erreurs utiles passent et les echo requests restent limités à cinq par seconde avec un burst de dix :

-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT
-A INPUT -p icmp --icmp-type parameter-problem -j ACCEPT
-A INPUT -p icmp --icmp-type echo-request \
  -m limit --limit 5/second --limit-burst 10 -j ACCEPT

En IPv6, les quatre messages d’erreur fondamentaux et la découverte des voisins sont autorisés. Les messages NDP reçus sur l’interface publique doivent avoir un hop limit de 255, ce qui empêche qu’ils aient été routés depuis ailleurs.

Flags TCP et fragments : garder les filtres qui agissent vraiment

Les flags TCP décrivent l’état d’une connexion. Certaines combinaisons n’ont aucun sens dans un échange normal et servent souvent à sonder la réaction d’une pile réseau. Les rejeter tôt réduit ce bruit avant le filtrage stateful :

-A PREROUTING -p tcp --tcp-flags FIN,SYN FIN,SYN -j DROP
-A PREROUTING -p tcp --tcp-flags SYN,RST SYN,RST -j DROP
-A PREROUTING -p tcp --tcp-flags FIN,SYN,RST,PSH,ACK,URG NONE -j DROP

Ces règles mangle restent donc en place. En revanche, le vieux DROP global des fragments IPv4 a disparu : l’ordre des hooks Netfilter place mangle à la priorité -150, après la défragmentation du noyau à -400. Le déplacer dans raw, à -300, n’aurait pas évité ce travail. Parfois, supprimer une règle inutile améliore surtout la sécurité de celui qui devra comprendre le fichier plus tard.

Rollback en 120 secondes : la vraie ceinture de sécurité

Sur ce coup là, Codex m’a appris un truc. Modifier un firewall à distance sans rollback automatique, c’est comme jouer avec une scie en étant assis sur une branche. La commande iptables-apply charge un fichier, demande confirmation et restaure les anciennes règles si personne ne répond avant le timeout. Ici, Codex choisi 120 secondes. Avant toute mutation, un snapshot root-only a regroupé les règles actives et persistantes, les ipsets, sysctls, routes, bans Fail2ban et l’état Docker.

Le principe minimal est celui-ci :

snapshot_dir="/root/firewall-migration-$(date -u +%Y%m%d-%H%M%S)"
install -d -m 0700 "$snapshot_dir"

iptables-save -c > "$snapshot_dir/live.v4"
ip6tables-save -c > "$snapshot_dir/live.v6"
ipset save > "$snapshot_dir/ipset.save"
cp -a /etc/iptables /etc/sysctl.d/customnetwork.conf "$snapshot_dir/"

Les candidats sont d’abord parsés sans être chargés. IPv4 et IPv6 sont ensuite appliqués séparément :

iptables-restore --test rules.v4.candidate
ip6tables-restore --test rules.v6.candidate

iptables-apply -t 120 rules.v4.candidate
ip6tables-apply -t 120 rules.v6.candidate

Attention, iptables-apply attend un terminal interactif. Mon premier lancement sans pseudo-terminal a chargé le candidat puis l’a immédiatement restauré faute de pouvoir confirmer. Pour le coup, voir le rollback fonctionner avant même la vraie migration était plutôt rassurant.

Pendant les deux minutes, une seconde session SSH reste ouverte et l’accès console du fournisseur doit être prêt. Avant de répondre « oui », nous avons contrôlé :

  • la session SSH existante et une nouvelle connexion ;
  • HTTP et HTTPS publics en IPv4 puis IPv6 ;
  • Nginx Proxy Manager, les chaînes Docker vers tous les conteneurs ;
  • le DNS et la sortie réseau depuis un conteneur ;
  • la passerelle IPv6, ICMPv6 et Neighbor Discovery ;
  • Fail2ban, CrowdSec, AbuseIPDB et l’ordre final des chaînes.

La base validée est ensuite installée en 0600 dans /etc/iptables/rules.v4 et rules.v6. Fail2ban et AbuseIPDB n’y figurent volontairement pas : leurs services reconstruisent leurs chaînes et leurs sets après le boot. Les captures live complètes restent dans le snapshot, et les versions commentées rules.v4.applied.commented.txt et rules.v6.applied.commented.txt sont jointes à l’article.

Un bon dessin de mon firewall iptables vallant mieux qu’un long discours (oh, trop tard !)…

Logigramme firewall iptables des flux SSH et web avec INPUT, conntrack, listes dynamiques, FORWARD, DOCKER-USER et Nginx Proxy Manager

Ce que mes anciens articles m’ont appris une seconde fois

Comme pour la modernisation de mon backup avec Codex en Remote SSH, VS Code ouvrait le serveur comme un workspace, Codex reliait les fichiers à l’état réel de la machine et chaque passage root restait explicite. Je ne suis absolument pas un aussi bon sysadmin, mais là avec VS Code, Remote SSH et Codex j’en vallait quatre en même temps (et avec beaucoup moins de café requis).

Mes règles de firewall iptables de 2017 n’étaient pas absurdes. Elles répondaient juste à un serveur de 2017 : une interface, des services locaux, principalement IPv4 et aucun chemin Docker à prendre en compte. Le script AbuseIPDB de 2022 possédait déjà les deux bonnes idées, ipset et le swap atomique, mais pas encore le modèle d’échec ni la gestion des secrets qu’on attends aujourd’hui. Fail2ban fait toujours son travail, CrowdSec l’enrichit, AbuseIPDB ajoute sa réputation, et aucun ne dispense l’autre de sa place dans la pile.

Le vrai upgrade n’est donc pas d’avoir ajouté davantage de règles. C’est d’avoir relié chacune à un paquet concret, documenté les compromis, prévu le rollback et vérifié IPv4, IPv6 ainsi que Docker. Une règle spectaculaire qui ne voit aucun trafic ne protège rien. Une règle spectaculaire placée sur le mauvais chemin peut faire pire : transformer votre firewall en attaquant interne particulièrement motivé.

Bref, auditez vos configurations firewall iptables de temps en temps, les IA savent le faire, et gardez une console de secours avant de les durcir. Sur ce, Geekez bien !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.