nixos/pihole-ftl: init

Add a module for pihole-ftl, which allows declaratively defining the
pihole.toml config file.

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
webapp (packaged as "pihole-web").

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.
This commit is contained in:
williamvds 2025-04-28 22:22:01 +01:00
parent 4bdf75f1cb
commit 8f5d24c1b2
No known key found for this signature in database
GPG key ID: 7A4DF5A8CDBD49C7
6 changed files with 711 additions and 0 deletions

View file

@ -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"
],

View file

@ -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).

View file

@ -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

View file

@ -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
''

View file

@ -0,0 +1,128 @@
# pihole-FTL {#module-services-networking-pihole-ftl}
*Upstream documentation*: <https://docs.pi-hole.net/ftldns/>
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*: <https://docs.pi-hole.net/main/pihole-command>
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)

View file

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