feat(mercury): pihole via container (native FTL segfaults on aarch64)

- services/pihole.nix: official pihole/pihole:2026.07.2 via podman, host net,
  caps NET_ADMIN/NET_RAW/SYS_NICE/CHOWN; FTLCONF_* env config (upstream unbound,
  DHCP 50-200, static lease jupiter, .sol domain, local records)
- unbound: resolveLocalQueries=false (was hijacking resolv.conf to :53 -> boot
  DNS deadlock; the real root cause of the earlier failures too)
- password via sops FTLCONF env file; /var/lib/pihole created via tmpfiles
- VM-verified: mercury.sol/jupiter.sol/external all resolve, 0 restarts
This commit is contained in:
erik
2026-07-14 00:10:43 +02:00
parent c2ac7e3af4
commit fd3ccf5f07
4 changed files with 53 additions and 52 deletions
+1
View File
@@ -73,6 +73,7 @@
./services/pihole.nix ./services/pihole.nix
({ lib, ... }: { ({ lib, ... }: {
networking.hostName = "mercury-vm"; networking.hostName = "mercury-vm";
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; # host resolver (not pihole)
users.users.darman.initialPassword = "test"; users.users.darman.initialPassword = "test";
users.users.root.initialPassword = "test"; users.users.root.initialPassword = "test";
services.openssh.settings.PasswordAuthentication = lib.mkForce true; services.openssh.settings.PasswordAuthentication = lib.mkForce true;
+5 -7
View File
@@ -31,15 +31,13 @@
# the imported service modules. pihole forwards to unbound at 127.0.0.1:5335. # the imported service modules. pihole forwards to unbound at 127.0.0.1:5335.
# ---- pihole web admin password (from sops) ---- # ---- pihole web admin password (from sops) ----
# pihole.toml is nix-managed read-only, so `pihole setpassword` can't write it. # The pihole container reads FTLCONF_* env vars. Render an env file from the
# Instead pihole-FTL reads FTLCONF_* env vars (they override the toml). Render # sops secret and feed it to the container — password stays out of repo/store.
# an env file from the sops secret and feed it to the service — password stays
# out of the repo and the nix store.
sops.secrets.pihole_webpassword = { }; sops.secrets.pihole_webpassword = { };
sops.templates."pihole-ftl.env".content = sops.templates."pihole.env".content =
"FTLCONF_webserver_api_password=${config.sops.placeholder.pihole_webpassword}"; "FTLCONF_webserver_api_password=${config.sops.placeholder.pihole_webpassword}";
systemd.services.pihole-ftl.serviceConfig.EnvironmentFile = virtualisation.oci-containers.containers.pihole.environmentFiles =
config.sops.templates."pihole-ftl.env".path; [ config.sops.templates."pihole.env".path ];
# Do not modify after first flash. # Do not modify after first flash.
system.stateVersion = "26.05"; system.stateVersion = "26.05";
+41 -45
View File
@@ -1,53 +1,49 @@
{ ... }: { pkgs, lib, ... }:
# Pi-hole (pihole-FTL v6) — network DNS (adblock) + DHCP, with the embedded # Pi-hole via the official container (the native nixpkgs pihole-ftl module
# web UI. Forwards to the local unbound recursive resolver (services/unbound.nix). # 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)
# `settings` is the raw pihole.toml. Static DHCP leases ARE declarative here # — these override pihole.toml on every start, so it stays effectively
# (settings.dhcp.hosts, dnsmasq "MAC,IP,hostname" format) — the reason we chose # declarative. The web admin password is added from sops in the host config.
# pihole over AdGuard.
#
# ⚠️ The pihole-ftl NixOS module + pihole.toml v6 schema are new; VALIDATE this
# at runtime (VM or the Pi) before trusting it — eval only checks the module,
# not the freeform TOML keys.
{ {
services.pihole-ftl = { virtualisation.podman = {
enable = true; enable = true;
openFirewallDNS = true; dockerCompat = true;
openFirewallDHCP = true; };
openFirewallWebserver = true;
lists = [ virtualisation.oci-containers = {
{ url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; type = "block"; } backend = "podman";
]; containers.pihole = {
image = "pihole/pihole:latest"; # TODO: pin to the tested version after VM check
settings = { autoStart = true;
dns = { extraOptions = [
upstreams = [ "127.0.0.1#5335" ]; # local unbound (recursive) "--network=host" # DHCP broadcast + host unbound on 127.0.0.1
domain = "sol"; # local domain -> jupiter.sol etc. "--cap-add=NET_ADMIN" # DHCP
# Local A records (/etc/hosts style). mercury is the DHCP server (no "--cap-add=NET_RAW" # DNS engine (dnsmasq) — REQUIRED
# lease of its own), so its name must be declared here. jupiter is also "--cap-add=SYS_NICE"
# pinned explicitly so it resolves regardless of lease state. "--cap-add=CHOWN" # entrypoint chowns /etc/pihole
hosts = [ ];
"10.0.0.10 mercury.sol mercury" volumes = [ "/var/lib/pihole:/etc/pihole" ]; # persist config/state
"10.0.0.20 jupiter.sol jupiter" 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";
}; };
dhcp = {
active = true;
start = "10.0.0.50";
end = "10.0.0.200";
router = "10.0.0.1";
leaseTime = "1h";
# Static leases (declarative). Format: "MAC,IP,hostname".
hosts = [ "00:e0:4c:3c:a3:1f,10.0.0.20,jupiter" ];
};
# Embedded web UI on :80. Set the admin password once after first boot:
# sudo pihole setpassword (kept out of the repo)
# (plain "80" so the module's openFirewall port parser is happy.)
webserver.port = "80";
}; };
}; };
# Bind-mount source must exist (podman won't create it).
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0755 root root -" ];
networking.firewall.allowedTCPPorts = [ 53 80 ];
networking.firewall.allowedUDPPorts = [ 53 67 ];
} }
+6
View File
@@ -8,6 +8,12 @@
{ {
services.unbound = { services.unbound = {
enable = true; enable = true;
# Do NOT point the host's resolv.conf at unbound: it listens on :5335, not
# :53, so that would leave the host with no working resolver until pihole
# binds :53 (a boot-time deadlock — can't pull images / build lists). The
# host resolves via networking.nameservers (upstream) instead; pihole
# forwards to unbound explicitly at 127.0.0.1#5335.
resolveLocalQueries = false;
# NixOS manages the DNSSEC root trust anchor (unbound-anchor). # NixOS manages the DNSSEC root trust anchor (unbound-anchor).
settings.server = { settings.server = {
interface = [ "127.0.0.1" ]; interface = [ "127.0.0.1" ];