Tuto : son propre résolveur DNS avec dnsmasq + Unbound

· 11 min de lecture

#dns#homelab#self-hosting#debian

Sommaire

Pourquoi monter son propre résolveur

Un résolveur récursif local (Unbound) derrière un serveur DNS/DHCP de LAN (dnsmasq) supprime toute dépendance à un résolveur public, valide DNSSEC lui-même et bloque la pub pour tout le réseau. Le tout tourne sur une petite VM ou un Raspberry Pi.

Les résolveurs publics deviennent fragiles en France :

  • OpenDNS (Cisco) a coupé son service en France en juin 2024, après des injonctions de blocage anti-piratage.
  • Quad9 refuse de bloquer des domaines de streaming sportif visés par une décision du tribunal judiciaire de Paris (13 mai 2026) et envisage de cesser de servir les internautes français face à une astreinte réclamée par beIN Sports (JustGeek, TorrentFreak).
  • Mullvad ferme ses DNS chiffrés publics le 2 novembre 2026 (annonce Mullvad).

Avec Unbound en mode récursif, vos requêtes partent directement vers les serveurs racine puis les serveurs faisant autorité : aucun intermédiaire ne voit l'ensemble de votre trafic DNS, et aucun ne peut disparaître du jour au lendemain.

Architecture

dnsmasq fait face au réseau, Unbound reste caché derrière lui sur localhost.

Chaîne de résolution : clients du LAN → dnsmasq → Unbound → serveurs racine

dnsmasq répond seul aux noms locaux, aux domaines bloqués et à ce qu'il a en cache ; tout le reste part vers Unbound, qui interroge directement les serveurs racine puis les serveurs faisant autorité et valide les signatures DNSSEC.

Prérequis et installation

Ce tuto a été réalisé sur Debian 13 (Trixie) dans une VM Proxmox (1 vCPU, 512 Mo de RAM suffisent). Valeurs d'exemple utilisées partout ci-dessous, à adapter :

Élément Valeur d'exemple
Réseau LAN 192.168.1.0/24
Box / passerelle 192.168.1.1
Serveur DNS (IP fixe) 192.168.1.53
Interface réseau eth0 (souvent ens18 sous Proxmox)
Domaine local maison.lan
Plage DHCP 192.168.1.10 → 192.168.1.149
  1. Donnez une IP fixe au serveur (dans /etc/network/interfaces ou via NetworkManager), hors de la plage DHCP.
  2. Installez les paquets :
apt update
apt install dnsmasq unbound dns-root-data dnsutils
  1. Vérifiez que rien n'occupe déjà le port 53 (ss -lntup | grep :53). Sur une distribution qui active systemd-resolved (Ubuntu notamment), désactivez son stub listener (DNSStubListener=no dans /etc/systemd/resolved.conf) avant de lancer dnsmasq.
  2. Désactivez le DHCP de votre box : dnsmasq devient le seul serveur DHCP du réseau. Deux DHCP sur le même LAN = baux incohérents.

Configuration d'Unbound

Unbound écoute uniquement sur 127.0.0.1:5335 : seul dnsmasq lui parle, le port 53 reste libre pour dnsmasq. Le fichier principal Debian (/etc/unbound/unbound.conf) ne fait qu'inclure /etc/unbound/unbound.conf.d/*.conf, on n'y touche pas.

Récupérez d'abord la liste des serveurs racine :

curl -sf -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.root
chown unbound:unbound /var/lib/unbound/root.hints

Puis créez /etc/unbound/unbound.conf.d/local.conf :

server:
    # Écoute locale uniquement, port dédié
    interface: 127.0.0.1
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes

    # Ne rien divulguer sur le serveur
    hide-identity: yes
    hide-version: yes

    # Performance : précharge les entrées populaires avant expiration
    prefetch: yes
    num-threads: 2
    cache-min-ttl: 300
    cache-max-ttl: 86400

    # Résolution récursive directe depuis la racine
    root-hints: "/var/lib/unbound/root.hints"

    # Logs sobres en production
    verbosity: 1

    # Seul localhost (donc dnsmasq) peut interroger Unbound
    access-control: 127.0.0.1/32 allow

Deux fichiers fournis par Debian sont à garder tels quels :

  • root-auto-trust-anchor-file.conf active la validation DNSSEC (auto-trust-anchor-file: "/var/lib/unbound/root.key"), la clé racine étant mise à jour automatiquement.
  • remote-control.conf active unbound-control via le socket /run/unbound.ctl (utile pour vider le cache ou lire les stats).

Note : cache-min-ttl: 300 garde en cache 5 minutes minimum même les enregistrements à TTL court. C'est plus rapide, mais un basculement d'IP côté serveur (CDN, failover) peut mettre jusqu'à 5 minutes à être vu. Mettez 0 si cela vous gêne.

Vérifiez et démarrez :

unbound-checkconf
systemctl enable --now unbound
dig @127.0.0.1 -p 5335 debian.org +short

Configuration de dnsmasq

dnsmasq répond aux clients sur le port 53, résout les noms locaux, sert le DHCP et transmet le reste à Unbound. La config est découpée en quatre fichiers.

/etc/dnsmasq.conf

# ===== INTERFACE =====
interface=eth0
bind-interfaces
listen-address=127.0.0.1,192.168.1.53

# Fichiers additionnels (seuls les *.conf sont lus)
conf-dir=/etc/dnsmasq.d/,*.conf

# ===== DNS =====
# Domaine local : les noms courts deviennent nom.maison.lan
domain=maison.lan
local=/maison.lan/
expand-hosts

# Ne jamais transmettre à Internet les noms sans point
# ni les reverse des plages privées
domain-needed
bogus-priv

# Upstream unique : Unbound en local
server=127.0.0.1#5335
# Transmet le bit AD (DNSSEC validé par Unbound) aux clients
proxy-dnssec

# Ignorer /etc/resolv.conf (évite les boucles)
no-resolv

# Cache
cache-size=10000

# Logs (pratique pour le debug, pensez à la rotation)
log-queries
log-facility=/var/log/dnsmasq.log
log-async
log-dhcp

# ===== DHCP =====
dhcp-range=192.168.1.10,192.168.1.149,1h
# Option 3 : passerelle
dhcp-option=3,192.168.1.1
# Option 6 : DNS poussé aux clients = ce serveur
dhcp-option=6,192.168.1.53
# Option 15 : domaine de recherche
dhcp-option=15,maison.lan
# Ce dnsmasq est LE DHCP du réseau
dhcp-authoritative
dhcp-leasefile=/var/lib/misc/dnsmasq.leases

/etc/dnsmasq.d/lan.conf

Les hôtes statiques et réservations DHCP vivent dans des dossiers dédiés : dnsmasq les relit automatiquement à chaque modification, sans redémarrage.

# Enregistrements DNS statiques
hostsdir=/etc/dnsmasq.hosts

# Réservations DHCP
dhcp-hostsdir=/etc/dnsmasq.dhcp

# Option : couper le DNS d'un appareil marqué "bloque"
#dhcp-option=tag:bloque,option:dns-server,0.0.0.0

/etc/dnsmasq.hosts/statiques

Format /etc/hosts : pour les machines à IP fixe (hyperviseur, NAS, reverse proxy…). Vous pouvez aussi y forcer un nom public vers une IP locale (split-horizon), par ex. service.mondomaine.fr vers votre reverse proxy.

192.168.1.53   dns.maison.lan     dns
192.168.1.150  pve.maison.lan     pve
192.168.1.152  proxy.maison.lan   proxy
192.168.1.250  nas.maison.lan     nas
192.168.1.152  service.mondomaine.fr

/etc/dnsmasq.dhcp/reservations

# <MAC>,<hostname>,<IP>[,durée_bail]
AA:BB:CC:00:00:01,raspberrypi,192.168.1.63
AA:BB:CC:00:00:02,imprimante,192.168.1.20
AA:BB:CC:00:00:03,pc-bureau,192.168.1.25

# Bloquer un appareil (avec la ligne tag:bloque de lan.conf)
#AA:BB:CC:DD:EE:FF,set:bloque

/etc/resolv.conf du serveur

Le serveur lui-même passe par dnsmasq :

nameserver 127.0.0.1

Vous pouvez ajouter un second nameserver public en secours, mais le serveur l'utilisera alors parfois sans passer par Unbound.

Vérifiez et démarrez :

dnsmasq --test
systemctl enable --now dnsmasq
systemctl restart dnsmasq

Tests et vérifications

Cinq commandes suffisent à valider toute la chaîne, à lancer depuis un poste du LAN (ou le serveur).

# 1. Résolution Internet via dnsmasq → Unbound
dig @192.168.1.53 debian.org

# 2. DNSSEC validé : le flag "ad" doit apparaître dans la réponse
dig @192.168.1.53 debian.org +dnssec | grep flags

# 3. DNSSEC cassé volontairement : doit répondre SERVFAIL
dig @192.168.1.53 dnssec-failed.org

# 4. Nom local
dig @192.168.1.53 nas.maison.lan +short

# 5. Le cache fonctionne : la 2e requête doit afficher ~0 ms
dig @192.168.1.53 wikipedia.org | grep "Query time"

Côté client, après renouvellement du bail DHCP, vérifiez que le DNS reçu est bien 192.168.1.53 (ipconfig /all, resolvectl status ou scutil --dns sur macOS).

Pour suivre le trafic :

tail -f /var/log/dnsmasq.log          # requêtes et DHCP
unbound-control stats_noreset | grep total   # stats Unbound
cat /var/lib/misc/dnsmasq.leases     # baux en cours

Maintenance, blocage pub et sauvegarde

Trois tâches cron gardent le serveur à jour sans intervention : liste de blocage quotidienne, root hints mensuels, sauvegarde nocturne.

Blocage pub/trackers avec OISD

OISD publie une liste au format dnsmasq natif (dnsmasq ≥ 2.86). La variante small bloque environ 58 000 domaines sans casser les sites. Script /usr/local/bin/update-adblock.sh : téléchargement, contrôle du contenu, test de syntaxe, rollback automatique en cas d'échec.

#!/bin/bash
# Mise à jour blocklist OISD small pour dnsmasq (>= 2.86, format dnsmasq2)
set -u
export PATH="/usr/sbin:/usr/bin:/sbin:/bin"

URL="https://small.oisd.nl/dnsmasq2"
DEST="/etc/dnsmasq.d/adblock.conf"
BACKUP="/etc/dnsmasq.d/.adblock.conf.bak"   # ne finit pas par .conf => ignoré par conf-dir
LOG="/var/log/adblock.log"
MIN_LINES=1000

log() { echo "$1 $(date '+%F %T') $2"; }

TMP=$(mktemp /var/tmp/adblock.XXXXXX) || { log "ERR" "mktemp échoué"; exit 1; }
trap 'rm -f "$TMP"' EXIT

# Téléchargement
if ! wget -q --timeout=30 --tries=3 -O "$TMP" "$URL"; then
    log "ERR" "Échec téléchargement"; exit 1
fi

# Vérification du contenu (compte les vraies règles)
COUNT=$(grep -c '^local=/' "$TMP")
if [ "$COUNT" -lt "$MIN_LINES" ]; then
    log "ERR" "Fichier suspect ($COUNT règles)"; exit 1
fi

# Rien à faire si la liste n'a pas changé
if [ -f "$DEST" ] && cmp -s "$TMP" "$DEST"; then
    log "INFO" "Liste inchangée ($COUNT domaines)"; exit 0
fi

# Sauvegarde puis remplacement
[ -f "$DEST" ] && cp -p "$DEST" "$BACKUP"
install -m 644 "$TMP" "$DEST"

rollback() {
    log "ERR" "$1 — rollback"
    if [ -f "$BACKUP" ]; then cp -p "$BACKUP" "$DEST"; else rm -f "$DEST"; fi
    systemctl restart dnsmasq
    exit 1
}

# Test de la conf COMPLÈTE (principale + conf-dir + nouvelle liste)
dnsmasq --test 2>>"$LOG" || rollback "Syntaxe invalide"

# Restart obligatoire : un reload (SIGHUP) ne relit pas conf-dir
systemctl restart dnsmasq || rollback "Restart dnsmasq échoué"
sleep 2
systemctl is-active --quiet dnsmasq || rollback "dnsmasq inactif après restart"

log "OK" "Adblock : $COUNT domaines bloqués"

Test : dig @192.168.1.53 doubleclick.net +short doit renvoyer une réponse vide (NXDOMAIN).

Sauvegarde

Archivez au minimum : /etc/dnsmasq.conf, /etc/dnsmasq.d, /etc/dnsmasq.hosts, /etc/dnsmasq.dhcp, /var/lib/misc/dnsmasq.leases, /etc/unbound/unbound.conf*, /etc/hosts, /etc/resolv.conf, vos scripts et la crontab root. Avec cp -a --parents puis tar -czf, les chemins complets sont conservés ; la restauration tient en une ligne :

tar -xzf dns-backup_AAAA-MM-JJ.tar.gz -C / --exclude=./_meta
dnsmasq --test && systemctl restart dnsmasq
unbound-checkconf && systemctl restart unbound

L'archive peut ensuite être copiée sur un NAS (montage NFS, rétention 30 jours par exemple).

Script complet /usr/local/bin/backup-dns.sh : archive locale dans /var/backups/dns, copie sur le NAS (montage NFS automatique si besoin), rotation à 30 jours des deux côtés, et un fichier _meta/INFOS.txt qui rappelle la procédure de restauration.

#!/bin/bash
# =====================================================
# Backup DNS - serveur dnsmasq/unbound vers un NAS (NFS)
# Sauvegarde : dnsmasq (conf, dnsmasq.d, hostsdir, dhcp-hostsdir, baux)
#              + unbound + /etc/hosts + resolv.conf + logrotate
#              + scripts perso + crontab + infos système
#
# Les fichiers sont archivés avec leur chemin complet.
# Restauration : tar -xzf <archive> -C / --exclude=./_meta
# =====================================================

# ===== CONFIGURATION =====
NAS_SERVER="192.168.1.250"
NAS_EXPORT="/volume1/backup"
NAS_ROOT="/mnt/nas"
NAS_MOUNT="${NAS_ROOT}/backupdns"
LOCAL_BACKUP_DIR="/var/backups/dns"
RETENTION_DAYS=30
HOSTNAME=$(hostname)
DATE=$(date +%Y-%m-%d_%H-%M-%S)
ARCHIVE_NAME="${HOSTNAME}_dns-backup_${DATE}.tar.gz"
LOG_FILE="/var/log/backup-dns.log"
TMP_DIR="/tmp/dns-backup-$$"

# Fichiers et dossiers à sauvegarder (chemins absolus)
BACKUP_PATHS=(
    # dnsmasq
    /etc/dnsmasq.conf
    /etc/dnsmasq.d
    /etc/dnsmasq.hosts
    /etc/dnsmasq.dhcp
    /var/lib/misc/dnsmasq.leases
    # unbound
    /etc/unbound/unbound.conf
    /etc/unbound/unbound.conf.d
    # système
    /etc/hosts
    /etc/resolv.conf
    /etc/logrotate.d/dnsmasq-lan
    # scripts perso
    /usr/local/bin/backup-dns.sh
    /usr/local/bin/update-adblock.sh
)

# ===== FONCTIONS =====
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

cleanup() {
    rm -rf "$TMP_DIR"
}
trap cleanup EXIT

log "===== Début du backup DNS ====="

# ===== PRÉPARATION =====
mkdir -p "$LOCAL_BACKUP_DIR"
mkdir -p "$TMP_DIR/_meta"

# ===== COLLECTE DES FICHIERS =====
log "Collecte des fichiers de configuration..."

for path in "${BACKUP_PATHS[@]}"; do
    if [ -e "$path" ]; then
        if cp -a --parents "$path" "$TMP_DIR/" 2>>"$LOG_FILE"; then
            log "  OK   $path"
        else
            log "  ERR  $path (échec copie)"
        fi
    else
        log "  WARN $path introuvable"
    fi
done

# Crontab root
crontab -l > "$TMP_DIR/_meta/crontab-root.txt" 2>/dev/null && log "  OK   crontab root"

# Infos système (utile pour restauration)
{
    echo "===== INFOS BACKUP ====="
    echo "Date       : $(date)"
    echo "Hostname   : $HOSTNAME"
    echo "IP         : $(hostname -I)"
    echo "OS         : $(lsb_release -d 2>/dev/null | cut -f2)"
    echo "Kernel     : $(uname -r)"
    echo ""
    echo "===== RESTAURATION ====="
    echo "tar -xzf ${ARCHIVE_NAME} -C / --exclude=./_meta"
    echo "crontab _meta/crontab-root.txt"
    echo "dnsmasq --test && systemctl restart dnsmasq"
    echo "unbound-checkconf && systemctl restart unbound"
    echo ""
    echo "===== VERSION DNSMASQ ====="
    dnsmasq --version 2>/dev/null | head -3
    echo ""
    echo "===== STATUS DNSMASQ ====="
    systemctl status dnsmasq --no-pager 2>/dev/null | head -10
    echo ""
    echo "===== STATUS UNBOUND ====="
    systemctl status unbound --no-pager 2>/dev/null | head -10
} > "$TMP_DIR/_meta/INFOS.txt"
log "  OK   INFOS.txt"

# ===== CRÉATION DE L'ARCHIVE =====
log "Création de l'archive..."
if tar -czf "${LOCAL_BACKUP_DIR}/${ARCHIVE_NAME}" -C "$TMP_DIR" . 2>>"$LOG_FILE"; then
    SIZE=$(du -h "${LOCAL_BACKUP_DIR}/${ARCHIVE_NAME}" | cut -f1)
    log "Archive locale créée : ${ARCHIVE_NAME} (${SIZE})"
else
    log "Échec création archive"
    exit 1
fi

# ===== VÉRIFICATION DU MONTAGE NFS =====
NAS_OK=0
if ! mountpoint -q "$NAS_ROOT"; then
    log "NAS non monté, tentative de montage..."
    mkdir -p "$NAS_ROOT"
    mount -t nfs "${NAS_SERVER}:${NAS_EXPORT}" "$NAS_ROOT" 2>>"$LOG_FILE"
    if mountpoint -q "$NAS_ROOT"; then
        log "NAS monté avec succès"
        NAS_OK=1
    else
        log "Impossible de monter le NAS. Backup local uniquement."
    fi
else
    log "NAS déjà monté"
    NAS_OK=1
fi

# ===== COPIE VERS NAS =====
if [ "$NAS_OK" = "1" ]; then
    mkdir -p "$NAS_MOUNT"
    if cp "${LOCAL_BACKUP_DIR}/${ARCHIVE_NAME}" "${NAS_MOUNT}/" 2>>"$LOG_FILE"; then
        log "Archive copiée sur NAS : ${NAS_MOUNT}/${ARCHIVE_NAME}"
    else
        log "Échec copie vers NAS"
        NAS_OK=0
    fi
fi

# ===== ROTATION LOCALE =====
DELETED_LOCAL=$(find "$LOCAL_BACKUP_DIR" -name "${HOSTNAME}_dns-backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete -print | wc -l)
log "Local : $DELETED_LOCAL ancien(s) supprimé(s) (> ${RETENTION_DAYS}j)"

# ===== ROTATION NAS =====
if [ "$NAS_OK" = "1" ]; then
    DELETED_NAS=$(find "$NAS_MOUNT" -name "${HOSTNAME}_dns-backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete -print | wc -l)
    log "NAS   : $DELETED_NAS ancien(s) supprimé(s) (> ${RETENTION_DAYS}j)"
fi

# ===== RÉSUMÉ =====
TOTAL_LOCAL=$(ls -1 "$LOCAL_BACKUP_DIR"/${HOSTNAME}_dns-backup_*.tar.gz 2>/dev/null | wc -l)
log "Backups locaux : $TOTAL_LOCAL"
if [ "$NAS_OK" = "1" ]; then
    TOTAL_NAS=$(ls -1 "$NAS_MOUNT"/${HOSTNAME}_dns-backup_*.tar.gz 2>/dev/null | wc -l)
    log "Backups NAS    : $TOTAL_NAS"
fi

log "===== Fin du backup DNS ====="
exit 0

Pour le montage NFS, installez nfs-common et autorisez l'IP du serveur DNS dans les droits NFS du dossier partagé côté NAS.

Crontab root

# Sauvegarde de la configuration DNS
0 3 * * * /usr/local/bin/backup-dns.sh >/dev/null 2>&1

# Mise à jour de la blocklist OISD
0 9 * * * /usr/local/bin/update-adblock.sh >> /var/log/adblock.log 2>&1

# Mise à jour mensuelle des root hints
0 0 1 * * curl -sf -o /var/lib/unbound/root.hints.tmp https://www.internic.net/domain/named.root && mv /var/lib/unbound/root.hints.tmp /var/lib/unbound/root.hints && systemctl reload unbound

Rotation des logs

log-queries écrit une ligne par requête : sans rotation, le fichier grossit vite. /etc/logrotate.d/dnsmasq-lan :

/var/log/dnsmasq.log /var/log/adblock.log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
    postrotate
        systemctl kill -s USR2 dnsmasq.service 2>/dev/null || true
    endscript
}

Pièges courants

  • IPv6 : si votre box annonce son propre DNS IPv6 (RDNSS dans les Router Advertisements), les clients peuvent le préférer et contourner dnsmasq. Désactivez cette option dans la box, ou faites annoncer le serveur par dnsmasq (enable-ra, dhcp-range=::,constructor:eth0,ra-stateless).
  • DNS en dur sur les appareils : certains objets connectés ou navigateurs (DNS-over-HTTPS) ignorent le DNS du DHCP. Désactivez DoH dans le navigateur ou bloquez le port 53 sortant sur le routeur, sauf depuis le serveur.
  • Box qui garde son DHCP actif : symptôme typique, des appareils avec la box comme DNS. Vérifiez qu'elle est bien coupée.
  • dnsmasq ne démarre pas après reboot : avec bind-interfaces, l'interface doit exister au démarrage. Préférez bind-dynamic si l'IP arrive tard.