From c244a3d3f08c78b73fad7e8f7cb7c7f0865420b2 Mon Sep 17 00:00:00 2001 From: williamvds Date: Wed, 5 Apr 2023 00:04:10 +0100 Subject: [PATCH] nixos/pihole-ftl: init Add a module for pihole-ftl, which allows declaratively defining the setupVars.conf and pihole-FTL.conf configuration files. Also provide options for adlists to use, which can be added through the pihole script (packaged as "pihole"). Other state such as clients and groups require complex database operations, which is normally performed by the pihole admin webapp (packaged as "pihole-admin"). Extend the dnsmasq module to avoid duplication, since pihole-ftl is a soft-fork of dnsmasq which maintains compatibility. Provide the pihole script in `environment.systemPackages` so pihole-ftl can be easily administrated. --- .../manual/release-notes/rl-2405.section.md | 2 + nixos/modules/module-list.nix | 1 + .../modules/services/networking/pihole-ftl.md | 53 +++ .../services/networking/pihole-ftl.nix | 450 ++++++++++++++++++ 4 files changed, 506 insertions(+) create mode 100644 nixos/modules/services/networking/pihole-ftl.md create mode 100644 nixos/modules/services/networking/pihole-ftl.nix diff --git a/nixos/doc/manual/release-notes/rl-2405.section.md b/nixos/doc/manual/release-notes/rl-2405.section.md index b036f40f4dc80b7..6509748f6335d1e 100644 --- a/nixos/doc/manual/release-notes/rl-2405.section.md +++ b/nixos/doc/manual/release-notes/rl-2405.section.md @@ -24,6 +24,8 @@ In addition to numerous new and upgraded packages, this release has the followin - [maubot](https://github.com/maubot/maubot), a plugin-based Matrix bot framework. Available as [services.maubot](#opt-services.maubot.enable). +- [Pi-hole](https://pi-hole.net/), a DNS sinkhole for advertisements based on Dnsmasq. Available as [services.pihole-ftl](#opt-services.pihole-ftl.enable), and [services.pihole-adminlte](#opt-services.pihole-adminlte.enable) for the web GUI. + - [Anki Sync Server](https://docs.ankiweb.net/sync-server.html), the official sync server built into recent versions of Anki. Available as [services.anki-sync-server](#opt-services.anki-sync-server.enable). The pre-existing [services.ankisyncd](#opt-services.ankisyncd.enable) has been marked deprecated and will be dropped after 24.05 due to lack of maintenance of the anki-sync-server softwares. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 52c6fe5028f1818..f15a537b10e6a35 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1052,6 +1052,7 @@ ./services/networking/pdnsd.nix ./services/networking/peroxide.nix ./services/networking/picosnitch.nix + ./services/networking/pihole-ftl.nix ./services/networking/pixiecore.nix ./services/networking/pleroma.nix ./services/networking/polipo.nix diff --git a/nixos/modules/services/networking/pihole-ftl.md b/nixos/modules/services/networking/pihole-ftl.md new file mode 100644 index 000000000000000..9c3416366981c00 --- /dev/null +++ b/nixos/modules/services/networking/pihole-ftl.md @@ -0,0 +1,53 @@ +# pihole-FTL {#module-services-networking-pihole-ftl} + +*Upstream documentation*: + +pihole-FTL is a fork of [Dnsmasq](#module-services-networking-dnsmasq), +providing some additional features, including an API for analysis and +statistics. + +This module uses the configuration [options of the Dnsmasq +module](#module-services-networking-dnsmasq). +Note that pihole-FTL and Dnsmasq cannot be enabled at +the same time. + +## Configuration {#module-services-networking-pihole-configuration} + +See the [Dnsmasq +example](#module-services-networking-dnsmasq-configuration-home) for the +required Dnsmasq configuration. Make sure to set +[](#opt-services.dnsmasq.enable) to false and +[](#opt-services.pihole-ftl.enable) to true instead: + +```nix +{ + services.pihole-ftl = { + enable = true; + adlists = [ + { + url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; + comment = "Steven Black's unified adlist"; + } + ]; + extraSetupVars = { + API_QUERY_LOG_SHOW = "blockedonly"; + }; + }; +} +``` + +## Administration {#module-services-networking-pihole-ftl-administration} + +*pihole command documentation*: + +Enabling pihole-FTL provides the `pihole` command, which can be used to control +the daemon and the configuration database in `/etc/pihole/`. This includes +blocking/allowing specific URLs, and adding adlists, e.g. **pihole -a adlist add +https://example.com/adlist.txt**. + +Note that in NixOS the script has been patched to remove the reinstallation, +update, and Dnsmasq configuration commands. In NixOS, Pi-hole's configuration is +immutable and must be done with NixOS options. + +For more convenient administration and monitoring, see [Pi-hole +Dashboard](#module-services-web-apps-pihole-web) diff --git a/nixos/modules/services/networking/pihole-ftl.nix b/nixos/modules/services/networking/pihole-ftl.nix new file mode 100644 index 000000000000000..2fac37b403aa512 --- /dev/null +++ b/nixos/modules/services/networking/pihole-ftl.nix @@ -0,0 +1,450 @@ +{ pkgs +, lib +, config +, ... +}: + +with { + inherit (lib) + elemAt + getExe + mdDoc + mkEnableOption + mkIf + mkOption + strings + types; +}; + +let + cfg = config.services.pihole-ftl; + confFile = "${cfg.stateDirectory}/pihole-FTL.conf"; + + ftlConf = (pkgs.formats.keyValue { }).generate "pihole-FTL.conf" ({ + PRIVACYLEVEL = cfg.privacyLevel; + } // cfg.extraConfig); + + # Keys in setupVars.conf, reverse-engineered from the installation script, + # adapted to use the dnsmasq module settings. + setupVars = + let + dnsmasqConfig = config.services.dnsmasq; + uiConfig = config.services.pihole-web; + settings = dnsmasqConfig.settings; + boolSetting = attribute: if lib.hasAttr attribute settings then (elemAt (lib.getAttr attribute settings) 0) else false; + dhcpEnabled = lib.hasAttr "dhcp-range" settings; + in + { + DHCP_ACTIVE = false; + DNSMASQ_LISTENING = + if boolSetting "local-service" then "local" + else if boolSetting "bind-interfaces" then "bind" + else "all"; + DNSSEC = boolSetting "dnssec"; + DNS_BOGUS_PRIV = boolSetting "bogus-priv"; + DNS_FQDN_REQUIRED = boolSetting "domain-needed"; + QUERY_LOGGING = boolSetting "log-queries"; + TEMPERATUREUNIT = uiConfig.temperatureUnit; + WEBTHEME = uiConfig.theme; + } + // lib.listToAttrs (lib.imap1 (i: ip: lib.nameValuePair "PIHOLE_DNS_${toString i}" ip) settings.server) + // lib.optionalAttrs dhcpEnabled ( + let + # Ranges will be in the pattern ",," + splitIPv4 = builtins.split "\\."; + isIPv4Address = range: builtins.length (splitIPv4 range) == 7; + ranges = map (builtins.split ",") settings.dhcp-range; + range = lib.head (lib.filter (range: isIPv4Address (elemAt range 0)) ranges); + in + { + DHCP_ACTIVE = true; + DHCP_END = elemAt (splitIPv4 (elemAt range 2)) 6; + # Lease time can be "infinite", or a number and optional suffix. No suffix means seconds. + # If not specified, defaults to 1 hour. + # pihole expects lease time to be in hours. + # If infinite is used, set it to 0. + DHCP_LEASE_TIME = + if (builtins.length range) < 4 then 1 + else + let + match = builtins.match "([[:digit:]]+)(m|h|d|w)?" (elemAt range 4); + multipliers = rec { + w = d * 7; + d = h * 24; + h = 1; + m = h / 60.0; + s = m / 60.0; + }; + suffix = let value = (elemAt match 1); in if value != null then value else "s"; + multiplier = lib.getAttr suffix multipliers; + in + if match != null then (strings.toInt (elemAt match 0)) * multiplier + else 0; + DHCP_ROUTER = config.networking.defaultGateway.address; + DHCP_START = elemAt (splitIPv4 (elemAt range 0)) 6; + DHCP_rapid_commit = boolSetting "dhcp-rapid-commit"; + } + ) + // cfg.extraSetupVars; + + setupVarsConf = (pkgs.formats.keyValue { }).generate "setupVars.conf" setupVars; + + dnsmasqConf = config.services.dnsmasq.configFile; + + # Check the syntax of the config file. Note that this does not check that all options are all valid + validatedDnsmasqConf = pkgs.runCommand "validated-dnsmasq.conf" {} '' + ${getExe cfg.package} -- --test --conf-file=${dnsmasqConf} > out 2>&1 || true + if ! grep -q "syntax check OK." out; then + echo pihole-FTL config validation failed. + echo config was ${dnsmasqConf}. + echo pihole-FTL output: + cat out + exit 1 + fi + cp ${dnsmasqConf} $out + ''; + + pihole-script = pkgs.writeScriptBin "pihole" '' + cd ${cfg.package} + sudo=exec + if [[ "$USER" != '${cfg.user}' ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${cfg.user}' + fi + $sudo ${getExe cfg.pihole-package} "$@" + ''; +in +{ + options.services.pihole-ftl = { + enable = mkEnableOption (mdDoc "Pi-hole FTL"); + + package = lib.mkPackageOptionMD pkgs "pihole-ftl" {}; + + pihole-package = lib.mkPackageOptionMD pkgs "pihole" {}; + + privacyLevel = mkOption { + type = types.numbers.between 0 3; + description = mdDoc '' + Level of detail in generated statistics. 0 enables full statistics, 3 + shows only anonymous statistics. + + See [the documentation](https://docs.pi-hole.net/ftldns/privacylevels). + + Also see services.dnsmasq.settings.log-queries to completely disable + query logging. + ''; + default = 0; + example = "3"; + }; + + openFirewall = mkOption { + type = types.bool; + default = true; + description = mdDoc "Open ports in the firewall for Pi-hole FTL."; + }; + + stateDirectory = mkOption { + type = types.path; + default = "/etc/pihole"; + internal = true; + description = '' + Path for any pihole state files. + pihole does not support any path other than /etc/pihole. + ''; + }; + + logDirectory = mkOption { + type = types.path; + default = "/var/log/pihole"; + internal = true; + description = '' + Path for pihole log files. + pihole does not support any path other than /var/log/pihole. + ''; + }; + + extraConfig = mkOption { + type = types.attrs; + default = { }; + description = mdDoc '' + Additional configuration values for Pi-hole FTL. + + See the upstream [documentation](https://docs.pi-hole.net/ftldns/configfile) + ''; + example = { + NICE = 0; + DEBUG_ALL = true; + }; + }; + + extraSetupVars = mkOption { + type = types.attrs; + default = { }; + description = mdDoc '' + Additional configuration values for Pi-hole setupVars.conf file. + ''; + example = { + API_QUERY_LOG_SHOW = "blockedonly"; + }; + }; + + pihole = mkOption { + type = types.package; + default = pihole-script; + internal = true; + description = mdDoc "Pi-hole admin script"; + }; + + adlists = + let + adlistType = types.submodule { + options = { + url = mkOption { + type = types.str; + description = mdDoc "URL of the adlist"; + }; + comment = mkOption { + type = types.str; + description = mdDoc "Comment to attach to the adlist"; + default = ""; + }; + }; + default = { }; + description = mdDoc "Pi-hole adlist definition"; + }; + in + mkOption { + type = with types; listOf attrs; + description = mdDoc "URLs of blocklists to use"; + default = [ ]; + example = [{ url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; }]; + }; + + user = mkOption { + type = types.str; + default = "pihole"; + description = mdDoc "User to run the service as."; + }; + + group = mkOption { + type = types.str; + default = "pihole"; + description = mdDoc "Group to run the service as."; + }; + + queryLogDeleter = { + enable = mkEnableOption (mdDoc "Pi-hole FTL DNS query log deleter"); + + age = mkOption { + type = types.int; + default = 90; + description = mdDoc '' + Delete DNS query logs older than this many days, if + [](#opt-services.pihole-ftl.queryLogDeleter.enable) is on. + ''; + }; + + interval = mkOption { + type = types.str; + default = "weekly"; + description = mdDoc '' + How often the query log deleter is run. See systemd.time(7) for more + information about the format. + ''; + }; + }; + }; + + config = mkIf cfg.enable { + + assertions = [ + { + assertion = !config.services.dnsmasq.enable; + message = "pihole-ftl conflicts with dnsmasq. Please disable one of them"; + } + ]; + + systemd.tmpfiles.rules = [ + "d ${cfg.stateDirectory} 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.logDirectory} 0700 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services = { + pihole-ftl-setup = { + description = "Pi-hole FTL setup"; + after = [ "network.target" ]; + serviceConfig = { + Type = "oneshot"; + inherit (config.systemd.services.pihole-ftl.serviceConfig) User Group; + }; + script = + let + escapeSQL = builtins.replaceStrings [ "'" ] [ "''" ]; + pihole = getExe cfg.pihole; + in + '' + set -euo pipefail + + # If the database doesn't exist, it needs to be created with gravity.sh + if [ ! -f '${cfg.stateDirectory}'/gravity.db ]; then + DONT_RESTART_FTL=1 ${pihole} -g + fi + + ${builtins.concatStringsSep "" + (map (list: ''${pihole} -a adlist add \ + ${strings.escapeShellArg (escapeSQL list.url)} \ + ${strings.escapeShellArg (escapeSQL list.comment)}'') + cfg.adlists)} + + # Run gravity.sh to load any new adlists + DONT_RESTART_FTL=1 ${pihole} -g + ''; + }; + + pihole-ftl = { + description = "Pi-hole FTL"; + + after = [ "network.target" "pihole-ftl-setup.service" ]; + requires = [ "pihole-ftl-setup.service" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + AmbientCapabilities = [ + "CAP_NET_BIND_SERVICE" + "CAP_NET_RAW" + "CAP_NET_ADMIN" + "CAP_SYS_NICE" + ]; + ExecStart = "${getExe cfg.package} no-daemon -- --conf-file=${validatedDnsmasqConf}"; + # HUP reloads configuration and lists. Used by the unpatched pihole script. + ExecReload = "${pkgs.procps}/bin/kill -HUP $MAINPID"; + Restart = "on-failure"; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + DevicePolicy = "closed"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ReadWritePaths = [ cfg.stateDirectory cfg.logDirectory ]; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + }; + }; + + pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable { + description = "Pi-hole FTL DNS query log deleter"; + serviceConfig = let ftlCfg = config.systemd.services.pihole-ftl; in { + Type = "oneshot"; + User = ftlCfg.serviceConfig.User; + Group = ftlCfg.serviceConfig.Group; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + DevicePolicy = "closed"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ReadWritePaths = [ cfg.stateDirectory ]; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + }; + script = + let + days = toString cfg.queryLogDeleter.age; + database = "${cfg.stateDirectory}/pihole-FTL.db"; + in + '' + set -euo pipefail + + echo "Deleting query logs older than ${days} days" + ${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" + + echo 'Reloading pihole-FTL' + ${pkgs.systemd}/bin/systemctl reload pihole-ftl + ''; + }; + }; + + systemd.timers.pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable { + description = "Pi-hole FTL DNS query log deleter"; + before = [ "pihole-ftl.service" "pihole-ftl-setup.service" ]; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.queryLogDeleter.interval; + Unit = "pihole-ftl-log-deleter.service"; + }; + }; + + services.dnsmasq.settings.dhcp-leasefile = "${cfg.stateDirectory}/dhcp.leases"; + + networking.firewall = mkIf cfg.openFirewall { + allowedUDPPorts = [ 53 ]; + allowedTCPPorts = [ 53 ]; + }; + + users.users.${cfg.user} = { + group = cfg.group; + isSystemUser = true; + }; + + users.groups.${cfg.group} = { }; + + environment.etc."pihole/setupVars.conf" = { + source = setupVarsConf; + user = cfg.user; + group = cfg.group; + mode = "400"; + }; + + environment.etc."pihole/pihole-FTL.conf" = { + source = ftlConf; + user = cfg.user; + group = cfg.group; + mode = "400"; + }; + + environment.systemPackages = [ pihole-script ]; + + services.logrotate.settings.pihole-ftl = { + enable = true; + files = [ "/var/log/pihole/FTL.log" ]; + }; + + # Required by gravity script + networking.hosts."127.0.0.1" = [ "pi.hole" ]; + + # The log deleter needs to reload pihole-ftl + security.polkit.extraConfig = mkIf cfg.queryLogDeleter.enable '' + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "pihole-ftl.service" && + action.lookup("verb") == "reload" && + subject.user == "${cfg.user}") { + return polkit.Result.YES; + } + }); + ''; + }; + + meta = { + doc = ./pihole-ftl.md; + maintainers = with lib.maintainers; [ williamvds ]; + }; +}