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
145 lines
6.4 KiB
Nix
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 ];
|
|
}
|