{ config, lib, options, pkgs, ... }: with lib; let cfg = config.local.ns; segmentRegex = "[a-z0-9_-]+(\\.[a-z0-9_-]+)*"; emailType = lib.types.strMatching "[a-z0-9._-]+(@${segmentRegex})?"; domainRefType = lib.types.strMatching "@|${segmentRegex}\\.?"; domainNameType = lib.types.strMatching "${segmentRegex}\\."; zoneHashCheck = name: zone: let zoneHash = algorithm: "${algorithm}-${builtins.hashString algorithm cfg.nullSerialZones.${name}.content}"; expected = zoneHash "sha256"; in { inherit expected zone; needsUpdate = zone.soa.serial == null || zone.nullSerialHash != expected; }; rrTypes = [ "A" "AAAA" "CNAME" "MX" "NS" "SOA" "SRV" "TXT" ]; in { options.local.ns = { nullSerialZones = mkOption { type = options.local.ns.zones.type; readOnly = true; }; zones = mkOption { default = { }; type = with lib.types; attrsOf (submodule ({ config, name, ... }: let nameOption = extra: mkOption (extra // { type = domainRefType; apply = value: if value == "@" then "${name}." else if ! hasSuffix "." value then "${value}.${name}." else value; }); rrType = options: mkOption { default = [ ]; type = listOf (submodule { options = options // { name = nameOption { }; ttl = mkOption { type = int; default = config.defaultTTL; }; }; }); }; rrConfig = { rrs, type, format, applyName ? (rr: rr.name) }: (map (rr: { inherit type; inherit (rr) ttl; data = format rr; name = applyName rr; }) rrs); in { options = { defaultTTL = mkOption { type = int; default = 3600; }; nsdConfig = mkOption { type = attrsOf unspecified; default = { }; }; content = mkOption { type = lines; readOnly = true; }; nullSerialHash = mkOption { type = nullOr str; default = null; }; rr = mkOption { default = [ ]; type = listOf (submodule { options = { name = nameOption { }; ttl = mkOption { type = int; }; class = mkOption { type = enum [ "IN" ]; default = "IN"; }; type = mkOption { type = enum rrTypes; }; data = mkOption { type = listOf (either int str); default = [ ]; }; }; }); }; soa = { ttl = mkOption { type = int; default = config.defaultTTL; }; primary = nameOption { default = "ns1"; }; hostmaster = mkOption { type = emailType; default = "hostmaster"; apply = address: let split = splitString "@" address; user = head split; domain = if length split == 2 then head (tail split) else name; in if hasSuffix "." address then address else "${replaceStrings [ "." ] [ "\\." ] user}.${domain}."; }; serial = mkOption { type = nullOr int; default = null; }; refresh = mkOption { type = int; default = 3 * 3600; }; retry = mkOption { type = int; default = 3600; }; expire = mkOption { type = int; default = 7 * 24 * 3600; }; negativeTTL = mkOption { type = int; default = 3600; }; }; a = rrType { ipv4 = mkOption { type = str; }; }; aaaa = rrType { ipv6 = mkOption { type = str; }; }; cname = rrType { target = nameOption { }; }; mx = rrType { host = nameOption { }; priority = mkOption { type = int; }; }; ns = rrType { host = nameOption { }; }; srv = rrType { host = nameOption { }; port = mkOption { type = port; }; priority = mkOption { type = int; }; proto = mkOption { type = enum [ "tcp" "udp" ]; }; service = mkOption { type = str; }; weight = mkOption { type = int; }; }; txt = rrType { text = mkOption { type = strMatching "[^\"\n\\]*\n?"; apply = removeSuffix "\n"; }; }; }; config = { nsdConfig.data = config.content; content = let rrLine = rr: concatMapStringsSep " " toString ([ rr.name rr.ttl rr.class rr.type ] ++ rr.data); in '' $ORIGIN ${name}. $TTL ${toString config.defaultTTL} '' + concatLines (map rrLine config.rr); rr = mkMerge [ (mkOrder 0 (singleton { inherit (config.soa) ttl; name = "${name}."; type = "SOA"; data = with config.soa; [ primary hostmaster (throwIf (serial == null) "No serial defined for zone ${name}" serial) refresh retry expire negativeTTL ]; })) (mkOrder 1 (rrConfig { rrs = config.ns; type = "NS"; format = rr: [ rr.host ]; })) (rrConfig { rrs = config.a; type = "A"; format = rr: [ rr.ipv4 ]; }) (rrConfig { rrs = config.aaaa; type = "AAAA"; format = rr: [ rr.ipv6 ]; }) (rrConfig { rrs = config.cname; type = "CNAME"; format = rr: [ rr.target ]; }) (rrConfig { rrs = config.mx; type = "MX"; format = rr: [ rr.priority rr.host ]; }) (rrConfig { rrs = config.srv; type = "SRV"; format = rr: [ rr.priority rr.weight rr.port rr.host ]; applyName = rr: "_${rr.service}._${rr.proto}.${rr.name}"; }) (rrConfig { rrs = config.txt; type = "TXT"; format = rr: let # nsd-zonecheck: text string is longer than 255 characters, try splitting it into multiple parts txtFragments = text: let max = 255; length = stringLength text; in singleton (substring 0 max text) ++ optionals (length > max) (txtFragments (substring max length text)); in map (fragment: "\"${fragment}\"") (txtFragments rr.text); }) ]; }; })); }; }; config = { assertions = [ ( let badZones = attrNames (filterAttrs (name: zone: (zoneHashCheck name zone).needsUpdate) cfg.zones); in { assertion = badZones == [ ]; message = "Update serials for these zones (null-serial hash mismatch): ${concatStringsSep ", " badZones}"; } ) ]; lib.local.zoneSerialUpdates = let zoneChecks = mapAttrs zoneHashCheck cfg.zones; updateInfo = check: { inherit (check) expected; inherit (check.zone.soa) serial; }; in mapAttrs (_: updateInfo) (filterAttrs (_: check: check.needsUpdate) zoneChecks); local.ns.nullSerialZones = mapAttrs (_: zone: mkMerge [ (filterAttrs (name: _: elem name ([ "defaultTTL" ] ++ map toLower rrTypes)) zone) { soa.serial = mkOverride 0 0; } ]) cfg.zones; }; }