at master 15 kB view raw
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}