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 | 33 | ||||
| -rw-r--r-- | sys/ns/ns.nix | 130 | ||||
| -rw-r--r-- | sys/ns/nsd.nix | 86 | ||||
| -rw-r--r-- | sys/ns/ptr/default.nix | 11 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v4/default.nix | 16 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v4/serial.nix | 7 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v6/default.nix | 16 | ||||
| -rw-r--r-- | sys/ns/ptr/gate-public-v6/serial.nix | 7 | ||||
| -rw-r--r-- | sys/ns/ptr/static-prefix-v6/default.nix | 16 | ||||
| -rw-r--r-- | sys/ns/ptr/static-prefix-v6/serial.nix | 7 | ||||
| -rw-r--r-- | sys/ns/rr.nix | 499 | ||||
| -rw-r--r-- | sys/ns/zones/README.md | 1 |
14 files changed, 840 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..40a5574 --- /dev/null +++ b/sys/ns/mx.nix @@ -0,0 +1,33 @@ +{ 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..1e74502 --- /dev/null +++ b/sys/ns/ns.nix @@ -0,0 +1,130 @@ +{ 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..1dfa16b --- /dev/null +++ b/sys/ns/nsd.nix @@ -0,0 +1,86 @@ +{ 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..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..454b3dd --- /dev/null +++ b/sys/ns/ptr/static-prefix-v6/serial.nix @@ -0,0 +1,7 @@ +{ + 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..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)); + }; + }; +} 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. |
