{ config, lib, pkgs, ... }: let domains = import ../../modules/dns/domains.nix; mesh = import ../../modules/mesh-hosts.nix { inherit config lib; }; hosts = import ../../modules/hosts.nix; in { imports = [ ../../modules/dns/authoritative.nix ../../modules/dns/acme-mx1-secret.nix ../../modules/dns/acme-web01-secret.nix ../../modules/static-ipv6.nix ../../modules/monitoring/exporters.nix ]; clan.core.sops.defaultGroups = [ "admins" ]; # Knot's state dir holds the non-regenerable DNSSEC key material (KSK/ZSK # private keys in the KASP keystore). Declaring it as clan state makes the # borgbackup client back it up; losing it forces an emergency DS rollover at # the registrar. mode 0700 owned by knot, but borg runs as root so it reads it. clan.core.state.knot.folders = [ "/var/lib/knot" ]; # The borgbackup repo is addressed as `borg@control`; mesh peers have no name # resolution, so map the control machine name to its ZeroTier mesh address. networking.hosts.${mesh.hosts.control} = [ "control" ]; # Public IPv6 (from modules/hosts.nix; matches the ns1 AAAA glue); SLAAC # doesn't bring it up here. cnx.staticIPv6 = { enable = true; address = hosts.${config.networking.hostName}.ipv6; }; time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST) services.timesyncd.enable = true; # ACME DNS-01 (RFC 2136), general key. A dedicated TSIG key scoped by acl_acme # (referenced by every zone below) to TXT updates at or under _acme-challenge. # Retrieve the client config with: # clan vars get ns1 dns-acme-tsig/acme.conf clan.core.vars.generators.dns-acme-tsig = { files."acme.conf" = { secret = true; owner = "knot"; group = "knot"; }; runtimeInputs = [ pkgs.knot-dns ]; script = '' keymgr -t acme_ddns hmac-sha256 > "$out"/acme.conf ''; }; # ACME DNS-01, dedicated mx1 key. A *separate* TSIG key (acme_mx1) that only # mx1 holds, rendered from the shared secret (generator dns-acme-mx1-secret, # imported above). acl_acme_mx1 scopes it to TXT updates at exactly # _acme-challenge.{mx1,mta-sts,mail} (the mail cert and its MTA-STS + client- # alias SANs), and it is attached only to the cnx.email zone below — so this # credential can write nothing but mx1's own cert challenges. clan.core.vars.generators.dns-acme-mx1-knot = { files."acme.conf" = { secret = true; owner = "knot"; group = "knot"; }; dependencies = [ "dns-acme-mx1-secret" ]; script = '' printf 'key:\n - id: acme_mx1\n algorithm: hmac-sha256\n secret: %s\n' \ "$(cat "$in"/dns-acme-mx1-secret/secret)" > "$out"/acme.conf ''; }; # ACME DNS-01, dedicated web01 key. A *separate* TSIG key (acme_web01) that only # web01 holds, rendered from the shared secret (generator dns-acme-web01-secret, # imported above). acl_acme_web01 scopes it to TXT updates at _acme-challenge on # the cnx.network zone — the owner the wildcard *.cnx.network challenge uses — so # this credential can write nothing but web01's own cert challenges. clan.core.vars.generators.dns-acme-web01-knot = { files."acme.conf" = { secret = true; owner = "knot"; group = "knot"; }; dependencies = [ "dns-acme-web01-secret" ]; script = '' printf 'key:\n - id: acme_web01\n algorithm: hmac-sha256\n secret: %s\n' \ "$(cat "$in"/dns-acme-web01-secret/secret)" > "$out"/acme.conf ''; }; services.knot.keyFiles = [ config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path config.clan.core.vars.generators.dns-acme-mx1-knot.files."acme.conf".path config.clan.core.vars.generators.dns-acme-web01-knot.files."acme.conf".path ]; services.knot.settings.acl = [ { id = "acl_acme"; key = "acme_ddns"; action = [ "update" ]; "update-type" = [ "TXT" ]; "update-owner" = "name"; "update-owner-match" = "sub-or-equal"; "update-owner-name" = [ "_acme-challenge" ]; } { id = "acl_acme_mx1"; key = "acme_mx1"; action = [ "update" ]; "update-type" = [ "TXT" ]; "update-owner" = "name"; "update-owner-match" = "sub-or-equal"; "update-owner-name" = [ "_acme-challenge.mx1" "_acme-challenge.mta-sts" "_acme-challenge.mail" ]; } { id = "acl_acme_web01"; key = "acme_web01"; action = [ "update" ]; "update-type" = [ "TXT" ]; "update-owner" = "name"; "update-owner-match" = "sub-or-equal"; # Wildcard *.cnx.network places its challenge at _acme-challenge.cnx.network, # i.e. _acme-challenge at the cnx.network apex (where this acl is attached). "update-owner-name" = [ "_acme-challenge" ]; } ]; # Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with # Knot's default key management: the ZSK auto-rolls and the KSK is kept stable, # so the DS at the registrar only changes on a manual KSK rollover. services.knot.settings.policy = [ { id = "cnx"; algorithm = "ecdsap256sha256"; } ]; # ns1 = primary (master): loads each zone from its file and serves it to ns2. # zonefile-load = difference-no-serial lets us edit records without touching the # SOA serial; Knot diffs the file, assigns a unixtime serial, signs the zone, # then notifies ns2 and lets it pull the signed zone via AXFR/IXFR. unixtime is # strictly monotonic per reload, so two zone versions can never share a serial # (the failure mode dateserial's 2-digit daily counter allowed after a journal reset). services.knot.settings.zone = map (d: { domain = d; file = ../../modules/dns/zones + "/${d}.zone"; "zonefile-load" = "difference-no-serial"; "zonefile-sync" = "-1"; "journal-content" = "all"; # required by difference-no-serial; holds the live signed zone "serial-policy" = "unixtime"; "dnssec-signing" = true; "dnssec-policy" = "cnx"; notify = [ "ns2" ]; # ns2 transfers; acme_ddns does general DNS-01 updates. The dedicated # acme_mx1 key is attached only to cnx.email, so it can't touch other zones. acl = [ "acl_ns2" "acl_acme" ] ++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ] ++ lib.optionals (d == "cnx.network") [ "acl_acme_web01" ]; }) domains; }