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
+11 -6
View File
@@ -210,14 +210,19 @@ another way in.
sudo nix-store --verify --check-contents # add --repair to fix
```
A card that has corrupted one path will corrupt more. Replace it and reflash.
- **A reflash wipes `/var/lib/pihole`**, which is NOT declarative. The
`FTLCONF_*` env vars rebuild the DNS/DHCP config, but the gravity database
goes with it — so adlists and blocking are gone and resolution silently
keeps working with nothing blocked (`Blocked DNS queries: 0` in the logs is
the tell). Re-add the adlists, then:
- **A reflash wipes `/var/lib/pihole`**, taking the gravity database with it.
The blocklists themselves are declared in `services/network/pihole.nix`, and
the `pihole-adlists` unit re-seeds them on boot and rebuilds gravity when it
finds it empty — so this heals itself, but the first boot after a reflash
spends several minutes downloading lists. Query history and dynamic DHCP
leases are genuinely lost (static leases are declarative). Check with:
```
sudo podman exec pihole pihole -g
systemctl status pihole-adlists
sudo podman exec pihole pihole-FTL sqlite3 /etc/pihole/gravity.db \
"SELECT address,enabled FROM adlist; SELECT COUNT(*) FROM gravity;"
```
`Blocked DNS queries: 0` in the pihole logs means gravity is empty — DNS
resolves fine, nothing is filtered.
- mercury's own `resolv.conf` is deliberately public resolvers, not its own
pihole (`resolveLocalQueries = false`, see `CLAUDE.md`) — so `.sol` names do
not resolve *on mercury itself*. That is expected, not a fault.
+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 ];