Configurare firewall avanzati con nftables su VPS gestite senza personale tecnico qualificato: guida operativa Debian e Ubuntu

Configurare firewall avanzati con nftables su VPS gestite senza personale tecnico qualificato: guida operativa Debian e Ubuntu

Qualche mese fa, durante un audit di routine su un VPS Hetzner CX21 di un'azienda del settore servizi digitali, con un portale Laravel per la gestione documentale, ho scoperto che il server non aveva nessun firewall attivo. Nessuno. La porta 22 (SSH) era aperta a tutto internet, la porta 3306 (MySQL) era raggiungibile dall'esterno con bind-address = 0.0.0.0, la porta 6379 (Redis) rispondeva senza autenticazione, e non c'era né ufw, né iptables, né nftables configurato. Il server era online da oltre un anno in quella condizione. Ho controllato i log SSH: 23.000 tentativi di autenticazione falliti nelle ultime 48 ore da IP distribuiti su tre continenti, con picchi di 800 tentativi all'ora. L'unica ragione per cui il server non era stato compromesso è che l'autenticazione SSH era a chiave, una configurazione che il freelance originario aveva impostato correttamente prima di sparire.

Quel VPS era una bomba a orologeria. Bastava un singolo errore (un utente con password debole creato per sbaglio, un servizio con credenziali di default) e l'intero patrimonio documentale sarebbe stato esposto. In mezza giornata ho configurato nftables da zero, integrato i ban dinamici, chiuso tutte le porte non necessarie e implementato il logging strutturato. In questo articolo ti racconto esattamente come, con la configurazione completa che uso come standard su ogni VPS Debian o Ubuntu con stack LEMP, e con le aggiunte che separano un firewall "che esiste" da un firewall che regge davvero un attacco: protezione anti SYN flood, filtraggio del traffico in uscita, e un loop di verifica che esegue ogni volta dall'esterno.

Stai cercando un Consulente Informatico esperto per mettere in sicurezza il tuo VPS? Nel mio profilo professionale trovi l'esperienza concreta su hardening Linux, nftables e sicurezza infrastrutturale per PMI.

Perché nftables e non ufw o iptables?

Su Debian, nftables è il framework firewall predefinito del kernel, e il progetto Debian raccomanda esplicitamente di costruire nuove configurazioni su nftables anziché su iptables. ufw (Uncomplicated Firewall) è un frontend per iptables che semplifica la gestione di regole base, e va benissimo per configurazioni semplici. Ma quando hai bisogno di rate limiting per IP, protezione anti flood, set dinamici di IP bannati e logging granulare, ufw diventa un collo di bottiglia e nftables è la scelta corretta.

I vantaggi concreti di nftables per un VPS in produzione sono tre. Primo: un unico strumento (nft) gestisce IPv4, IPv6, ARP e bridging con la stessa sintassi, niente più doppi set di regole iptables/ip6tables. Secondo: le regole vengono compilate in bytecode che il kernel esegue direttamente, riducendo l'overhead per pacchetto rispetto all'interpretazione sequenziale di iptables. Terzo: i set e le mappe di nftables permettono la gestione efficiente di grandi liste di IP (come quelle generate dai sistemi di ban automatico) con aggiornamento atomico, senza ricostruire l'intera chain.

Un dettaglio che vale la pena chiarire subito, perché toglie un dubbio frequente: la sintassi che vedi in questo articolo è identica su Debian 12, Debian 13 trixie e Ubuntu. nftables fa parte del kernel Linux e il linguaggio del file nftables.conf non è cambiato nel passaggio di major release della distribuzione. Se stai pianificando l'aggiornamento del sistema operativo, la tua configurazione firewall ti segue senza riscritture: l'ho verificato nel passaggio da Debian 12 a Debian 13, dove il /etc/nftables.conf è sopravvissuto intatto all'upgrade.

La configurazione base: default-deny per stack LEMP

Il principio fondamentale di un firewall è default-deny: tutto ciò che non è esplicitamente permesso è bloccato. La configurazione che installo su ogni VPS con Nginx, PHP-FPM, MySQL e Redis è questa, e la commento riga per riga subito dopo:

#!/usr/sbin/nft -f
# /etc/nftables.conf - configurazione standard VPS LEMP

flush ruleset

table inet filter {

    # Set per IP fidati (gestione SSH)
    set trusted_ssh {
        type ipv4_addr
        flags interval
        elements = {
            93.XX.XX.XX,        # IP ufficio consulente
            85.YY.YY.YY         # IP ufficio cliente
        }
    }

    chain input {
        type filter hook input priority 0; policy drop;

        # Connessioni gia stabilite: accettare sempre
        ct state established,related accept

        # Pacchetti invalidi: drop immediato (evasion prevention)
        ct state invalid drop

        # Loopback: accettare (PHP-FPM, MySQL socket, Redis locale)
        iif lo accept

        # ICMP: accettare con rate limiting (diagnostica, ma no flood)
        ip protocol icmp icmp type { echo-request, echo-reply } \
            limit rate 5/second accept

        # SSH: solo da IP fidati
        tcp dport 22 ip saddr @trusted_ssh accept

        # HTTP e HTTPS: anti SYN flood, poi accettare
        tcp dport { 80, 443 } ct state new \
            limit rate 60/second burst 100 packets accept
        tcp dport { 80, 443 } ct state new drop
        tcp dport { 80, 443 } accept

        # Logging dei pacchetti droppati (per diagnostica)
        log prefix "NFT_DROP: " level info limit rate 5/minute
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}

Ogni riga ha una ragione precisa. Il ct state invalid drop è la prima regola dopo established,related perché i pacchetti con stato invalido sono spesso tentativi di evasione (pacchetti fuori sequenza, SYN-ACK senza SYN precedente) e vanno scartati prima di qualsiasi altra valutazione. Il set trusted_ssh limita l'accesso SSH solo agli IP autorizzati: questo è il singolo cambiamento che elimina il 99% dei tentativi di brute force. MySQL (3306), Redis (6379) e PHP-FPM (9000) non appaiono perché sono raggiungibili solo via socket locale o su 127.0.0.1, mai dall'esterno.

Il logging con limit rate 5/minute evita che un port scan massiccio saturi il syslog: senza il limit, un attaccante che scansiona tutte le 65.535 porte genererebbe 65.535 righe di log in pochi secondi.

Come si difende un VPS dalle connessioni di massa su porte 80 e 443?

Le porte web devono restare aperte a tutti, ma "aperte a tutti" non significa "senza difese". La difesa di base è un baseline di rate limiting sul rate di nuove connessioni TCP: si accettano i pacchetti SYN fino a una soglia di sicurezza e si scartano quelli oltre, così un SYN flood non esaurisce le risorse del kernel. È esattamente quello che fanno le tre righe sulle porte { 80, 443 } nella configurazione sopra.

La logica è quella del token bucket documentata sul wiki ufficiale di nftables: limit rate 60/second burst 100 packets consente in regime stabile 60 nuove connessioni al secondo, assorbendo picchi legittimi fino a 100 grazie al burst. La riga immediatamente successiva, tcp dport { 80, 443 } ct state new drop, intercetta tutto ciò che supera la soglia e lo scarta. La terza riga accetta le connessioni già conteggiate. L'ordine è vincolante: la regola di limit deve precedere il drop, e il drop deve precedere l'accept incondizionato, altrimenti il rate limit non scatta mai.

Se il problema è un singolo IP che apre centinaia di connessioni simultanee (tipico di uno scraper aggressivo o di un layer-7 rudimentale), il rate globale non basta e serve un limite per sorgente. nftables lo ottiene con ct count over su un set dinamico:

    # Massimo 30 connessioni simultanee per singolo IP verso il web
    set conn_limit {
        type ipv4_addr
        size 65535
        flags dynamic
    }

    # ... dentro la chain input, prima dell'accept su 80/443:
    tcp dport { 80, 443 } ct state new \
        add @conn_limit { ip saddr ct count over 30 } drop

Questa regola aggiunge al set conn_limit la sorgente di ogni nuova connessione e, se quell'IP ha già 30 connessioni tracciate dal conntrack, droppa la nuova. Un avvertimento operativo che evita un errore di sintassi frustrante: con ct count non si può abbinare un timeout al set (il kernel restituisce Operation not supported); la scadenza è governata dai timer della tabella conntrack. La spiegazione canonica e gli esempi completi sono sulla pagina Connlimits del wiki nftables.

Il rate limiting non sostituisce un servizio web dimensionato bene né un CDN davanti all'origin. È il primo strato: ferma il rumore di fondo (scanner, flood banali, IP impazziti) prima che arrivi a PHP-FPM, dove ogni richiesta costa molto di più.

Come applicare la configurazione senza bloccarsi fuori

Il rischio più grande quando configuri un firewall via SSH è bloccarti fuori dal server: se sbagli una regola e SSH viene filtrato, hai perso l'accesso. Prima di toccare qualsiasi cosa, salva sempre il ruleset corrente, così hai un punto di ripristino immediato:

# Backup del ruleset attuale prima di qualsiasi modifica
nft list ruleset > /root/nftables-backup-$(date +%F).rules

# Per ripristinare in caso di problemi:
# nft -f /root/nftables-backup-AAAA-MM-GG.rules

A quel punto la procedura sicura che uso sempre è questa:

# 1. Salvare la configurazione in /etc/nftables.conf
nano /etc/nftables.conf  # incollare la configurazione

# 2. Verificare la sintassi PRIMA di applicare
nft -c -f /etc/nftables.conf
# Se restituisce errori, correggere PRIMA di procedere

# 3. Applicare con timeout di sicurezza
# Se perdi la connessione, dopo 60 secondi le regole vengono resettate
nft -f /etc/nftables.conf && sleep 60 && nft flush ruleset

# 4. Se tutto funziona (SSH risponde), interrompere con Ctrl+C
# e rendere le regole persistenti
systemctl enable --now nftables

# 5. Verificare che le regole siano attive
nft list ruleset

Il trucco è nella riga del punto 3: nft -f /etc/nftables.conf && sleep 60 && nft flush ruleset. Se la connessione SSH si interrompe dopo l'applicazione delle regole (perché ti sei bloccato fuori), il sleep 60 non viene mai interrotto, e dopo 60 secondi il nft flush ruleset resetta tutte le regole e ripristina l'accesso. Se invece tutto funziona, premi Ctrl+C durante il sleep per annullare il flush e mantenere le regole attive. Questo metodo, abbinato al backup del ruleset, mi ha salvato da diversi lockout accidentali.

Filtrare anche il traffico in uscita: la lezione che quasi tutti saltano

Quasi tutte le configurazioni nftables che audito hanno la chain output con policy accept. È il default ovvio, e per anni l'ho usato anch'io senza pensarci. Ma c'è una falla logica precisa: se un attaccante ottiene l'esecuzione di codice sul server (una RCE in un componente PHP vulnerabile, una dipendenza compromessa), un output completamente aperto gli consente di esfiltrare dati e scaricare payload secondari verso qualsiasi destinazione, su qualsiasi porta. Il firewall protegge l'ingresso e lascia l'uscita spalancata.

Il filtraggio egress inverte la policy della chain output a drop e consente solo il traffico che il server deve davvero generare: risoluzione DNS, aggiornamenti dei pacchetti, NTP, le risposte alle connessioni in ingresso. Tutto il resto viene bloccato, e un payload che prova a chiamare casa verso un IP arbitrario fallisce.

    chain output {
        type filter hook output priority 0; policy drop;

        # Risposte a connessioni gia stabilite
        ct state established,related accept

        # Loopback
        oif lo accept

        # DNS verso i resolver (adatta agli IP del tuo provider)
        ip daddr { 1.1.1.1, 8.8.8.8 } udp dport 53 accept
        ip daddr { 1.1.1.1, 8.8.8.8 } tcp dport 53 accept

        # NTP per la sincronizzazione oraria
        udp dport 123 accept

        # HTTP/HTTPS in uscita (apt update, Composer, API esterne note)
        tcp dport { 80, 443 } accept

        # Log di cio che viene bloccato in uscita (diagnostica esfiltrazione)
        log prefix "NFT_EGRESS_DROP: " level info limit rate 5/minute
    }

Va calibrato sul carico reale del server: se un applicativo deve raggiungere un SMTP esterno, un'API su porta non standard o un database remoto, quelle destinazioni vanno aggiunte esplicitamente. L'egress filtering è più scomodo da mantenere dell'ingress, e per questo viene saltato, ma è proprio la riga di log NFT_EGRESS_DROP che ti dice se un processo sul server sta tentando di parlare con un IP che non dovrebbe. Su un server che ospita codice di terze parti, è una delle poche difese che funzionano dopo che la prima linea è caduta.

Integrazione con Fail2ban per ban dinamici

nftables gestisce le regole statiche (chi può accedere a cosa), Fail2ban gestisce i ban dinamici (chi ha fatto troppi tentativi sbagliati viene bloccato automaticamente). L'integrazione tra i due richiede una configurazione di Fail2ban che usi nftables come backend:

# /etc/fail2ban/jail.local
[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports
chain = input
backend = systemd

[sshd]
enabled = true
port = ssh
maxretry = 3
findtime = 600
bantime = 3600

[nginx-http-auth]
enabled = true
port = http,https
maxretry = 5
findtime = 300
bantime = 1800

Quando Fail2ban banna un IP, crea automaticamente un set nftables con gli IP bannati e una regola che li droppa. Puoi vedere i ban attivi e, soprattutto, rimuovere un ban quando ti sei auto-bannato (succede più spesso di quanto si ammetta, tipicamente sbagliando ripetutamente la passphrase della chiave):

# IP attualmente bannati e stato della jail
fail2ban-client status sshd

# Rimuovere un ban (recupero da auto-ban)
fail2ban-client unban 93.42.10.7

# Sbloccare TUTTO in caso di emergenza
fail2ban-client unban --all

Ho descritto in dettaglio la configurazione di Fail2ban per applicazioni Laravel, incluse le jail custom per il login web, nell'articolo su Fail2ban fermo su VPS con Laravel.

Quando ha senso aggiungere CrowdSec a Fail2ban?

Fail2ban è perfetto per la difesa locale e stateless basata su pattern: un IP fa troppi tentativi falliti, lo banni. Il limite è strutturale: ogni server impara da zero, ragiona su regex di una singola riga di log, e non sa nulla di un attaccante finché quell'attaccante non bussa alla sua porta. CrowdSec ha senso quando vuoi superare entrambi questi limiti. È un sistema di intrusion prevention comportamentale, in sviluppo attivo e maturo (oltre sei anni di progetto, team finanziato, scritto in Go), che porta due differenze sostanziali.

La prima è la detection comportamentale: invece della regex piatta di Fail2ban, CrowdSec usa scenari in YAML che esprimono un comportamento nel tempo ("5 login falliti in 2 minuti seguiti da uno tentativo su un endpoint sensibile"), riducendo i falsi positivi. La seconda è la crowd intelligence: quando un nodo della rete CrowdSec rileva un attaccante, tutti gli altri possono bloccare quell'IP in modo proattivo, prima che l'attacco li raggiunga. È un blocklist condiviso alimentato da milioni di segnali al giorno.

L'architettura separa la detection (l'agent che legge i log) dalla remediation (i bouncer che applicano i ban): puoi rilevare su un server ed enforce su un altro. Su un VPS con nftables, l'installazione su Debian o Ubuntu passa per il repository ufficiale e per il bouncer nftables dedicato:

# Aggiungere il repository ufficiale CrowdSec
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash

# Installare prima l'agent, poi il bouncer (l'ordine conta)
sudo apt-get install crowdsec
sudo apt-get install crowdsec-firewall-bouncer-nftables

# Installare la collection netfilter (valida anche per nftables)
sudo cscli collections install crowdsecurity/iptables
sudo systemctl reload crowdsec

Il bouncer in modalità nftables crea due set, crowdsec e crowdsec6 (IPv4 e IPv6), e li popola in automatico con la blocklist. La gestione si fa con cscli (cscli decisions list, cscli metrics show bouncers), e l'intera procedura è documentata nella guida ufficiale al bouncer firewall di CrowdSec.

Il mio consiglio operativo, allineato alla pratica più diffusa: non scegliere uno o l'altro, stratifica. Tieni Fail2ban su SSH, dove è semplice e collaudato, e affianca CrowdSec sui servizi web, dove la detection comportamentale e il blocklist condiviso fanno una differenza misurabile. CrowdSec richiede connettività verso la rete e una curva di apprendimento più ripida (agent, bouncer, parser, scenari): su un VPS gestito da chi non ha personale tecnico dedicato, vale la pena introdurlo solo quando il sito web è bersaglio di traffico ostile distribuito che la regex di Fail2ban non intercetta.

IPv6: il firewall che tutti dimenticano

Un errore che trovo in almeno metà dei VPS che audito: il firewall è configurato solo per IPv4. Su Hetzner, OVH e Digital Ocean, ogni VPS riceve sia un IPv4 che un IPv6 (spesso un intero blocco /64). Se le regole coprono solo IPv4, l'intera superficie di attacco IPv6 resta completamente esposta: SSH, MySQL, Redis, tutto raggiungibile da chiunque via IPv6.

La soluzione è usare la famiglia inet (non ip) nelle tabelle. La configurazione che ho mostrato sopra usa già table inet filter, e la keyword inet applica le regole sia a IPv4 che a IPv6 contemporaneamente. Se la tua configurazione usa table ip filter, coprirà solo IPv4 e dovrai migrarla a inet.

# Verificare se il VPS ha un indirizzo IPv6
ip -6 addr show | grep "scope global"

# Verificare che il ruleset sia su famiglia inet, non ip
nft list ruleset | head -3
# Deve mostrare "table inet filter", non "table ip filter"

Se il tuo VPS ha IPv6 ma non lo usi per le applicazioni, la scelta più sicura è disabilitarlo a livello di kernel:

echo "net.ipv6.conf.all.disable_ipv6 = 1" >> /etc/sysctl.conf
echo "net.ipv6.conf.default.disable_ipv6 = 1" >> /etc/sysctl.conf
sysctl -p

Verificare dall'esterno: il firewall che non testi non esiste

Configurare le regole e fidarsi è il modo più rapido per ritrovarsi un servizio esposto che credevi chiuso. La verifica va fatta da fuori e da dentro, perché le due viste rispondono a domande diverse: dall'esterno vedi cosa vede un attaccante, dall'interno vedi cosa sta davvero ascoltando.

Dall'esterno (da un'altra macchina, non dal VPS stesso) un SYN scan con nmap ti dice quali porte rispondono, su IPv4 e su IPv6:

# Scan da una macchina esterna - IPv4
nmap -sS -p 1-65535 IP_DEL_VPS

# Lo stesso scan su IPv6 (la meta che tutti dimenticano)
nmap -6 -sS -p 1-65535 IPV6_DEL_VPS

Il risultato atteso su un VPS LEMP configurato bene: solo 80, 443 e (eventualmente, se non filtri SSH per IP) 22 risultano open; tutto il resto è filtered. Se vedi 3306 o 6379 aperte, hai un problema serio e immediato.

Dall'interno, ss ti mostra quali processi stanno effettivamente ascoltando e su quale interfaccia:

# Tutti i socket TCP/UDP in ascolto, con processo
ss -tlnp

# Quello che cerchi: MySQL e Redis devono ascoltare su 127.0.0.1, MAI su 0.0.0.0
ss -tlnp | grep -E '3306|6379'

La regola operativa che applico a fine configurazione: un servizio che non deve essere pubblico non deve né ascoltare sull'interfaccia pubblica (ss lo verifica) né essere raggiungibile dall'esterno (nmap lo verifica). Il firewall è la seconda linea; la prima è che il servizio non si affacci proprio sull'interfaccia pubblica.

Questo loop (configura, applica con la rete di sicurezza, verifica da fuori, verifica da dentro, rendi persistente) è ciò che trasforma un firewall "scritto" in un firewall "validato". Saltare la verifica è l'errore più comune che vedo, e quasi sempre nasconde una porta dimenticata aperta.

Gli errori più comuni nella configurazione nftables su VPS

Nei miei audit trovo sempre gli stessi errori. Il primo è lasciare il policy accept sulla chain input: rende il firewall decorativo, accetta tutto e le regole di drop servono solo come ornamento. La policy corretta per la chain input è sempre drop.

Il secondo errore è non gestire i pacchetti ct state invalid. Senza quella regola, pacchetti malformati o fuori sequenza (spesso sonde di un attaccante) attraversano tutte le regole della chain e vengono processati normalmente.

Il terzo errore è esporre servizi che devono essere solo locali. MySQL deve avere bind-address = 127.0.0.1 nel suo my.cnf, Redis deve avere bind 127.0.0.1 nel redis.conf, e PHP-FPM deve ascoltare su un socket Unix (listen = /run/php/php8.4-fpm.sock) anziché su una porta TCP.

Il quarto errore è dimenticare di rendere le regole persistenti. nft -f /etc/nftables.conf applica le regole alla sessione corrente, ma se il server viene riavviato senza systemctl enable nftables, le regole spariscono. Ho visto server con firewall "configurato" che dopo un riavvio tornavano completamente aperti.

Gestione operativa: aggiungere e rimuovere regole in produzione

In produzione capita di dover aprire temporaneamente una porta, per un debug o un test di integrazione. La procedura corretta è aggiungere la regola a runtime con nft add, testare, e poi decidere se renderla permanente aggiornando /etc/nftables.conf:

# Aggiungere una regola temporanea (es. porta 8080 per test)
nft add rule inet filter input tcp dport 8080 accept

# Verificare che sia attiva
nft list chain inet filter input

# Per rimuoverla serve l'handle number, trovabile con -a
nft -a list chain inet filter input | grep 8080
# Output: tcp dport 8080 accept # handle 42
nft delete rule inet filter input handle 42

Non modificare mai /etc/nftables.conf e ricaricare per aggiungere una regola temporanea: se il reload fallisce per un errore di sintassi, perdi tutte le regole e il server resta esposto. Per questo la modifica del file di configurazione va sempre preceduta dal nft -c -f e dal backup del ruleset visti sopra.

Cosa è cambiato sul VPS dopo la configurazione

I numeri parlano da soli. Prima di nftables: 23.000 tentativi SSH in 48 ore, MySQL raggiungibile dall'esterno, Redis senza autenticazione esposto a internet. Dopo: zero tentativi SSH da IP non autorizzati (perché SSH è accessibile solo da due IP fidati), MySQL e Redis raggiungibili solo su localhost, il rate limit anti SYN flood ad assorbire i picchi ostili sul web, e i ban automatici a fermare i pochi IP che insistono sui servizi web. Il tutto con un impatto sulle performance del server inferiore all'1%: nftables è estremamente efficiente perché lavora a livello di kernel.

Se gestisci un server dedicato o un VPS unmanaged presso Hetzner, OVH, Aruba, Digital Ocean o Contabo, e non hai personale tecnico interno, la configurazione di un firewall non è un'opzione avanzata: è il primo strato di difesa che separa un server in produzione da un server che è solo una questione di tempo.

La configurazione che ho descritto è il punto di partenza. Per un hardening completo, che include la configurazione di SSH, le policy di aggiornamento, il monitoring e l'audit periodico, rimando alla checklist di hardening in quattordici giorni e all'articolo sull'hardening di server Debian e Ubuntu per applicativi PHP. Il firewall è il primo strato della difesa, non l'unico, ed è la ragione per cui in questa guida ho insistito tanto sul filtraggio in uscita e sulla verifica dall'esterno: sono i due controlli che reggono quando la prima linea, prima o poi, cade.

Se il tuo VPS è in produzione senza un firewall configurato, o con un firewall scritto anni fa e mai più verificato, sei esposto a rischi che crescono ogni giorno. Nel mio lavoro di consulente trovo server con MySQL esposto a internet in almeno un caso su tre degli audit iniziali su PMI italiane. La buona notizia è che configurare nftables richiede meno di un'ora, e che la stessa configurazione vale identica su Debian 12, Debian 13 e Ubuntu. Se vuoi un audit della sicurezza della tua infrastruttura, o se hai un VPS che non ha mai avuto un firewall configurato correttamente, contattami per una consulenza diretta: un'ora di lavoro oggi può evitare settimane di incident response domani.

Ultima modifica: