This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/pinix/localServices/bind.nix
pijukka ffa62020c8 host(pinix): Wildcard-Zertifikate per DNS-01 gegen das eigene BIND
Wildcards brauchen DNS-01, HTTP-01 kann sie nicht. Statt eines fremden
API-Tokens laeuft die Challenge jetzt per RFC2136 gegen das lokale BIND.

Entscheidend ist der Zuschnitt: delegiert wird nur
_acme-challenge.lukasrosier.com, nicht die Domain. MX, SPF, DKIM und der
Apex bleiben bei Cloudflare, die Mailzustellung haengt also nicht an
pinix. Volle Delegation bleibt eine spaetere, eigene Entscheidung.

Die Challenge-Zone ist eine eigene, beschreibbare Zone unter /var/lib,
weil dynamische Updates Schreibrechte auf Zonendatei und Journal
brauchen und der Store read-only ist. Die Hauptzone bleibt dadurch
unveraendert deklarativ im Store. update-policy erlaubt dem Schluessel
ausschliesslich TXT unter genau diesem Namen -- geprueft: Update dort
akzeptiert, derselbe Schluessel gegen die Hauptzone REFUSED, kein
Eintrag entstanden.

ACME laeuft ueber security.acme statt Traefiks eigenen Resolver (Hinweis
des Nutzers auf das NixOS-Modul): Zustand pro Zertifikat unter
/var/lib/acme statt gesammelt in Traefiks acme.json, andere Dienste
koennen dasselbe Zertifikat lesen, Timer und Reload-Hooks kommen vom
Upstream-Modul. Traefik haengt die Dateien nur ein. Das agenix-Secret
liegt in Environment-Form und passt unveraendert auf environmentFile;
bind.nix rendert daraus zusaetzlich BINDs key-Syntax, also ein Secret in
zwei Formaten statt zweier verschluesselter Dateien.

dnsResolver bleibt unbesetzt, damit die Propagationspruefung oeffentlich
fragt und dasselbe sieht wie Let's Encrypt. Zeigte sie auf das lokale
BIND, wuerde sie den Eintrag pruefen, den sie selbst gesetzt hat.

checkConfig = false ist unvermeidbar: das Modul laesst named-checkconf
mit -z laufen, das jede Zonendatei laden will, und eine dynamische Zone
existiert beim Bauen nicht. Als Ersatz laeuft die statische Zone durch
named-checkzone, sodass ein Fehler in MX/SPF/DKIM den Build bricht statt
erst named.

Fallstrick, der Zeit gekostet hat: systemd legt RuntimeDirectory als
root:root mit 0750 an. named ist nicht in der Gruppe root, konnte das
Verzeichnis nicht durchqueren und meldete "loading configuration: file
not found" -- was wie ein falscher Pfad aussieht. Deshalb chown auch auf
das Verzeichnis, nicht nur die Datei.

Stand: lego schreibt die Challenge nachweislich in unser BIND. Die
Ausstellung wartet noch auf den oeffentlichen NS-Eintrag fuer
_acme-challenge in der Cloudflare-Zone; bis dahin haelt das Modul das
selbst signierte Platzhalterzertifikat (minica) mit korrektem SAN fuer
Apex und Wildcard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aw4viUJyXGP8ANocHiF15q
2026-09-12 19:10:55 +02:00

145 lines
6.4 KiB
Nix

{ 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 ];
}