pihole: fix gravity writes, declare the blocklists

FTL could not write gravity.db, reporting "attempt to write a readonly
database". The database file was writable; the directory was not. sqlite
creates a sibling gravity.db-journal for every write transaction, so FTL
needs to CREATE files in /var/lib/pihole, and the tmpfiles rule left it
root-owned. The error names the database rather than the directory, which
sends you looking at the file and the filesystem, neither of which is at
fault.

Own the directory as 1000 instead -- the pihole user FTL drops to after
the entrypoint's root phase. Podman is rootful here with no userns
remapping, so the number is the same inside and out; on the host it
collides with darman, harmlessly.

The blocklists are now declared in this module and seeded by a oneshot,
because /var/lib/pihole is not declarative and a reflash took gravity
with it. INSERT OR IGNORE keyed on the URL is idempotent so it can run on
every boot, while the expensive rebuild only runs when gravity is empty.
Adding a list to the Nix attribute needs a manual `pihole -g` -- that is
deliberate, since the rebuild downloads every list and is slow on a Pi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:52:01 +02:00
co-authored by Claude Opus 4.8
parent 4fb4297e12
commit 6bf0eeab04
2 changed files with 74 additions and 8 deletions
+63 -2
View File
@@ -1,5 +1,15 @@
{ pkgs, lib, ... }:
let
# Blocklists, kept here so a reflash restores them. /var/lib/pihole is NOT
# declarative: the gravity database lives there and goes with the card, and
# the failure is quiet — DNS keeps resolving, just with nothing blocked.
adlists = [
"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/adblock/pro.txt"
];
in
# Pi-hole via the official container (the native nixpkgs pihole-ftl module
# segfaults on aarch64 / Pi 3B+). Host networking so it can serve DHCP and reach
# the host's unbound at 127.0.0.1:5335. Config via FTLCONF_* env vars (pihole v6)
@@ -43,8 +53,59 @@
};
};
# Bind-mount source must exist (podman won't create it).
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0755 root root -" ];
# Bind-mount source must exist (podman won't create it), and it must be
# owned by 1000 — the `pihole` user FTL drops to after the entrypoint's root
# phase. Podman here is rootful with no userns remapping, so that number is
# the same inside and out (on the host it collides with darman, harmlessly).
#
# Ownership of gravity.db alone is not enough: sqlite creates a sibling
# gravity.db-journal for every write transaction, so FTL needs to CREATE
# files in this directory. Root-owned, it fails with
# open(/etc/pihole/gravity.db-journal) - (14)
# attempt to write a readonly database
# which reads like a corrupt or read-only database and is neither.
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0750 1000 1000 -" ];
# Seed the adlists above into gravity. `INSERT OR IGNORE` keyed on the URL
# makes this idempotent, so it is safe on every boot; the expensive rebuild
# (`pihole -g`, which downloads every list) only runs when gravity is empty,
# i.e. after a reflash. Add a list above and run `pihole -g` by hand.
systemd.services.pihole-adlists = {
description = "Seed pihole's blocklists from the Nix config";
after = [ "podman-pihole.service" "network-online.target" ];
wants = [ "network-online.target" ];
requires = [ "podman-pihole.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
podman="${pkgs.podman}/bin/podman"
db=/etc/pihole/gravity.db
sql() { $podman exec pihole pihole-FTL sqlite3 "$db" "$1"; }
# The container creates gravity.db on first start; wait for it.
for _ in $(seq 1 60); do
$podman exec pihole test -f "$db" && break
sleep 2
done
$podman exec pihole test -f "$db" || {
echo "gravity.db never appeared; is podman-pihole healthy?" >&2
exit 1
}
${lib.concatMapStringsSep "\n" (url: ''
sql "INSERT OR IGNORE INTO adlist (address, enabled, comment)
VALUES ('${url}', 1, 'declared in services/network/pihole.nix');"
'') adlists}
if [ "$(sql 'SELECT COUNT(*) FROM gravity;')" = "0" ]; then
echo "gravity is empty building blocklists (this downloads every list)"
$podman exec pihole pihole -g
fi
'';
};
networking.firewall.allowedTCPPorts = [ 53 80 ];
networking.firewall.allowedUDPPorts = [ 53 67 ];