Automatiser les DNS locaux de Nginx Proxy Manager dans AdGuard Home

Automatiser les DNS locaux de Nginx Proxy Manager dans AdGuard Home

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.

S’abonner
Notification pour
guest
2 Commentaires
Le plus ancien
Le plus récent Le plus populaire
groovy-bouby
groovy-bouby
Invité
27/07/2026 21h50

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 ?