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 .conf local (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
EOF

priority -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 /28 ou /29 chez 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. /28 reste assez étroit pour ne pas déborder sur un /24 entier (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 /128 unique ne retient personne plus de quelques minutes. La réponse « naturelle » serait de bannir le /64 généralement attribué à un client par son FAI, mais un /64 est 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. /96 est 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 /64 complet.

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
EOF

Deux 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
EOF

Sous-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 reload

Cette 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 ignoreip au 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 ignoreip bien configuré, une box internet peut se faire bannir. Un incident réel : une box opérateur domestique a été bannie par le filtre nginx-critical suite à une requête diagnostique automatique portant un User-Agent curl/..., 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 dans ignoreip, 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 comme ssh 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 reload après ajout de nouvelles prisons. Sur Debian, le paquet fail2ban démarre automatiquement à l’installation, souvent avant l’ajout des jails propres à ce guide. fail2ban-client status ne listera alors que sshd (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 avec fail2ban-regex sur 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