2025-07-11 17:19:58 +02:00

494 lines
14 KiB
Nix

{
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";
};
openFirewallDNS = mkOption {
type = types.bool;
default = false;
description = "Open ports in the firewall for pihole-FTL's DNS server.";
};
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.cert = "${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.openFirewallDNS {
allowedUDPPorts = [ 53 ];
allowedTCPPorts = [ 53 ];
})
(mkIf cfg.openFirewallDHCP {
allowedUDPPorts = [ 67 ];
})
(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; [ averyvigolo ];
};
}