From fd3ccf5f071c7413428a4b5de41c34ddacf10bc1 Mon Sep 17 00:00:00 2001 From: erik Date: Tue, 14 Jul 2026 00:10:43 +0200 Subject: [PATCH] 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 --- flake.nix | 1 + hosts/mercury/configuration.nix | 12 ++--- services/pihole.nix | 86 ++++++++++++++++----------------- services/unbound.nix | 6 +++ 4 files changed, 53 insertions(+), 52 deletions(-) diff --git a/flake.nix b/flake.nix index ae100d2..574727b 100644 --- a/flake.nix +++ b/flake.nix @@ -73,6 +73,7 @@ ./services/pihole.nix ({ lib, ... }: { 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.root.initialPassword = "test"; services.openssh.settings.PasswordAuthentication = lib.mkForce true; diff --git a/hosts/mercury/configuration.nix b/hosts/mercury/configuration.nix index fb36885..0e597e5 100644 --- a/hosts/mercury/configuration.nix +++ b/hosts/mercury/configuration.nix @@ -31,15 +31,13 @@ # the imported service modules. pihole forwards to unbound at 127.0.0.1:5335. # ---- pihole web admin password (from sops) ---- - # pihole.toml is nix-managed read-only, so `pihole setpassword` can't write it. - # Instead pihole-FTL reads FTLCONF_* env vars (they override the toml). Render - # an env file from the sops secret and feed it to the service — password stays - # out of the repo and the nix store. + # The pihole container reads FTLCONF_* env vars. Render an env file from the + # sops secret and feed it to the container — password stays out of repo/store. sops.secrets.pihole_webpassword = { }; - sops.templates."pihole-ftl.env".content = + sops.templates."pihole.env".content = "FTLCONF_webserver_api_password=${config.sops.placeholder.pihole_webpassword}"; - systemd.services.pihole-ftl.serviceConfig.EnvironmentFile = - config.sops.templates."pihole-ftl.env".path; + virtualisation.oci-containers.containers.pihole.environmentFiles = + [ config.sops.templates."pihole.env".path ]; # Do not modify after first flash. system.stateVersion = "26.05"; diff --git a/services/pihole.nix b/services/pihole.nix index 28993e2..b7ac31b 100644 --- a/services/pihole.nix +++ b/services/pihole.nix @@ -1,53 +1,49 @@ -{ ... }: +{ pkgs, lib, ... }: -# Pi-hole (pihole-FTL v6) — network DNS (adblock) + DHCP, with the embedded -# web UI. Forwards to the local unbound recursive resolver (services/unbound.nix). -# -# `settings` is the raw pihole.toml. Static DHCP leases ARE declarative here -# (settings.dhcp.hosts, dnsmasq "MAC,IP,hostname" format) — the reason we chose -# 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. +# 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. { - services.pihole-ftl = { + virtualisation.podman = { enable = true; - openFirewallDNS = true; - openFirewallDHCP = true; - openFirewallWebserver = true; + dockerCompat = true; + }; - lists = [ - { url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"; type = "block"; } - ]; - - settings = { - dns = { - upstreams = [ "127.0.0.1#5335" ]; # local unbound (recursive) - domain = "sol"; # local domain -> jupiter.sol etc. - # Local A records (/etc/hosts style). mercury is the DHCP server (no - # lease of its own), so its name must be declared here. jupiter is also - # pinned explicitly so it resolves regardless of lease state. - hosts = [ - "10.0.0.10 mercury.sol mercury" - "10.0.0.20 jupiter.sol jupiter" - ]; + virtualisation.oci-containers = { + backend = "podman"; + containers.pihole = { + image = "pihole/pihole:latest"; # TODO: pin to the tested version after VM check + 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"; }; - - 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 ]; } diff --git a/services/unbound.nix b/services/unbound.nix index 88fbc70..e48eafb 100644 --- a/services/unbound.nix +++ b/services/unbound.nix @@ -8,6 +8,12 @@ { services.unbound = { 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). settings.server = { interface = [ "127.0.0.1" ];