{ config, pkgs, lib, ... }: # AdGuard Home - DNS resolver + optional network-wide ad blocker. # # DNS listens on port 53 (udp/tcp) directly on the public IP. # The web UI listens on 127.0.0.1:3000 and is fronted by Caddy. # # 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 = "127.0.0.1:3000"; }; dns = { bind_hosts = [ "0.0.0.0" ]; port = 53; # 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"). 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" ''}" ]; }; }