diff options
Diffstat (limited to 'sys/ns/rr.nix')
| -rw-r--r-- | sys/ns/rr.nix | 520 |
1 files changed, 520 insertions, 0 deletions
diff --git a/sys/ns/rr.nix b/sys/ns/rr.nix new file mode 100644 index 0000000..7f089d1 --- /dev/null +++ b/sys/ns/rr.nix @@ -0,0 +1,520 @@ +{ + 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)); + }; + }; +} |
