{ config, pkgs, lib, ... }: # AdGuard Home - DNS resolver + optional network-wide ad blocker. # # Plain DNS (port 53, udp/tcp) is disabled (dns.port = 0 below): CERT-Bund/BSI # flagged this host as an open DNS resolver, abusable for UDP reflection/ # amplification DDoS since a spoofed source IP gets an amplified response with # no handshake required. DNS-over-TLS (853/tcp) and DNS-over-HTTPS (8443/tcp, # path /dns-query) are unaffected by this - both require a real TLS handshake # with the real client IP, so they aren't spoofable the same way - and remain # the only supported way to use this resolver. They're 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 = 0 disables the plain UDP/TCP:53 listener entirely; it does # not affect the DoT/DoH listeners below, which are configured # separately under `tls` and stay on. Clients use DoT/DoH exclusively # - see the client setup notes in README.md. port = 0; # 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" ''}" ]; }; }