diff options
Diffstat (limited to 'sys/ns')
| -rw-r--r-- | sys/ns/default.nix | 10 | ||||
| -rw-r--r-- | sys/ns/dkim/README.md | 1 | ||||
| -rw-r--r-- | sys/ns/mx.nix | 60 | ||||
| -rw-r--r-- | sys/ns/ns.nix | 153 | ||||
| -rw-r--r-- | sys/ns/nsd.nix | 87 | ||||
| -rw-r--r-- | sys/ns/ptr/default.nix | 9 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v4/default.nix | 14 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v4/serial.nix | 6 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v6/default.nix | 14 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v6/serial.nix | 6 | ||||
| -rw-r--r-- | sys/ns/ptr/static-prefix-v6/default.nix | 14 | ||||
| -rw-r--r-- | sys/ns/ptr/static-prefix-v6/serial.nix | 6 | ||||
| -rw-r--r-- | sys/ns/rr.nix | 520 | ||||
| -rw-r--r-- | sys/ns/zones/README.md | 1 |
14 files changed, 901 insertions, 0 deletions
diff --git a/sys/ns/default.nix b/sys/ns/default.nix new file mode 100644 index 0000000..b1a4da3 --- /dev/null +++ b/sys/ns/default.nix @@ -0,0 +1,10 @@ +{ + imports = [ + ./mx.nix + ./ns.nix + ./nsd.nix + ./ptr + ./rr.nix + ./zones + ]; +} diff --git a/sys/ns/dkim/README.md b/sys/ns/dkim/README.md new file mode 100644 index 0000000..37073ba --- /dev/null +++ b/sys/ns/dkim/README.md @@ -0,0 +1 @@ +# This directory has been lustrated. diff --git a/sys/ns/mx.nix b/sys/ns/mx.nix new file mode 100644 index 0000000..892b684 --- /dev/null +++ b/sys/ns/mx.nix @@ -0,0 +1,60 @@ +{ + config, + lib, + ... +}: +with lib; let + inherit (config.local) domains; +in { + options.local.ns.zones = mkOption { + type = with lib.types; + attrsOf (submodule ({ + config, + name, + ... + }: { + options.localMX = { + enable = mkEnableOption "local MX settings"; + }; + + config = mkIf config.localMX.enable { + mx = [ + { + name = "@"; + priority = 10; + host = "${domains.smtp.gated}."; + } + { + name = "@"; + priority = 20; + host = "${domains.smtp-backup.main}."; + } + # Many thanks to junkemailfilter.com for all their years of service. RIP. + #{ name = "@"; priority = 30; host = "mxbackup1.junkemailfilter.com."; } + #{ name = "@"; priority = 40; host = "mxbackup2.junkemailfilter.com."; } + ]; + + txt = + [ + { + name = "@"; + text = "v=spf1 mx a -all"; + } + { + name = "_dmarc"; + text = "v=DMARC1;p=reject;sp=reject;adkim=r;aspf=r;fo=1;rf=afrf;rua=mailto:postmaster@${name}"; + } + { + name = "_adsp._domainkey"; + text = "dkim=all"; + } + ] + ++ map + (selector: { + name = "${toString selector}._domainkey"; + text = readFile (./dkim + "/${toString selector}.txt"); + }) [202001 202102 202402 202408]; + }; + })); + }; +} diff --git a/sys/ns/ns.nix b/sys/ns/ns.nix new file mode 100644 index 0000000..e5b30e8 --- /dev/null +++ b/sys/ns/ns.nix @@ -0,0 +1,153 @@ +{ + config, + lib, + ... +}: +with lib; let + 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 + inherit (config.soa) primary; + + cfg = config.localNS; + ptrDomain = cfg.ptrNet.v4 != null || cfg.ptrNet.v6 != null; + in { + options.localNS = { + enable = mkEnableOption "local NS settings"; + + acme = mkOption { + default = {}; + type = attrsOf str; + }; + + ptrNet = { + v4 = mkOption { + type = nullOr str; + default = null; + }; + + v6 = mkOption { + type = nullOr str; + default = null; + }; + }; + }; + + 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}."; + }; + + cname = + mapAttrsToList + (name: id: { + name = "_acme-challenge" + optionalString (name != "@") ".${name}"; + target = "${id}.acme-challenge.${domain}."; + }) + cfg.acme; + }; + })); + }; + + 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/nsd.nix b/sys/ns/nsd.nix new file mode 100644 index 0000000..d49e464 --- /dev/null +++ b/sys/ns/nsd.nix @@ -0,0 +1,87 @@ +{ + config, + lib, + ... +}: +with lib; let + inherit (config.networking) domain; + + cfg = config.local.ns.server; + + acmeChallengeDomain = "acme-challenge.${domain}"; +in { + options. local. ns. server = { + enable = mkEnableOption "nsd authoritative server"; + + tsigName = mkOption { + type = types.str; + default = "NOKEY"; + }; + + acme = { + apiListen.v6 = mkOption { + type = types.str; + }; + + dnsListen.v6 = mkOption { + type = types.str; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.tsigName == "NOKEY" || config.services.nsd.keys ? "${cfg.tsigName}"; + message = "TSIG key '${cfg.tsigName}' not defined"; + } + ]; + + networking.firewall = let + inherit (config.services.nsd) port; + in { + allowedTCPPorts = [port]; + allowedUDPPorts = [port]; + }; + + services = { + acme-dns = { + enable = true; + settings = { + api = { + ip = "[${cfg.acme.apiListen.v6}]"; + port = 80; + }; + + general = { + domain = acmeChallengeDomain; + nsname = acmeChallengeDomain; + nsadmin = "hostmaster.${domain}"; + + listen = "[${cfg.acme.dnsListen.v6}]:53"; + + records = [ + "${acmeChallengeDomain}. NS ${acmeChallengeDomain}." + "${acmeChallengeDomain}. AAAA ${cfg.acme.dnsListen.v6}" + ]; + }; + }; + }; + + nsd = { + enable = true; + + ipFreebind = true; + + bind8Stats = true; + statistics = 3600; + + tcpCount = 128; + tcpTimeout = 30; + tcpQueryCount = 128; + + zones = mapAttrs' (name: zone: nameValuePair "${name}." zone.nsdConfig) config.local.ns.zones; + }; + }; + }; +} diff --git a/sys/ns/ptr/default.nix b/sys/ns/ptr/default.nix new file mode 100644 index 0000000..b4fba7e --- /dev/null +++ b/sys/ns/ptr/default.nix @@ -0,0 +1,9 @@ +{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..44c7f2e --- /dev/null +++ b/sys/ns/ptr/gate-public-v4/default.nix @@ -0,0 +1,14 @@ +{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..008e5d8 --- /dev/null +++ b/sys/ns/ptr/gate-public-v4/serial.nix @@ -0,0 +1,6 @@ +{ + 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..674421f --- /dev/null +++ b/sys/ns/ptr/gate-public-v6/default.nix @@ -0,0 +1,14 @@ +{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..126a17e --- /dev/null +++ b/sys/ns/ptr/gate-public-v6/serial.nix @@ -0,0 +1,6 @@ +{ + 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..7688b97 --- /dev/null +++ b/sys/ns/ptr/static-prefix-v6/default.nix @@ -0,0 +1,14 @@ +{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..a7c214a --- /dev/null +++ b/sys/ns/ptr/static-prefix-v6/serial.nix @@ -0,0 +1,6 @@ +{ + config = { + soa.serial = 2025042600; + nullSerialHash = "sha256-a5ce7781b014aa816998410db440dd40278d8b566d1de76e06776a83c9839b35"; + }; +} 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)); + }; + }; +} diff --git a/sys/ns/zones/README.md b/sys/ns/zones/README.md new file mode 100644 index 0000000..37073ba --- /dev/null +++ b/sys/ns/zones/README.md @@ -0,0 +1 @@ +# This directory has been lustrated. |
