Je gère mon serveur DNS avec AdGuard Home afin de limiter le pistage, la pollution publicitaire et certains blocages imposés par les fournisseurs d’accès. Je gère également les reverse proxies de mes applications auto-hébergées avec Nginx Proxy Manager. Autant relier les deux pour gagner en simplicité, en rapidité et en fiabilité.
Je rappelle que je publie mes listes de filrage, agrégées en une seule, et également HomeGuard, un serveur WiregGuard avec création de clients qui permet d’utiliser son résolveur personnel auto-hébergé.
Exemple : j’héberge un serveur Plex sur la machine 192.168.0.139 et je lui attribue le domaine plex.moi.fr dans Nginx Proxy Manager.
Sans DNS local, mon client Plex installé sur la Nvidia Shield résout plex.moi.fr via le DNS public. Comme le domaine est proxifié par Cloudflare (par habitude), la connexion sort de mon réseau, passe par Cloudflare, revient vers Nginx Proxy Manager, puis est transmise à Plex.
En ajoutant une réécriture DNS locale dans AdGuard Home, plex.moi.fr ne renvoie plus vers Cloudflare pour les appareils du réseau local. AdGuard Home renvoie directement l’adresse privée de Nginx Proxy Manager. Celui-ci reçoit la requête HTTPS, gère le certificat, puis la transmet au serveur Plex sur 192.168.0.139:32400. Le flux reste ainsi entièrement sur le réseau local, tout en conservant la même adresse https://plex.moi.fr à l’intérieur comme à l’extérieur du domicile.
C’est du split DNS, qui permet à un même nom de domaine de renvoyer vers une adresse différente selon l’endroit d’où il est interrogé. Sur le réseau local, plex.moi.fr pointe directement vers Nginx Proxy Manager, tandis que si je l’interroge hors LAN/WiFi, il continue de passer par le DNS public et Cloudflare.
Pour automatiser tout ça, voici un script Python et le .env de configurtation. A faire tourner en cron ou à lancer dès qu’on ajoute des reverses à NPM.
npm_to_adguard.py
#!/usr/bin/env python3
"""
Synchronise les Proxy Hosts de Nginx Proxy Manager vers les réécritures DNS
d'AdGuard Home.
- Lit les domain_names dans NPM.
- Ajoute chaque domaine dans AdGuard Home vers DNS_TARGET.
- Met à jour les entrées précédemment gérées par ce script.
- Supprime les anciennes entrées gérées lorsque le Proxy Host disparaît de NPM.
- Ne touche pas aux autres réécritures AdGuard Home.
- Aucune dépendance Python externe : bibliothèque standard uniquement.
Variables obligatoires :
NPM_URL
NPM_IDENTITY
NPM_SECRET
ADGUARD_URL
ADGUARD_USER
ADGUARD_PASSWORD
DNS_TARGET
Exemple :
NPM_URL=http://192.168.0.196:81
ADGUARD_URL=http://192.168.0.64:3000
DNS_TARGET=192.168.0.196
"""
from __future__ import annotations
import argparse
import base64
import json
import logging
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
LOG = logging.getLogger("npm-to-adguard")
class SyncError(RuntimeError):
"""Erreur contrôlée de synchronisation."""
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "oui"}
def normalize_url(value: str) -> str:
return value.strip().rstrip("/")
def normalize_domain(value: str) -> str:
domain = value.strip().lower().rstrip(".")
if domain.startswith("http://") or domain.startswith("https://"):
parsed = urllib.parse.urlparse(domain)
domain = parsed.hostname or ""
return domain
@dataclass(frozen=True)
class Config:
npm_url: str
npm_identity: str
npm_secret: str
adguard_url: str
adguard_user: str
adguard_password: str
dns_target: str
state_file: Path
verify_tls: bool
include_disabled: bool
domain_suffix: str | None
take_over_existing: bool
timeout: int
class HttpClient:
def __init__(self, verify_tls: bool, timeout: int) -> None:
self.timeout = timeout
self.ssl_context = ssl.create_default_context()
if not verify_tls:
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
def request(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
payload: Any | None = None,
) -> Any:
req_headers = {
"Accept": "application/json",
"User-Agent": "npm-to-adguard/1.0",
}
if headers:
req_headers.update(headers)
body = None
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req_headers["Content-Type"] = "application/json"
request = urllib.request.Request(
url=url,
data=body,
headers=req_headers,
method=method,
)
try:
with urllib.request.urlopen(
request,
timeout=self.timeout,
context=self.ssl_context,
) as response:
raw = response.read()
if not raw:
return None
content_type = response.headers.get("Content-Type", "")
if "json" in content_type or raw[:1] in (b"{", b"["):
return json.loads(raw.decode("utf-8"))
return raw.decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
response_body = exc.read().decode("utf-8", errors="replace")
raise SyncError(
f"{method} {url} a répondu HTTP {exc.code}: {response_body}"
) from exc
except urllib.error.URLError as exc:
raise SyncError(f"Impossible de joindre {url}: {exc.reason}") from exc
except json.JSONDecodeError as exc:
raise SyncError(f"Réponse JSON invalide depuis {url}") from exc
class NpmClient:
def __init__(self, config: Config, http: HttpClient) -> None:
self.config = config
self.http = http
self.token: str | None = None
def authenticate(self) -> None:
response = self.http.request(
"POST",
f"{self.config.npm_url}/api/tokens",
payload={
"identity": self.config.npm_identity,
"secret": self.config.npm_secret,
"expiry": "1h",
},
)
if not isinstance(response, dict) or not response.get("token"):
raise SyncError("NPM n'a pas renvoyé de jeton d'authentification.")
self.token = str(response["token"])
def get_proxy_hosts(self) -> list[dict[str, Any]]:
if not self.token:
self.authenticate()
response = self.http.request(
"GET",
f"{self.config.npm_url}/api/nginx/proxy-hosts",
headers={"Authorization": f"Bearer {self.token}"},
)
if not isinstance(response, list):
raise SyncError(
"La réponse NPM pour les Proxy Hosts n'est pas une liste."
)
return [item for item in response if isinstance(item, dict)]
def get_domains(self) -> set[str]:
domains: set[str] = set()
for host in self.get_proxy_hosts():
enabled = bool(host.get("enabled", True))
if not enabled and not self.config.include_disabled:
continue
raw_domains = host.get("domain_names", [])
if not isinstance(raw_domains, list):
continue
for raw_domain in raw_domains:
if not isinstance(raw_domain, str):
continue
domain = normalize_domain(raw_domain)
if not domain or " " in domain or "/" in domain:
LOG.warning("Domaine NPM ignoré car invalide : %r", raw_domain)
continue
suffix = self.config.domain_suffix
if suffix:
suffix = normalize_domain(suffix)
plain_domain = domain[2:] if domain.startswith("*.") else domain
if plain_domain != suffix and not plain_domain.endswith("." + suffix):
LOG.debug(
"Domaine ignoré, hors du suffixe %s : %s",
suffix,
domain,
)
continue
domains.add(domain)
return domains
class AdGuardClient:
def __init__(self, config: Config, http: HttpClient) -> None:
self.config = config
self.http = http
credentials = (
f"{config.adguard_user}:{config.adguard_password}".encode("utf-8")
)
encoded = base64.b64encode(credentials).decode("ascii")
self.headers = {"Authorization": f"Basic {encoded}"}
def list_rewrites(self) -> list[dict[str, Any]]:
response = self.http.request(
"GET",
f"{self.config.adguard_url}/control/rewrite/list",
headers=self.headers,
)
if not isinstance(response, list):
raise SyncError(
"La réponse AdGuard Home pour les réécritures n'est pas une liste."
)
return [item for item in response if isinstance(item, dict)]
def add_rewrite(self, domain: str, answer: str, *, dry_run: bool) -> None:
LOG.info("AJOUT %s -> %s", domain, answer)
if dry_run:
return
self.http.request(
"POST",
f"{self.config.adguard_url}/control/rewrite/add",
headers=self.headers,
payload={"domain": domain, "answer": answer, "enabled": True},
)
def delete_rewrite(self, domain: str, answer: str, *, dry_run: bool) -> None:
LOG.info("SUPPR. %s -> %s", domain, answer)
if dry_run:
return
self.http.request(
"POST",
f"{self.config.adguard_url}/control/rewrite/delete",
headers=self.headers,
payload={"domain": domain, "answer": answer},
)
def load_state(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SyncError(f"Impossible de lire le fichier d'état {path}: {exc}") from exc
if not isinstance(data, dict):
raise SyncError(f"Format invalide dans le fichier d'état {path}")
managed = data.get("managed", {})
if not isinstance(managed, dict):
raise SyncError(f"Champ 'managed' invalide dans {path}")
return {
normalize_domain(str(domain)): str(answer)
for domain, answer in managed.items()
if normalize_domain(str(domain))
}
def save_state(path: Path, managed: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(
{
"version": 1,
"managed": dict(sorted(managed.items())),
},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(path)
def index_rewrites(
rewrites: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
indexed: dict[str, list[dict[str, Any]]] = {}
for rewrite in rewrites:
domain = normalize_domain(str(rewrite.get("domain", "")))
answer = str(rewrite.get("answer", "")).strip()
if not domain or not answer:
continue
indexed.setdefault(domain, []).append(
{
"domain": domain,
"answer": answer,
"enabled": bool(rewrite.get("enabled", True)),
}
)
return indexed
def sync(config: Config, *, dry_run: bool) -> int:
http = HttpClient(config.verify_tls, config.timeout)
npm = NpmClient(config, http)
adguard = AdGuardClient(config, http)
desired_domains = npm.get_domains()
old_managed = load_state(config.state_file)
current_rewrites = index_rewrites(adguard.list_rewrites())
LOG.info("%d domaine(s) actif(s) trouvé(s) dans NPM.", len(desired_domains))
LOG.info("%d domaine(s) précédemment géré(s).", len(old_managed))
new_managed: dict[str, str] = {}
conflicts = 0
changes = 0
# Création et mise à jour.
for domain in sorted(desired_domains):
desired_answer = config.dns_target
existing = current_rewrites.get(domain, [])
exact = next(
(
item
for item in existing
if item["answer"] == desired_answer and item["enabled"]
),
None,
)
if exact:
LOG.debug("OK %s -> %s", domain, desired_answer)
new_managed[domain] = desired_answer
continue
was_managed = domain in old_managed
may_take_over = was_managed or config.take_over_existing
if existing and not may_take_over:
answers = ", ".join(sorted({item["answer"] for item in existing}))
LOG.error(
"CONFLIT %s existe déjà dans AdGuard Home vers [%s]. "
"Utilise --take-over pour le remplacer.",
domain,
answers,
)
conflicts += 1
continue
if existing and may_take_over:
for item in existing:
adguard.delete_rewrite(
domain,
str(item["answer"]),
dry_run=dry_run,
)
changes += 1
adguard.add_rewrite(domain, desired_answer, dry_run=dry_run)
changes += 1
new_managed[domain] = desired_answer
# Suppression des entrées qui étaient gérées mais ne sont plus dans NPM.
stale_domains = set(old_managed) - desired_domains
for domain in sorted(stale_domains):
old_answer = old_managed[domain]
matching = [
item
for item in current_rewrites.get(domain, [])
if item["answer"] == old_answer
]
for item in matching:
adguard.delete_rewrite(
domain,
str(item["answer"]),
dry_run=dry_run,
)
changes += 1
if dry_run:
LOG.info(
"Simulation terminée : %d changement(s), %d conflit(s).",
changes,
conflicts,
)
else:
save_state(config.state_file, new_managed)
LOG.info(
"Synchronisation terminée : %d changement(s), %d conflit(s).",
changes,
conflicts,
)
return 2 if conflicts else 0
def require_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise SyncError(f"Variable d'environnement obligatoire absente : {name}")
return value
def build_config(args: argparse.Namespace) -> Config:
domain_suffix = os.getenv("DOMAIN_SUFFIX", "").strip() or None
return Config(
npm_url=normalize_url(require_env("NPM_URL")),
npm_identity=require_env("NPM_IDENTITY"),
npm_secret=require_env("NPM_SECRET"),
adguard_url=normalize_url(require_env("ADGUARD_URL")),
adguard_user=require_env("ADGUARD_USER"),
adguard_password=require_env("ADGUARD_PASSWORD"),
dns_target=require_env("DNS_TARGET"),
state_file=Path(
os.getenv(
"STATE_FILE",
str(Path(__file__).with_name("npm-to-adguard-state.json")),
)
),
verify_tls=not (args.insecure or env_bool("INSECURE_TLS", False)),
include_disabled=(
args.include_disabled or env_bool("INCLUDE_DISABLED", False)
),
domain_suffix=domain_suffix,
take_over_existing=(
args.take_over or env_bool("TAKE_OVER_EXISTING", False)
),
timeout=int(os.getenv("HTTP_TIMEOUT", "20")),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Synchronise les domaines NPM dans AdGuard Home."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Affiche les changements sans modifier AdGuard Home.",
)
parser.add_argument(
"--take-over",
action="store_true",
help="Remplace aussi les réécritures existantes non créées par le script.",
)
parser.add_argument(
"--include-disabled",
action="store_true",
help="Inclut les Proxy Hosts désactivés dans NPM.",
)
parser.add_argument(
"--insecure",
action="store_true",
help="Désactive la vérification TLS pour les interfaces HTTPS locales.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Active les journaux détaillés.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
try:
config = build_config(args)
return sync(config, dry_run=args.dry_run)
except SyncError as exc:
LOG.error("%s", exc)
return 1
except KeyboardInterrupt:
LOG.error("Interrompu.")
return 130
except Exception:
LOG.exception("Erreur inattendue.")
return 1
if __name__ == "__main__":
sys.exit(main())
Et exemple de .env
# URL de l'interface/API de Nginx Proxy Manager
# Le plus souvent : http://IP_DU_SERVEUR:81
NPM_URL=http://192.168.0.196:817
[email protected]
NPM_SECRET=xxx
# URL de l'interface d'AdGuard Home
# Adapte le port si son interface est publiée ailleurs.
ADGUARD_URL=http://192.168.0.64
ADGUARD_USER=xxx
ADGUARD_PASSWORD=xxx
# Adresse IP locale vers laquelle tous les domaines NPM doivent pointer.
# C'est l'IP LAN du serveur qui publie les ports 80/443 de NPM.
DNS_TARGET=192.168.0.196
# Facultatif : ne synchroniser que ce domaine et ses sous-domaines.
# DOMAIN_SUFFIX=upandclear.org
# Emplacement persistant utilisé pour savoir quelles entrées appartiennent au script.
STATE_FILE=/home/aerya/npm-to-adguard/npm-to-adguard-state.json
# Facultatif
HTTP_TIMEOUT=20
INSECURE_TLS=false
INCLUDE_DISABLED=false
TAKE_OVER_EXISTING=false
Il faut forcer l’utilisation du .env à l’exécution du script Python
set -a
source /home/aerya/npm-to-adguard/.env
set +a
python3 /home/aerya/npm-to-adguard/npm_to_adguard.py
# ajouter --dry-run --verbose pour seulement tester
Ce qui donne par exemple
root@LincStation:/home/aerya# set -a
source /home/aerya/npm-to-adguard/.env
set +a
python3 /home/aerya/npm-to-adguard/npm_to_adguard.py --dry-run --verbose
2026-07-26 18:40:20,574 [DEBUG] Domaine ignoré, hors du suffixe domain.tld : cmtnet.upandclear.org
2026-07-26 18:40:20,651 [INFO] 24 domaine(s) actif(s) trouvé(s) dans NPM.
2026-07-26 18:40:20,651 [INFO] 0 domaine(s) précédemment géré(s).
2026-07-26 18:40:20,651 [DEBUG] OK 2fa.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK companion.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK hoarder.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK jelly.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK netronome.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK nzbdav.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK orchestrator.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,651 [DEBUG] OK prowlarr.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK recherche.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-aiolists.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-aiomanager.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-aiometadata.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-aiostreams.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-comet.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-nexotv.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-postersplus.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-rss-catalog.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-seerrcatalog.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-streamfusion.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-streamnzb.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-wastream.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-watchly.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK stremio-sfr.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [DEBUG] OK vaultward3n.domain.tld -> 192.168.0.196
2026-07-26 18:40:20,652 [INFO] Simulation terminée : 0 changement(s), 0 conflit(s).
Lancé sans dry run, sous AdGuard Home on voit les entrées ajoutées sur /#dns_rewrites (Filtres / Réécritures DNS).
Voici un exemple de cron pour que le script s’exécute chaque 24h, à 3h du matin
0 3 * * * set -a; . /home/aerya/npm-to-adguard/.env; set +a; /usr/bin/python3 /home/aerya/npm-to-adguard/npm_to_adguard.py >> /home/aerya/npm-to-adguard/sync.log 2>&1
Attention si vous utilisez un VPN/proxy via Gluetun qui utilise (logiquement) votre instance AdGuard Home en résolveur DNS : par défaut Gluetun bloque le DNS rebiding (la réécriture DNS, ce qu’on fait), il faut donc l’autoriser explicitement dans l’environnement du compose avec DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES: "*.domain.tld"
Exemple
services:
gluetun-airvpn:
image: qmcgaw/gluetun:latest
container_name: gluetun-airvpn
[...]
environment:
VPN_SERVICE_PROVIDER: airvpn
[...]
DNS_UPSTREAM_PLAIN_ADDRESSES: 192.168.0.64:53
DNS_REBINDING_PROTECTION_EXEMPT_HOSTNAMES: "*.mondomaine.com"
[...]
Si vous utilisez Pi-Hole, vous pouvez tester https://github.com/CypriotUnknown/npm-to-pihole
En bonus, je vais voir pour intégrer PlugNPiN, en option, à Dockge-Enhanced.
Salut!
J’avais essayé il y a quelques temps pour faire pointer mon réseau VPN vers mes ip locales selon le même principe.
Résultat, j’étais tout le temps obligé de vider mes caches si je passais du local DNS de pihole via le split dns de Tailscale au réseau public.
Bref, j’ai fini par lâcher l’affaire.
En navigation privée, ça marchait à tous les coups. En session normale, toujours des problèmes.
Tu constates pas de dysfonctionnements ?
Hello,
Non, je ne rencontre pas ce souci chez moi vu que mon navigateur, certes passe entièrement par un VPN, mais utilise toujours mon instance AdGuard Home comme résolveur DNS, comme le reste de mon réseau hors VPN. Je ne bascule donc pas entre deux résolveurs différents, ce qui explique peut-être la différence avec ton installation.