From 1039d1d47a53be0c814a03608e94a9d0e8f4405b Mon Sep 17 00:00:00 2001 From: Alejandro Soto Date: Sat, 19 Apr 2025 10:48:15 -0600 Subject: sys/ns: implement automatic PTR zones --- hooks/lib/update-zone-serials | 2 +- .../increment-zone-serials.py | 29 ++-- sys/net/options.nix | 89 +++++++++- sys/ns/default.nix | 1 + sys/ns/ns.nix | 152 ++++++++++++----- sys/ns/ptr/default.nix | 11 ++ sys/ns/ptr/gate-public-v4/default.nix | 16 ++ sys/ns/ptr/gate-public-v4/serial.nix | 7 + sys/ns/ptr/gate-public-v6/default.nix | 16 ++ sys/ns/ptr/gate-public-v6/serial.nix | 7 + sys/ns/ptr/static-prefix-v6/default.nix | 16 ++ sys/ns/ptr/static-prefix-v6/serial.nix | 7 + sys/ns/rr.nix | 180 ++++++++++++++++++--- 13 files changed, 450 insertions(+), 83 deletions(-) create mode 100644 sys/ns/ptr/default.nix create mode 100644 sys/ns/ptr/gate-public-v4/default.nix create mode 100644 sys/ns/ptr/gate-public-v4/serial.nix create mode 100644 sys/ns/ptr/gate-public-v6/default.nix create mode 100644 sys/ns/ptr/gate-public-v6/serial.nix create mode 100644 sys/ns/ptr/static-prefix-v6/default.nix create mode 100644 sys/ns/ptr/static-prefix-v6/serial.nix diff --git a/hooks/lib/update-zone-serials b/hooks/lib/update-zone-serials index 272d5f6..e0a8585 100755 --- a/hooks/lib/update-zone-serials +++ b/hooks/lib/update-zone-serials @@ -4,5 +4,5 @@ set -e set -o pipefail nix eval --json '.#nixosConfigurations.gate.config.lib.local.zoneSerialUpdates' \ - | nix run '.#increment-zone-serials' -- sys/ns/zones \ + | nix run '.#increment-zone-serials' -- sys/ns \ | while read file; do nix fmt -- "$file"; git add -v -- "$file"; done diff --git a/pkgs/increment-zone-serials/increment-zone-serials.py b/pkgs/increment-zone-serials/increment-zone-serials.py index 2d5753a..394075a 100755 --- a/pkgs/increment-zone-serials/increment-zone-serials.py +++ b/pkgs/increment-zone-serials/increment-zone-serials.py @@ -2,18 +2,14 @@ import datetime, json, os.path, sys -base_dir = sys.argv[1] +root_dir = sys.argv[1] serial_num = lambda n: f'{today}{n:02}' -today_str = datetime.datetime.utcnow().date().strftime('%Y%m%d') -today = int(today_str) +today_str = datetime.datetime.now(datetime.UTC).date().strftime('%Y%m%d') +today = int(today_str) -paths = [] - -for zone, data in json.load(sys.stdin).items(): - expected_hash = data['expected'] - - old_serial_int = data['serial'] +def update_serial(*, zone_class, name, expected, serial): + old_serial_int = serial new_serial = serial_num(0) if old_serial_int is not None: @@ -28,17 +24,24 @@ for zone, data in json.load(sys.stdin).items(): if new_serial is None: new_serial = str(old_serial_int + 1) - path = os.path.join(base_dir, zone, 'serial.nix') - with open(os.path.join(base_dir, zone, 'serial.nix'), 'w') as serial_file: + path = os.path.join(root_dir, zone_class, name, 'serial.nix') + with open(path, 'w') as serial_file: print(f'''\ {{ config = {{ soa.serial = {new_serial}; - nullSerialHash = "{expected_hash}"; + nullSerialHash = "{expected}"; }}; }} ''', file=serial_file) - paths.append(path) + + return path + +paths = [] + +for zone_class, zones in json.load(sys.stdin).items(): + for zone in zones.values(): + paths.append(update_serial(**zone, zone_class=zone_class)) # Se imprime al final para evitar estados intermedios si algo tira excepción for path in paths: diff --git a/sys/net/options.nix b/sys/net/options.nix index 2930567..11b913c 100644 --- a/sys/net/options.nix +++ b/sys/net/options.nix @@ -1,5 +1,46 @@ { config, lib, ... }: -with lib; { +with lib; let + v4PtrHierarchy = address: bits: reverseList (sublist 0 (bits / 8) (splitString "." address)); + + v6PtrHierarchy = address: bits: + let + separator = lists.findFirstIndex (hextet: hextet == "") null colonSplit; + colonSplit = splitString ":" address; + + zeroFill = replicate (8 - length colonSplit + 1) "0000"; + leftSplit = sublist 0 separator colonSplit; + rightSplit = sublist (separator + 1) (length colonSplit - separator - 1) colonSplit; + + fullSplit = + if separator != null + then leftSplit ++ zeroFill ++ rightSplit + else colonSplit; + + padded = map (hextet: strings.replicate (4 - stringLength hextet) "0" + hextet) fullSplit; + in + reverseList (sublist 0 (bits / 4) (flatten (map stringToCharacters padded))); + + matchPtrRecordName = { splitter, netAddress, netBits, targetAddress, targetBits }: + let + netSplit = splitter netAddress netBits; + targetSplit = splitter targetAddress targetBits; + + netLength = length netSplit; + lengthDelta = length targetSplit - netLength; + + withinNet = lengthDelta >= 0 && sublist lengthDelta netLength targetSplit == netSplit; + throwMessage = "${targetAddress}/${toString targetBits} is not a subset of ${netAddress}/${toString netBits}"; + + recordHierarchy = sublist 0 lengthDelta targetSplit; + + recordName = + if recordHierarchy != [ ] + then concatStringsSep "." recordHierarchy + else "@"; + in + throwIfNot withinNet throwMessage recordName; +in +{ options.local.nets = with lib.types; mkOption { readOnly = true; @@ -126,14 +167,37 @@ with lib; { type = str; readOnly = true; }; + + ptrDomain = mkOption { + type = str; + readOnly = true; + }; + + ptrRecordName = mkOption { + type = functionTo (functionTo str); + readOnly = true; + }; }; config = { cidr = "${config.subnet}/${toString config.bits}"; + subnet = if config.bits != 0 then config.prefix + strings.replicate (4 - config.bits / 8) ".0" else "0.0.0.0"; + + ptrDomain = concatStrings (map (x: x + ".") (v4PtrHierarchy config.subnet config.bits)) + "in-addr.arpa"; + + ptrRecordName = address: bits: matchPtrRecordName { + splitter = v4PtrHierarchy; + + netBits = config.bits; + netAddress = config.subnet; + + targetBits = bits; + targetAddress = address; + }; }; })); }; @@ -162,14 +226,37 @@ with lib; { type = str; readOnly = true; }; + + ptrDomain = mkOption { + type = str; + readOnly = true; + }; + + ptrRecordName = mkOption { + type = functionTo (functionTo str); + readOnly = true; + }; }; config = { cidr = "${config.subnet}/${toString config.bits}"; + subnet = if config.bits == 128 || length (splitString "::" config.prefix) > 1 then config.prefix else "${config.prefix}::"; + + ptrDomain = concatStrings (map (x: x + ".") (v6PtrHierarchy config.subnet config.bits)) + "ip6.arpa"; + + ptrRecordName = address: bits: matchPtrRecordName { + splitter = v6PtrHierarchy; + + netBits = config.bits; + netAddress = config.subnet; + + targetBits = bits; + targetAddress = address; + }; }; })); }; diff --git a/sys/ns/default.nix b/sys/ns/default.nix index d9e0063..b1a4da3 100644 --- a/sys/ns/default.nix +++ b/sys/ns/default.nix @@ -3,6 +3,7 @@ ./mx.nix ./ns.nix ./nsd.nix + ./ptr ./rr.nix ./zones ]; diff --git a/sys/ns/ns.nix b/sys/ns/ns.nix index 4c242b6..a1b1605 100644 --- a/sys/ns/ns.nix +++ b/sys/ns/ns.nix @@ -1,54 +1,118 @@ { config, lib, ... }: with lib; let - inherit (config.local.ns.server) tsigName; + inherit (config.networking) domain; inherit (config.local.nets) gate-public; + inherit (config.local.ns.server) tsigName; + + ptrNets = config.local.ns.ptr; in { options.local.ns.zones = mkOption { - type = with lib.types; attrsOf (submodule ({ config, name, ... }: - let - cfg = config.localNS; - in - { - options.localNS = { - enable = mkEnableOption "local NS settings"; - - primary = mkOption { - type = str; - default = "ns1"; - }; - }; - - config = mkIf cfg.enable { - # https://docs.gandi.net/en/domain_names/advanced_users/secondary_nameserver.html - nsdConfig = - let - providerSecondary = [ - "37.205.15.45 ${tsigName}" # ns3.vpsfree.cz - "37.205.11.85 ${tsigName}" # ns4.vpsfree.cz - "2a03:3b40:fe:2be::1 ${tsigName}" # ns3.vpsfree.cz - "2a03:3b40:101:4::1 ${tsigName}" # ns4.vpsfree.cz - ]; - in - { - notify = providerSecondary; - provideXFR = providerSecondary; + type = with lib.types; attrsOf + (submodule + ({ config, name, ... }: + let + inherit (config.soa) primary; + + cfg = config.localNS; + ptrDomain = cfg.ptrNet.v4 != null || cfg.ptrNet.v6 != null; + in + { + options.localNS = { + enable = mkEnableOption "local NS settings"; + + ptrNet = { + v4 = mkOption { + type = nullOr str; + default = null; + }; + + v6 = mkOption { + type = nullOr str; + default = null; + }; + }; }; - ns = [ - { name = "@"; host = cfg.primary; } - { name = "@"; host = "ns3.vpsfree.cz."; } - { name = "@"; host = "ns4.vpsfree.cz."; } - ]; - - a = [ - { name = cfg.primary; ipv4 = gate-public.hosts.gate.v4.address; } - ]; - - aaaa = [ - { name = cfg.primary; ipv6 = gate-public.hosts.gate.v6.address; } - ]; - }; - })); + config = mkIf cfg.enable + { + ptrName = + let + name = + if cfg.ptrNet.v6 != null + then "${cfg.ptrNet.v6}-v6" + else "${cfg.ptrNet.v4}-v4"; + in + mkIf ptrDomain name; + + # https://docs.gandi.net/en/domain_names/advanced_users/secondary_nameserver.html + nsdConfig = + let + providerSecondary = [ + "37.205.15.45 ${tsigName}" # ns3.vpsfree.cz + "37.205.11.85 ${tsigName}" # ns4.vpsfree.cz + "2a03:3b40:fe:2be::1 ${tsigName}" # ns3.vpsfree.cz + "2a03:3b40:101:4::1 ${tsigName}" # ns4.vpsfree.cz + ]; + in + { + notify = providerSecondary; + provideXFR = providerSecondary; + }; + + ns = [ + { name = "@"; host = primary; } + { name = "@"; host = "ns3.vpsfree.cz."; } + { name = "@"; host = "ns4.vpsfree.cz."; } + ]; + + a = optional (!ptrDomain) + { name = primary; ipv4 = gate-public.hosts.gate.v4.address; ptr = null; }; + + aaaa = optional (!ptrDomain) + { name = primary; ipv6 = gate-public.hosts.gate.v6.address; ptr = null; }; + + ptr = + let + ptrsToRecords = mapAttrsToList (suffix: target: { + name = suffix; + inherit target; + }); + + v4Net = cfg.ptrNet.v4; + v6Net = cfg.ptrNet.v6; + + v4Records = optionals (v4Net != null) (ptrsToRecords ptrNets.${v4Net}.v4.targets); + v6Records = optionals (v6Net != null) (ptrsToRecords ptrNets.${v6Net}.v6.targets); + in + v4Records ++ v6Records; + + soa = mkIf ptrDomain { + authorityZone = mkDefault "${domain}."; + }; + }; + })); }; + + config = + { + assertions = mapAttrsToList + (name: zone: { + assertion = zone.localNS.ptrNet.v4 != null -> zone.localNS.ptrNet.v6 == null; + message = "zone '${name}' defined as both a v4 and v6 PTR zone"; + }) + config.local.ns.zones; + + local.ns.ptr = + let + zonePtrNets = name: zone: + optionalAttrs (zone.localNS.ptrNet.v4 != null) + { + ${zone.localNS.ptrNet.v4}.v4.zone = name; + } // optionalAttrs (zone.localNS.ptrNet.v6 != null) { + ${zone.localNS.ptrNet.v6}.v6.zone = name; + }; + in + mkMerge (flatten (mapAttrsToList zonePtrNets (filterAttrs (_: zone: zone.localNS.enable) config.local.ns.zones))); + }; } diff --git a/sys/ns/ptr/default.nix b/sys/ns/ptr/default.nix new file mode 100644 index 0000000..d583dd7 --- /dev/null +++ b/sys/ns/ptr/default.nix @@ -0,0 +1,11 @@ +{ config, ... }: +let + inherit (config.local) nets; +in +{ + config.local.ns.zones = { + ${nets.gate-public.v4.ptrDomain} = import ./gate-public-v4; + ${nets.gate-public.v6.ptrDomain} = import ./gate-public-v6; + ${nets.static-prefix.v6.ptrDomain} = import ./static-prefix-v6; + }; +} diff --git a/sys/ns/ptr/gate-public-v4/default.nix b/sys/ns/ptr/gate-public-v4/default.nix new file mode 100644 index 0000000..a2595d9 --- /dev/null +++ b/sys/ns/ptr/gate-public-v4/default.nix @@ -0,0 +1,16 @@ +{ config, ... }: +let + inherit (config.local) nets; +in +{ + imports = [ + ./serial.nix + ]; + + config = { + localNS = { + enable = true; + ptrNet.v4 = "gate-public"; + }; + }; +} diff --git a/sys/ns/ptr/gate-public-v4/serial.nix b/sys/ns/ptr/gate-public-v4/serial.nix new file mode 100644 index 0000000..c3a41e9 --- /dev/null +++ b/sys/ns/ptr/gate-public-v4/serial.nix @@ -0,0 +1,7 @@ +{ + config = { + soa.serial = 2025042402; + nullSerialHash = "sha256-afaedee02017aabd45b944a657ce91515866982c7cb900927edcee6d2b39c731"; + }; +} + diff --git a/sys/ns/ptr/gate-public-v6/default.nix b/sys/ns/ptr/gate-public-v6/default.nix new file mode 100644 index 0000000..15a4095 --- /dev/null +++ b/sys/ns/ptr/gate-public-v6/default.nix @@ -0,0 +1,16 @@ +{ config, ... }: +let + inherit (config.local) nets; +in +{ + imports = [ + ./serial.nix + ]; + + config = { + localNS = { + enable = true; + ptrNet.v6 = "gate-public"; + }; + }; +} diff --git a/sys/ns/ptr/gate-public-v6/serial.nix b/sys/ns/ptr/gate-public-v6/serial.nix new file mode 100644 index 0000000..2f1b4a9 --- /dev/null +++ b/sys/ns/ptr/gate-public-v6/serial.nix @@ -0,0 +1,7 @@ +{ + config = { + soa.serial = 2025042402; + nullSerialHash = "sha256-9a8ac8849ea6c8993e44feefe439b96c643e2ccf3a03d0d700558e9a188f57d7"; + }; +} + diff --git a/sys/ns/ptr/static-prefix-v6/default.nix b/sys/ns/ptr/static-prefix-v6/default.nix new file mode 100644 index 0000000..f02222c --- /dev/null +++ b/sys/ns/ptr/static-prefix-v6/default.nix @@ -0,0 +1,16 @@ +{ config, ... }: +let + inherit (config.local) nets; +in +{ + imports = [ + ./serial.nix + ]; + + config = { + localNS = { + enable = true; + ptrNet.v6 = "static-prefix"; + }; + }; +} diff --git a/sys/ns/ptr/static-prefix-v6/serial.nix b/sys/ns/ptr/static-prefix-v6/serial.nix new file mode 100644 index 0000000..c09a24a --- /dev/null +++ b/sys/ns/ptr/static-prefix-v6/serial.nix @@ -0,0 +1,7 @@ +{ + config = { + soa.serial = 2025042402; + nullSerialHash = "sha256-92c2046d390891a99618c5cf92efee1cda3549799ef26f1f0ca234e0a105aec8"; + }; +} + diff --git a/sys/ns/rr.nix b/sys/ns/rr.nix index a80eaf4..e4fbe12 100644 --- a/sys/ns/rr.nix +++ b/sys/ns/rr.nix @@ -1,5 +1,7 @@ { config, lib, options, pkgs, ... }: with lib; let + inherit (config.local) nets; + cfg = config.local.ns; globalConfig = config; @@ -25,6 +27,7 @@ with lib; let "CNAME" "MX" "NS" + "PTR" "SOA" "SRV" "TXT" @@ -37,21 +40,63 @@ in 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 = extra: mkOption (extra // { - type = domainRefType; - - apply = value: - if value == "@" - then "${name}." - else if ! hasSuffix "." value - then "${value}.${name}." - else value; - }); + 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 = [ ]; @@ -90,6 +135,23 @@ in 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 = { }; @@ -134,12 +196,17 @@ in }; soa = { + authorityZone = nameOption { default = "@"; permitRelative = false; }; + ttl = mkOption { type = int; default = config.defaultTTL; }; - primary = nameOption { default = "ns1"; }; + primary = nameOption { + default = "ns1"; + defaultZone = config.soa.authorityZone; + }; hostmaster = mkOption { type = emailType; @@ -150,7 +217,7 @@ in split = splitString "@" address; user = head split; - domain = if length split == 2 then head (tail split) else name; + domain = if length split == 2 then head (tail split) else removeSuffix "." config.soa.authorityZone; in if hasSuffix "." address then address @@ -187,12 +254,22 @@ in 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 { @@ -211,6 +288,10 @@ in host = nameOption { }; }; + ptr = rrType { + target = nameOption { }; + }; + srv = rrType { host = nameOption { }; @@ -303,6 +384,12 @@ in format = rr: [ rr.priority rr.host ]; }) + (rrConfig { + rrs = config.ptr; + type = "PTR"; + format = rr: [ rr.target ]; + }) + (rrConfig { rrs = config.srv; type = "SRV"; @@ -344,24 +431,69 @@ in 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 - zoneChecks = mapAttrs zoneHashCheck cfg.zones; - updateInfo = check: { + 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 - 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; + { + 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)); + }; }; } -- cgit v1.2.3