aboutsummaryrefslogtreecommitdiff
path: root/modules/adguard.nix
blob: fc012786f14db9fc9cff632ed2469544058ff9d0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
{ 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;
      };

      # Query log retention. `interval` accepts a duration string between
      # "1h" and "8760h" (1 year) - trimmed down from AdGuard's 90-day
      # default to 1 hour.
      querylog = {
        enabled = true;
        interval = "1h";
      };

      # 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"
      ''}"
    ];
  };
}