fail2ban#
Pourquoi#
Le pare-feu (chapitre nftables) limite le débit, mais ne mémorise pas les
comportements répétés dans le temps : une IP qui scanne le serveur toutes
les quelques minutes reste sous le seuil de rate-limiting instantané tout
en étant clairement malveillante sur la durée. fail2ban comble ce vide
en analysant les journaux applicatifs (nginx, SSH) pour détecter des
motifs répétés et bannir les IPs fautives à plus long terme.
Ce guide ne vise pas l’exhaustivité en matière de sécurité. La configuration ci-dessous couvre les besoins de ce projet, pas une politique anti-intrusion complète. Le chapitre Lynis, plus loin, propose une méthode d’audit pour aller au-delà.
Pourquoi fail2ban plutôt que CrowdSec (et consorts)#
CrowdSec est aujourd’hui l’alternative moderne la plus citée à fail2ban, la question mérite donc une réponse argumentée. Plusieurs raisons ont fait pencher la balance vers fail2ban sur ce serveur précis :
- L’indépendance. L’intérêt principal de CrowdSec, sa liste de
blocage communautaire, dépend d’un service central auquel chaque agent
remonte des signalements et reçoit en retour une réputation d’IP :
une dépendance réseau externe permanente, à l’opposé de l’esprit de ce
site (statique, sans appel réseau externe à l’exécution, cf.
hugo.toml) et de ce serveur, dont l’objet même est de fonctionner de façon autonome et auditable. - La surface d’attaque. CrowdSec ajoute un démon résident, un composant bouncer séparé pour appliquer les bans, et une API locale (LAPI) qui écoute par défaut sur un port : des services supplémentaires à durcir et à filtrer, sur une machine qui cherche au contraire à réduire ce qui tourne en permanence (même logique qu’au chapitre Munin).
- Le besoin réel. Ce serveur affronte un profil d’attaque étroit et bien compris (scan HTTP, force brute SSH), pas la diversité de menaces que la threat intelligence communautaire de CrowdSec vise à couvrir. fail2ban, régler par regex sur des journaux locaux, correspond précisément à ce périmètre.
- L’auditabilité complète. Une prison fail2ban se lit intégralement
dans un fichier
.conflocal (regex, seuils, action). Les scénarios CrowdSec, distribués via un hub communautaire et un DSL YAML propre, ajoutent une couche d’abstraction et une dépendance à du contenu tiers, moins immédiate à inspecter entièrement.
Ce choix reste un choix personnel de l’auteur, cohérent avec les contraintes de ce projet (autonomie, surface d’attaque minimale), pas un jugement définitif sur CrowdSec : un lecteur qui gère plusieurs serveurs et valorise la détection collaborative peut tout à fait lui préférer CrowdSec, ou tout autre outil équivalent (Sagan, OSSEC/Wazuh côté HIDS…). C’est un arbitrage à reconsidérer au cas par cas, pas une règle à copier sans réflexion.
Comment#
Action nftables dédiée#
fail2ban provisionne ses propres tables nftables, isolées de la
configuration principale du chapitre précédent :
cat > /etc/fail2ban/action.d/nftables-subnets.conf <<'EOF'
[Definition]
allowipv6 = auto
actionstart = nft add table ip fail2ban
nft add chain ip fail2ban <name>-ip \{ type filter hook input priority -1 \; \}
nft add set ip fail2ban <name>-ip-set \{ type ipv4_addr \; flags interval \; \}
nft add rule ip fail2ban <name>-ip ip saddr @<name>-ip-set reject
nft add table ip6 fail2ban
nft add chain ip6 fail2ban <name>-ip6 \{ type filter hook input priority -1 \; \}
nft add set ip6 fail2ban <name>-ip6-set \{ type ipv6_addr \; flags interval \; \}
nft add rule ip6 fail2ban <name>-ip6 ip6 saddr @<name>-ip6-set reject
actionstop = nft flush chain ip fail2ban <name>-ip
nft delete chain ip fail2ban <name>-ip
nft delete set ip fail2ban <name>-ip-set
nft flush chain ip6 fail2ban <name>-ip6
nft delete chain ip6 fail2ban <name>-ip6
nft delete set ip6 fail2ban <name>-ip6-set
actioncheck =
# Bannissement par sous-réseau, pas juste l'IP unique (/28 IPv4, /96 IPv6)
actionban = if [ "<family>" = "inet6" ]; then nft add element ip6 fail2ban <name>-ip6-set \{ <ip>/96 \}; else nft add element ip fail2ban <name>-ip-set \{ <ip>/28 \}; fi
actionunban = if [ "<family>" = "inet6" ]; then nft delete element ip6 fail2ban <name>-ip6-set \{ <ip>/96 \}; else nft delete element ip fail2ban <name>-ip-set \{ <ip>/28 \}; fi
[Init]
name = default
EOFpriority -1 place cette chaîne dédiée avant la chaîne principale du
pare-feu (chapitre nftables, qui utilise la priorité nommée filter,
équivalente à 0) : les priorités de chaîne de base nftables s’évaluent
par ordre numérique croissant, donc un ban fail2ban s’applique avant même
que le paquet n’atteigne les règles générales, quel que soit leur
contenu.
Pourquoi bannir un sous-réseau entier plutôt qu’une IP isolée, et pourquoi ces deux tailles précises :
- IPv4,
/28(16 adresses). Les scanners de masse (Censys, Shodan, ZGrab, Expanse…) ne sondent pas depuis une IP unique : ils font tourner leurs machines sur des blocs qu’ils possèdent, souvent des allocations aussi petites qu’un/28ou/29chez un hébergeur. Bannir seulement l’IP qui a déclenché le filtre laisse la porte grande ouverte à la sonde suivante du même bloc, quelques secondes plus tard. Constat en production sur ce serveur : une grosse centaine d’IPs bannies individuellement suffit à couvrir près de 2 000 adresses effectivement bloquées une fois élargies en/28, ce qui confirme empiriquement cette logique de scan par blocs./28reste assez étroit pour ne pas déborder sur un/24entier (256 adresses) qu’un hébergeur aurait pu répartir entre plusieurs clients sans rapport avec l’attaque. - IPv6,
/96. Le problème inverse se pose. Un hôte IPv6 change trivialement les 64 derniers bits de son adresse (privacy extensions de la RFC 4941, ou simple choix d’une autre adresse dans le préfixe qui lui est alloué) : bannir un/128unique ne retient personne plus de quelques minutes. La réponse « naturelle » serait de bannir le/64généralement attribué à un client par son FAI, mais un/64est censé couvrir tout le réseau local d’un foyer ou d’une entreprise : le bannir en entier punirait potentiellement des centaines d’appareils sans rapport avec l’attaque./96est un compromis délibéré : assez large (2³² adresses, la taille de tout l’espace IPv4 historique) pour absorber une rotation triviale des 32 derniers bits, mais nettement plus étroit qu’un/64complet.
Filtres et prisons#
printf '%s\n' \
'[Definition]' \
'failregex = ^<HOST> - \S+ \[.*\] ".*" (444|400|405|418) \d+' \
'ignoreregex =' \
> /etc/fail2ban/filter.d/nginx-critical.conf
printf '%s\n' \
'[Definition]' \
'failregex = ^<HOST> - \S+ \[.*\] ".*" 4\d{2} \d+' \
'ignoreregex =' \
> /etc/fail2ban/filter.d/nginx-4xx.conf
cat > /etc/fail2ban/jail.d/nginx-rejets.conf <<'EOF'
[nginx-critical]
enabled = true
filter = nginx-critical
action = nftables-subnets[name=nginx-critical]
maxretry = 1
findtime = 24h
bantime = 30d
logpath = /var/log/nginx/access.log
[nginx-4xx-global]
enabled = true
port = http,https
filter = nginx-4xx
action = nftables-subnets[name=nginx-4xx-global]
maxretry = 10
findtime = 60m
bantime = 24h
logpath = /var/log/nginx/access.log
EOFDeux prisons nginx distinctes, pas une seule : nginx-critical bannit
immédiatement (maxretry = 1) sur des codes révélateurs d’un scan
délibéré (444, 405, 418, impossibles en usage normal), tandis que
nginx-4xx-global tolère un seuil plus large (maxretry = 10 sur 60
minutes) pour capter un comportement anormal plus diffus (erreurs 404
répétées, par exemple) sans bannir un visiteur légitime ayant simplement
mal tapé une URL une ou deux fois. La durée de ban suit la même logique
de sévérité : 30 jours pour un scan délibéré avéré (nginx-critical),
24 h seulement pour un comportement simplement suspect (nginx-4xx-global,
sshd), suffisant pour décourager sans risquer de pénaliser trop
longtemps un faux positif.
cat > /etc/fail2ban/jail.d/sshd.conf <<'EOF'
[sshd]
enabled = true
backend = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
maxretry = 3
findtime = 60m
bantime = 24h
EOFSous-réseaux de confiance#
cat > /etc/fail2ban/jail.d/local-trust.conf <<'EOF'
[DEFAULT]
ignoreip = 127.0.0.0/8 ::1 fe80::/10 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16
EOF
fail2ban-client reloadCette liste n’est qu’un exemple de ce que « confiance » signifie sur ce déploiement précis, pas une bonne pratique universelle. Elle suppose un réseau domestique NAT à un seul foyer, où aucune adresse RFC 1918 ne peut légitimement arriver depuis Internet (postulat détaillé dans l’avertissement plus bas). Ce postulat ne tient pas partout : sur un réseau partagé (colocation, hébergement mutualisé, LAN d’entreprise avec des postes ou des IoT non maîtrisés), une IP privée peut très bien appartenir à une machine compromise ou hostile. Dans ce genre de contexte, considérer tout le RFC 1918 comme « de confiance » revient à désactiver la protection fail2ban pour un pan entier d’attaquants potentiels : mieux vaut alors restreindre
ignoreipau strict réseau d’administration réellement maîtrisé, plutôt que copier cette liste telle quelle.
Le pipeline en un schéma#
┌───────────────────┐ ┌─────────────────────┐
│ /var/log/nginx/ │ │ systemd-journald │
│ access.log │ │ (événements sshd) │
└───────────────────┘ └─────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌───────────────────────────────┐
│ fail2ban │ │ fail2ban │
│ jail nginx │ │ jail sshd │
│ (444/400/405/418) │ │ (échecs d'authentification) │
└─────────────────────┘ └───────────────────────────────┘
│ │
└────────────────────┬────────────────────┘
▼
┌───────────────────────────┐
│ action nftables-subnets │
└───────────────────────────┘
│
▼
┌────────────────────────────────┐
│ table ip fail2ban : set /28 │
│ table ip6 fail2ban : set /96 │
│ → reject │
└────────────────────────────────┘Erreurs à éviter#
Sans
ignoreipbien configuré, une box internet peut se faire bannir. Un incident réel : une box opérateur domestique a été bannie par le filtrenginx-criticalsuite à une requête diagnostique automatique portant un User-Agentcurl/..., exactement le motif que le filtre cible. Conséquence en cascade : le bannissement de la box a cassé le renouvellement ACME de Let’s Encrypt (chapitre précédent) et le monitoring intégré du fournisseur d’accès. Couvrir toutes les plages RFC 1918 dansignoreip, pas seulement le réseau d’administration explicitement utilisé, est une précaution peu coûteuse : aucune IP RFC 1918 ne devrait jamais atteindre le serveur depuis Internet via un NAT correctement configuré.
- Tester une commande SSH admin sans
ignoreip. Une commande commessh root@serveuréchoue immédiatement (root login désactivé, chapitre Hardening SSH) mais compte comme une tentative, répétée plusieurs fois en debug, elle peut mener à un auto-bannissement de l’IP en cours d’usage. - Oublier
fail2ban-client reloadaprès ajout de nouvelles prisons. Sur Debian, le paquetfail2bandémarre automatiquement à l’installation, souvent avant l’ajout des jails propres à ce guide.fail2ban-client statusne listera alors quesshd(la prison par défaut) tant que le rechargement n’a pas eu lieu. - Un timestamp de log mal anticipé casse le
failregex. Le format de log nginx par défaut sur Debian inclut le timestamp entre crochets ([04/May/2026:18:04:30 +0200]), une expression régulière écrite pour un format différent ne matchera simplement rien, silencieusement. Toujours valider avecfail2ban-regexsur un vrai fichier de log avant de faire confiance à un nouveau filtre.
Vérifier#
fail2ban-client status
# Doit lister : sshd, nginx-critical, nginx-4xx-global
fail2ban-client status nginx-critical
# Affiche le nombre d'IPs actuellement bannies
# Validation d'un filtre AVANT de le déployer
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-critical.conf | grep -E "Failregex:|Lines:"
# "Failregex: N" avec N > 0 si le motif matche au moins une ligne réelle
nft list table ip fail2ban | head -20
# Confirme que les tables nftables dédiées sont bien provisionnées