CrowdSec analyse les journaux Web, SSH et mail ; environ 20 000 adresses malveillantes bloquées au pare-feu via les listes communautaires.
Étude de cas — Durcissement d’un homelab auto-hébergé
Deux jours de chantier, de l’audit à la production : IPS collaboratif, pare-feu, passerelle réseau, DNS chiffré et accès d’administration.
Ce que le projet démontre
Paquet dépaqueté sans démarrage, configuration corrigée, puis premier lancement et test sur une adresse de documentation.
Ordre systemd, relance après rechargement et watchdog : les règles de blocage survivent au « flush ruleset ».
Restriction SSH appliquée avec un retour arrière automatique à 3 minutes, annulé seulement après validation.
Trace nftables d’un paquet table par table pour isoler la règle fautive au lieu de deviner.
Un seul DNS distribué, résolution chiffrée DoH dans l’ordre, fin des requêtes envoyées à dix fournisseurs.
Étude de cas en images
Schémas réalisés pour cette étude de cas ; adresses et noms anonymisés.




Description
Cette étude de cas retrace le durcissement d’un homelab de deux machines Linux : une passerelle et serveur d’applications (mini-PC x86, Debian 12) et un point d’accès Wi-Fi (carte ARM, Debian 13). La passerelle publie des services Web et mail et sort sur Internet par un tunnel WireGuard vers un fournisseur d’accès associatif, qui lui attribue une IP fixe.
L’objectif était d’ajouter une détection d’intrusion collaborative avec blocage réel au pare-feu, sans jamais perdre l’accès d’administration ni casser les services en production. Chaque étape a suivi la même discipline : audit en lecture seule, sauvegarde, correctif ciblé, validation, puis retour arrière automatique pour les changements à risque.
Le chantier a révélé plusieurs problèmes qu’un simple « apt install » aurait laissés passer : un bouncer HTTP incompatible avec le portail d’authentification Lua du serveur Web, un « flush ruleset » qui effaçait les règles de blocage à chaque rechargement du pare-feu, un générateur de règles dont le mode simulation supprimait les tables actives, un DNS qui interrogeait dix résolveurs à chaque requête, des appareils du réseau local qui contournaient la passerelle, et une protection anti-usurpation de wg-quick qui empêchait le LAN de joindre l’IP publique.
Chaque problème a été diagnostiqué avec des preuves (captures tcpdump, traces nftables, journaux) avant d’être corrigé de façon persistante. Les nouveaux scénarios de détection ont d’abord été observés en mode simulation pour écarter les faux positifs, et les alertes ont été regroupées pour rester exploitables.
Note de transparence : les adresses IP (plages de documentation RFC 5737), noms d’hôtes, ports, matériel et fournisseurs présentés ici sont modifiés pour la publication. La démarche, les problèmes rencontrés et les configurations sont ceux d’un déploiement réel.
Fonctionnalités & points techniques
- ▹Audit en lecture seule avant chaque modification, rapport après chaque étape.
- ▹Choix argumenté du bouncer pare-feu plutôt que du bouncer HTTP (conflit avec le portail Lua).
- ▹Bouncer nftables limité au trafic entrant, tables dédiées, aucune règle existante modifiée.
- ▹Résistance au « flush ruleset » : dépendance systemd, relance et watchdog testé.
- ▹Scénarios HTTP observés 48 h en simulation, sans faux positif, avant activation.
- ▹Listes d’exceptions pour ne jamais bloquer les accès d’administration.
- ▹Alertes mail regroupées : une ligne par adresse, pays et opérateur.
- ▹Passerelle du LAN via WireGuard : NAT, MSS clamping, repli si le tunnel tombe.
- ▹Correction d’un générateur de règles dont le mode simulation supprimait les tables actives.
- ▹DHCP et DNS centralisés, résolution DNS-over-HTTPS, un seul DNS distribué.
- ▹Accès SSH restreint sur le lien WAN, administration de secours par Tailscale.
- ▹Relais mail unique : expéditeur unique et envoi authentifié en TLS obligatoire.
- ▹Document de référence tenu à jour : topologie, sauvegardes, retours arrière, points ouverts.
Extraits de configuration (anonymisés)
Extraits représentatifs du chantier. Adresses en plages de documentation (RFC 5737), noms et ports modifiés.
Bouncer CrowdSec nftables
Tables dédiées, priorité -10, hook input uniquement.
# --- [1] Bouncer CrowdSec nftables : hook input uniquement --------------------# /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml (extrait)mode: nftablesupdate_frequency: 10snftables: ipv4: enabled: true set-only: false table: crowdsec chain: crowdsec-chain priority: -10 ipv6: enabled: true set-only: false table: crowdsec6 chain: crowdsec6-chain priority: -10nftables_hooks: - input # forward volontairement absent : le trafic routé LAN / VPN n'est pas filtré iciInstallation contrôlée et test
Configuration corrigée avant le premier démarrage, puis test sur une adresse de documentation.
# --- [2] Installation contrôlée : corriger la configuration AVANT le démarrage -dpkg --unpack crowdsec-firewall-bouncer-nftables_*.deb # conffiles déposés en .dpkg-newCFG=/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yamlsed -i '/^nftables_hooks:/,/^[^ ]/{/^ - forward$/d}' "$CFG.dpkg-new"dpkg --configure crowdsec-firewall-bouncer-nftables # clé API + premier démarragegrep -q '^ - forward$' "$CFG" && { systemctl stop crowdsec-firewall-bouncer; exit 1; }# Test fonctionnel avec une adresse de documentationcscli decisions add --ip 203.0.113.123 --duration 5m --reason validationsleep 15nft list table ip crowdsec | grep -q 203.0.113.123 && echo "OK : adresse bloquée au pare-feu"cscli decisions delete --ip 203.0.113.123Résister au « flush ruleset »
Ordre systemd, relance après rechargement de nftables et watchdog.
# --- [3] Résister au "flush ruleset" de nftables.conf -------------------------# /etc/systemd/system/crowdsec-firewall-bouncer.service.d/10-after-nftables.conf[Unit]After=nftables.service# /etc/systemd/system/nftables.service.d/zz-crowdsec-bouncer.conf[Service]ExecStartPost=-/bin/systemctl --no-block try-restart crowdsec-firewall-bouncer.serviceExecReload=-/bin/systemctl --no-block try-restart crowdsec-firewall-bouncer.service# /etc/systemd/system/crowdsec-nft-watchdog.service (timer toutes les 5 min)[Service]Type=oneshotExecStart=/bin/bash -c 'systemctl is-active --quiet crowdsec-firewall-bouncer || exit 0; \ nft list table ip crowdsec >/dev/null 2>&1 || { logger -t crowdsec-nft-watchdog "table absente"; \ systemctl restart crowdsec-firewall-bouncer; }'Restriction SSH avec retour arrière
Rechargement atomique d’une seule table et rollback automatique à 3 minutes.
# --- [4] Restriction SSH sur le lien WAN, avec retour arrière automatique -------F=/etc/nftables.d/40-ssh-bastion.confBK=/root/40-ssh-bastion.conf.before-$(date +%Y%m%d-%H%M%S)cp -a "$F" "$BK"cat > "$F" <<'NFT'table inet ssh-bastion { chain input { type filter hook input priority filter -20; policy accept; ip saddr 10.40.0.0/24 tcp dport 2222 accept comment "LAN" iifname "wg0" tcp dport 2222 accept comment "VPN d'administration" iifname "tailscale0" ip saddr 100.64.0.10 tcp dport 2222 accept comment "poste d'admin" iifname "wg-wan" ip saddr { 203.0.113.50 } tcp dport 2222 accept comment "IP d'admin publique" tcp dport 2222 drop comment "tout le reste" }}NFTnft -c -f "$F" || { cp -a "$BK" "$F"; exit 1; }systemd-run --unit=ssh-bastion-rollback --on-active=180 /bin/bash -c \ "cp -a '$BK' '$F' && { echo 'flush table inet ssh-bastion'; cat '$F'; } | nft -f -"{ echo 'flush table inet ssh-bastion'; cat "$F"; } | nft -f - # rechargement atomique d'une seule table# Nouvelle session validée → systemctl stop ssh-bastion-rollback.timerPasserelle du LAN
NAT vers le tunnel, MSS clamping et repli.
# --- [5] Passerelle du LAN via le FAI associatif (NAT + MSS clamping) ----------table ip nat_wan { chain postrouting { type nat hook postrouting priority srcnat; policy accept; ip saddr 10.40.0.0/24 oifname "wg-wan" masquerade comment "LAN → FAI associatif" ip saddr 10.40.10.0/24 oifname "wg-wan" masquerade comment "clients VPN → Internet" ip saddr 10.40.0.0/24 oifname "eth0" masquerade comment "repli si le tunnel tombe" }}table inet fwd_wan { chain forward { type filter hook forward priority filter; policy accept; oifname "wg-wan" tcp flags syn tcp option maxseg size set rt mtu comment "MSS clamping (MTU 1420)" iifname "eth0" oifname "wg-wan" accept iifname "wg-wan" oifname "eth0" ct state established,related accept }}Trace nftables d’un paquet
Table temporaire avec nftrace pour suivre un paquet et identifier la règle qui le jette.
# --- [6] Diagnostic : tracer un paquet table par table ------------------------nft add table inet zz_tracenft add chain inet zz_trace pre '{ type filter hook prerouting priority -350; }'nft add rule inet zz_trace pre ip saddr 10.40.0.50 ip daddr 198.51.100.25 tcp dport 443 meta nftrace set 1timeout 15 nft monitor trace# ip wg-quick-wg-wan preraw rule iifname != "wg-wan" ip daddr 198.51.100.25# fib saddr type != local drop (verdict drop) ← anti-usurpation de wg-quicknft delete table inet zz_traceCorrectif persistant WireGuard
Exceptions ajoutées par PostUp avant la règle anti-usurpation de wg-quick, pour le LAN et les clients VPN.
# Correctif persistant — /etc/wireguard/wg-wan.conf, section [Interface]PostUp = nft insert rule ip wg-quick-%i preraw iifname "wg0" ip saddr 10.40.10.0/24 ip daddr 198.51.100.25 acceptPostUp = nft insert rule ip wg-quick-%i preraw iifname "eth0" ip saddr 10.40.0.0/24 ip daddr 198.51.100.25 acceptDNS sans fuite
Amont DoH dans l’ordre et DNS distribué unique.
# --- [7] DNS : un amont chiffré, dans l'ordre, sans fuite ----------------------# /etc/dnsmasq.d/lan.conf (extrait)strict-order # remplace "all-servers" : chaque requête partait vers 10 résolveursno-resolvserver=127.0.0.1#5555 # client DNS-over-HTTPS local (profil filtré)server=45.90.28.0 # secours si le client DoH est arrêtédhcp-option=option:dns-server,10.40.0.2 # un seul DNS distribué : plus de contournement# Les requêtes DNSBL du serveur mail conservent des résolveurs dédiés.Alertes regroupées
Modèle Go : une ligne par adresse avec pays, opérateur et scénarios.
# --- [8] Alertes regroupées : une ligne par adresse ---------------------------# /etc/crowdsec/notifications/email.yaml (extrait) — ~300 mails/jour → 1 toutes les 30 min maxgroup_wait: 30mgroup_threshold: 50format: | {{- $scen := dict -}}{{- $info := dict -}} {{- range . -}} {{- $ip := .Source.Value -}} {{- $s := trimPrefix "crowdsecurity/" .Scenario -}} {{- if hasKey $scen $ip -}} {{- $_ := set $scen $ip (printf "%s, %s" (get $scen $ip) $s) -}} {{- else -}} {{- $_ := set $scen $ip $s -}} {{- $_ := set $info $ip (printf "%s · AS%s" (default "?" .Source.Cn) (default "?" .Source.AsNumber)) -}} {{- end -}} {{- end -}} {{ len $scen }} IP bloquée(s) — {{ len . }} alerte(s) regroupée(s) {{- range $ip, $s := $scen }} {{ $ip }} {{ get $info $ip }} {{ $s }} {{- end }}