Usare BIND come DNS locale per lo sviluppo di progetti web

Usare BIND come DNS locale per lo sviluppo di progetti web

Quando i progetti web in locale diventano più di due o tre, il file /etc/hosts comincia a mostrare tutti i suoi limiti: non supporta i wildcard, non è condivisibile con gli altri dispositivi della rete, va replicato manualmente su ogni macchina e non serve i container Docker né le macchine virtuali. La soluzione professionale è installare un server DNS autoritativo in rete locale, e BIND (Berkeley Internet Name Domain) resta lo strumento di riferimento: è lo stesso software che risolve buona parte dei nomi di Internet, è documentato in modo esaustivo e permette di replicare in locale esattamente le stesse configurazioni che si trovano in produzione.

In questo articolo vedremo come installare e configurare BIND 9 su una macchina Linux della rete locale, come definire una zona di sviluppo con record wildcard, come integrarla con Nginx, Docker e i certificati TLS, e come configurare i client su Linux, macOS e Windows.

Perché non basta il file hosts

Il file /etc/hosts (o C:\Windows\System32\drivers\etc\hosts su Windows) è un meccanismo di risoluzione statica che precede il DNS. Funziona, ma presenta limiti strutturali:

  • Nessun supporto per i wildcard. Non è possibile scrivere una riga che risolva *.myproject.test: ogni sottodominio va aggiunto a mano. Per un'applicazione multi-tenant che assegna un sottodominio a ciascun cliente questo è un ostacolo insormontabile.
  • Nessuna condivisione. Il file vale solo per la macchina su cui risiede. Se si vuole testare il sito da uno smartphone o da un tablet sulla stessa rete Wi-Fi, il file hosts è inutile.
  • Nessun record diverso da A e AAAA. Non si possono definire record MX, SRV, TXT o CNAME, che invece servono per testare l'invio di email, la scoperta di servizi o la validazione di domini.
  • Invisibile ai container. Un container Docker non legge il file hosts dell'host, a meno di ricorrere a opzioni come --add-host ripetute per ogni nome.
  • Manutenzione manuale. Ogni macchina di sviluppo, ogni VM e ogni container va aggiornato separatamente.

Un server BIND in rete locale risolve tutti questi punti in un colpo solo: si configura una volta, e tutti i dispositivi che lo usano come resolver vedono immediatamente i nomi definiti.

Scegliere il dominio giusto

La scelta del TLD da usare per lo sviluppo non è un dettaglio estetico: alcune scelte diffuse causano problemi concreti.

  • .test — la scelta corretta. La RFC 6761 riserva formalmente .test per il testing e garantisce che non verrà mai delegato a un registrar. È l'unica opzione a prova di futuro.
  • .localhost — riservato ma problematico. È riservato dalla stessa RFC, ma molti resolver lo forzano a 127.0.0.1 ignorando il DNS, il che rende impossibile puntare a un altro host della LAN.
  • .local — da evitare. È riservato a mDNS (Bonjour/Avahi). Usarlo con BIND provoca conflitti con avahi-daemon e con la risoluzione dei nomi su macOS.
  • .dev, .app, .new — da evitare assolutamente. Sono TLD reali di proprietà di Google, inseriti nella lista HSTS preload dei browser: qualunque richiesta HTTP viene forzata a HTTPS senza possibilità di eccezione, e senza un certificato valido il sito diventa irraggiungibile.
  • Un sottodominio di un dominio proprio, ad esempio local.miodominio.com, è un'alternativa solida se si possiede già il dominio: consente anche di ottenere certificati Let's Encrypt reali tramite la validazione DNS-01.

Negli esempi che seguono useremo la zona dev.test, servita da un server BIND all'indirizzo 192.168.1.10, con i progetti web ospitati sullo stesso host.

Installazione di BIND 9

Su Debian e Ubuntu i pacchetti necessari sono bind9, gli strumenti di controllo e la documentazione:

# Aggiorna gli indici dei pacchetti e installa BIND 9
sudo apt update
sudo apt install -y bind9 bind9utils bind9-doc dnsutils

# Verifica la versione installata
named -v

Su RHEL, Rocky Linux e AlmaLinux il pacchetto si chiama bind:

# Installazione su famiglia RHEL
sudo dnf install -y bind bind-utils

# Abilita l'avvio automatico del servizio
sudo systemctl enable --now named

Su macOS è disponibile via Homebrew, anche se in genere conviene tenere il server DNS su una macchina Linux sempre accesa (un mini PC, un vecchio laptop o un container):

# Installazione su macOS tramite Homebrew
brew install bind

Il conflitto con systemd-resolved

Su Ubuntu il servizio systemd-resolved occupa già la porta 53 sull'indirizzo 127.0.0.53. Se BIND deve ascoltare su tutte le interfacce, i due servizi entrano in conflitto e named non parte. La soluzione più pulita non è disattivare systemd-resolved, ma disabilitarne solo lo stub listener:

# Crea una configurazione supplementare per systemd-resolved
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/no-stub.conf > /dev/null <<'EOF'
[Resolve]
DNSStubListener=no
EOF

# Sostituisci il link simbolico di resolv.conf con quello reale
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf

# Riavvia il servizio e verifica che la porta 53 sia libera
sudo systemctl restart systemd-resolved
sudo ss -lnup | grep ':53'

In alternativa, se la macchina è dedicata a BIND, si può disattivare del tutto systemd-resolved:

# Disattivazione completa dello stub resolver di systemd
sudo systemctl disable --now systemd-resolved

La struttura dei file di configurazione

Su Debian e Ubuntu la configurazione vive in /etc/bind ed è suddivisa in file distinti, inclusi dal file principale:

  • named.conf — il punto di ingresso, si limita a includere gli altri file.
  • named.conf.options — le opzioni globali del server: forwarders, ACL, ascolto, sicurezza.
  • named.conf.local — la dichiarazione delle zone gestite localmente.
  • named.conf.default-zones — le zone standard (root hints, localhost, zone inverse di loopback).
  • db.* — i file di zona veri e propri, che contengono i record.

Su RHEL invece tutto converge in /etc/named.conf e le zone stanno in /var/named. Gli esempi seguono la convenzione Debian, ma la sintassi è identica.

Configurare le opzioni globali

Il file /etc/bind/named.conf.options definisce il comportamento generale del server. Ecco una configurazione completa e commentata per un resolver di rete locale:

// ACL che identifica le reti considerate "interne"
acl trusted {
    127.0.0.0/8;
    192.168.1.0/24;   // Rete LAN domestica
    172.16.0.0/12;    // Reti bridge di Docker
    10.0.0.0/8;       // Reti delle macchine virtuali
};

options {
    directory "/var/cache/bind";

    // Interfacce e porte su cui il server resta in ascolto
    listen-on port 53 { 127.0.0.1; 192.168.1.10; };
    listen-on-v6 { none; };

    // Solo le reti fidate possono interrogare il server
    allow-query { trusted; };

    // La ricorsione è abilitata soltanto per la LAN, mai verso l'esterno
    recursion yes;
    allow-recursion { trusted; };

    // I trasferimenti di zona sono disabilitati: non ci sono server secondari
    allow-transfer { none; };

    // Le query per domini non gestiti localmente vengono inoltrate
    forwarders {
        1.1.1.1;
        1.0.0.1;
        9.9.9.9;
    };

    // Se i forwarder non rispondono, prova comunque la risoluzione ricorsiva
    forward first;

    // Disabilita DNSSEC solo se causa problemi con zone di test interne
    dnssec-validation auto;

    // Nasconde la versione del server alle query di tipo CHAOS
    version "not disclosed";

    // Consente di risolvere nomi che puntano a indirizzi privati
    // (necessario quando si usano forwarder con protezione da rebinding)
    auth-nxdomain no;
};

Due punti meritano attenzione. Il primo è allow-recursion: un resolver ricorsivo aperto a Internet è un vettore di attacchi di amplificazione DNS, quindi va sempre limitato alle reti interne, anche in un contesto di sviluppo. Il secondo è forward first: garantisce che se Cloudflare o Quad9 non rispondono la navigazione non si blocchi, perché BIND ripiega sulla risoluzione ricorsiva autonoma.

Dichiarare la zona di sviluppo

Nel file /etc/bind/named.conf.local si dichiara la zona autoritativa e la corrispondente zona inversa:

// Zona diretta per tutti i progetti di sviluppo
zone "dev.test" {
    type master;
    file "/etc/bind/zones/db.dev.test";
    allow-update { none; };
};

// Zona inversa per la sottorete 192.168.1.0/24
zone "1.168.192.in-addr.arpa" {
    type master;
    file "/etc/bind/zones/db.192.168.1";
    allow-update { none; };
};

La zona inversa non è obbligatoria per il funzionamento dei siti, ma è utile: alcuni servizi (Postfix in primis, ma anche i log di Nginx con resolver attivo) eseguono lookup PTR, e un PTR mancante introduce timeout di alcuni secondi.

Scrivere il file di zona

Il file di zona è il cuore della configurazione. Creiamo la directory e il file:

# Crea la directory dedicata alle zone con i permessi corretti
sudo mkdir -p /etc/bind/zones
sudo chown root:bind /etc/bind/zones
sudo chmod 775 /etc/bind/zones

Il contenuto di /etc/bind/zones/db.dev.test:

$TTL    60
@       IN      SOA     ns1.dev.test. admin.dev.test. (
                        2026071701      ; Serial: formato AAAAMMGGNN
                        3600            ; Refresh
                        900             ; Retry
                        604800          ; Expire
                        60 )            ; Negative Cache TTL

; Server dei nomi autoritativo per la zona
@               IN      NS      ns1.dev.test.
ns1             IN      A       192.168.1.10

; Il dominio radice punta alla macchina di sviluppo
@               IN      A       192.168.1.10

; Wildcard: qualunque sottodominio non definito esplicitamente
; risolve verso la macchina di sviluppo. È questa la riga
; che rende BIND superiore al file hosts.
*               IN      A       192.168.1.10

; Progetti che puntano alla stessa macchina
laravel         IN      A       192.168.1.10
api             IN      A       192.168.1.10
node            IN      A       192.168.1.10

; Un progetto ospitato su una macchina diversa della LAN
legacy          IN      A       192.168.1.42

; Wildcard di secondo livello per un'applicazione multi-tenant
*.saas          IN      A       192.168.1.10

; Alias verso un nome esistente
adminer         IN      CNAME   laravel.dev.test.
mail            IN      CNAME   ns1.dev.test.

; Record MX per testare l'invio di email verso un catcher locale
@               IN      MX      10 mail.dev.test.

; Record TXT di esempio, utili per testare validazioni SPF o simili
@               IN      TXT     "v=spf1 ip4:192.168.1.10 -all"

; Record SRV per la scoperta di un servizio interno
_http._tcp      IN      SRV     10 5 8080 node.dev.test.

Alcune osservazioni importanti su questo file.

Il TTL basso

Il valore $TTL 60 è deliberatamente aggressivo. In produzione un TTL di 60 secondi genererebbe un traffico eccessivo, ma in sviluppo è esattamente ciò che serve: quando si sposta un progetto da una macchina all'altra, non si vuole aspettare ore che le cache dei client si svuotino. Lo stesso vale per il Negative Cache TTL, l'ultimo valore del record SOA, che governa per quanto tempo viene ricordata la risposta NXDOMAIN.

Il serial

Il campo Serial deve essere incrementato a ogni modifica del file, altrimenti i server secondari e alcune cache non si accorgeranno del cambiamento. La convenzione più diffusa è il formato AAAAMMGGNN, dove le ultime due cifre sono un contatore delle modifiche fatte nella giornata. Con un solo server master la dimenticanza è meno grave, ma è una buona abitudine da mantenere.

Il wildcard e i suoi limiti

Il record * risolve qualunque nome non definito esplicitamente, ma non è ricorsivo: *.dev.test copre foo.dev.test, non bar.foo.dev.test. Per coprire i sottodomini di secondo livello serve una riga dedicata, come nell'esempio con *.saas. Inoltre, un wildcard non si applica ai nomi per cui esiste già un altro record: se laravel.dev.test ha un record A esplicito, il wildcard viene ignorato per quel nome.

Il punto finale

La sintassi dei file di zona è impietosa su un dettaglio: i nomi che terminano con un punto sono assoluti (FQDN), quelli senza punto vengono completati automaticamente con il nome della zona. Scrivere adminer IN CNAME laravel.dev.test senza il punto finale produce il nome laravel.dev.test.dev.test, e il CNAME non funzionerà. È l'errore più comune in assoluto.

La zona inversa

Il file /etc/bind/zones/db.192.168.1 associa gli indirizzi IP ai nomi:

$TTL    60
@       IN      SOA     ns1.dev.test. admin.dev.test. (
                        2026071701      ; Serial
                        3600            ; Refresh
                        900             ; Retry
                        604800          ; Expire
                        60 )            ; Negative Cache TTL

@       IN      NS      ns1.dev.test.

; L'ultimo ottetto dell'indirizzo IP corrisponde al nome
10      IN      PTR     ns1.dev.test.
42      IN      PTR     legacy.dev.test.

Si noti che a un indirizzo IP dovrebbe corrispondere un solo record PTR: non ha senso creare un PTR per ognuno dei venti nomi che puntano a 192.168.1.10.

Validare e attivare la configurazione

BIND mette a disposizione due strumenti di verifica che vanno usati sistematicamente prima di riavviare il servizio:

# Verifica la sintassi di tutti i file di configurazione
sudo named-checkconf

# Verifica la sintassi e la coerenza di una singola zona
sudo named-checkzone dev.test /etc/bind/zones/db.dev.test

# Verifica anche la zona inversa
sudo named-checkzone 1.168.192.in-addr.arpa /etc/bind/zones/db.192.168.1

Un output corretto per named-checkzone termina con OK e riporta il serial letto. Se la validazione passa, si può ricaricare la configurazione senza interrompere il servizio:

# Ricarica a caldo l'intera configurazione
sudo rndc reload

# Ricarica una singola zona (più veloce)
sudo rndc reload dev.test

# Mostra lo stato del server: zone caricate, query servite, versione
sudo rndc status

# Svuota la cache del resolver, utile dopo aver cambiato un record
sudo rndc flush

Il comando rndc comunica con named tramite una chiave condivisa generata all'installazione e salvata in /etc/bind/rndc.key. Se rndc restituisce un errore di connessione, è quasi sempre perché named non è in esecuzione: conviene controllare i log.

Verificare la risoluzione

Lo strumento canonico è dig, che interroga direttamente il server specificato bypassando le cache del sistema operativo:

# Interroga il record A di un nome esplicito
dig @192.168.1.10 laravel.dev.test +short

# Verifica che il wildcard funzioni con un nome inventato sul momento
dig @192.168.1.10 qualsiasi-cosa.dev.test +short

# Verifica il wildcard di secondo livello
dig @192.168.1.10 cliente-uno.saas.dev.test +short

# Controlla la risoluzione inversa
dig @192.168.1.10 -x 192.168.1.10 +short

# Controlla che il forwarding verso l'esterno funzioni
dig @192.168.1.10 www.wikipedia.org +short

# Output completo con la sezione AUTHORITY, utile in fase di debug
dig @192.168.1.10 dev.test SOA

Nella risposta completa vanno controllati due elementi: il flag aa (authoritative answer) nella riga dei flags, che conferma che il server è autoritativo per la zona, e lo status: NOERROR. Uno status: REFUSED indica quasi sempre un problema di allow-query; uno status: SERVFAIL punta a un errore nel file di zona o alla validazione DNSSEC.

Configurare i client

Definire la zona non basta: i dispositivi devono usare BIND come resolver. La configurazione più elegante consiste nell'impostare l'IP del server BIND come DNS primario nel server DHCP del router: in questo modo ogni dispositivo che si collega alla rete, smartphone inclusi, vede automaticamente i nomi di sviluppo. Se non si ha accesso al router, si procede per singola macchina.

Linux con systemd-resolved

# Imposta il DNS e il dominio di ricerca per una specifica interfaccia
sudo resolvectl dns eth0 192.168.1.10
sudo resolvectl domain eth0 '~dev.test'

# Verifica la configurazione applicata
resolvectl status eth0

La sintassi ~dev.test indica un routing-only domain: solo le query per dev.test vengono indirizzate a BIND, tutto il resto continua a usare il DNS abituale. Per rendere la configurazione persistente è preferibile agire su NetworkManager o su Netplan.

macOS

macOS offre un meccanismo particolarmente comodo: la directory /etc/resolver permette di dirottare un singolo dominio verso un resolver specifico, lasciando intatta la configurazione di rete generale.

# Crea la directory dei resolver personalizzati
sudo mkdir -p /etc/resolver

# Associa il dominio dev.test al server BIND della LAN
sudo tee /etc/resolver/dev.test > /dev/null <<'EOF'
nameserver 192.168.1.10
EOF

# Verifica quale resolver viene usato per il dominio
scutil --dns | grep -A 3 'dev.test'

# Svuota la cache DNS del sistema
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder

Attenzione: il nome del file deve corrispondere esattamente al dominio, e il comando dig ignora questo meccanismo perché parla direttamente con il resolver indicato. Per verificare il funzionamento reale bisogna usare ping o il browser.

Windows

Da PowerShell con privilegi di amministratore:

# Elenca le interfacce di rete disponibili
Get-DnsClientServerAddress

# Imposta il server DNS sull'interfacca desiderata
Set-DnsClientServerAddress -InterfaceAlias "Ethernet" -ServerAddresses ("192.168.1.10","1.1.1.1")

# Svuota la cache DNS del client
Clear-DnsClientCache

Integrazione con Nginx

Con il wildcard DNS attivo, Nginx può servire un numero illimitato di progetti senza toccare più il DNS. Il primo pattern è il virtual host classico:

server {
    listen 80;
    server_name laravel.dev.test;

    root /var/www/laravel/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
}

Il pattern più interessante sfrutta però il wildcard per creare virtual host dinamici: una sola configurazione che mappa automaticamente ogni sottodominio a una directory.

server {
    listen 80;

    # Cattura il primo segmento del nome host in una variabile
    server_name ~^(?<project>.+)\.dev\.test$;

    # La radice del sito viene composta dinamicamente
    root /var/www/$project/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
}

Con questa configurazione, creare un nuovo progetto significa creare una directory in /var/www: il DNS lo risolve già grazie al wildcard, Nginx lo serve già grazie alla regex. Nessun riavvio, nessuna modifica di configurazione.

HTTPS in locale con certificati wildcard

Il DNS wildcard dà il meglio di sé quando è accompagnato da un certificato wildcard. Lo strumento più semplice è mkcert, che installa una Certificate Authority locale nel trust store del sistema e dei browser.

# Installa la CA locale nel trust store del sistema
# ATTENZIONE: va eseguito come utente normale, mai con sudo
mkcert -install

# Genera un certificato wildcard valido per la zona e i suoi sottodomini
mkcert "*.dev.test" "dev.test" "*.saas.dev.test"

# Sposta i file generati nella posizione usata da Nginx
sudo mv _wildcard.dev.test+2.pem /etc/nginx/certs/dev.test.crt
sudo mv _wildcard.dev.test+2-key.pem /etc/nginx/certs/dev.test.key

La configurazione Nginx corrispondente:

server {
    listen 443 ssl;
    http2 on;

    server_name ~^(?<project>.+)\.dev\.test$;

    ssl_certificate     /etc/nginx/certs/dev.test.crt;
    ssl_certificate_key /etc/nginx/certs/dev.test.key;

    root /var/www/$project/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
}

server {
    listen 80;
    server_name *.dev.test;

    # Redirect permanente verso HTTPS
    return 301 https://$host$request_uri;
}

Se invece si è scelto un sottodominio di un dominio realmente posseduto, si può usare Certbot con il plugin DNS del proprio provider per ottenere un certificato wildcard Let's Encrypt autentico, valido anche sui dispositivi mobili dove installare una CA personalizzata è scomodo:

# Certificato wildcard reale tramite validazione DNS-01
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d 'local.miodominio.com' \
  -d '*.local.miodominio.com'

Integrazione con Docker

I container Docker ereditano per default il resolver dell'host, ma con una complicazione: se l'host usa systemd-resolved su 127.0.0.53, Docker non può inoltrare quell'indirizzo ai container (dentro il container 127.0.0.53 è il container stesso) e ripiega su Google DNS, perdendo la risoluzione dei nomi locali.

La soluzione è dichiarare esplicitamente il server BIND in /etc/docker/daemon.json:

{
  "dns": ["192.168.1.10", "1.1.1.1"],
  "dns-search": ["dev.test"]
}
# Applica la nuova configurazione del demone
sudo systemctl restart docker

La stessa impostazione può essere applicata a un singolo servizio in Docker Compose, il che è preferibile quando non si vuole toccare la configurazione globale:

services:
  app:
    image: node:22-alpine
    dns:
      - 192.168.1.10
      - 1.1.1.1
    dns_search:
      - dev.test
    working_dir: /app
    volumes:
      - ./:/app
    command: npm run dev

Da questo momento un container può risolvere api.dev.test esattamente come lo risolve il browser sull'host: si elimina così la classica discrepanza per cui il frontend e il backend devono usare URL diversi a seconda che il codice giri nel browser o nel container.

BIND stesso in un container

Se si preferisce non installare BIND direttamente sull'host, l'immagine internetsystemsconsortium/bind9 è l'immagine ufficiale:

services:
  bind:
    image: internetsystemsconsortium/bind9:9.20
    container_name: bind
    restart: unless-stopped
    # La rete host evita problemi di NAT sulle query UDP
    network_mode: host
    volumes:
      - ./config:/etc/bind
      - ./cache:/var/cache/bind
      - ./records:/var/lib/bind
    command: ["-4", "-g", "-c", "/etc/bind/named.conf"]

L'opzione -g mantiene il processo in foreground e invia i log allo standard error, comportamento corretto per un container. L'opzione -4 disabilita IPv6, che in un contesto di sviluppo locale è quasi sempre superfluo e fonte di ritardi nella risoluzione.

Le view: server diversi per client diversi

Una funzionalità di BIND che il file hosts non potrà mai replicare è il DNS split-horizon: rispondere in modo diverso alla stessa query a seconda di chi la pone. È utile, ad esempio, per far sì che l'host risolva un nome verso 127.0.0.1 e i container lo risolvano verso l'IP della rete bridge.

// Vista riservata alle richieste provenienti dalla macchina stessa
view "localhost" {
    match-clients { 127.0.0.0/8; };
    recursion yes;

    zone "dev.test" {
        type master;
        file "/etc/bind/zones/db.dev.test.local";
    };

    include "/etc/bind/named.conf.default-zones";
};

// Vista per il resto della rete locale
view "lan" {
    match-clients { 192.168.1.0/24; 172.16.0.0/12; };
    recursion yes;

    zone "dev.test" {
        type master;
        file "/etc/bind/zones/db.dev.test";
    };

    include "/etc/bind/named.conf.default-zones";
};

Una regola ferrea: nel momento in cui si dichiara anche una sola view, tutte le zone devono trovarsi dentro una view. Una zona dichiarata fuori provoca un errore di configurazione bloccante. Inoltre l'ordine delle view conta: BIND valuta le match-clients dall'alto verso il basso e usa la prima che corrisponde.

Logging e diagnostica

La configurazione di default di BIND è avara di log. Per lo sviluppo conviene abilitare un canale dedicato alle query, che mostra in tempo reale cosa i client stanno chiedendo:

logging {
    channel query_log {
        file "/var/log/bind/query.log" versions 3 size 20m;
        severity info;
        print-time yes;
        print-category yes;
    };

    channel default_log {
        file "/var/log/bind/default.log" versions 3 size 20m;
        severity dynamic;
        print-time yes;
        print-severity yes;
        print-category yes;
    };

    category queries { query_log; };
    category default { default_log; };
    category config  { default_log; };
    category security { default_log; };
};
# Crea la directory dei log con il proprietario corretto
sudo mkdir -p /var/log/bind
sudo chown bind:bind /var/log/bind

# Attiva il logging delle query anche a caldo, senza riavviare
sudo rndc querylog on

# Osserva le query in tempo reale
sudo tail -f /var/log/bind/query.log

Il log delle query è lo strumento diagnostico più efficace in assoluto: se un client sostiene di non risolvere un nome e nel log non compare nessuna query corrispondente, il problema non è BIND ma la configurazione del resolver sul client.

AppArmor su Ubuntu

Su Ubuntu il profilo AppArmor di named limita le directory scrivibili. Se i log non vengono creati o una zona non viene caricata nonostante i permessi sembrino corretti, è quasi sempre AppArmor:

# Cerca i rifiuti di AppArmor relativi a named
sudo dmesg | grep -i 'apparmor.*named'

# Verifica lo stato del profilo
sudo aa-status | grep named

Problemi comuni e soluzioni

  • Il browser ignora il DNS e cerca su Google. Chrome e Firefox usano il DoH (DNS over HTTPS) verso un resolver esterno, scavalcando completamente il DNS di sistema. Va disattivato nelle impostazioni di sicurezza del browser, oppure configurato per non applicarsi ai domini locali.
  • Il nome risolve ma il browser aggiunge automaticamente www o cerca su Internet. Alcuni browser trattano una parola singola come una ricerca. Usare sempre il FQDN completo, e in caso di dubbio aggiungere lo schema: http://laravel.dev.test/.
  • SERVFAIL su una zona che sembra corretta. Nove volte su dieci è un punto finale mancante in un record, oppure un serial non incrementato dopo una modifica. named-checkzone individua il primo caso, il log di named il secondo.
  • Il wildcard non funziona per un nome specifico. Esiste già un altro record per quel nome, anche di tipo diverso: la presenza di qualunque record esplicito annulla il wildcard per quel nodo.
  • Le modifiche non si propagano. Tra il file di zona e il browser ci sono almeno tre cache: quella di BIND (rndc flush), quella del sistema operativo (resolvectl flush-caches o dscacheutil -flushcache) e quella interna del browser (chrome://net-internals/#dns).
  • Risoluzione lenta di circa cinque secondi. È il timeout tipico di una query AAAA senza risposta. Aggiungere filter-aaaa-on-v4 non è più supportato nelle versioni recenti; la soluzione è avviare BIND con l'opzione -4 o definire record AAAA espliciti.

Automatizzare la creazione dei progetti

Con il wildcard attivo, nella maggior parte dei casi non serve toccare il DNS per un nuovo progetto. Quando invece serve un record esplicito, per esempio perché il progetto vive su un'altra macchina, uno script riduce il rischio di errori sul serial:

#!/usr/bin/env bash
set -euo pipefail

ZONE="dev.test"
ZONE_FILE="/etc/bind/zones/db.${ZONE}"

if [[ $# -ne 2 ]]; then
    echo "Uso: $0 <sottodominio> <indirizzo-ip>" >&2
    exit 1
fi

NAME="$1"
IP="$2"

# Calcola il nuovo serial nel formato AAAAMMGGNN
TODAY="$(date +%Y%m%d)"
CURRENT="$(grep -oE '[0-9]{10}' "$ZONE_FILE" | head -n 1)"

if [[ "${CURRENT:0:8}" == "$TODAY" ]]; then
    COUNTER=$(( 10#${CURRENT:8:2} + 1 ))
else
    COUNTER=1
fi
NEW_SERIAL=$(printf '%s%02d' "$TODAY" "$COUNTER")

# Sostituisce il serial e aggiunge il nuovo record in coda
sudo sed -i "s/${CURRENT}/${NEW_SERIAL}/" "$ZONE_FILE"
echo -e "${NAME}\t\tIN\tA\t${IP}" | sudo tee -a "$ZONE_FILE" > /dev/null

# Valida la zona prima di applicare le modifiche
if ! sudo named-checkzone "$ZONE" "$ZONE_FILE"; then
    echo "Zona non valida: modifiche da rivedere manualmente" >&2
    exit 1
fi

sudo rndc reload "$ZONE"
echo "Aggiunto ${NAME}.${ZONE} -> ${IP} (serial ${NEW_SERIAL})"

Un approccio più raffinato consiste nell'abilitare gli aggiornamenti dinamici con nsupdate, che modifica la zona senza toccare il file e gestisce il serial automaticamente. Richiede una chiave TSIG e una direttiva allow-update nella dichiarazione della zona, e in cambio permette di aggiungere record da uno script o da una pipeline CI senza permessi di root sui file.

Conclusioni

Configurare BIND per lo sviluppo locale richiede un'ora di lavoro iniziale, ma ripaga in modo sproporzionato. Il record wildcard elimina per sempre la manutenzione del DNS per i nuovi progetti; il server condiviso rende i siti raggiungibili da qualunque dispositivo della rete, compresi gli smartphone su cui testare il responsive; l'integrazione con Docker fa sparire la discrepanza tra URL interni ed esterni; i record MX, SRV e TXT permettono di testare in locale scenari che altrimenti richiederebbero un ambiente di staging.

C'è anche un beneficio meno tangibile ma altrettanto reale: la sintassi dei file di zona, il significato del record SOA, il funzionamento della delega e della ricorsione sono gli stessi che governano il DNS di produzione. Chi ha configurato una zona in locale, quando in produzione un dominio non risolve, sa dove guardare.