Configurare WireGuard tramite Docker (utilizzando immagini popolari come wg-easy) è spesso venduta come un’operazione da 5 minuti. E lo è, se il tuo unico obiettivo è avere un tunnel funzionante senza preoccuparti troppo di cosa accade “sotto il cofano”.
Tuttavia, quando si hanno requisiti ingegneristici specifici — come l’integrazione con un DNS locale (es. Pi-hole), la necessità di log reali (No-NAT) e una sicurezza granulare (Firewalling) — la configurazione “out-of-the-box” mostra tutti i suoi limiti, specialmente su distribuzioni Linux moderne (Debian 12/13, Ubuntu 22.04+) che utilizzano nftables e un sottosistema Docker complesso.
In questo articolo analizzeremo come costruire un’architettura VPN Split Tunnel robusta, risolvendo i conflitti tra iptables-legacy e nftables e gestendo correttamente il flusso dei pacchetti attraverso le catene di Docker.
1. L’Obiettivo Architetturale
Vogliamo ottenere un ambiente dove:
- Split Tunnel Reale: I client inviano nel tunnel solo il traffico destinato ai servizi interni e al DNS. Tutto il resto (Internet) viaggia sulla connessione del client (4G/Wi-Fi).
- Osservabilità (No-NAT): Il server DNS (Pi-hole) deve vedere l’IP reale del client VPN (es.
10.13.13.2), non l’IP del gateway Docker. - Sicurezza “Least Privilege”: Anche se un client manipola la sua configurazione per inviare tutto il traffico, il server deve scartare (DROP) tutto ciò che non è esplicitamente autorizzato.
2. Configurazione Client-Side: WG_ALLOWED_IPS
Il primo livello di gestione del traffico avviene sul Client. WireGuard non ha un meccanismo di “push” delle rotte dinamico come OpenVPN; le rotte vengono definite staticamente nella configurazione del peer.
Nel docker-compose.yml di wg-easy, usiamo la variabile WG_ALLOWED_IPS per generare le config dei client:
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy
network_mode: host
environment:
# Sottorete interna della VPN
- WG_DEFAULT_ADDRESS=10.13.13.x
# IP del DNS (Pi-hole)
- WG_DEFAULT_DNS=192.168.0.2
# FONDAMENTALE: Split Tunneling
# Istruisce il client a creare rotte nel tunnel SOLO per questi IP/Range.
# Se qui mettessimo 0.0.0.0/0, il client perderebbe la connettività internet
# (perché il nostro firewall lato server bloccherà l'uscita verso WAN).
- WG_ALLOWED_IPS=192.168.0.2/32,192.168.0.4/32,10.13.13.0/24
Nota Tecnica: Questa è una configurazione che risiede sul dispositivo dell’utente. Un utente malizioso (o curioso) può modificarla manualmente sul proprio smartphone impostando AllowedIPs = 0.0.0.0/0. Per questo motivo, non possiamo fidarci solo di questa configurazione: serve un firewall severo lato server (vedi Sezione 5).
3. Visibilità e Routing: Perché uccidere il Masquerading
La configurazione di default di wg-easy abilita il Masquerading (NAT). Questo significa che il server VPN sostituisce l’IP sorgente del pacchetto (10.13.13.2) con il proprio IP LAN (192.168.0.4) prima di inoltrarlo.
- Pro: Non serve configurare nulla sulla rete locale.
- Contro: I log del Pi-hole diventano inutili (tutto sembra arrivare dal server VPN).
La Soluzione No-NAT:
Disabilitiamo il masquerading (vedremo come nella Sezione 4). Tuttavia, senza NAT, quando il pacchetto arriva al Pi-hole con mittente 10.13.13.2, il Pi-hole cercherà di rispondere inviando il pacchetto al suo Gateway predefinito (il Router Internet), che scarterà il pacchetto perché non conosce la rete 10.13.13.0/24.
Dobbiamo aggiungere una Rotta Statica sul Gateway della LAN (il router di casa) o direttamente sugli host target (Pi-hole):
Destination Network:
10.13.13.0/24-> Next Hop (Gateway):192.168.0.4(IP del Server VPN).
Inoltre, su Pi-hole (Settings -> DNS), bisogna abilitare “Permit all origins”, altrimenti le query provenienti da una subnet diversa (10.x.x.x) verranno ignorate per policy di sicurezza predefinita.
4. Il Paradosso di Docker e il Firewall “Split Brain”
Qui arriviamo al cuore del problema tecnico su sistemi Linux moderni.
Il Problema: Legacy vs NFT
L’immagine wg-easy tenta, all’avvio, di iniettare regole permissive usando i binari iptables all’interno del container. Spesso questi sono iptables-legacy. L’host (Debian 13) usa iptables-nft (wrapper per nftables). Questo crea due set di regole disgiunti che possono andare in conflitto o essere ignorati.
Il Problema: Le regole di Default
Di default, wg-easy esegue comandi simili a questi:
iptables -A FORWARD -i wg0 -j ACCEPT
iptables -A FORWARD -o wg0 -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.13.13.0/24 -j MASQUERADE
Se noi vogliamo gestire la sicurezza e rimuovere il NAT, dobbiamo inibire questo comportamento. Lo facciamo “hackerando” la variabile WG_POST_UP nel compose:
environment:
# Un comando dummy che impedisce l'esecuzione degli script default
- WG_POST_UP=echo "Skipping default rules"
- WG_POST_DOWN=echo "Nothing to do"
Il Blocco: Perché la connettività muore?
Appena rimuoviamo le regole automatiche sopra citate (specialmente le prime due di ACCEPT), la VPN smette di funzionare verso i container Docker. Perché?
Docker, per isolare i container, imposta la Policy della catena FORWARD su DROP.
Quando un pacchetto arriva da wg0 (rete non gestita da Docker) diretto a un container:
- Senza regole esplicite: Docker non riconosce l’interfaccia, scorre le sue catene, non trova match, e applica il
DROPfinale. - Con Masquerading (se fosse attivo): Il pacchetto sembrerebbe provenire dall’IP dell’host o del gateway. Docker tende a fidarsi del traffico “locale”, quindi spesso lascia passare i pacchetti nattati. Questo è il motivo per cui il NAT spesso “risolve” magicamente i problemi: nasconde la provenienza esterna del traffico.
Poiché noi non vogliamo usare il NAT, il pacchetto si presenta con l’IP alieno 10.13.13.x e viene brutalmente scartato dalla policy di Docker.
La Soluzione: DOCKER-USER Chain
Docker fornisce una catena specifica, chiamata DOCKER-USER, che viene valutata prima delle regole di blocco interne. Dobbiamo inserire qui le nostre eccezioni.
È fondamentale usare -I (Insert) e non -A (Append) per assicurarsi che la regola sia in cima alla lista.
# Permetti ai pacchetti della VPN di entrare nel routing di Docker
sudo iptables -I DOCKER-USER -i wg0 -j ACCEPT
# Permetti alle risposte di tornare indietro
sudo iptables -I DOCKER-USER -o wg0 -j ACCEPT
Nota: Questi comandi vanno resi persistenti (es. via systemd o rc.local) poiché al riavvio Docker resetta le catene.
5. NFTables: La Whitelist Finale (Server-Side)
Ora che abbiamo aperto le porte di Docker tramite DOCKER-USER, il traffico scorre. Ma scorre troppo. Senza ulteriori filtri, un client VPN potrebbe provare a raggiungere qualsiasi cosa.
Dobbiamo configurare nftables sull’host per applicare una logica di Whitelist rigorosa.
File /etc/nftables.conf:
#!/usr/sbin/nft -f
flush ruleset
table inet my_shield {
# Catena INPUT: Protegge l'Host stesso
chain input {
type filter hook input priority 0; policy accept;
# Accetta traffico VPN solo verso l'IP del server (es. 192.168.0.4)
iifname "wg0" ip daddr 192.168.0.4 accept
# Blocca tentativi di accesso ad altre porte/servizi dell'host
iifname "wg0" drop
}
# Catena FORWARD: Gestisce il traffico passante (verso Docker, LAN, Internet)
chain forward {
type filter hook forward priority 0; policy accept;
# Accetta connessioni stabilite (necessario per il ritorno dei pacchetti)
ct state established,related accept
# --- REGOLA CRUCIALE PER DOCKER ---
# Quando accedi a un container (es. porta 8080), Docker fa DNAT verso un IP interno (172.x...).
# Questa regola accetta esplicitamente il traffico "nattato" (destinato ai container).
iifname "wg0" ct status dnat accept
# Accesso al Pi-hole (IP fisico)
iifname "wg0" ip daddr 192.168.0.2 accept
# Accesso diretto al server (Ridondanza di sicurezza)
iifname "wg0" ip daddr 192.168.0.4 accept
# --- DROP FINALE ---
# Se un client prova a uscire su Internet o accedere ad altri IP LAN, viene bloccato qui.
iifname "wg0" drop
}
}
Conclusione
Abbiamo sostituito la “magia” delle configurazioni automatiche con una comprensione profonda del packet flow. Il risultato è un sistema dove:
- Docker accetta i pacchetti VPN grezzi (grazie a
DOCKER-USER). - Il Routing gestisce il ritorno dei pacchetti (grazie alle rotte statiche, senza NAT).
- NFTables agisce come guardiano finale, bloccando ogni connessione non strettamente necessaria.
Questo setup non è solo “funzionante”, è osservabile e sicuro by-design.