From 6bf0eeab04f3f7eed70b62c1804586219c81596f Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Mon, 20 Jul 2026 21:52:01 +0200 Subject: [PATCH] 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 --- README.md | 17 ++++++---- services/network/pihole.nix | 65 +++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6f42dec..5eeb546 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/services/network/pihole.nix b/services/network/pihole.nix index 16c34a5..e14ead9 100644 --- a/services/network/pihole.nix +++ b/services/network/pihole.nix @@ -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 ];