diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index 44ab6c6b860c..080756744e56 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -56,6 +56,12 @@ "module-services-opencloud-basic-usage": [ "index.html#module-services-opencloud-basic-usage" ], + "module-services-networking-pihole-ftl-configuration-inherit-dnsmasq": [ + "index.html#module-services-networking-pihole-ftl-configuration-inherit-dnsmasq" + ], + "module-services-networking-pihole-ftl-configuration-multiple-interfaces": [ + "index.html#module-services-networking-pihole-ftl-configuration-multiple-interfaces" + ], "module-services-strfry": [ "index.html#module-services-strfry" ], @@ -1448,6 +1454,15 @@ "module-services-input-methods-kime": [ "index.html#module-services-input-methods-kime" ], + "module-services-networking-pihole-ftl": [ + "index.html#module-services-networking-pihole-ftl" + ], + "module-services-networking-pihole-ftl-administration": [ + "index.html#module-services-networking-pihole-ftl-administration" + ], + "module-services-networking-pihole-ftl-configuration": [ + "index.html#module-services-networking-pihole-ftl-configuration" + ], "ch-profiles": [ "index.html#ch-profiles" ], diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 07322ea56669..ce146d0cdedf 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -13,6 +13,8 @@ - [gtklock](https://github.com/jovanlanik/gtklock), a GTK-based lockscreen for Wayland. Available as [programs.gtklock](#opt-programs.gtklock.enable). - [Chrysalis](https://github.com/keyboardio/Chrysalis), a graphical configurator for Kaleidoscope-powered keyboards. Available as [programs.chrysalis](#opt-programs.chrysalis.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-web](#opt-services.pihole-web.enable) for the web GUI and API. + - [FileBrowser](https://filebrowser.org/), a web application for managing and sharing files. Available as [services.filebrowser](#opt-services.filebrowser.enable). - [LACT](https://github.com/ilya-zlobintsev/LACT), a GPU monitoring and configuration tool, can now be enabled through [services.lact.enable](#opt-services.lact.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 79f5c22f5b98..303781f6645e 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1268,6 +1268,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/powerdns.nix diff --git a/nixos/modules/services/networking/pihole-ftl-setup-script.nix b/nixos/modules/services/networking/pihole-ftl-setup-script.nix new file mode 100644 index 000000000000..50236a9a49e2 --- /dev/null +++ b/nixos/modules/services/networking/pihole-ftl-setup-script.nix @@ -0,0 +1,82 @@ +{ + cfg, + config, + lib, + pkgs, +}: + +let + pihole = pkgs.pihole; + makePayload = + list: + builtins.toJSON { + inherit (list) type enabled; + address = list.url; + comment = list.description; + }; + payloads = map makePayload cfg.lists; +in +'' + # Can't use -u (unset) because api.sh uses API_URL before it is set + set -eo pipefail + pihole="${lib.getExe pihole}" + jq="${lib.getExe pkgs.jq}" + + # If the database doesn't exist, it needs to be created with gravity.sh + if [ ! -f '${cfg.stateDirectory}'/gravity.db ]; then + $pihole -g + # Send SIGRTMIN to FTL, which makes it reload the database, opening the newly created one + ${pkgs.procps}/bin/kill -s SIGRTMIN $(systemctl show --property MainPID --value ${config.systemd.services.pihole-ftl.name}) + fi + + source ${pihole}/usr/share/pihole/advanced/Scripts/api.sh + source ${pihole}/usr/share/pihole/advanced/Scripts/utils.sh + + any_failed=0 + + addList() { + local payload="$1" + + echo "Adding list: $payload" + local result=$(PostFTLData "lists" "$payload") + + local error="$($jq '.error' <<< "$result")" + if [[ "$error" != "null" ]]; then + echo "Error: $error" + any_failed=1 + return + fi + + id="$($jq '.lists.[].id?' <<< "$result")" + if [[ "$id" == "null" ]]; then + any_failed=1 + error="$($jq '.processed.errors.[].error' <<< "$result")" + echo "Error: $error" + return + fi + + echo "Added list ID $id: $result" + } + + for i in 1 2 3; do + (TestAPIAvailability) && break + echo "Retrying API shortly..." + ${pkgs.coreutils}/bin/sleep .5s + done; + + LoginAPI + + ${builtins.concatStringsSep "\n" ( + map ( + payload: + lib.pipe payload [ + lib.strings.escapeShellArg + (payload: "addList ${payload}") + ] + ) payloads + )} + + # Run gravity.sh to load any new lists + $pihole -g + exit $any_failed +'' diff --git a/nixos/modules/services/networking/pihole-ftl.md b/nixos/modules/services/networking/pihole-ftl.md new file mode 100644 index 000000000000..4a1b1f986708 --- /dev/null +++ b/nixos/modules/services/networking/pihole-ftl.md @@ -0,0 +1,128 @@ +# pihole-FTL {#module-services-networking-pihole-ftl} + +*Upstream documentation*: + +pihole-FTL is a fork of [Dnsmasq](index.html#module-services-networking-dnsmasq), +providing some additional features, including an API for analysis and +statistics. + +Note that pihole-FTL and Dnsmasq cannot be enabled at +the same time. + +## Configuration {#module-services-networking-pihole-ftl-configuration} + +pihole-FTL can be configured with [{option}`services.pihole-ftl.settings`](options.html#opt-services.pihole-ftl.settings), which controls the content of `pihole.toml`. + +The template pihole.toml is provided in `pihole-ftl.passthru.settingsTemplate`, +which describes all settings. + +Example configuration: + +```nix +{ + services.pihole-ftl = { + enable = true; + openFirewallDHCP = true; + queryLogDeleter.enable = true; + lists = [ + { + url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; + # Alternatively, use the file from nixpkgs. Note its contents won't be + # automatically updated by Pi-hole, as it would with an online URL. + # url = "file://${pkgs.stevenblack-blocklist}/hosts"; + description = "Steven Black's unified adlist"; + } + ]; + settings = { + dns = { + domainNeeded = true; + expandHosts = true; + interface = "br-lan"; + listeningMode = "BIND"; + upstreams = [ "127.0.0.1#5053" ]; + }; + dhcp = { + active = true; + router = "192.168.10.1"; + start = "192.168.10.2"; + end = "192.168.10.254"; + leaseTime = "1d"; + ipv6 = true; + multiDNS = true; + hosts = [ + # Static address for the current host + "aa:bb:cc:dd:ee:ff,192.168.10.1,${config.networking.hostName},infinite" + ]; + rapidCommit = true; + }; + misc.dnsmasq_lines = [ + # This DHCP server is the only one on the network + "dhcp-authoritative" + # Source: https://data.iana.org/root-anchors/root-anchors.xml + "trust-anchor=.,38696,8,2,683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16" + ]; + }; + }; +} +``` + +### Inheriting configuration from Dnsmasq {#module-services-networking-pihole-ftl-configuration-inherit-dnsmasq} + +If [{option}`services.pihole-ftl.useDnsmasqConfig`](options.html#opt-services.pihole-ftl.useDnsmasqConfig) is enabled, the configuration [options of the Dnsmasq +module](index.html#module-services-networking-dnsmasq) will be automatically +used by pihole-FTL. Note that this may cause duplicate option errors +depending on pihole-FTL settings. + +See the [Dnsmasq +example](index.html#module-services-networking-dnsmasq-configuration-home) for +an exemplar Dnsmasq configuration. Make sure to set +[{option}`services.dnsmasq.enable`](options.html#opt-services.dnsmasq.enable) to false and +[{option}`services.pihole-ftl.enable`](options.html#opt-services.pihole-ftl.enable) to true instead: + +```nix +{ + services.pihole-ftl = { + enable = true; + useDnsmasqConfig = true; + }; +} +``` + +### Serving on multiple interfaces {#module-services-networking-pihole-ftl-configuration-multiple-interfaces} + +Pi-hole's configuration only supports specifying a single interface. If you want +to configure additional interfaces with different configuration, use +`misc.dnsmasq_lines` to append extra Dnsmasq options. + +```nix +{ + services.pihole-ftl = { + settings.misc.dnsmasq_lines = [ + # Specify the secondary interface + "interface=enp1s0" + # A different device is the router on this network, e.g. the one + # provided by your ISP + "dhcp-option=enp1s0,option:router,192.168.0.1" + # Specify the IPv4 ranges to allocate, with a 1-day lease time + "dhcp-range=enp1s0,192.168.0.10,192.168.0.253,1d" + # Enable IPv6 + "dhcp-range=::f,::ff,constructor:enp1s0,ra-names,ra-stateless" + ]; + }; + }; +} +``` + +## 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 some configuration. + +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 000000000000..45cd2e5f927b --- /dev/null +++ b/nixos/modules/services/networking/pihole-ftl.nix @@ -0,0 +1,483 @@ +{ + config, + lib, + pkgs, + ... +}: + +with { + inherit (lib) + elemAt + getExe + hasAttrByPath + mkEnableOption + mkIf + mkOption + strings + types + ; +}; + +let + mkDefaults = lib.mapAttrsRecursive (n: v: lib.mkDefault v); + + cfg = config.services.pihole-ftl; + + piholeScript = pkgs.writeScriptBin "pihole" '' + sudo=exec + if [[ "$USER" != '${cfg.user}' ]]; then + sudo='exec /run/wrappers/bin/sudo -u ${cfg.user}' + fi + $sudo ${getExe cfg.piholePackage} "$@" + ''; + + settingsFormat = pkgs.formats.toml { }; + settingsFile = settingsFormat.generate "pihole.toml" cfg.settings; +in +{ + options.services.pihole-ftl = { + enable = mkEnableOption "Pi-hole FTL"; + + package = lib.mkPackageOption pkgs "pihole-ftl" { }; + piholePackage = lib.mkPackageOption pkgs "pihole" { }; + + privacyLevel = mkOption { + type = types.numbers.between 0 3; + description = '' + 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"; + }; + + openFirewallDHCP = mkOption { + type = types.bool; + default = false; + description = "Open ports in the firewall for pihole-FTL's DHCP server."; + }; + + openFirewallWebserver = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for pihole-FTL's webserver, as configured in `settings.webserver.port`. + ''; + }; + + configDirectory = mkOption { + type = types.path; + default = "/etc/pihole"; + internal = true; + readOnly = true; + description = '' + Path for pihole configuration. + pihole does not currently support any path other than /etc/pihole. + ''; + }; + + stateDirectory = mkOption { + type = types.path; + default = "/var/lib/pihole"; + description = '' + Path for pihole state files. + ''; + }; + + logDirectory = mkOption { + type = types.path; + default = "/var/log/pihole"; + description = "Path for Pi-hole log files"; + }; + + settings = mkOption { + type = settingsFormat.type; + description = '' + Configuration options for pihole.toml. + See the upstream [documentation](https://docs.pi-hole.net/ftldns/configfile). + ''; + }; + + useDnsmasqConfig = mkOption { + type = types.bool; + default = false; + description = '' + Import options defined in [](#opt-services.dnsmasq.settings) via + misc.dnsmasq_lines in Pi-hole's config. + ''; + }; + + pihole = mkOption { + type = types.package; + default = piholeScript; + internal = true; + description = "Pi-hole admin script"; + }; + + lists = + let + adlistType = types.submodule { + options = { + url = mkOption { + type = types.str; + description = "URL of the domain list"; + }; + type = mkOption { + type = types.enum [ + "allow" + "block" + ]; + default = "block"; + description = "Whether domains on this list should be explicitly allowed, or blocked"; + }; + enabled = mkOption { + type = types.bool; + default = true; + description = "Whether this list is enabled"; + }; + description = mkOption { + type = types.str; + description = "Description of the list"; + default = ""; + }; + }; + }; + in + mkOption { + type = with types; listOf adlistType; + description = "Deny (or allow) domain lists to use"; + default = [ ]; + example = [ + { + url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; + } + ]; + }; + + user = mkOption { + type = types.str; + default = "pihole"; + description = "User to run the service as."; + }; + + group = mkOption { + type = types.str; + default = "pihole"; + description = "Group to run the service as."; + }; + + queryLogDeleter = { + enable = mkEnableOption ("Pi-hole FTL DNS query log deleter"); + + age = mkOption { + type = types.int; + default = 90; + description = '' + 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 = '' + 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."; + } + + { + assertion = + builtins.length cfg.lists == 0 + || ( + (hasAttrByPath [ "webserver" "port" ] cfg.settings) + && !builtins.elem cfg.settings.webserver.port [ + "" + null + ] + ); + message = '' + 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. + services.pihole-ftl.settings.port must be defined, e.g. by enabling services.pihole-web.enable and defining services.pihole-web.port. + ''; + } + + { + assertion = + builtins.length cfg.lists == 0 + || !(hasAttrByPath [ "webserver" "api" "cli_pw" ] cfg.settings) + || cfg.settings.webserver.api.cli_pw == true; + message = '' + 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. + This enables an ephemeral password used by the pihole command. + ''; + } + ]; + + services.pihole-ftl.settings = lib.mkMerge [ + # Defaults + (mkDefaults { + misc.readOnly = true; # Prevent config changes via API or CLI by default + webserver.port = ""; # Disable the webserver by default + misc.privacyLevel = cfg.privacyLevel; + }) + + # Move state files to cfg.stateDirectory + { + # TODO: Pi-hole currently hardcodes dhcp-leasefile this in its + # generated dnsmasq.conf, and we can't override it + misc.dnsmasq_lines = [ + # "dhcp-leasefile=${cfg.stateDirectory}/dhcp.leases" + # "hostsdir=${cfg.stateDirectory}/hosts" + ]; + + files = { + database = "${cfg.stateDirectory}/pihole-FTL.db"; + gravity = "${cfg.stateDirectory}/gravity.db"; + macvendor = "${cfg.stateDirectory}/gravity.db"; + log.ftl = "${cfg.logDirectory}/FTL.log"; + log.dnsmasq = "${cfg.logDirectory}/pihole.log"; + log.webserver = "${cfg.logDirectory}/webserver.log"; + }; + + webserver.tls = "${cfg.stateDirectory}/tls.pem"; + } + + (lib.optionalAttrs cfg.useDnsmasqConfig { + misc.dnsmasq_lines = lib.pipe config.services.dnsmasq.configFile [ + builtins.readFile + (lib.strings.splitString "\n") + (builtins.filter (s: s != "")) + ]; + }) + ]; + + systemd.tmpfiles.rules = [ + "d ${cfg.configDirectory} 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDirectory} 0700 ${cfg.user} ${cfg.group} - -" + "d ${cfg.logDirectory} 0700 ${cfg.user} ${cfg.group} - -" + ]; + + systemd.services = { + pihole-ftl = + let + setupService = config.systemd.services.pihole-ftl-setup.name; + in + { + description = "Pi-hole FTL"; + + after = [ "network.target" ]; + before = [ setupService ]; + + wantedBy = [ "multi-user.target" ]; + wants = [ setupService ]; + + environment = { + # Currently unused, but allows the service to be reloaded + # automatically when the config is changed. + PIHOLE_CONFIG = settingsFile; + + # pihole is executed by the /actions/gravity API endpoint + PATH = lib.mkForce ( + lib.makeBinPath [ + cfg.piholePackage + ] + ); + }; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + AmbientCapabilities = [ + "CAP_NET_BIND_SERVICE" + "CAP_NET_RAW" + "CAP_NET_ADMIN" + "CAP_SYS_NICE" + "CAP_IPC_LOCK" + "CAP_CHOWN" + "CAP_SYS_TIME" + ]; + ExecStart = "${getExe cfg.package} no-daemon"; + Restart = "on-failure"; + RestartSec = 1; + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + DevicePolicy = "closed"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ReadWritePaths = [ + cfg.configDirectory + 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-setup = { + description = "Pi-hole FTL setup"; + # Wait for network so lists can be downloaded + after = [ "network-online.target" ]; + requires = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + DevicePolicy = "closed"; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ReadWritePaths = [ + cfg.configDirectory + cfg.stateDirectory + cfg.logDirectory + ]; + RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + }; + script = import ./pihole-ftl-setup-script.nix { + inherit + cfg + config + lib + pkgs + ; + }; + }; + + pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable { + description = "Pi-hole FTL DNS query log deleter"; + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.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" + ''; + }; + }; + + systemd.timers.pihole-ftl-log-deleter = mkIf cfg.queryLogDeleter.enable { + description = "Pi-hole FTL DNS query log deleter"; + before = [ + config.systemd.services.pihole-ftl.name + config.systemd.services.pihole-ftl-setup.name + ]; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.queryLogDeleter.interval; + Unit = "pihole-ftl-log-deleter.service"; + }; + }; + + networking.firewall = lib.mkMerge [ + (mkIf cfg.openFirewallDHCP { + allowedUDPPorts = [ 53 ]; + allowedTCPPorts = [ 53 ]; + }) + + (mkIf cfg.openFirewallWebserver { + allowedTCPPorts = lib.pipe cfg.settings.webserver.port [ + (lib.splitString ",") + (map ( + port: + lib.pipe port [ + (builtins.split "[[:alpha:]]+") + builtins.head + lib.toInt + ] + )) + ]; + }) + ]; + + users.users.${cfg.user} = { + group = cfg.group; + isSystemUser = true; + }; + + users.groups.${cfg.group} = { }; + + environment.etc."pihole/pihole.toml" = { + source = settingsFile; + user = cfg.user; + group = cfg.group; + mode = "400"; + }; + + environment.systemPackages = [ cfg.pihole ]; + + services.logrotate.settings.pihole-ftl = { + enable = true; + files = [ "${cfg.logDirectory}/FTL.log" ]; + }; + }; + + meta = { + doc = ./pihole-ftl.md; + maintainers = with lib.maintainers; [ williamvds ]; + }; +}