{ config, pkgs, lib, ... }: # AdGuard Home - DNS resolver + optional network-wide ad blocker. # # Plain DNS listens on port 53 (udp/tcp) directly on the public IP. # DNS-over-TLS (853/tcp) and DNS-over-HTTPS (8443/tcp, path /dns-query) are # also served directly by AdGuard itself, terminating TLS with their own # independent Let's Encrypt cert (modules/acme.nix) - NOT via Caddy, since # Caddy already owns port 443 for its own reverse-proxied vhosts and # AdGuard's HTTPS/DoH listener needs its own port. # # AdGuard's HTTPS/DoH listener binds to the same host as the plain `http` # listener below (confirmed in AdGuardHome's source: both come from # HTTPConfig.Address) - that's why `http.address` binds 0.0.0.0 rather than # 127.0.0.1 even though the plain-HTTP web UI is still meant to be reached # only via Caddy's reverse proxy: port 3000 itself is never opened in the # firewall (modules/common.nix), so it's still unreachable directly. # DNS-over-TLS reuses `dns.bind_hosts` below, so it needs no such change. # # Admin password is seeded from a sops secret containing a bcrypt hash. # To generate the hash on your local machine: # htpasswd -nB admin # Copy the hash portion (everything after "admin:") into secrets.yaml. { services.adguardhome = { enable = true; mutableSettings = false; # declarative mode - config comes from Nix only settings = { http = { address = "0.0.0.0:3000"; }; tls = { enabled = true; server_name = "dns.karanj.com"; port_https = 8443; port_dns_over_tls = 853; # DNS-over-QUIC and DNSCrypt were not requested - keep them off. port_dns_over_quic = 0; port_dnscrypt = 0; certificate_path = "/var/lib/acme/dns.karanj.com/fullchain.pem"; private_key_path = "/var/lib/acme/dns.karanj.com/key.pem"; }; dns = { bind_hosts = [ "0.0.0.0" ]; port = 53; # Clients configure this server's public IPv4 address (not # dns.karanj.com - that hostname only resolves to the HTTPS web UI # via Caddy) as their plain DNS resolver, e.g. :53. # For encrypted DNS, see the client setup notes in README.md. # Upstream DNS resolvers (privacy-respecting) upstream_dns = [ "https://dns.quad9.net/dns-query" "https://cloudflare-dns.com/dns-query" ]; bootstrap_dns = [ "9.9.9.9" "1.1.1.1" ]; enable_dnssec = true; }; # Users block: username "admin", password from sops secret. # The ExecStartPre below writes the hash into the config on every # service start, because mutableSettings=false uses a static config # file but the password hash must be injected at runtime (it contains # a secret). users = [ { name = "admin"; # Placeholder - replaced at service start by the ExecStartPre below password = "REPLACED_AT_ACTIVATION"; } ]; # Basic filtering filtering = { enabled = true; update_interval = 24; }; # Block lists filters = [ { enabled = true; url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt"; name = "AdGuard DNS filter"; id = 1; } { enabled = true; url = "https://adaway.org/hosts.txt"; name = "AdAway Default Blocklist"; id = 2; } ]; }; }; # AdGuardHome needs a static system user rather than the module's default # DynamicUser=true: sops-nix chowns the "adguard/password_hash" secret to # this user *during activation*, which happens while the service is # stopped (for a restart) - a DynamicUser only exists while its service is # actually running, so that chown would fail to resolve the user otherwise. users.users.adguardhome = { isSystemUser = true; group = "adguardhome"; }; users.groups.adguardhome = { }; # mutableSettings = false makes the module's own ExecStartPre unconditionally # `cp --force` the store-generated config (with the literal placeholder) # over $STATE_DIRECTORY/AdGuardHome.yaml on every service start. An # activation script can't win that race, so inject the real hash as a # second ExecStartPre, ordered after the module's via mkAfter, so it always # runs right before ExecStart. # # Plain bash substring substitution + `>` redirect is used instead of # `sed -i`: sed -i creates a temp file and chowns/renames it in place, and # this unit's hardened SystemCallFilter (~@privileged) blocks the chown # syscall, killing sed with SIGSYS ("Bad system call"). # Don't attempt the first start until the ACME cert referenced by # tls.certificate_path/private_key_path above actually exists. systemd.services.adguardhome.after = [ "acme-dns.karanj.com.service" ]; systemd.services.adguardhome.wants = [ "acme-dns.karanj.com.service" ]; systemd.services.adguardhome.serviceConfig = { DynamicUser = lib.mkForce false; User = "adguardhome"; Group = "adguardhome"; ExecStartPre = lib.mkAfter [ "${pkgs.writeShellScript "adguard-inject-password" '' set -eu CFG="$STATE_DIRECTORY/AdGuardHome.yaml" HASH=$(cat "${config.sops.secrets."adguard/password_hash".path}") CONTENT=$(cat "$CFG") printf '%s\n' "''${CONTENT//REPLACED_AT_ACTIVATION/$HASH}" > "$CFG" ''}" ]; }; }