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