Files
homelab/services/network/pihole.nix
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

107 lines
4.4 KiB
Nix

{ 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 <service>.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 be owned by 1000,
# the `pihole` user FTL drops to (rootful podman, no userns remapping, so the
# uid is the same inside and out). Must be the whole DIRECTORY, not just
# gravity.db — sqlite needs to create a sibling gravity.db-journal per write,
# and a root-owned dir makes that fail with a misleading "readonly database".
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 ];
}