Gestire BIND con Bash: automazione completa di un server DNS autoritativo

Gestire BIND con Bash: automazione completa di un server DNS autoritativo

BIND (Berkeley Internet Name Domain) è ancora oggi il server DNS più diffuso sui sistemi Unix-like, e la sua superficie di amministrazione è quasi interamente costituita da file di testo e da utility a riga di comando. Questa caratteristica lo rende un candidato ideale per l'automazione con Bash: ogni operazione, dalla creazione di una zona alla verifica delle firme DNSSEC, può essere incapsulata in uno script idempotente, verificabile e ripetibile.

In questo articolo costruiremo, passo dopo passo, una piccola cassetta degli attrezzi in Bash per gestire un server BIND autoritativo: validazione della configurazione, creazione automatica di zone, gestione del numero di serie SOA, aggiornamenti dinamici via nsupdate, controllo del demone tramite rndc, backup, monitoraggio della propagazione, analisi dei log e supervisione di DNSSEC. Gli script sono pensati per BIND 9.16 e versioni successive su distribuzioni Debian/Ubuntu, ma sono facilmente adattabili a RHEL e derivate cambiando i percorsi.

Perché automatizzare BIND con Bash

Le modifiche manuali a un file di zona sono una delle cause più frequenti di interruzione del servizio DNS. Un errore di sintassi non rilevato, un numero di serie non incrementato o un reload dimenticato producono effetti che si propagano per ore attraverso le cache dei resolver di tutto il mondo. Bash offre alcuni vantaggi concreti in questo contesto:

  • gli strumenti ufficiali di BIND (named-checkconf, named-checkzone, rndc, nsupdate, dig, delv) sono già progettati per essere invocati da script e restituiscono codici di uscita significativi;
  • non è richiesta alcuna dipendenza aggiuntiva: gli script funzionano su un server appena installato, anche in modalità di ripristino;
  • ogni operazione può essere resa transazionale, con copia di sicurezza preventiva e ripristino automatico in caso di validazione fallita;
  • l'integrazione con systemd, cron, logger e i sistemi di monitoraggio esistenti è immediata.

Struttura dei file e convenzioni

Prima di scrivere codice conviene fissare la mappa dei percorsi con cui lavoreremo. La tabella seguente riassume la disposizione tipica su Debian e Ubuntu.

Percorso Contenuto
/etc/bind/named.conf File principale, include gli altri frammenti di configurazione
/etc/bind/named.conf.options Opzioni globali: ascolto, inoltro, ACL, DNSSEC
/etc/bind/named.conf.local Dichiarazione delle zone locali
/var/lib/bind/ File di zona scrivibili dal demone (zone dinamiche e journal)
/var/cache/bind/ Directory di lavoro di named, dump della cache
/etc/bind/rndc.key Chiave condivisa per il canale di controllo
/var/log/named/ Log dedicati (query, sicurezza, trasferimenti)

Su RHEL, Rocky e AlmaLinux i file corrispondenti si trovano in /etc/named.conf e /var/named/. Gli script che seguono leggono i percorsi da variabili d'ambiente, così da restare portabili.

Una convenzione utile è nominare i file di zona con il prefisso db. seguito dal nome della zona, così che il percorso sia derivabile in modo deterministico dal nome del dominio.

/var/lib/bind/
├── db.example.com
├── db.example.com.jnl
├── db.example.net
└── db.192.0.2

Un modulo di funzioni condivise

Tutti gli script che scriveremo condividono le stesse esigenze: registrare messaggi, interrompersi con un errore leggibile, verificare i privilegi e normalizzare i nomi di zona. Conviene quindi isolare queste funzioni in un unico file da includere con source.

#!/usr/bin/env bash
# /usr/local/lib/bindlib.sh - funzioni condivise per gli script di gestione di BIND
# Da includere negli altri script con: source /usr/local/lib/bindlib.sh

# Percorsi di riferimento, sovrascrivibili da variabili d'ambiente
BIND_CONF_DIR="${BIND_CONF_DIR:-/etc/bind}"
ZONE_DIR="${ZONE_DIR:-/var/lib/bind}"
ZONE_CONF="${ZONE_CONF:-${BIND_CONF_DIR}/named.conf.local}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/bind}"
RNDC="${RNDC:-/usr/sbin/rndc}"
LOG_TAG="${LOG_TAG:-bind-tools}"

# Scrive un messaggio sullo standard error e lo inoltra a syslog
log() {
    local level="$1"; shift
    printf '[%s] %-5s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$*" >&2
    logger -t "$LOG_TAG" -p "daemon.${level,,}" "$*" || true
}

# Interrompe l'esecuzione registrando la causa dell'errore
die() {
    log ERROR "$*"
    exit 1
}

# Verifica che tutti i comandi richiesti siano presenti nel PATH
require_commands() {
    local cmd
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null 2>&1 || die "Comando non trovato: ${cmd}"
    done
}

# Impedisce l'esecuzione a utenti non privilegiati
require_root() {
    [[ "$(id -u)" -eq 0 ]] || die "Questo script richiede i privilegi di root"
}

# Rimuove il punto finale dal nome di una zona
normalize_zone() {
    printf '%s' "${1%.}"
}

# Restituisce il percorso convenzionale del file di zona
zone_file_path() {
    printf '%s/db.%s' "$ZONE_DIR" "$(normalize_zone "$1")"
}

# Verifica che il nome della zona sia sintatticamente valido
validate_zone_name() {
    local zone="$1"
    [[ "$zone" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$ ]] \
        || die "Nome di zona non valido: ${zone}"
}

# Verifica che l'argomento sia un indirizzo IPv4 ben formato
validate_ipv4() {
    local ip="$1" octet
    [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "Indirizzo IPv4 non valido: ${ip}"
    for octet in ${ip//./ }; do
        (( 10#$octet <= 255 )) || die "Indirizzo IPv4 non valido: ${ip}"
    done
}

# Impedisce l'esecuzione simultanea di più istanze dello stesso script
acquire_lock() {
    local lock_file="/run/lock/$(basename "$0").lock"
    exec 9>"$lock_file"
    flock -n 9 || die "Un'altra istanza è già in esecuzione"
}

L'uso di flock su un descrittore di file dedicato è particolarmente importante quando gli script vengono pianificati: due esecuzioni concorrenti che modificano lo stesso file di zona possono generare un serial incoerente o, peggio, un file troncato.

Validazione della configurazione

Il primo script della cassetta degli attrezzi non modifica nulla: si limita a verificare che la configurazione sia coerente. È lo strumento da eseguire prima di ogni reload e, idealmente, come hook di pre-commit se la configurazione è versionata.

Il comando named-checkconf -p stampa la configurazione già interpretata da BIND, con tutti gli include risolti e la sintassi normalizzata. Questo lo rende molto più affidabile di un parsing artigianale di named.conf.local.

#!/usr/bin/env bash
# /usr/local/sbin/check-config.sh - valida named.conf e tutti i file di zona master
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_commands named-checkconf named-checkzone awk

log INFO "Validazione della sintassi globale"
named-checkconf "${BIND_CONF_DIR}/named.conf" \
    || die "named.conf contiene errori di sintassi"

# Estrae le coppie "zona -> file" dalla configurazione già interpretata da BIND
mapfile -t entries < <(
    named-checkconf -p "${BIND_CONF_DIR}/named.conf" | awk '
        /^[[:space:]]*zone "/ {
            zone = $2
            gsub(/"/, "", zone)
            file = ""
            is_master = 0
        }
        /type[[:space:]]+(master|primary);/ { is_master = 1 }
        /file[[:space:]]+"/ {
            file = $2
            gsub(/[";]/, "", file)
        }
        /^[[:space:]]*};/ {
            if (zone != "" && file != "" && is_master) print zone "\t" file
            zone = ""; file = ""; is_master = 0
        }
    '
)

(( ${#entries[@]} > 0 )) || die "Nessuna zona master trovata nella configurazione"

failures=0
for entry in "${entries[@]}"; do
    IFS=$'\t' read -r zone file <<< "$entry"

    # I percorsi relativi sono risolti rispetto alla directory di lavoro di named
    [[ "$file" == /* ]] || file="${ZONE_DIR}/${file}"

    if named-checkzone "$zone" "$file" >/dev/null 2>&1; then
        log INFO "Zona valida: ${zone}"
    else
        log ERROR "Zona non valida: ${zone} (${file})"
        named-checkzone "$zone" "$file" >&2 || true
        failures=$(( failures + 1 ))
    fi
done

(( failures == 0 )) || die "${failures} zone non superano la validazione"
log INFO "Configurazione e zone validate correttamente"

Vale la pena ricordare che named-checkzone accetta alcune opzioni molto utili in ambito di automazione: -i local attiva i controlli di integrità sui record di delega, -k fail rende fatali i problemi di conformità ai nomi host, e -o - permette di stampare la zona canonicalizzata sullo standard output, formato ideale per un confronto con diff.

# Confronta la forma canonica di due file di zona ignorando le differenze di formattazione
diff <(named-checkzone -o - example.com /var/lib/bind/db.example.com 2>/dev/null) \
     <(named-checkzone -o - example.com /var/backups/bind/db.example.com 2>/dev/null)

Creazione automatica di una zona

La creazione di una zona coinvolge tre operazioni distinte che devono riuscire tutte o nessuna: la scrittura del file di zona, la sua dichiarazione in named.conf.local e la ricarica della configurazione. Lo script che segue le esegue in sequenza, interrompendosi al primo errore e senza mai lasciare BIND in uno stato non caricabile.

#!/usr/bin/env bash
# /usr/local/sbin/create-zone.sh - crea una nuova zona master, la registra e ricarica BIND
# Uso: create-zone.sh <zone> <primary-ns> <hostmaster-email> [ipv4]
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
require_commands named-checkzone named-checkconf "$RNDC"
acquire_lock

zone="$(normalize_zone "${1:?Uso: create-zone.sh <zone> <ns> <email> [ip]}")"
primary_ns="$(normalize_zone "${2:?Nome del server DNS primario mancante}")"
hostmaster="${3:?Indirizzo e-mail dell'amministratore mancante}"
address="${4:-}"

validate_zone_name "$zone"
[[ -n "$address" ]] && validate_ipv4 "$address"

zone_file="$(zone_file_path "$zone")"
[[ -e "$zone_file" ]] && die "Il file di zona esiste già: ${zone_file}"

# Nel record SOA la chiocciola dell'indirizzo e-mail è sostituita da un punto
soa_contact="${hostmaster/@/.}"
serial="$(date +%Y%m%d)01"

cat > "$zone_file" <<EOF
\$TTL 3600
@       IN      SOA     ${primary_ns}. ${soa_contact}. (
                        ${serial} ; Serial
                        7200      ; Refresh
                        3600      ; Retry
                        1209600   ; Expire
                        3600 )    ; Negative TTL

@       IN      NS      ${primary_ns}.
EOF

if [[ -n "$address" ]]; then
    printf '@\tIN\tA\t%s\n' "$address" >> "$zone_file"
    printf 'www\tIN\tCNAME\t@\n' >> "$zone_file"
fi

chown root:bind "$zone_file"
chmod 640 "$zone_file"

named-checkzone "$zone" "$zone_file" >/dev/null \
    || die "Il file di zona generato non è valido"

# Copia di sicurezza della configurazione prima di modificarla
cp -a "$ZONE_CONF" "${ZONE_CONF}.bak"

cat >> "$ZONE_CONF" <<EOF

zone "${zone}" {
    type master;
    file "${zone_file}";
    allow-transfer { key transfer-key; };
    notify yes;
};
EOF

if ! named-checkconf "${BIND_CONF_DIR}/named.conf"; then
    # Ripristino della configurazione precedente in caso di errore
    mv "${ZONE_CONF}.bak" "$ZONE_CONF"
    rm -f "$zone_file"
    die "Configurazione non valida: modifiche annullate"
fi

rm -f "${ZONE_CONF}.bak"
"$RNDC" reconfig
log INFO "Zona ${zone} creata e caricata con serial ${serial}"

Si noti l'uso di \$TTL all'interno dell'here-document non quotato: la barra rovesciata impedisce a Bash di interpretare la variabile, così che nel file finisca la direttiva letterale $TTL. Se invece si desidera disabilitare ogni espansione, è sufficiente quotare il delimitatore scrivendo <<'EOF', ma in questo caso perderemmo la sostituzione delle altre variabili.

Il file di zona così generato ha questo aspetto:

$TTL 3600
@       IN      SOA     ns1.example.com. hostmaster.example.com. (
                        2026072401 ; Serial
                        7200       ; Refresh
                        3600       ; Retry
                        1209600    ; Expire
                        3600 )     ; Negative TTL

@       IN      NS      ns1.example.com.
@       IN      A       192.0.2.10
www     IN      CNAME   @

Gestione del numero di serie SOA

Il numero di serie è il meccanismo con cui i server secondari capiscono se una zona è cambiata. Dimenticare di incrementarlo significa che le modifiche non verranno mai propagate, anche se il primario le ha caricate correttamente. La convenzione più diffusa è il formato YYYYMMDDnn, che consente fino a novantanove modifiche al giorno mantenendo la leggibilità.

Lo script seguente individua il serial nel file di zona, lo incrementa secondo la convenzione e lo riscrive, gestendo anche il caso in cui la data memorizzata sia futura rispetto a quella odierna (situazione che può verificarsi dopo una correzione dell'orologio di sistema).

#!/usr/bin/env bash
# /usr/local/sbin/bump-serial.sh - incrementa il numero di serie SOA in formato YYYYMMDDnn
# Uso: bump-serial.sh <zone-file>
set -euo pipefail
source /usr/local/lib/bindlib.sh

zone_file="${1:?Uso: bump-serial.sh <zone-file>}"
[[ -f "$zone_file" ]] || die "File di zona inesistente: ${zone_file}"

# Il serial è la prima cifra seguita da un commento che contiene la parola Serial
current_serial="$(grep -Eim1 '^[[:space:]]*[0-9]+[[:space:]]*;[[:space:]]*serial' "$zone_file" \
    | grep -Eo '[0-9]+' | head -n 1)"

[[ -n "$current_serial" ]] || die "Serial non individuato in ${zone_file}"

today="$(date +%Y%m%d)"

if [[ "${current_serial:0:8}" == "$today" && ${#current_serial} -eq 10 ]]; then
    # Stessa giornata: si incrementa il contatore a due cifre
    counter=$(( 10#${current_serial:8:2} + 1 ))
    if (( counter > 99 )); then
        # Contatore esaurito: si ricade sull'incremento puro e semplice
        new_serial=$(( current_serial + 1 ))
    else
        new_serial="$(printf '%s%02d' "$today" "$counter")"
    fi
else
    new_serial="${today}01"
fi

# Il serial deve sempre crescere, anche se l'orologio è tornato indietro
if (( 10#$new_serial <= 10#$current_serial )); then
    new_serial=$(( current_serial + 1 ))
fi

sed -ri "0,/^([[:space:]]*)${current_serial}([[:space:]]*;[[:space:]]*[Ss]erial)/ \
    s//\1${new_serial}\2/" "$zone_file"

log INFO "Serial aggiornato in ${zone_file}: ${current_serial} -> ${new_serial}"
printf '%s\n' "$new_serial"

La forma 0,/regex/ s//sostituzione/ di sed limita la sostituzione alla prima occorrenza: è una precauzione contro i file che contengono commenti simili in altre posizioni. La notazione 10# nell'aritmetica di Bash è indispensabile, perché una stringa numerica con zero iniziale verrebbe altrimenti interpretata in base ottale, con risultati imprevedibili per i mesi e i giorni con cifre come 08 e 09.

Aggiunta e rimozione di record

Per le zone statiche, l'approccio più semplice consiste nel manipolare direttamente il file di testo. La regola d'oro è: copia di sicurezza, modifica, validazione, e ripristino automatico se la validazione fallisce. Il reload avviene solo dopo che il file è stato dichiarato valido.

#!/usr/bin/env bash
# /usr/local/sbin/add-record.sh - aggiunge o sostituisce un record in una zona statica
# Uso: add-record.sh <zone> <name> <type> <value> [ttl]
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
require_commands named-checkzone awk "$RNDC"
acquire_lock

zone="$(normalize_zone "${1:?Zona mancante}")"
name="${2:?Nome del record mancante}"
rtype="${3^^}"
value="${4:?Valore del record mancante}"
ttl="${5:-3600}"

validate_zone_name "$zone"
zone_file="$(zone_file_path "$zone")"
[[ -f "$zone_file" ]] || die "File di zona inesistente: ${zone_file}"

# Copia di sicurezza da cui ripristinare in caso di errore
backup="${zone_file}.bak"
cp -a "$zone_file" "$backup"

tmp_file="$(mktemp "${zone_file}.XXXXXX")"

# Elimina un eventuale record omonimo dello stesso tipo, poi accoda quello nuovo
awk -v name="$name" -v rtype="$rtype" '
    $1 == name {
        for (i = 2; i <= NF; i++) {
            if (toupper($i) == rtype) next
        }
    }
    { print }
' "$zone_file" > "$tmp_file"

printf '%s\t%s\tIN\t%s\t%s\n' "$name" "$ttl" "$rtype" "$value" >> "$tmp_file"

cat "$tmp_file" > "$zone_file"
rm -f "$tmp_file"

/usr/local/sbin/bump-serial.sh "$zone_file" >/dev/null

if named-checkzone "$zone" "$zone_file" >/dev/null; then
    "$RNDC" reload "$zone"
    rm -f "$backup"
    log INFO "Record aggiunto: ${name} ${rtype} ${value} nella zona ${zone}"
else
    # Ripristino automatico: la zona torna allo stato precedente
    mv "$backup" "$zone_file"
    die "Modifica annullata: il file di zona risultante non è valido"
fi

Si osservi che il file temporaneo viene creato nella stessa directory del file di zona e poi riversato con cat: questo preserva proprietario, permessi e contesto SELinux del file originale, cosa che un semplice mv da /tmp non garantirebbe.

La rimozione segue la stessa struttura, filtrando anche sul valore per evitare di cancellare l'intero gruppo di record con lo stesso nome:

#!/usr/bin/env bash
# /usr/local/sbin/del-record.sh - rimuove un record specifico da una zona statica
# Uso: del-record.sh <zone> <name> <type> [value]
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
acquire_lock

zone="$(normalize_zone "${1:?Zona mancante}")"
name="${2:?Nome del record mancante}"
rtype="${3^^}"
value="${4:-}"

zone_file="$(zone_file_path "$zone")"
backup="${zone_file}.bak"
cp -a "$zone_file" "$backup"

tmp_file="$(mktemp "${zone_file}.XXXXXX")"
removed=0

# Conta le righe eliminate per segnalare l'eventuale assenza del record
awk -v name="$name" -v rtype="$rtype" -v value="$value" '
    $1 == name {
        for (i = 2; i <= NF; i++) {
            if (toupper($i) == rtype) {
                if (value == "" || $NF == value) {
                    removed++
                    next
                }
            }
        }
    }
    { print }
    END { print removed > "/dev/stderr" }
' "$zone_file" > "$tmp_file" 2> "${tmp_file}.count"

removed="$(cat "${tmp_file}.count")"
rm -f "${tmp_file}.count"

if (( removed == 0 )); then
    rm -f "$tmp_file" "$backup"
    die "Nessun record corrispondente trovato in ${zone}"
fi

cat "$tmp_file" > "$zone_file"
rm -f "$tmp_file"

/usr/local/sbin/bump-serial.sh "$zone_file" >/dev/null

if named-checkzone "$zone" "$zone_file" >/dev/null; then
    "$RNDC" reload "$zone"
    rm -f "$backup"
    log INFO "Rimossi ${removed} record ${rtype} per ${name} nella zona ${zone}"
else
    mv "$backup" "$zone_file"
    die "Rimozione annullata: file di zona non valido"
fi

Aggiornamenti dinamici con nsupdate e TSIG

La manipolazione diretta dei file di testo ha un limite strutturale: non è applicabile alle zone che accettano aggiornamenti dinamici, perché BIND mantiene le modifiche in un journal (.jnl) e riscriverebbe il file sovrascrivendo le nostre modifiche. Per queste zone lo strumento corretto è nsupdate, che invia richieste DNS UPDATE autenticate tramite TSIG.

Il primo passo è generare una chiave condivisa:

# Genera una chiave TSIG dedicata agli aggiornamenti dinamici
tsig-keygen -a hmac-sha256 update-key > /etc/bind/update.key
chown root:bind /etc/bind/update.key
chmod 640 /etc/bind/update.key

La chiave va poi inclusa nella configurazione e autorizzata sulla zona interessata:

include "/etc/bind/update.key";

zone "example.com" {
    type master;
    file "/var/lib/bind/db.example.com";
    allow-update { key update-key; };
    allow-transfer { key transfer-key; };
};

A questo punto lo script di aggiornamento diventa molto compatto e, soprattutto, atomico: BIND applica l'intero blocco compreso tra zone e send come una singola transazione, incrementando automaticamente il serial.

#!/usr/bin/env bash
# /usr/local/sbin/dyn-update.sh - aggiorna un record via DNS UPDATE senza toccare il file di zona
# Uso: dyn-update.sh <zone> <fqdn> <type> <value> [ttl]
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_commands nsupdate dig

zone="$(normalize_zone "${1:?Zona mancante}")"
fqdn="$(normalize_zone "${2:?FQDN mancante}")"
rtype="${3^^}"
value="${4:?Valore mancante}"
ttl="${5:-300}"

server="${DNS_SERVER:-127.0.0.1}"
keyfile="${TSIG_KEYFILE:-/etc/bind/update.key}"
[[ -r "$keyfile" ]] || die "Chiave TSIG non leggibile: ${keyfile}"

# L'intero blocco viene applicato come un'unica transazione atomica
nsupdate -k "$keyfile" <<EOF
server ${server}
zone ${zone}.
update delete ${fqdn}. ${rtype}
update add ${fqdn}. ${ttl} ${rtype} ${value}
send
EOF

# Verifica immediata interrogando direttamente il server autoritativo
answer="$(dig +short +norecurse @"$server" "${fqdn}." "$rtype")"
[[ -n "$answer" ]] || die "Aggiornamento non confermato per ${fqdn}"

log INFO "Aggiornamento confermato: ${fqdn} ${rtype} -> ${answer}"

Un caso d'uso ricorrente è l'aggiornamento di un record A quando l'indirizzo pubblico di una connessione domestica cambia. Bastano poche righe per costruire un client DNS dinamico completo:

#!/usr/bin/env bash
# /usr/local/sbin/ddns-sync.sh - allinea un record A all'indirizzo pubblico corrente
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_commands dig nsupdate

zone="${DDNS_ZONE:?Variabile DDNS_ZONE non impostata}"
fqdn="${DDNS_FQDN:?Variabile DDNS_FQDN non impostata}"
server="${DNS_SERVER:-127.0.0.1}"
keyfile="${TSIG_KEYFILE:-/etc/bind/update.key}"

# Rileva l'indirizzo pubblico interrogando un resolver che espone il servizio
current_ip="$(dig +short +time=3 +tries=2 @resolver1.opendns.com myip.opendns.com A)"
validate_ipv4 "$current_ip"

# Indirizzo attualmente pubblicato nella zona
published_ip="$(dig +short +norecurse @"$server" "${fqdn}." A | head -n 1)"

if [[ "$current_ip" == "$published_ip" ]]; then
    log INFO "Nessuna variazione: ${fqdn} punta già a ${current_ip}"
    exit 0
fi

nsupdate -k "$keyfile" <<EOF
server ${server}
zone ${zone}.
update delete ${fqdn}. A
update add ${fqdn}. 60 A ${current_ip}
send
EOF

log INFO "Indirizzo aggiornato: ${published_ip:-nessuno} -> ${current_ip}"

Controllo del demone con rndc

Il canale di controllo rndc è l'interfaccia con cui BIND espone le proprie operazioni di runtime. Conoscerne i comandi principali è essenziale per scrivere automazioni che non richiedano mai il riavvio del servizio.

Comando Effetto
rndc status Stato del server: versione, numero di zone, query in corso
rndc reload [zona] Ricarica i file di zona modificati, o una zona specifica
rndc reconfig Rilegge named.conf caricando solo le zone nuove o cambiate
rndc retransfer zona Forza un trasferimento completo su un server secondario
rndc freeze zona Sospende gli aggiornamenti dinamici e riversa il journal sul file
rndc thaw zona Ricarica il file di zona e riabilita gli aggiornamenti dinamici
rndc sync -clean [zona] Riversa i journal sui file di zona e li rimuove
rndc flush Svuota interamente la cache del resolver
rndc flushname nome Rimuove dalla cache un singolo nome
rndc zonestatus zona Serial caricato, ora dell'ultimo caricamento, stato del journal
rndc stats Scrive le statistiche su named.stats
rndc querylog on|off Attiva o disattiva il log delle query a caldo
rndc dnssec -status zona Stato delle chiavi e dei rollover DNSSEC

La coppia freeze/thaw merita un'attenzione particolare. È l'unico modo corretto di modificare a mano una zona che accetta aggiornamenti dinamici: senza il freeze, il contenuto del journal non ancora riversato sovrascriverebbe le modifiche al file. Lo script seguente incapsula la procedura completa, garantendo con un trap che la zona venga scongelata anche in caso di interruzione.

#!/usr/bin/env bash
# /usr/local/sbin/safe-edit.sh - modifica in sicurezza una zona con aggiornamenti dinamici attivi
# Uso: safe-edit.sh <zone>
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
require_commands named-checkzone "$RNDC"
acquire_lock

zone="$(normalize_zone "${1:?Zona mancante}")"
zone_file="$(zone_file_path "$zone")"
editor="${EDITOR:-vi}"

[[ -f "$zone_file" ]] || die "File di zona inesistente: ${zone_file}"

# Sospende gli aggiornamenti dinamici e riversa il journal nel file di zona
"$RNDC" freeze "$zone"

# Lo scongelamento avviene comunque, anche in caso di errore o interruzione
trap '"$RNDC" thaw "$zone" >/dev/null 2>&1 || true' EXIT

backup="${zone_file}.bak"
cp -a "$zone_file" "$backup"

"$editor" "$zone_file"

if named-checkzone "$zone" "$zone_file" >/dev/null; then
    /usr/local/sbin/bump-serial.sh "$zone_file" >/dev/null
    rm -f "$backup"
    log INFO "Zona ${zone} modificata correttamente"
else
    mv "$backup" "$zone_file"
    die "Modifiche scartate: sintassi non valida"
fi

Il comando rndc zonestatus è particolarmente utile per verificare in modo programmatico che una modifica sia stata effettivamente recepita dal demone, e non solo scritta su disco:

# Estrae il serial effettivamente caricato in memoria da named
loaded_serial="$(rndc zonestatus example.com | awk '/^serial:/ { print $2 }')"

# Estrae il serial presente nel file su disco
file_serial="$(grep -Eim1 '^[[:space:]]*[0-9]+[[:space:]]*;[[:space:]]*serial' \
    /var/lib/bind/db.example.com | grep -Eo '[0-9]+' | head -n 1)"

if [[ "$loaded_serial" != "$file_serial" ]]; then
    echo "Disallineamento: in memoria ${loaded_serial}, su disco ${file_serial}" >&2
fi

Backup e ripristino

Un backup di BIND deve comprendere sia la configurazione sia i file di zona, e deve essere eseguito dopo aver riversato i journal delle zone dinamiche: in caso contrario, le modifiche recenti resterebbero solo nei file .jnl, che è preferibile non archiviare perché privi di significato senza il file di zona corrispondente allo stesso istante.

#!/usr/bin/env bash
# /usr/local/sbin/backup-zones.sh - archivia configurazione e file di zona con rotazione
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
require_commands tar "$RNDC"
acquire_lock

retention_days="${RETENTION_DAYS:-30}"
stamp="$(date +%Y%m%d-%H%M%S)"
archive="${BACKUP_DIR}/bind-${stamp}.tar.gz"

install -d -m 700 "$BACKUP_DIR"

# Riversa i journal sulle zone dinamiche per ottenere file coerenti
"$RNDC" sync -clean

tar --create --gzip --file "$archive" \
    --exclude='*.jnl' \
    --directory / \
    "${BIND_CONF_DIR#/}" "${ZONE_DIR#/}"

chmod 600 "$archive"

# Verifica che l'archivio sia leggibile prima di considerarlo valido
tar --test --file "$archive" >/dev/null || die "Archivio corrotto: ${archive}"

log INFO "Archivio creato: ${archive} ($(du -h "$archive" | cut -f1))"

# Rotazione: elimina gli archivi più vecchi della soglia impostata
deleted="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'bind-*.tar.gz' \
    -mtime "+${retention_days}" -printf '.' -delete | wc -c)"

log INFO "Archivi rimossi dalla rotazione: ${deleted}"

Il ripristino è l'operazione che si esegue meno spesso e che, proprio per questo, va provata periodicamente. Uno script dedicato riduce il rischio di errori sotto pressione:

#!/usr/bin/env bash
# /usr/local/sbin/restore-zones.sh - ripristina un archivio dopo averne validato il contenuto
# Uso: restore-zones.sh <archivio.tar.gz>
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_root
archive="${1:?Archivio mancante}"
[[ -r "$archive" ]] || die "Archivio non leggibile: ${archive}"

# Estrazione in una directory temporanea per validare prima di sovrascrivere
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' EXIT

tar --extract --file "$archive" --directory "$staging"

named-checkconf "${staging}/${BIND_CONF_DIR#/}/named.conf" \
    || die "La configurazione contenuta nell'archivio non è valida"

systemctl stop named

# Copia di sicurezza dello stato corrente prima della sovrascrittura
mv "$BIND_CONF_DIR" "${BIND_CONF_DIR}.pre-restore.$(date +%s)"
mv "$ZONE_DIR" "${ZONE_DIR}.pre-restore.$(date +%s)"

cp -a "${staging}/${BIND_CONF_DIR#/}" "$BIND_CONF_DIR"
cp -a "${staging}/${ZONE_DIR#/}" "$ZONE_DIR"

chown -R root:bind "$BIND_CONF_DIR" "$ZONE_DIR"
systemctl start named

log INFO "Ripristino completato da ${archive}"

Verifica della propagazione tra primario e secondari

Un serial incrementato sul primario non garantisce che i secondari lo abbiano recepito: notifiche perse, ACL errate o firewall possono bloccare il trasferimento. Il controllo di coerenza dei serial è quindi la sonda più informativa che si possa aggiungere a un sistema di monitoraggio DNS.

#!/usr/bin/env bash
# /usr/local/sbin/check-serials.sh - confronta il serial del primario con quello dei secondari
# Uso: check-serials.sh <zone> <primary> <secondary> [secondary...]
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_commands dig awk

zone="$(normalize_zone "${1:?Zona mancante}")"
primary="${2:?Server primario mancante}"
shift 2
secondaries=("$@")

(( ${#secondaries[@]} > 0 )) || die "Nessun server secondario indicato"

# Il serial è il terzo campo della risposta SOA abbreviata
get_serial() {
    dig +short +norecurse +time=3 +tries=1 @"$1" "${2}." SOA | awk 'NF { print $3; exit }'
}

primary_serial="$(get_serial "$primary" "$zone")"
[[ -n "$primary_serial" ]] || die "Nessuna risposta SOA da ${primary}"

log INFO "Serial del primario ${primary}: ${primary_serial}"

status=0
for secondary in "${secondaries[@]}"; do
    secondary_serial="$(get_serial "$secondary" "$zone")"

    if [[ -z "$secondary_serial" ]]; then
        log ERROR "${secondary}: nessuna risposta"
        status=2
    elif (( 10#$secondary_serial < 10#$primary_serial )); then
        log WARN "${secondary}: in ritardo (${secondary_serial} invece di ${primary_serial})"
        (( status < 1 )) && status=1
    elif (( 10#$secondary_serial > 10#$primary_serial )); then
        log WARN "${secondary}: serial più recente del primario (${secondary_serial})"
        (( status < 1 )) && status=1
    else
        log INFO "${secondary}: allineato"
    fi
done

exit "$status"

Lo script restituisce codici di uscita compatibili con la convenzione dei plugin di monitoraggio: 0 per stato normale, 1 per avviso, 2 per stato critico. Questo permette di riutilizzarlo direttamente in Nagios, Icinga, Zabbix o in un semplice controllo pianificato.

Un controllo complementare consiste nel verificare che il trasferimento di zona sia consentito solo a chi di dovere:

#!/usr/bin/env bash
# /usr/local/sbin/check-axfr.sh - verifica che il trasferimento di zona non sia aperto a tutti
# Uso: check-axfr.sh <zone> <server>
set -euo pipefail

zone="${1:?Zona mancante}"
server="${2:?Server mancante}"

# Un trasferimento riuscito senza chiave indica una configurazione permissiva
if dig +noall +answer +time=5 @"$server" "${zone}." AXFR | grep -q 'SOA'; then
    echo "CRITICAL - trasferimento di zona consentito senza autenticazione su ${server}"
    exit 2
fi

echo "OK - trasferimento di zona correttamente limitato su ${server}"
exit 0

Monitoraggio dello stato del servizio

Oltre alla coerenza dei dati, va sorvegliata la reattività del demone. Una sonda minimale misura il tempo di risposta a una query nota e classifica il risultato secondo due soglie.

#!/usr/bin/env bash
# /usr/local/sbin/health-check.sh - sonda di stato con soglie di avviso e criticità
# Uso: health-check.sh [server] [nome-da-interrogare]
set -uo pipefail

server="${1:-127.0.0.1}"
probe="${2:-localhost.}"
warn_ms="${WARN_MS:-200}"
crit_ms="${CRIT_MS:-800}"

start="$(date +%s%3N)"

if ! dig +short +time=2 +tries=1 @"$server" "$probe" A >/dev/null 2>&1; then
    echo "CRITICAL - nessuna risposta da ${server}"
    exit 2
fi

elapsed=$(( $(date +%s%3N) - start ))

if (( elapsed >= crit_ms )); then
    echo "CRITICAL - tempo di risposta ${elapsed}ms|rt=${elapsed}ms;${warn_ms};${crit_ms}"
    exit 2
elif (( elapsed >= warn_ms )); then
    echo "WARNING - tempo di risposta ${elapsed}ms|rt=${elapsed}ms;${warn_ms};${crit_ms}"
    exit 1
fi

echo "OK - risposta in ${elapsed}ms|rt=${elapsed}ms;${warn_ms};${crit_ms}"
exit 0

Per una visione più ricca si possono estrarre i contatori interni prodotti da rndc stats, che scrive un file testuale in formato tabellare pronto per essere trasformato in metriche:

#!/usr/bin/env bash
# /usr/local/sbin/export-stats.sh - converte le statistiche di BIND in formato chiave=valore
set -euo pipefail
source /usr/local/lib/bindlib.sh

stats_file="${STATS_FILE:-/var/cache/bind/named.stats}"

# Azzera il file per non accumulare le esecuzioni precedenti
: > "$stats_file"
"$RNDC" stats

# Le righe utili hanno la forma "valore nome della metrica"
awk '
    /^\+\+/ { section = $0; gsub(/[^a-zA-Z ]/, "", section); gsub(/ +/, "_", section); next }
    /^[[:space:]]*[0-9]+ / {
        value = $1
        $1 = ""
        metric = $0
        gsub(/^ +| +$/, "", metric)
        gsub(/[^a-zA-Z0-9]+/, "_", metric)
        printf "%s_%s=%s\n", tolower(section), tolower(metric), value
    }
' "$stats_file"

Analisi dei log delle query

Il log delle query è disattivato per impostazione predefinita, perché su un server molto sollecitato produce un volume considerevole di dati. Può essere attivato a caldo con rndc querylog on, oppure configurato in modo permanente su un canale dedicato:

logging {
    channel query_channel {
        file "/var/log/named/query.log" versions 5 size 50m;
        severity info;
        print-time yes;
        print-category yes;
    };
    category queries { query_channel; };
};

Le righe prodotte hanno una struttura fissa, che si presta bene all'estrazione con awk:

24-Jul-2026 10:12:33.412 queries: info: client @0x7f2a 192.0.2.10#54321 (www.example.com): query: www.example.com IN A +E(0)K (198.51.100.1)

Lo script seguente produce un riepilogo con i client più attivi, i nomi più richiesti e la distribuzione dei tipi di record.

#!/usr/bin/env bash
# /usr/local/sbin/query-report.sh - riepilogo delle query dal log di BIND
# Uso: query-report.sh [file-di-log]
set -euo pipefail

log_file="${1:-/var/log/named/query.log}"
top="${TOP:-10}"

[[ -r "$log_file" ]] || { echo "Log non leggibile: ${log_file}" >&2; exit 1; }

echo "== Client più attivi =="
awk '{
    for (i = 1; i <= NF; i++) {
        # Il campo del client contiene indirizzo e porta separati dal cancelletto
        if ($i ~ /#[0-9]+$/) {
            split($i, parts, "#")
            print parts[1]
            break
        }
    }
}' "$log_file" | sort | uniq -c | sort -rn | head -n "$top"

echo
echo "== Nomi più richiesti =="
awk '{
    for (i = 1; i <= NF; i++) {
        if ($i == "query:") { print tolower($(i + 1)); break }
    }
}' "$log_file" | sort | uniq -c | sort -rn | head -n "$top"

echo
echo "== Distribuzione dei tipi di record =="
awk '{
    for (i = 1; i <= NF; i++) {
        if ($i == "query:") { print $(i + 3); break }
    }
}' "$log_file" | sort | uniq -c | sort -rn

echo
echo "== Query per ora =="
awk '{
    # Il campo dell'orario ha la forma HH:MM:SS.mmm
    split($2, t, ":")
    print t[1] ":00"
}' "$log_file" | sort | uniq -c

Con lo stesso approccio si possono individuare anomalie di sicurezza, per esempio i client che generano un numero sproporzionato di richieste ANY, tipiche dei tentativi di amplificazione:

# Elenca i client che hanno inviato richieste di tipo ANY, ordinati per volume
awk '{
    for (i = 1; i <= NF; i++) {
        if ($i == "query:" && $(i + 3) == "ANY") {
            for (j = 1; j < i; j++) {
                if ($j ~ /#[0-9]+$/) {
                    split($j, parts, "#")
                    print parts[1]
                    break
                }
            }
            break
        }
    }
}' /var/log/named/query.log | sort | uniq -c | sort -rn | head -n 20

Gestione e supervisione di DNSSEC

Dalla versione 9.16 BIND è in grado di gestire autonomamente le chiavi e i rollover attraverso le politiche DNSSEC, riducendo drasticamente la quantità di automazione manuale necessaria. La firma di una zona si riduce a due direttive:

zone "example.com" {
    type master;
    file "/var/lib/bind/db.example.com";
    dnssec-policy default;
    inline-signing yes;
};

Restano però da sorvegliare due aspetti critici: lo stato dei rollover e la scadenza delle firme. Il primo si ispeziona con rndc dnssec -status:

#!/usr/bin/env bash
# /usr/local/sbin/dnssec-status.sh - riepiloga lo stato delle chiavi per tutte le zone firmate
set -euo pipefail
source /usr/local/lib/bindlib.sh

require_commands named-checkconf awk "$RNDC"

# Estrae le zone che dichiarano una politica DNSSEC
mapfile -t zones < <(
    named-checkconf -p "${BIND_CONF_DIR}/named.conf" | awk '
        /^[[:space:]]*zone "/ { zone = $2; gsub(/"/, "", zone); signed = 0 }
        /dnssec-policy/ && $2 != "none;" { signed = 1 }
        /^[[:space:]]*};/ { if (zone != "" && signed) print zone; zone = "" }
    '
)

for zone in "${zones[@]}"; do
    echo "== ${zone} =="
    "$RNDC" dnssec -status "$zone" || log WARN "Stato non disponibile per ${zone}"
    echo
done

La scadenza delle firme è invece verificabile dall'esterno, interrogando il server come farebbe un resolver validante. Il campo di espirazione di un record RRSIG è il quinto, nel formato YYYYMMDDHHMMSS in ora UTC.

#!/usr/bin/env bash
# /usr/local/sbin/dnssec-expiry.sh - verifica la scadenza delle firme RRSIG di una zona
# Uso: dnssec-expiry.sh <zone> [server]
set -euo pipefail

zone="${1:?Zona mancante}"
server="${2:-127.0.0.1}"
threshold_days="${THRESHOLD_DAYS:-7}"

now="$(date -u +%s)"
status=0
found=0

while read -r expiry; do
    [[ -n "$expiry" ]] || continue
    found=1

    # Ricompone la data nel formato accettato da date
    formatted="${expiry:0:4}-${expiry:4:2}-${expiry:6:2} ${expiry:8:2}:${expiry:10:2}:${expiry:12:2}"
    expiry_epoch="$(date -u -d "${formatted} UTC" +%s)"
    remaining_days=$(( (expiry_epoch - now) / 86400 ))

    if (( remaining_days < 0 )); then
        echo "CRITICAL - firma già scaduta il ${formatted} UTC"
        status=2
    elif (( remaining_days < threshold_days )); then
        echo "WARNING - firma in scadenza tra ${remaining_days} giorni (${formatted} UTC)"
        (( status < 1 )) && status=1
    fi
done < <(dig +dnssec +short +norecurse @"$server" "${zone}." SOA | awk '/^SOA/ { print $5 }')

if (( found == 0 )); then
    echo "CRITICAL - nessuna firma RRSIG trovata per ${zone}"
    exit 2
fi

if (( status == 0 )); then
    echo "OK - tutte le firme sono valide per almeno ${threshold_days} giorni"
fi

exit "$status"

Per una verifica end-to-end della catena di fiducia, dalla radice fino alla zona, lo strumento corretto è delv, che a differenza di dig esegue realmente la validazione:

#!/usr/bin/env bash
# /usr/local/sbin/dnssec-validate.sh - verifica la catena di fiducia con delv
# Uso: dnssec-validate.sh <fqdn> [type]
set -euo pipefail

fqdn="${1:?FQDN mancante}"
rtype="${2:-A}"

output="$(delv +rtrace "$fqdn" "$rtype" 2>&1)"

if grep -q 'fully validated' <<< "$output"; then
    echo "OK - ${fqdn} ${rtype} validato correttamente"
    exit 0
fi

if grep -q 'unsigned answer' <<< "$output"; then
    echo "WARNING - ${fqdn} ${rtype} non è firmato"
    exit 1
fi

echo "CRITICAL - validazione fallita per ${fqdn} ${rtype}"
printf '%s\n' "$output" >&2
exit 2

Quando si adotta una politica DNSSEC, il record DS presso il registrar va aggiornato a ogni rollover della chiave KSK. Il comando dnssec-dsfromkey genera il record da comunicare:

# Genera i record DS a partire dalle chiavi pubbliche presenti nella directory delle chiavi
for key in /var/lib/bind/keys/Kexample.com.+*.key; do
    # Solo le chiavi KSK, riconoscibili dal flag 257
    if grep -q ' DNSKEY 257 ' "$key"; then
        dnssec-dsfromkey -a SHA-256 "$key"
    fi
done

Pianificazione con systemd

Le verifiche periodiche possono essere pianificate con cron, ma le unità systemd offrono alcuni vantaggi concreti: la registrazione automatica nel journal, la ripresa delle esecuzioni perse dopo un riavvio e la possibilità di limitare le risorse consumate.

# /etc/systemd/system/bind-backup.service
[Unit]
Description=Backup delle zone e della configurazione di BIND
After=named.service
Requires=named.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup-zones.sh
Nice=10
IOSchedulingClass=idle
PrivateTmp=yes
ProtectSystem=full
# /etc/systemd/system/bind-backup.timer
[Unit]
Description=Esecuzione giornaliera del backup di BIND

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=600

[Install]
WantedBy=timers.target

Attivazione e verifica:

systemctl daemon-reload
systemctl enable --now bind-backup.timer

# Elenca i timer relativi a BIND con la prossima esecuzione prevista
systemctl list-timers 'bind-*'

# Esegue immediatamente il servizio per collaudarlo
systemctl start bind-backup.service
journalctl -u bind-backup.service --since '10 minutes ago'

Per i controlli ad alta frequenza, come il confronto dei serial, un timer con OnUnitActiveSec è più appropriato di una pianificazione a orario fisso:

# /etc/systemd/system/bind-serial-check.timer
[Unit]
Description=Controllo periodico della propagazione dei serial

[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
AccuracySec=1min

[Install]
WantedBy=timers.target

Sicurezza e buone pratiche

Gli script che modificano un servizio DNS di produzione meritano lo stesso rigore del codice applicativo. Alcune regole si sono dimostrate particolarmente efficaci nel prevenire incidenti:

  • Attivare sempre le opzioni difensive della shell. La riga set -euo pipefail trasforma errori silenziosi in interruzioni immediate; senza pipefail, il codice di uscita di una pipeline è quello dell'ultimo comando, mascherando il fallimento di named-checkzone a monte.
  • Quotare ogni espansione di variabile. Un nome di zona contenente uno spazio, magari frutto di un errore di battitura, può altrimenti trasformare rm in un'operazione distruttiva.
  • Validare gli input prima di usarli. Nomi di zona e indirizzi vanno confrontati con espressioni regolari restrittive, soprattutto quando gli script sono invocati da interfacce web o da API.
  • Rendere ogni modifica reversibile. Copia di sicurezza prima, validazione dopo, ripristino automatico in caso di fallimento: nessun reload deve mai partire su una configurazione non validata.
  • Non incorporare mai le chiavi negli script. Le chiavi TSIG risiedono in file con permessi 640 e proprietà root:bind, e vengono passate a nsupdate con l'opzione -k, mai con -y sulla riga di comando, dove sarebbero visibili nella tabella dei processi.
  • Limitare i privilegi. Se il personale operativo deve poter aggiungere record senza essere root, una regola sudoers mirata è preferibile all'accesso completo all'account amministrativo.
  • Serializzare le esecuzioni. flock evita che due processi concorrenti corrompano lo stesso file di zona o generino serial duplicati.
  • Registrare tutto su syslog. Il passaggio da logger consente di correlare le modifiche automatiche con gli eventi registrati da named, elemento decisivo durante l'analisi di un incidente.
  • Versionare la configurazione. Tenere /etc/bind sotto controllo di versione, con un hook di pre-commit che invoca lo script di validazione, offre una cronologia completa delle modifiche e un percorso di rollback immediato.

Un esempio di regola sudoers restrittiva:

Cmnd_Alias BIND_OPS = /usr/sbin/rndc reload *, \
                      /usr/sbin/rndc reconfig, \
                      /usr/sbin/rndc zonestatus *, \
                      /usr/local/sbin/add-record.sh, \
                      /usr/local/sbin/del-record.sh

%dnsops ALL=(root) NOPASSWD: BIND_OPS

E un hook di pre-commit da collocare in .git/hooks/pre-commit all'interno di /etc/bind:

#!/usr/bin/env bash
# Blocca il commit se la configurazione o una zona non superano la validazione
set -euo pipefail

if ! /usr/local/sbin/check-config.sh; then
    echo "Commit rifiutato: la configurazione di BIND non è valida" >&2
    exit 1
fi

exit 0

Conclusioni

BIND espone tutto ciò che serve per un'amministrazione completamente automatizzata: strumenti di validazione con codici di uscita affidabili, un canale di controllo granulare come rndc, un protocollo di aggiornamento autenticato come DNS UPDATE e un formato di log strutturato. Bash, con la sua capacità di orchestrare questi strumenti senza dipendenze aggiuntive, è il collante naturale.

Il principio guida da conservare è quello della transazione: ogni operazione che tocca una zona deve prevedere una copia di sicurezza, una validazione esplicita e un percorso di ripristino automatico. Applicando questo schema in modo sistematico, la manutenzione del DNS smette di essere l'attività più rischiosa dell'infrastruttura e diventa una routine verificabile, ripetibile e, soprattutto, reversibile.

I passi successivi naturali sono l'integrazione degli script in una pipeline di distribuzione continua, con la configurazione versionata come unica fonte di verità, e l'esportazione delle metriche prodotte da rndc stats verso un sistema di serie temporali, per trasformare il monitoraggio reattivo in analisi delle tendenze.