{ config, lib, pkgs, ... }: let # Ersatz fuer die Pruefung, die unten abgeschaltet werden muss: die # statische Zone laeuft beim Bauen durch named-checkzone und der Build # scheitert, wenn sie fehlerhaft ist. Genau diese Datei traegt MX, SPF und # DKIM -- ein Tippfehler darin kostet Mailzustellung, also soll er nicht # erst beim Start von named auffallen. checkedZone = pkgs.runCommand "lukasrosier.com.zone" { } '' ${pkgs.bind}/bin/named-checkzone ${config.domain2} ${./dns/lukasrosier.com.zone} cp ${./dns/lukasrosier.com.zone} $out ''; in { # Authoritative DNS candidate; registrar delegation is still Cloudflare. # See docs/pinix-bind.md before changing delegation or zone contents. services.bind = { enable = true; # Muss aus: das Modul laesst named-checkconf mit -z laufen, das jede # Zonendatei zu laden versucht. Die Challenge-Zone liegt zwingend # ausserhalb des Stores, weil BIND in sie schreibt -- beim Bauen # existiert sie also nicht, und die Pruefung scheitert zwangslaeufig. # Dynamische Zonen und diese Pruefung schliessen sich damit aus. Als # Ersatz laeuft die statische Zone oben durch named-checkzone. checkConfig = false; listenOn = [ "127.0.0.1" ]; # Deliberately not "any". This server is authoritative-only and exists to # answer the public via the router's port forwarding, which targets the # LAN SLAAC address -- so that address plus loopback is the whole # requirement. With "any" it also bound the Tailnet address and every # link-local, which served nothing and collides with the recursive # resolver for Tailnet clients (unbound.nix): both want port 53, and a # host cannot have two daemons on the same address and port. listenOnIpv6 = [ "::1" config.lanIPv6.pinix ]; cacheNetworks = [ "none" ]; forwarders = [ ]; extraOptions = '' recursion no; dnssec-validation no; minimal-responses yes; version none; notify no; ''; # Ein key-Statement muss auf oberster Ebene von named.conf stehen, nicht # in options -- daher extraConfig und nicht extraOptions. Die Datei # entsteht zur Laufzeit aus dem agenix-Secret (bind-acme-tsig unten), # steht also nie im weltlesbaren Store. extraConfig = '' include "/run/bind-acme-tsig/key.conf"; ''; # Aufgabe seit 12.09.2026: domain2, nicht domain. 0ff.space bleibt bei # Cloudflare und auf blunix -- dort haengen XMPP-Foederation und # ActivityPub dran, deren Ausfall sofort Identitaetszustand kostet. # lukasrosier.com ist der Kandidat, der tatsaechlich delegiert werden # kann. Folgenlos ist er aber nicht: die Zone traegt MX, SPF und DKIM # fuer Zoho-Mail. Voraussetzungen vor der Delegation in # docs/pinix-bind.md. zones."${config.domain2}" = { master = true; file = checkedZone; allowQuery = [ "any" ]; slaves = [ ]; extraConfig = "allow-update { none; };"; }; # Eigene, *beschreibbare* Zone nur fuer die ACME-Challenge. Der Grund # fuer den getrennten Unterbaum: dynamische Updates verlangen, dass BIND # Zonendatei und Journal schreiben kann, und der Nix-Store ist # read-only. Waere die Hauptzone dynamisch, muesste sie komplett nach # /var/lib wandern und waere kein deklarativer Zustand mehr. So bleibt # lukasrosier.com unveraendert im Store, und veraenderlich ist nur # dieser eine Name. # # update-policy statt allow-update: der Schluessel darf ausschliesslich # TXT-Eintraege unter genau diesem Namen setzen -- nicht die Hauptzone, # keine anderen Typen, kein anderer Name. Ein kompromittierter # ACME-Client kann damit kein A/MX/NS umbiegen. zones."_acme-challenge.${config.domain2}" = { master = true; file = "/var/lib/bind/_acme-challenge.${config.domain2}.zone"; allowQuery = [ "any" ]; slaves = [ ]; extraConfig = '' update-policy { grant acme-lukasrosier name _acme-challenge.${config.domain2}. TXT; }; ''; }; }; # Der TSIG-Schluessel liegt als agenix-Secret in Environment-Form, damit # Traefik ihn unveraendert als EnvironmentFile lesen kann. BIND braucht # dieselbe Angabe in seiner eigenen key-Syntax, also wird sie hier vor dem # Start daraus gerendert -- ein Secret, zwei Formate, statt derselben # Zeichenfolge in zwei verschluesselten Dateien, die auseinanderlaufen # koennen. Gleiches Muster wie dex-ldap-env auf blunix. systemd.services.bind-acme-tsig = { description = "Render the ACME TSIG key into BIND's key syntax"; before = [ "bind.service" ]; requiredBy = [ "bind.service" ]; restartTriggers = [ config.age.secrets.bindAcmeTsig.file ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; RuntimeDirectory = "bind-acme-tsig"; RuntimeDirectoryMode = "0750"; ExecStart = pkgs.writeShellScript "bind-acme-tsig" '' set -eu umask 077 set -a . ${config.age.secrets.bindAcmeTsig.path} set +a # Die Algorithmusangabe tragt im Env-File den Punkt am Ende, weil # lego sie als DNS-Namen erwartet; BIND will sie ohne. printf 'key "%s" {\n algorithm %s;\n secret "%s";\n};\n' \ "$RFC2136_TSIG_KEY" \ "''${RFC2136_TSIG_ALGORITHM%.}" \ "$RFC2136_TSIG_SECRET" \ > /run/bind-acme-tsig/key.conf # Auch das Verzeichnis, nicht nur die Datei: systemd legt # RuntimeDirectory als root:root mit 0750 an, und named ist nicht in # der Gruppe root. Ohne das Durchqueren-Recht meldet named beim Start # "loading configuration: file not found" -- was wie ein Tippfehler # im Pfad aussieht und keiner ist. chown named:named /run/bind-acme-tsig /run/bind-acme-tsig/key.conf ''; }; }; # Die Challenge-Zone ist veraenderlicher Zustand: einmal aus dem Store # gesaet, danach schreibt BIND selbst. `C` kopiert nur, wenn das Ziel # fehlt, ueberschreibt also keine laufende Zone samt Journal. systemd.tmpfiles.rules = [ "d /var/lib/bind 0750 named named -" "C /var/lib/bind/_acme-challenge.${config.domain2}.zone 0640 named named - ${./dns/acme-challenge.zone}" ]; # BIND's NixOS module otherwise adds localhost to resolv.conf. This # authoritative server does not resolve Internet names or provide DNS64. networking.resolvconf.useLocalResolver = lib.mkForce false; networking.firewall.allowedTCPPorts = [ 53 ]; networking.firewall.allowedUDPPorts = [ 53 ]; }