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 ];
+ };
+}