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