{ config, lib, options, pkgs, ... }: with lib; let inherit (config.local) nets; cfg = config.local.ns; globalConfig = config; 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" "PTR" "SOA" "SRV" "TXT" ]; in { options.local.ns = { nullSerialZones = mkOption { type = options.local.ns.zones.type; readOnly = true; }; ptr = mkOption { default = { }; type = with lib.types; attrsOf (submodule { options = { v4 = { zone = mkOption { type = nullOr str; default = null; }; targets = mkOption { type = attrsOf str; default = { }; }; }; v6 = { zone = mkOption { type = nullOr str; default = null; }; targets = mkOption { type = attrsOf str; default = { }; }; }; }; }); }; zones = mkOption { default = { }; type = with lib.types; attrsOf (submodule ({ config, name, ... }: let nameOption = args@{ defaultZone ? "${name}.", permitRelative ? true, ... }: mkOption (removeAttrs args [ "defaultZone" "permitRelative" ] // { type = domainRefType; apply = value: let zone = throwIfNot (hasSuffix "." defaultZone) "zone expression '${defaultZone}' must be absolute, not relative" defaultZone; in if value == "@" then zone else if hasSuffix "." value then value else if permitRelative then "${value}.${zone}" else throw "zone expression '${value}' in zone '${zone}' must be absolute, not relative"; }); 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 = { local = mkOption { type = unspecified; default = globalConfig.local; readOnly = true; }; defaultTTL = mkOption { type = int; default = 3600; }; ptrName = mkOption { type = nullOr str; default = null; }; defaultPtr = { v4 = mkOption { type = nullOr str; default = null; }; v6 = mkOption { type = nullOr str; default = null; }; }; 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 = { authorityZone = nameOption { default = "@"; permitRelative = false; }; ttl = mkOption { type = int; default = config.defaultTTL; }; primary = nameOption { default = "ns1"; defaultZone = config.soa.authorityZone; }; 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 removeSuffix "." config.soa.authorityZone; 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; }; ptr = mkOption { type = nullOr str; default = config.defaultPtr.v4; }; }; aaaa = rrType { ipv6 = mkOption { type = str; }; ptr = mkOption { type = nullOr str; default = config.defaultPtr.v6; }; }; cname = rrType { target = nameOption { }; }; mx = rrType { host = nameOption { }; priority = mkOption { type = int; }; }; ns = rrType { host = nameOption { }; }; ptr = rrType { target = 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.ptr; type = "PTR"; format = rr: [ rr.target ]; }) (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}"; } ) ] ++ flatten (mapAttrsToList (name: ptr: [ { assertion = ptr.v4.targets != { } -> ptr.v4.zone != null; message = "undefined v4 PTR net '${name}': ${concatStringsSep ", " (attrValues ptr.v4.targets)}"; } { assertion = ptr.v6.targets != { } -> ptr.v6.zone != null; message = "undefined v6 PTR net '${name}': ${concatStringsSep ", " (attrValues ptr.v6.targets)}"; } ]) cfg.ptr); lib.local.zoneSerialUpdates = let ptrChecks = filterAttrs (_: check: check.zone.ptrName != null) allZoneChecks; zoneChecks = filterAttrs (_: check: check.zone.ptrName == null) allZoneChecks; allZoneChecks = filterAttrs (_: check: check.needsUpdate) (mapAttrs zoneHashCheck cfg.zones); updateInfo = name: check: { inherit name; inherit (check) expected; inherit (check.zone.soa) serial; }; in { ptr = mapAttrs (_: check: updateInfo check.zone.ptrName check) ptrChecks; zones = mapAttrs updateInfo zoneChecks; }; local.ns = { nullSerialZones = let defaultAttrs = [ "defaultTTL" "defaultPtr" "ptrName" ]; filteredAttrs = defaultAttrs ++ map toLower rrTypes; in mapAttrs (_: zone: mkMerge [ (filterAttrs (name: _: elem name filteredAttrs) zone) { soa.serial = mkOverride 0 0; } ]) cfg.zones; ptr = let zonePtrs = zone: let v4Ptrs = map (a: { ${a.ptr}.v4.targets.${nets.${a.ptr}.v4.ptrRecordName a.ipv4 32} = a.name; }) (filter (a: a.ptr != null) zone.a); v6Ptrs = map (aaaa: { ${aaaa.ptr}.v6.targets.${nets.${aaaa.ptr}.v6.ptrRecordName aaaa.ipv6 128} = aaaa.name; }) (filter (aaaa: aaaa.ptr != null) zone.aaaa); in v4Ptrs ++ v6Ptrs; in mkMerge (flatten (mapAttrsToList (_: zonePtrs) cfg.zones)); }; }; }