# AdGuard Home Module Design **Date:** 2026-03-18 **Status:** Approved ## Overview Add a NixOS module for AdGuard Home that provides private DNS-over-HTTPS (DoH) with ClientID-based access control. Only requests with recognized ClientIDs are processed; unknown requests are dropped. ## Requirements - AdGuard Home running in Podman container - DoH via nginx at `dns.ashisgreat.xyz` - ClientID-based authentication (acts as password for DNS) - ClientIDs stored as SOPS secrets - Upstream DNS: Mullvad DoH (`https://dns.mullvad.net/dns-query`) - Port 53 blocked externally (already handled by existing firewall) - No admin UI exposed externally ## Module Options ```nix myModules.adguard = { enable = lib.mkEnableOption "AdGuard Home DNS server"; domain = lib.mkOption { type = lib.types.str; example = "dns.example.com"; description = "Public domain name for DoH endpoint"; }; port = lib.mkOption { type = lib.types.port; default = 3000; description = "Internal port for AdGuard HTTP/DoH listener"; }; upstreamDoh = lib.mkOption { type = lib.types.str; default = "https://dns.mullvad.net/dns-query"; description = "Upstream DoH server URL"; }; bootstrapDns = lib.mkOption { type = lib.types.listOf (lib.types.str); default = [ "194.242.2.2" "2a07:e340::2" ]; description = "Bootstrap DNS servers for resolving DoH upstream"; }; clients = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { name = lib.mkOption { type = lib.types.str; description = "Friendly name for the client device"; }; idSecret = lib.mkOption { type = lib.types.str; description = "SOPS secret name containing the ClientID"; }; }; }); default = [ ]; description = "List of clients with their ClientID secrets"; }; }; ``` ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ VPS Host │ │ │ │ ┌─────────────┐ ┌─────────────────────────────────────┐ │ │ │ nginx │ │ adguard container │ │ │ │ (host) │ │ ┌─────────────────────────────┐ │ │ │ │ │ │ │ AdGuard Home │ │ │ │ │ dns.ashis │─────▶│ │ │ │ │ │ │ .great.xyz │:3000 │ │ - DoH server on :3000 │ │ │ │ │ │ │ │ - Validates ClientIDs │ │ │ │ │ /dns-query/ │ │ │ - Proxies to Mullvad DoH │ │ │ │ │ {clientId} │ │ └─────────────────────────────┘ │ │ │ └─────────────┘ │ ▲ │ │ │ │ │ │ read-only │ │ │ │ │ ┌──────┴──────┐ │ │ │ │ │ │AdGuardHome. │ ← generated by │ │ │ │ │ │ yaml │ Nix from SOPS │ │ │ │ │ └─────────────┘ │ │ │ │ │ │ │ │ │ │ ┌─────────────┐ │ │ │ │ │ │ data/ │ ← persistent vol │ │ │ │ │ │ (stats,logs)│ │ │ │ │ │ └─────────────┘ │ │ │ │ └─────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Firewall │ │ │ │ │ │ │ │ Block 53/udp│ ← External port 53 blocked │ │ │ Block 53/tcp│ │ │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Request Flow 1. External client sends DoH request to `https://dns.ashisgreat.xyz/dns-query/CLIENTID` - Example: `https://dns.ashisgreat.xyz/dns-query/xK9mP2vL7nQ4` 2. Nginx handles TLS termination and proxies to AdGuard container on `127.0.0.1:3000` 3. AdGuard validates the ClientID in the request path 4. Unknown ID → Connection dropped 5. Valid ID → Query resolved via Mullvad DoH upstream ## ClientID Configuration ### SOPS Secrets ClientIDs are stored encrypted in `secrets/secrets.yaml`: ```yaml adguard_client_phone: "xK9mP2vL7nQ4" adguard_client_laptop: "jH3fR8wT5yB1" ``` ### Module Configuration In `configuration.nix`: ```nix myModules.adguard = { enable = true; domain = "dns.ashisgreat.xyz"; clients = [ { name = "phone"; idSecret = "adguard_client_phone"; } { name = "laptop"; idSecret = "adguard_client_laptop"; } ]; }; # SOPS secret declarations (auto-created by module or manual) sops.secrets.adguard_client_phone = { }; sops.secrets.adguard_client_laptop = { }; ``` ## Implementation Details ### Dependencies ```nix config = lib.mkIf cfg.enable { myModules.podman.enable = true; # Container runtime ... }; ``` ### Container Definition ```nix virtualisation.oci-containers.containers."adguard" = { image = "docker.io/adguard/adguardhome:latest"; ports = [ "127.0.0.1:${toString cfg.port}:3000/tcp" ]; extraOptions = [ "--cap-drop=ALL" "--read-only" "--tmpfs=/tmp" ]; volumes = [ "${config.sops.templates."adguardhome.yaml".path}:/opt/adguardhome/conf/AdGuardHome.yaml:ro" "adguard-data:/opt/adguardhome/work" ]; }; ``` **Notes:** - Container runs with minimal capabilities (`--cap-drop=ALL`) - Config file is read-only (managed by Nix/SOPS) - `adguard-data` named volume persists stats and query logs (no host directory needed) ### SOPS Template for AdGuardHome.yaml ```nix sops.templates."adguardhome.yaml" = { content = '' http: address: 0.0.0.0:3000 dns: upstream_dns: - ${cfg.upstreamDoh} bootstrap_dns: ${lib.concatStringsSep "\n " (map (d: "- ${d}") cfg.bootstrapDns)} querylog_enabled: true querylog_file_enabled: true statistics_enabled: true clients: persistent: ${lib.concatStringsSep "\n " ( map (client: '' - name: ${client.name} ids: - ${config.sops.placeholder.${client.idSecret}} '') cfg.clients )} filtering: protection_enabled: true filtering_enabled: true safebrowsing: enabled: false parental: enabled: false safesearch: enabled: false log: file: "" max_backups: 0 max_size: 100 compress: false local_time: false verbose: false ''; }; ``` ### SOPS Secret Declarations (Auto-generated) The module automatically declares SOPS secrets for each client: ```nix sops.secrets = lib.mkMerge ( map (client: { ${client.idSecret} = { }; }) cfg.clients ); ``` ## Nginx Configuration The module configures nginx directly (not via the nginx module) because DoH requires special handling: ```nix services.nginx.virtualHosts."${cfg.domain}" = { enableACME = true; forceSSL = true; # Regex location to match /dns-query and /dns-query/{clientId} locations."~ ^/dns-query" = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; extraConfig = '' proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # DoH uses POST with application/dns-message proxy_pass_request_body on; proxy_set_header Content-Type "application/dns-message"; # Buffer settings for DNS queries proxy_buffers 8 16k; proxy_buffer_size 32k; ''; }; # Block all other paths including admin UI locations."/" = { return = "404"; }; }; # Ensure nginx user can access ACME certs users.users.nginx.extraGroups = [ "acme" ]; ``` **Security Notes:** - ClientIDs appear in URL paths and may be logged by nginx - Consider filtering nginx logs to redact ClientIDs if needed - Admin UI is completely blocked (returns 404) - No rate limiting configured (can be added if abuse occurs) ## Firewall No changes needed. Port 53 is already blocked by the existing firewall configuration since it's not in `allowedTCPPorts`. Only ports 22 (SSH), 80, and 443 (via nginx) are open. ## Files to Create/Modify | File | Action | |------|--------| | `modules/adguard.nix` | Create - new module | | `modules/default.nix` | Modify - add import | | `configuration.nix` | Modify - enable module with clients | | `secrets/secrets.yaml` | Modify - add ClientID secrets | ## Dependencies - `myModules.podman` - container runtime - `sops-nix` - secrets management - `services.nginx` - reverse proxy (shared with other modules)