Vai al contenuto

CrowdSec + Discord notifier: la pipeline di alert che ha sostituito email e Slack

CrowdSec + Discord notifier: la pipeline di alert che ha sostituito email e Slack

CrowdSec + Discord notifier: la pipeline di alert che ha sostituito email e Slack

Dicembre 2025, la fleet su cui giriamo i siti dei clienti editoriali (HestiaCP su Debian, qualche VM Proxmox, container LXC dedicati) riceve in media 3000 eventi sospetti al giorno aggregati: probe HTTP, bruteforce SSH, scan di file .env, bot WordPress su /wp-login.php. Il fail2ban locale per host non bastava più, le email di alert finivano filtrate, Slack era diventato caro per la dimensione del team. Ho introdotto CrowdSec con bouncer su Caddy e una pipeline di notifica via Discord. Questo pezzo racconta il setup, gli scenarios attivati, il template del notifier YAML, e un falso positivo pesante che mi è costato 4 ore di disservizio su un singolo nodo. Vedi anche gli altri post di questa rubrica: Production.

Il problema: state of the internet 2025

Una fleet di siti editoriali, anche piccola, è un bersaglio costante. Non perché interessi qualcuno in particolare: perché il traffico ostile è ormai un rumore di fondo. Una domenica pomeriggio del settembre 2025 ho aggregato due ore di log del reverse proxy della fleet:

  • ~1800 GET su /.env, /.git/config, /wp-config.php.bak da IP residenziali e VPN
  • ~600 tentativi POST su /wp-login.php con username admin, editor, redazione
  • ~200 tentativi SSH con username comuni (root, ubuntu, admin, git)
  • una manciata di scan automatizzati per CVE specifiche di plugin WordPress

Niente di personalizzato, tutto classico. Il fail2ban con jail SSH e nginx-http-auth faceva il suo lavoro per host singolo, ma con tre limiti seri che a volume aggregato pesavano:

  1. Niente memoria fra host. Un IP bannato sul primo nodo poteva ricominciare il lavoro sul secondo cinque minuti dopo. Ogni host ricominciava la conta da zero.
  2. Niente threat intelligence condivisa. L’IP del bot di turno era spesso già noto e bannato in altre community: il mio fail2ban non lo sapeva.
  3. Le email finivano filtrate. Il provider del cliente ha filtri SPF/DKIM aggressivi sui mittenti noreply@, e gli alert finivano regolarmente in spam o quarantena. Notifica utile a niente.

Slack era stato testato per due settimane: funzionava, ma il pricing per workspace piccoli (5 persone interne, qualche guest cliente) non scalava bene. Discord, che già usavamo per alcuni progetti collaborativi, è gratis sui workspace privati e supporta webhook con role mention nativi. La scelta è stata quella.

La scelta: CrowdSec, non un altro fail2ban

Ho valutato tre alternative prima di scegliere CrowdSec.

Alternativa A: fail2ban distribuito via syslog centralizzato. Push dei log da tutti i nodi a un syslog server, jail aggregata. Tecnicamente fattibile, ma il routing della decisione di ban (chi banna chi, e su quale interfaccia) diventa complesso, e la latenza dal log al ban su tutti i nodi è alta.

Alternativa B: WAF cloud (Cloudflare WAF custom rules). Già usavamo Cloudflare in front su molti siti. Funziona bene per gli attacchi HTTP, ma non copre SSH (che è fuori CDN per definizione) e ha un costo che cresce con la complessità delle regole. E le decisioni custom diventano difficili da auditare a posteriori.

Alternativa C: CrowdSec. IPS open source con architettura agent + bouncer + decisions API. Gli agent leggono i log locali (nginx, ssh, WordPress), applicano scenarios riconoscono pattern, scrivono decisioni in un database SQLite locale. I bouncer (su Caddy, nginx, iptables) interrogano la decisions API e applicano i ban. Le decisioni si propagano fra agent della fleet via il proprio Local API. E in più CrowdSec contribuisce a / pesca da una community blocklist opt-in: gli IP segnalati da molti utenti diventano una lista che ogni installazione può consumare.

CrowdSec ha vinto per tre motivi: copre HTTP e SSH nello stesso strumento, la community blocklist è valore reale (decine di migliaia di IP segnalati al giorno da utenti opt-in), e le decisioni sono trasparenti (CLI cscli decisions list per vedere chi è bannato e perché).

Architettura: agent ovunque, bouncer in front, LAPI centralizzata

L’architettura della fleet, semplificata:

[ Caddy reverse-proxy ]──bouncer──┐
                                  │
[ Nodo web 1 ]──agent ──┐        ▼
[ Nodo web 2 ]──agent ──┼──► LAPI centralizzato ◄─── community blocklist
[ Nodo web 3 ]──agent ──┘        │
[ VM SSH-bastion ]──agent ───────┤
                                  ▼
                          [ Decisioni: SQLite ]
                                  │
                                  ▼
                          [ Notifier Discord ]

Ogni nodo della fleet ha un agent CrowdSec locale, configurato per leggere i log applicativi (nginx access/error, ssh, WordPress access via il modulo apposito). Le decisioni vanno tutte verso un Local API (LAPI) centralizzato, che è anche quello che parla con la community blocklist e che il bouncer su Caddy interroga. Il bouncer su Caddy fa la cosa più importante: blocca le richieste HTTP a livello di reverse-proxy, prima ancora che arrivino ai nodi web. Per SSH ho un secondo bouncer (crowdsec-firewall-bouncer) che gestisce iptables sul bastion.

Setup: install standard, configurazione mirata

L’install è quello documentato sul sito CrowdSec. Il pacchetto deb è ben mantenuto:

curl -s https://install.crowdsec.net | sudo bash
sudo apt install crowdsec
sudo apt install crowdsec-firewall-bouncer-iptables

La parte interessante è la configurazione degli scenarios. CrowdSec ha un Hub di “collections” preconfezionate per i casi comuni. Ho attivato:

sudo cscli collections install crowdsecurity/nginx
sudo cscli collections install crowdsecurity/sshd
sudo cscli collections install crowdsecurity/base-http-scenarios
sudo cscli collections install crowdsecurity/wordpress

Le collection installano gli scenarios (regole di rilevamento) e i parsers (per leggere i log). Per la fleet editoriale gli scenarios attivi sono:

  • crowdsecurity/http-probing: scan di file sensibili (/.env, /wp-config.php.bak, ecc.)
  • crowdsecurity/ssh-bf: bruteforce SSH
  • crowdsecurity/http-crawl-non-statics-extensions: scan di estensioni dinamiche su path random
  • crowdsecurity/wordpress-bf: bruteforce WordPress su /wp-login.php
  • crowdsecurity/http-bad-user-agent: user agent noti come ostili (script kiddie tools)

Ogni scenario ha una soglia (numero di eventi in una finestra temporale) che fa scattare la decisione. I default sono ragionevoli, li ho lasciati così per le prime due settimane prima di toccare. Decisioni di tipo ban con TTL 4 ore di default, escalabile a 24 ore per recidivi.

Il bouncer Caddy: dove inizia il blocco vero

Il bouncer su Caddy è quello che fa la differenza in termini di carico assorbito. Ogni request HTTP, prima di arrivare al nodo upstream, passa per il bouncer che chiede al LAPI: “questo IP è bannato?”. Se sì, response 403 immediato a livello di Caddy, niente PHP, niente database. Configurazione:

{
    order crowdsec first
    crowdsec {
        api_url http://lapi-internal:8080
        api_key {env.CROWDSEC_BOUNCER_API_KEY}
        ticker_interval 15s
    }
}

(common) {
    crowdsec
    log {
        output file /var/log/caddy/access.log
    }
}

La direttiva crowdsec first dice a Caddy di consultare il bouncer come primo step del request handling. ticker_interval 15s è il polling della cache locale del bouncer verso il LAPI: ogni 15 secondi il bouncer aggiorna le sue decisioni. Latenza accettabile, traffico al LAPI minimo.

La pipeline Discord: webhook con severity routing

Qui sta la parte che ho costruito su misura. CrowdSec ha un sistema di notifiers modulare: ogni notifier è un YAML che definisce dove mandare le decisioni e con che template. Discord supporta webhook con embed colorati e role mention. Il file /etc/crowdsec/notifications/discord.yaml:

type: http
name: discord
log_level: info
format: |
  {
    "username": "CrowdSec",
    "embeds": [
      {{range . -}}
      {
        "title": "{{.Decisions[0].Type}} → {{.Decisions[0].Value}}",
        "description": "Scenario: `{{.Scenario}}`\nDurata: `{{.Decisions[0].Duration}}`\nNodo: `{{ index .Meta \"hostname\" }}`",
        "color": {{ if eq .Decisions[0].Type "ban" }}15158332{{ else }}3447003{{ end }},
        "fields": [
          {"name": "Eventi", "value": "{{.EventsCount}}", "inline": true},
          {"name": "Origine", "value": "{{.Source.Cn}}/{{.Source.AsName}}", "inline": true}
        ]
      }
      {{end}}
    ],
    "content": "{{ if .Decisions }}<@&ROLE_ID_SECURITY>{{ end }}"
  }
url: "<DISCORD_WEBHOOK_URL>"
method: POST
headers:
  Content-Type: application/json

Il template ha tre cose intenzionali:

  1. Severity routing via colore: rosso (15158332) per i ban, blu (3447003) per le altre decisioni (captcha, throttle).
  2. Role mention: <@&ROLE_ID_SECURITY> pingue il role Security nel server Discord, che è composto da chi è di turno on-call. Niente @everyone, niente notifica al canale generale.
  3. Field “Origine” con AS Name: il sistema autonomo dietro l’IP è quello che dice se è un IP residenziale, un VPN/cloud, un datacenter cinese. Permette al triage di reagire più in fretta.

Per attivarlo, registrato nel profilo:

# /etc/crowdsec/profiles.yaml
name: default_ip_remediation
filters:
 - Alert.Remediation == true && Alert.GetScope() == "Ip"
decisions:
 - type: ban
   duration: 4h
notifications:
 - discord
on_success: break

Restart del service, e il primo ban è arrivato su Discord 11 minuti dopo. Embedded ben formattato, role mention che notifica solo chi serve, tempo di reazione del triage misurato sotto i 90 secondi nelle prime due settimane.

Il falso positivo: 4 ore di Google News bloccato

Tre settimane dopo il rollout, il caporedattore di un cliente mi scrive: “non ci vediamo più su Google News, hai cambiato qualcosa?”. Risposta corretta: niente. Risposta vera: lo scenario crowdsecurity/http-crawl-non-statics-extensions aveva bannato il range IP del crawler Googlebot-News sul nodo che serve quel sito.

Il crawler legittimo di Google News fa un pattern di scan che, su un sito con molti URL alternativi (paginazioni, archivi anno/mese, varianti di taxonomy), supera la soglia dello scenario. Lo scenario non distingue tra crawler legittimo e crawler ostile sul pattern di richieste, distingue solo sulla qualità dell’IP, e il check di Source.AsName == "GOOGLE" non era nello scenario di base.

Diagnosi:

sudo cscli decisions list --ip <IP_BANNATO>
sudo cscli alerts inspect <ALERT_ID>
sudo cscli explain --type http --log "<sample_log_line>"

Il cscli explain mostra come quel log line viene parsato e che scenario lo intercetta. È stato il comando più utile dell’incident: ti mostra esattamente perché l’agent ha deciso quello che ha deciso. Su 4 ore senza Google News, il sito ha perso una porzione significativa di traffico. Il caporedattore non era contento, e aveva ragione.

Fix in due step:

Whitelist degli ASN noti. CrowdSec ha le whitelist YAML semplici:

# /etc/crowdsec/parsers/s02-enrich/whitelist.yaml
name: crowdsecurity/whitelist-googlebot
description: "Whitelist Googlebot e Google News crawler"
whitelist:
  reason: "Trusted crawlers"
  expression:
   - evt.Enriched.AsName == "GOOGLE"
   - evt.Enriched.AsName == "GOOGLE-CLOUD-PLATFORM" && evt.Parsed.user_agent matches "Googlebot.*"

La whitelist agisce a livello di parser, prima ancora che lo scenario veda l’evento. È più efficace di una whitelist nello scenario perché libera anche gli scenari downstream da false positive sullo stesso IP.

Refinement dello scenario: per i crawler “borderline” che superano la soglia ma sono identificabili come legittimi, ho clonato lo scenario in versione locale con soglia più alta e una condizione evt.Parsed.user_agent che esclude i pattern bot whitelistati.

Aggiunta una dashboard Grafana dedicata alle decisioni CrowdSec: top scenarios, top ASN bannati, ratio di whitelist hit. Era una cosa che avevo rimandato; il falso positivo l’ha fatta diventare prioritaria. Da quel momento, prima di promote di un nuovo scenario, lo guardo per 48 ore in modalità simulation (decisione registrata ma non applicata). È il dry-run dello scenario: vedi cosa avrebbe bannato senza bannarlo davvero.

I numeri di esercizio

Metrica Valore
Eventi sospetti aggregati / giorno ~3000
Decisioni di ban applicate / giorno ~120
Mean-time-to-notice (era email) ~30 min
Mean-time-to-notice (Discord) ~90 sec
Falsi positivi notabili dal rollout 1 (Google News, 4 ore)
Hit della community blocklist (% sui ban totali) ~35%
Latenza bouncer Caddy → LAPI (p95) sotto 5 ms
Container LXC che ospitano l’agent 9

I numeri che mi interessano di più sono il mean-time-to-notice e la quota di ban dalla community blocklist. Il primo dice che la pipeline di alert funziona; il secondo dice che il valore della community è reale: senza, un terzo dei ban semplicemente non sarebbe scattato.

Cosa rifarei

Prima. La dashboard Grafana per le decisioni CrowdSec va costruita prima di mettere in produzione, non dopo il primo falso positivo. Il cscli è ottimo per il triage di un singolo alert, ma non dà la visione d’insieme che serve per accorgersi di un pattern strano (es. uno scenario che improvvisamente scatta tre volte più del normale). Il dataset esiste già nel database SQLite locale; basta esportarlo via Prometheus exporter e farne i grafici. È due ore di lavoro che vale dieci.

Seconda. Il simulation mode per gli scenari nuovi va sempre acceso per le prime 48 ore. È letteralmente dry-run, e il costo è zero: vedi cosa avrebbe bannato lo scenario senza l’effetto collaterale di bannarlo davvero. Sarebbe stato sufficiente per intercettare il problema Googlebot prima che diventasse un disservizio.

Terza. Il template del notifier Discord va mantenuto in repo runbook interno con la stessa serietà del codice di produzione. Le tue future-self ti ringrazieranno quando dovrai capire perché un alert è stato instradato male sei mesi dopo. Il YAML del notifier è codice, va versionato e revieweato.

Quarta. La whitelist degli ASN andava attivata fin dal giorno zero per i crawler noti (Google, Bing, Apple, DuckDuckGo, Cloudflare). È disciplina di setup, non un’ottimizzazione successiva.

Quello che mi sono portato a casa

Il rumore di fondo del traffico ostile è una variabile che cresce. Quello che faceva un fail2ban locale dieci anni fa, oggi non è più sufficiente per una fleet di una decina di nodi: serve memoria condivisa fra host, threat intelligence community-driven, e una pipeline di notifica che la gente legge davvero. Discord ha sostituito email e Slack non per ideologia, ma per due ragioni misurabili: deliverability (le email finivano filtrate) e costo (Slack non scala bene per workspace piccoli). Ogni team dovrebbe scegliere il canale che tracking del MTTN, non quello che è “standard di settore”.

C’è un’osservazione di processo che vale per ogni introduzione di un IPS, non solo CrowdSec. Il primo falso positivo non è una sconfitta: è un test di calibrazione. Quattro ore di disservizio mi sono costate, ma mi hanno regalato una whitelist robusta, un simulation mode protocollato, una dashboard Grafana, e una conversazione franca con il caporedattore sul tradeoff sicurezza/disponibilità che ora capiamo entrambi. Il valore di una partnership lunga con un cliente è anche questo: dopo un incident del genere, il rapporto si rafforza, non si rompe. Una richiesta una tantum non avrebbe sopportato lo stesso costo.