1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with {
9 inherit (lib)
10 elemAt
11 getExe
12 hasAttrByPath
13 mkEnableOption
14 mkIf
15 mkOption
16 strings
17 types
18 ;
19};
20
21let
22 mkDefaults = lib.mapAttrsRecursive (n: v: lib.mkDefault v);
23
24 cfg = config.services.pihole-ftl;
25
26 piholeScript = pkgs.writeScriptBin "pihole" ''
27 sudo=exec
28 if [[ "$USER" != '${cfg.user}' ]]; then
29 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user}'
30 fi
31 $sudo ${getExe cfg.piholePackage} "$@"
32 '';
33
34 settingsFormat = pkgs.formats.toml { };
35 settingsFile = settingsFormat.generate "pihole.toml" cfg.settings;
36in
37{
38 options.services.pihole-ftl = {
39 enable = mkEnableOption "Pi-hole FTL";
40
41 package = lib.mkPackageOption pkgs "pihole-ftl" { };
42 piholePackage = lib.mkPackageOption pkgs "pihole" { };
43
44 privacyLevel = mkOption {
45 type = types.numbers.between 0 3;
46 description = ''
47 Level of detail in generated statistics. 0 enables full statistics, 3
48 shows only anonymous statistics.
49
50 See [the documentation](https://docs.pi-hole.net/ftldns/privacylevels).
51
52 Also see services.dnsmasq.settings.log-queries to completely disable
53 query logging.
54 '';
55 default = 0;
56 example = 3;
57 };
58
59 openFirewallDNS = mkOption {
60 type = types.bool;
61 default = false;
62 description = "Open ports in the firewall for pihole-FTL's DNS server.";
63 };
64
65 openFirewallDHCP = mkOption {
66 type = types.bool;
67 default = false;
68 description = "Open ports in the firewall for pihole-FTL's DHCP server.";
69 };
70
71 openFirewallWebserver = mkOption {
72 type = types.bool;
73 default = false;
74 description = ''
75 Open ports in the firewall for pihole-FTL's webserver, as configured in `settings.webserver.port`.
76 '';
77 };
78
79 configDirectory = mkOption {
80 type = types.path;
81 default = "/etc/pihole";
82 internal = true;
83 readOnly = true;
84 description = ''
85 Path for pihole configuration.
86 pihole does not currently support any path other than /etc/pihole.
87 '';
88 };
89
90 stateDirectory = mkOption {
91 type = types.path;
92 default = "/var/lib/pihole";
93 description = ''
94 Path for pihole state files.
95 '';
96 };
97
98 logDirectory = mkOption {
99 type = types.path;
100 default = "/var/log/pihole";
101 description = "Path for Pi-hole log files";
102 };
103
104 settings = mkOption {
105 type = settingsFormat.type;
106 description = ''
107 Configuration options for pihole.toml.
108 See the upstream [documentation](https://docs.pi-hole.net/ftldns/configfile).
109 '';
110 };
111
112 useDnsmasqConfig = mkOption {
113 type = types.bool;
114 default = false;
115 description = ''
116 Import options defined in [](#opt-services.dnsmasq.settings) via
117 misc.dnsmasq_lines in Pi-hole's config.
118 '';
119 };
120
121 macvendorURL = mkOption {
122 type = types.str;
123 default = "https://ftl.pi-hole.net/macvendor.db";
124 description = ''
125 URL from which to download the macvendor.db file.
126 '';
127 };
128
129 pihole = mkOption {
130 type = types.package;
131 default = piholeScript;
132 internal = true;
133 description = "Pi-hole admin script";
134 };
135
136 lists =
137 let
138 adlistType = types.submodule {
139 options = {
140 url = mkOption {
141 type = types.str;
142 description = "URL of the domain list";
143 };
144 type = mkOption {
145 type = types.enum [
146 "allow"
147 "block"
148 ];
149 default = "block";
150 description = "Whether domains on this list should be explicitly allowed, or blocked";
151 };
152 enabled = mkOption {
153 type = types.bool;
154 default = true;
155 description = "Whether this list is enabled";
156 };
157 description = mkOption {
158 type = types.str;
159 description = "Description of the list";
160 default = "";
161 };
162 };
163 };
164 in
165 mkOption {
166 type = with types; listOf adlistType;
167 description = "Deny (or allow) domain lists to use";
168 default = [ ];
169 example = [
170 {
171 url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts";
172 }
173 ];
174 };
175
176 user = mkOption {
177 type = types.str;
178 default = "pihole";
179 description = "User to run the service as.";
180 };
181
182 group = mkOption {
183 type = types.str;
184 default = "pihole";
185 description = "Group to run the service as.";
186 };
187
188 queryLogDeleter = {
189 enable = mkEnableOption ("Pi-hole FTL DNS query log deleter");
190
191 age = mkOption {
192 type = types.int;
193 default = 90;
194 description = ''
195 Delete DNS query logs older than this many days, if
196 [](#opt-services.pihole-ftl.queryLogDeleter.enable) is on.
197 '';
198 };
199
200 interval = mkOption {
201 type = types.str;
202 default = "weekly";
203 description = ''
204 How often the query log deleter is run. See systemd.time(7) for more
205 information about the format.
206 '';
207 };
208 };
209
210 webserverEnabled = mkOption {
211 type = types.bool;
212 default = (
213 (hasAttrByPath [ "webserver" "port" ] cfg.settings)
214 && !builtins.elem cfg.settings.webserver.port [
215 ""
216 null
217 ]
218 );
219 internal = true;
220 description = "Whether the webserver is enabled.";
221 };
222 };
223
224 config = mkIf cfg.enable {
225 assertions = [
226 {
227 assertion = !config.services.dnsmasq.enable;
228 message = "pihole-ftl conflicts with dnsmasq. Please disable one of them.";
229 }
230
231 {
232 assertion = builtins.length cfg.lists == 0 || cfg.webserverEnabled;
233 message = ''
234 The Pi-hole webserver must be enabled for lists set in services.pihole-ftl.lists to be automatically loaded on startup via the web API.
235 services.pihole-ftl.settings.port must be defined, e.g. by enabling services.pihole-web.enable and defining services.pihole-web.port.
236 '';
237 }
238
239 {
240 assertion =
241 builtins.length cfg.lists == 0
242 || !(hasAttrByPath [ "webserver" "api" "cli_pw" ] cfg.settings)
243 || cfg.settings.webserver.api.cli_pw == true;
244 message = ''
245 services.pihole-ftl.settings.webserver.api.cli_pw must be true for lists set in services.pihole-ftl.lists to be automatically loaded on startup.
246 This enables an ephemeral password used by the pihole command.
247 '';
248 }
249 ];
250
251 services.pihole-ftl.settings = lib.mkMerge [
252 # Defaults
253 (mkDefaults {
254 misc.readOnly = true; # Prevent config changes via API or CLI by default
255 webserver.port = ""; # Disable the webserver by default
256 misc.privacylevel = cfg.privacyLevel;
257 })
258
259 # Move state files to cfg.stateDirectory
260 {
261 # TODO: Pi-hole currently hardcodes dhcp-leasefile this in its
262 # generated dnsmasq.conf, and we can't override it
263 misc.dnsmasq_lines = [
264 # "dhcp-leasefile=${cfg.stateDirectory}/dhcp.leases"
265 # "hostsdir=${cfg.stateDirectory}/hosts"
266 ];
267
268 files = {
269 database = "${cfg.stateDirectory}/pihole-FTL.db";
270 gravity = "${cfg.stateDirectory}/gravity.db";
271 macvendor = "${cfg.stateDirectory}/macvendor.db";
272 log.ftl = "${cfg.logDirectory}/FTL.log";
273 log.dnsmasq = "${cfg.logDirectory}/pihole.log";
274 log.webserver = "${cfg.logDirectory}/webserver.log";
275 };
276
277 webserver.tls.cert = "${cfg.stateDirectory}/tls.pem";
278 }
279
280 (lib.optionalAttrs cfg.useDnsmasqConfig {
281 misc.dnsmasq_lines = lib.pipe config.services.dnsmasq.configFile [
282 builtins.readFile
283 (lib.strings.splitString "\n")
284 (builtins.filter (s: s != ""))
285 ];
286 })
287 ];
288
289 systemd.tmpfiles.rules = [
290 "d ${cfg.configDirectory} 0700 ${cfg.user} ${cfg.group} - -"
291 "d ${cfg.stateDirectory} 0700 ${cfg.user} ${cfg.group} - -"
292 "d ${cfg.logDirectory} 0700 ${cfg.user} ${cfg.group} - -"
293 ];
294
295 systemd.services = {
296 pihole-ftl =
297 let
298 setupService = config.systemd.services.pihole-ftl-setup.name;
299 in
300 {
301 description = "Pi-hole FTL";
302
303 after = [ "network.target" ];
304 before = [ setupService ];
305
306 wantedBy = [ "multi-user.target" ];
307 wants = [ setupService ];
308
309 environment = {
310 # Currently unused, but allows the service to be reloaded
311 # automatically when the config is changed.
312 PIHOLE_CONFIG = settingsFile;
313
314 # pihole is executed by the /actions/gravity API endpoint
315 PATH = lib.mkForce (
316 lib.makeBinPath [
317 cfg.piholePackage
318 ]
319 );
320 };
321
322 serviceConfig = {
323 Type = "simple";
324 User = cfg.user;
325 Group = cfg.group;
326 AmbientCapabilities = [
327 "CAP_NET_BIND_SERVICE"
328 "CAP_NET_RAW"
329 "CAP_NET_ADMIN"
330 "CAP_SYS_NICE"
331 "CAP_IPC_LOCK"
332 "CAP_CHOWN"
333 "CAP_SYS_TIME"
334 ];
335 ExecStart = "${getExe cfg.package} no-daemon";
336 Restart = "on-failure";
337 RestartSec = 1;
338 # Hardening
339 NoNewPrivileges = true;
340 PrivateTmp = true;
341 PrivateDevices = true;
342 DevicePolicy = "closed";
343 ProtectSystem = "strict";
344 ProtectHome = "read-only";
345 ProtectControlGroups = true;
346 ProtectKernelModules = true;
347 ProtectKernelTunables = true;
348 ReadWritePaths = [
349 cfg.configDirectory
350 cfg.stateDirectory
351 cfg.logDirectory
352 ];
353 RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
354 RestrictNamespaces = true;
355 RestrictRealtime = true;
356 RestrictSUIDSGID = true;
357 MemoryDenyWriteExecute = true;
358 LockPersonality = true;
359 };
360 };
361
362 pihole-ftl-setup = {
363 description = "Pi-hole FTL setup";
364 enable = builtins.length cfg.lists > 0;
365
366 # Wait for network so lists can be downloaded
367 after = [ "network-online.target" ];
368 requires = [ "network-online.target" ];
369 serviceConfig = {
370 Type = "oneshot";
371 User = cfg.user;
372 Group = cfg.group;
373
374 # Hardening
375 NoNewPrivileges = true;
376 PrivateTmp = true;
377 PrivateDevices = true;
378 DevicePolicy = "closed";
379 ProtectSystem = "strict";
380 ProtectHome = "read-only";
381 ProtectControlGroups = true;
382 ProtectKernelModules = true;
383 ProtectKernelTunables = true;
384 ReadWritePaths = [
385 cfg.configDirectory
386 cfg.stateDirectory
387 cfg.logDirectory
388 ];
389 RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
390 RestrictNamespaces = true;
391 RestrictRealtime = true;
392 RestrictSUIDSGID = true;
393 MemoryDenyWriteExecute = true;
394 LockPersonality = true;
395 };
396 script = import ./pihole-ftl-setup-script.nix {
397 inherit
398 cfg
399 config
400 lib
401 pkgs
402 ;
403 };
404 };
405
406 pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable {
407 description = "Pi-hole FTL DNS query log deleter";
408 serviceConfig = {
409 Type = "oneshot";
410 User = cfg.user;
411 Group = cfg.group;
412 # Hardening
413 NoNewPrivileges = true;
414 PrivateTmp = true;
415 PrivateDevices = true;
416 DevicePolicy = "closed";
417 ProtectSystem = "strict";
418 ProtectHome = "read-only";
419 ProtectControlGroups = true;
420 ProtectKernelModules = true;
421 ProtectKernelTunables = true;
422 ReadWritePaths = [ cfg.stateDirectory ];
423 RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
424 RestrictNamespaces = true;
425 RestrictRealtime = true;
426 RestrictSUIDSGID = true;
427 MemoryDenyWriteExecute = true;
428 LockPersonality = true;
429 };
430 script =
431 let
432 days = toString cfg.queryLogDeleter.age;
433 database = cfg.settings.files.database;
434 in
435 ''
436 set -euo pipefail
437
438 # Avoid creating an empty database file if it doesn't yet exist
439 if [ ! -f "${database}" ]; then
440 exit 0;
441 fi
442
443 echo "Deleting query logs older than ${days} days"
444 ${getExe cfg.package} sqlite3 "${database}" "DELETE FROM query_storage WHERE timestamp <= CAST(strftime('%s', date('now', '-${days} day')) AS INT); select changes() from query_storage limit 1"
445 '';
446 };
447 };
448
449 systemd.timers.pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable {
450 description = "Pi-hole FTL DNS query log deleter";
451 before = [
452 config.systemd.services.pihole-ftl.name
453 config.systemd.services.pihole-ftl-setup.name
454 ];
455 wantedBy = [ "timers.target" ];
456 timerConfig = {
457 OnCalendar = cfg.queryLogDeleter.interval;
458 Unit = "pihole-ftl-log-deleter.service";
459 };
460 };
461
462 networking.firewall = lib.mkMerge [
463 (mkIf cfg.openFirewallDNS {
464 allowedUDPPorts = [ 53 ];
465 allowedTCPPorts = [ 53 ];
466 })
467
468 (mkIf cfg.openFirewallDHCP {
469 allowedUDPPorts = [ 67 ];
470 })
471
472 (mkIf cfg.openFirewallWebserver {
473 allowedTCPPorts = lib.pipe cfg.settings.webserver.port [
474 (lib.splitString ",")
475 (map (
476 port:
477 lib.pipe port [
478 (builtins.split "[[:alpha:]]+")
479 builtins.head
480 lib.toInt
481 ]
482 ))
483 ];
484 })
485 ];
486
487 users.users.${cfg.user} = {
488 group = cfg.group;
489 isSystemUser = true;
490 };
491
492 users.groups.${cfg.group} = { };
493
494 environment.etc."pihole/pihole.toml" = {
495 source = settingsFile;
496 user = cfg.user;
497 group = cfg.group;
498 mode = "400";
499 };
500
501 environment.systemPackages = [ cfg.pihole ];
502
503 services.logrotate.settings.pihole-ftl = {
504 enable = true;
505 files = [ "${cfg.logDirectory}/FTL.log" ];
506 };
507 };
508
509 meta = {
510 doc = ./pihole-ftl.md;
511 maintainers = with lib.maintainers; [ averyvigolo ];
512 };
513}