{ 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) # — these override pihole.toml on every start, so it stays effectively # declarative. The web admin password is added from sops in the host config. { virtualisation.podman = { enable = true; dockerCompat = true; }; virtualisation.oci-containers = { backend = "podman"; containers.pihole = { image = "pihole/pihole:2026.07.2"; # validated in mercury-vm autoStart = true; extraOptions = [ "--network=host" # DHCP broadcast + host unbound on 127.0.0.1 "--cap-add=NET_ADMIN" # DHCP "--cap-add=NET_RAW" # DNS engine (dnsmasq) — REQUIRED "--cap-add=SYS_NICE" "--cap-add=CHOWN" # entrypoint chowns /etc/pihole ]; volumes = [ "/var/lib/pihole:/etc/pihole" ]; # persist config/state environment = { TZ = "Europe/Berlin"; FTLCONF_dns_upstreams = "127.0.0.1#5335"; # host unbound (recursive) FTLCONF_dns_listeningMode = "all"; # serve the LAN FTLCONF_dns_domain = "sol"; FTLCONF_dhcp_active = "true"; FTLCONF_dhcp_start = "10.0.0.50"; FTLCONF_dhcp_end = "10.0.0.200"; FTLCONF_dhcp_router = "10.0.0.1"; FTLCONF_dhcp_leaseTime = "1h"; # Arrays (format validated in the VM): static lease + local DNS records. FTLCONF_dhcp_hosts = "00:e0:4c:3c:a3:1f,10.0.0.20,jupiter"; FTLCONF_dns_hosts = "10.0.0.10 mercury.sol;10.0.0.20 jupiter.sol"; # Wildcard: any .jupiter.sol -> jupiter (caddy routes by hostname). FTLCONF_misc_dnsmasq_lines = "address=/jupiter.sol/10.0.0.20"; }; }; }; # 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 ]; }