Le problème du hairpin NAT
Vous avez un domaine — disons mondomaine.ovh — avec un wildcard DNS chez votre registrar qui pointe vers votre IP publique. Depuis l’extérieur, tout fonctionne : le trafic arrive sur votre box, est redirigé vers votre reverse proxy (Traefik), et atteint le bon service.
Mais depuis le LAN, ça coince. Voici ce qui se passe :
- Votre PC demande l’IP de
grafana.mondomaine.ovh - Le DNS public répond avec votre IP publique (
82.x.x.x) - Le PC envoie la requête HTTP à cette IP publique
- La box reçoit le paquet de l’intérieur et devrait le rediriger vers l’intérieur — c’est le hairpin NAT
- Selon le modèle de routeur, ça marche… ou pas
Chez certains FAI (Freebox en mode routeur, par exemple), le hairpin NAT fonctionne. Chez d’autres, non. Et même quand ça marche, c’est un détour inutile : le paquet sort sur Internet pour revenir 2 mètres plus loin sur le même réseau local.
La solution : le split-horizon DNS
Le principe est élégant : fournir une réponse DNS différente selon l’origine de la requête.
- Depuis Internet :
app.mondomaine.ovh→ IP publique (82.x.x.x) - Depuis le LAN :
app.mondomaine.ovh→ IP locale du reverse proxy (192.168.1.52)
Résultat : le trafic LAN ne sort jamais vers Internet. Il va directement au reverse proxy sur le réseau local. Plus rapide, plus fiable, et indépendant du support hairpin NAT de la box.
Architecture DNS : AdGuard Home + Unbound
Mon architecture DNS va plus loin qu’un simple split-horizon. Elle combine deux composants dans un LXC dédié :
Client LAN ──→ AdGuard Home (port 53)
│
├─ Réécritures locales (split-horizon)
├─ Filtrage publicitaire (listes de blocage)
│
└──→ Unbound (port 5335)
│
├─ Résolution récursive (pas de forwarder)
├─ Validation DNSSEC
└─ Cache DNS (TTL 60s-24h)
Pourquoi Unbound plutôt qu’un forwarder ?
La plupart des guides configurent AdGuard Home avec un upstream DNS public (Cloudflare 1.1.1.1, Google 8.8.8.8). Ça fonctionne, mais ça envoie toutes vos requêtes DNS à un tiers.
Avec Unbound en résolveur récursif, les requêtes vont directement aux serveurs racine et aux serveurs autoritaires des domaines. Personne ne voit l’ensemble de vos requêtes DNS. C’est le mode de fonctionnement le plus respectueux de la vie privée.
De plus, Unbound valide les signatures DNSSEC, ce qui protège contre le DNS spoofing.
Pourquoi pas Unbound seul ?
Unbound fait la résolution, mais il n’a pas d’interface de gestion ni de filtrage publicitaire. AdGuard Home apporte :
- L’interface web pour gérer les réécritures
- Le filtrage pub/malware via des listes (style Pi-hole)
- Les statistiques de requêtes DNS
- La gestion fine des clients (bypass pour certains appareils)
Chacun fait ce qu’il fait le mieux.
Mise en place
1. LXC dédié
Le DNS est un service critique — s’il tombe, plus rien ne fonctionne. Il mérite son propre conteneur LXC avec une IP fixe :
pct create 101 local:vztmpl/debian-13-standard_13.3-1_amd64.tar.zst \
--hostname adguard \
--unprivileged 1 \
--features nesting=1 \
--cores 2 \
--memory 1024 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.54/24,gw=192.168.1.1
Les deux composants tournent dans des conteneurs Docker sur ce LXC, connectés par un réseau Docker dédié :
networks:
dns:
ipam:
config:
- subnet: 172.20.0.0/24
2. Configurer Unbound
Unbound est configuré en résolveur récursif avec validation DNSSEC et quelques optimisations de sécurité :
server:
verbosity: 0
port: 5335
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
# Sécurité
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
# Confidentialité
qname-minimisation: yes
# Cache
prefetch: yes
cache-min-ttl: 60
cache-max-ttl: 86400
# DNSSEC
auto-trust-anchor-file: "/opt/unbound/etc/unbound/root.key"
# Accès
access-control: 127.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
access-control: 10.0.0.0/8 allow
Points importants :
hide-identity/hide-version: empêche les requêtesversion.bind/hostname.bindde révéler des infos sur le serveurharden-glue: protège contre les attaques par injection de glue recordsqname-minimisation: n’envoie au serveur autoritaire que le minimum nécessaire (RFC 7816)prefetch: renouvelle le cache avant expiration pour les domaines fréquents
On peut aussi déclarer des enregistrements locaux directement dans Unbound (fichiers a-records.conf et srv-records.conf) pour les services internes qui ne passent pas par AdGuard.
3. Configurer AdGuard Home
Dans l’interface d’AdGuard → Paramètres → Paramètres DNS :
- Upstream DNS :
172.20.0.2:5335(Unbound sur le réseau Dockerdns) - Pas de fallback vers des DNS publics — si Unbound tombe, on veut le savoir
4. Configurer le split-horizon
Dans Filtres → Réécritures DNS :
| Domaine | Réponse |
|---|---|
*.mondomaine.ovh | 192.168.1.52 |
Le wildcard * couvre tous les sous-domaines. 192.168.1.52 est l’IP de la VM qui héberge Traefik.
Une seule règle suffit pour tous les services. C’est la beauté du wildcard : chaque nouveau sous-domaine est automatiquement résolu vers le reverse proxy, sans toucher à la configuration DNS.
5. Exceptions au wildcard
Certains services ne sont pas derrière le reverse proxy local. Il faut des réécritures spécifiques qui prennent priorité sur le wildcard :
| Domaine | Réponse | Raison |
|---|---|---|
derp-paris.mondomaine.ovh | 158.x.x.x | Serveur DERP sur VPS externe |
s3.mondomaine.ovh | 192.168.1.199 | Garage S3 (LXC dédié) |
vpn.mondomaine.ovh | 192.168.1.139 | Headscale (LXC dédié) |
AdGuard applique la première règle qui correspond, du plus spécifique au plus général. Les réécritures explicites ont toujours priorité sur le wildcard.
6. Configurer les clients
Deux options :
Option A — DHCP : configurer le serveur DHCP de votre box pour distribuer 192.168.1.54 comme DNS primaire. Tous les appareils du réseau l’utiliseront automatiquement.
Option B — Manuelle : sur chaque machine, pointer le DNS vers 192.168.1.54. Plus de contrôle, mais plus de maintenance.
Recommandation : DHCP pour les appareils grand public (smartphones, TV, tablettes), configuration manuelle pour les serveurs (IPs fixes de toute façon).
DNS de fallback
Un seul serveur DNS, c’est un SPOF (Single Point of Failure). Si le LXC AdGuard tombe, plus aucun appareil ne peut résoudre de domaine.
Ma solution : une instance AdGuard réplica sur un autre matériel. Elle tourne sur une VM distincte avec la même configuration de réécritures. Le DHCP distribue les deux DNS :
- DNS primaire :
192.168.1.54(LXC AdGuard) - DNS secondaire :
192.168.1.8(réplica sur autre VM)
Si le primaire tombe, les clients basculent automatiquement sur le secondaire. La synchronisation des réécritures entre les deux instances se fait manuellement (c’est suffisant pour un homelab — les réécritures ne changent pas souvent).
Vérification
# Depuis le LAN — doit retourner l'IP locale
nslookup grafana.mondomaine.ovh 192.168.1.54
# → 192.168.1.52 ✅
# Depuis Internet — retourne l'IP publique
nslookup grafana.mondomaine.ovh 1.1.1.1
# → 82.x.x.x ✅
# Vérifier que DNSSEC fonctionne
dig @192.168.1.54 cloudflare.com +dnssec
# → flags: ad (Authenticated Data) ✅
# Vérifier qu'un domaine signé invalide est rejeté
dig @192.168.1.54 dnssec-failed.org
# → SERVFAIL ✅ (la signature est invalide, Unbound refuse)
Le piège mortel : le search domain sur les VMs Docker
C’est le bug qui m’a coûté le plus de temps à diagnostiquer. Plusieurs heures à chercher pourquoi Traefik retournait des 502 Bad Gateway de façon intermittente.
Le symptôme
Traefik ne parvient pas à joindre les conteneurs backend. Les logs montrent des connexions refusées ou des timeouts sur des services qui fonctionnent parfaitement en accès direct.
La cause
La VM Docker avait un search domain configuré dans netplan :
# ❌ Configuration PROBLÉMATIQUE
nameservers:
addresses:
- 192.168.1.54
search:
- intra.mondomaine.ovh # ← LE COUPABLE
Voici ce qui se passe :
- Traefik veut joindre le conteneur
immich-server(nom Docker interne) - Le DNS interne Docker (127.0.0.11) reçoit la requête
- Il ne trouve pas
immich-serveren interne, et ajoute le search domain :immich-server.intra.mondomaine.ovh - La requête arrive à AdGuard, qui matche le wildcard
*.mondomaine.ovh - AdGuard retourne
192.168.1.52— l’IP de la VM hôte, pas du conteneur - Traefik essaie de se connecter à son propre hôte sur le port du backend → 502
Le diagnostic
# Vérifier depuis l'intérieur d'un conteneur
docker exec traefik cat /etc/resolv.conf
# Si vous voyez "search intra.mondomaine.ovh" → problème !
docker exec traefik nslookup immich-server
# Si la réponse est 192.168.1.52 au lieu de 172.18.0.x → confirmé
La solution
Ne JAMAIS mettre de search domain sur une VM qui héberge Docker :
# ✅ Configuration CORRECTE
network:
version: 2
ethernets:
ens18:
addresses:
- 192.168.1.52/24
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses:
- 192.168.1.54
- 192.168.1.8
search: [] # ← TOUJOURS VIDE sur VM Docker
Cette règle est maintenant gravée dans mes conventions d’infrastructure. Chaque nouvelle VM Docker est vérifiée systématiquement.
Statistiques DNS
Un bénéfice collatéral d’AdGuard Home : les statistiques de requêtes DNS. Sur mon réseau, le dashboard montre :
- ~30 000 requêtes DNS par jour (tous appareils confondus)
- ~35% de requêtes bloquées (pub, tracking, télémétrie)
- Les domaines les plus bloqués : télémétrie Microsoft/Apple, trackers publicitaires, analytics
- Temps de réponse moyen : < 1 ms pour le cache, 30-50 ms pour les requêtes récursives
Le filtrage pub au niveau DNS est plus efficace qu’un ad-blocker navigateur car il couvre tous les appareils du réseau — y compris les smartphones, TV connectées et objets IoT qui envoient de la télémétrie sans demander.
Conclusion
Le couple AdGuard Home + Unbound est la brique DNS idéale pour un homelab :
- Split-horizon en une seule règle wildcard
- Filtrage pub/tracking pour tout le réseau
- DNSSEC pour l’intégrité des réponses
- Résolution récursive sans dépendance à un DNS tiers
- Statistiques pour comprendre ce qui se passe sur son réseau
L’investissement est minimal (un LXC avec 1 Go de RAM), et le retour est immédiat. C’est la première brique que je mettrais en place sur un nouveau homelab.