summaryrefslogtreecommitdiff
path: root/sys/ns/rr.nix
diff options
context:
space:
mode:
Diffstat (limited to 'sys/ns/rr.nix')
-rw-r--r--sys/ns/rr.nix520
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));
+ };
+ };
+}