about summary refs log tree commit diff stats
path: root/modules/by-name/dn
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-23 18:34:58 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-03-09 13:44:17 +0100
commit116c55f3308efc8e6c0a35556404ab59539a6a99 (patch)
tree96c1bc14ea5f4b2704ed039898bde83406d52cb4 /modules/by-name/dn
parenttests/email: Test the mvp (diff)
downloadnixos-server-116c55f3308efc8e6c0a35556404ab59539a6a99.zip
{modules,tests}/dns: Init
Most of the dns module was taken from: <https://github.com/nix-community/dns.nix>
Diffstat (limited to 'modules/by-name/dn')
-rw-r--r--modules/by-name/dn/dns/dns/default.nix13
-rw-r--r--modules/by-name/dn/dns/dns/types/default.nix16
-rw-r--r--modules/by-name/dn/dns/dns/types/record.nix75
-rw-r--r--modules/by-name/dn/dns/dns/types/records/A.nix19
-rw-r--r--modules/by-name/dn/dns/dns/types/records/AAAA.nix19
-rw-r--r--modules/by-name/dn/dns/dns/types/records/CAA.nix42
-rw-r--r--modules/by-name/dn/dns/dns/types/records/CNAME.nix27
-rw-r--r--modules/by-name/dn/dns/dns/types/records/DKIM.nix75
-rw-r--r--modules/by-name/dn/dns/dns/types/records/DMARC.nix108
-rw-r--r--modules/by-name/dn/dns/dns/types/records/DNAME.nix15
-rw-r--r--modules/by-name/dn/dns/dns/types/records/DNSKEY.nix63
-rw-r--r--modules/by-name/dn/dns/dns/types/records/DS.nix48
-rw-r--r--modules/by-name/dn/dns/dns/types/records/HTTPS.nix5
-rw-r--r--modules/by-name/dn/dns/dns/types/records/MX.nix32
-rw-r--r--modules/by-name/dn/dns/dns/types/records/NS.nix24
-rw-r--r--modules/by-name/dn/dns/dns/types/records/OPENPGPKEY.nix18
-rw-r--r--modules/by-name/dn/dns/dns/types/records/PTR.nix24
-rw-r--r--modules/by-name/dn/dns/dns/types/records/SOA.nix65
-rw-r--r--modules/by-name/dn/dns/dns/types/records/SRV.nix51
-rw-r--r--modules/by-name/dn/dns/dns/types/records/SSHFP.nix39
-rw-r--r--modules/by-name/dn/dns/dns/types/records/SVCB.nix100
-rw-r--r--modules/by-name/dn/dns/dns/types/records/TLSA.nix50
-rw-r--r--modules/by-name/dn/dns/dns/types/records/TXT.nix24
-rw-r--r--modules/by-name/dn/dns/dns/types/records/default.nix42
-rw-r--r--modules/by-name/dn/dns/dns/types/records/dnssec.nix48
-rw-r--r--modules/by-name/dn/dns/dns/types/simple.nix9
-rw-r--r--modules/by-name/dn/dns/dns/types/zone.nix119
-rw-r--r--modules/by-name/dn/dns/dns/util/default.nix76
-rw-r--r--modules/by-name/dn/dns/module.nix45
29 files changed, 1291 insertions, 0 deletions
diff --git a/modules/by-name/dn/dns/dns/default.nix b/modules/by-name/dn/dns/dns/default.nix
new file mode 100644
index 0000000..4ce07d8
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/default.nix
@@ -0,0 +1,13 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{lib}: let
+  util = import ./util {inherit lib;};
+  types = import ./types {inherit lib util;};
+in {
+  inherit
+    types
+    ;
+}
diff --git a/modules/by-name/dn/dns/dns/types/default.nix b/modules/by-name/dn/dns/dns/types/default.nix
new file mode 100644
index 0000000..ece315f
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/default.nix
@@ -0,0 +1,16 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{
+  lib,
+  util,
+}: let
+  simple = {types = import ./simple.nix {inherit lib;};};
+in {
+  record = import ./record.nix {inherit lib util;};
+  records = import ./records {inherit lib util simple;};
+
+  zone = import ./zone.nix {inherit lib util simple;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/record.nix b/modules/by-name/dn/dns/dns/types/record.nix
new file mode 100644
index 0000000..e992bf9
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/record.nix
@@ -0,0 +1,75 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+# SPDX-FileCopyrightText: 2021 Naïm Favier <n@monade.li>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{lib, ...}: let
+  inherit (lib) hasSuffix isString mkOption removeSuffix types;
+
+  recordType = rsubt: let
+    submodule = types.submodule {
+      options =
+        {
+          class = mkOption {
+            type = types.enum ["IN"];
+            default = "IN";
+            example = "IN";
+            description = "Resource record class. Only IN is supported";
+          };
+          ttl = mkOption {
+            type = types.nullOr types.ints.unsigned; # TODO: u32
+            default = null;
+            example = 300;
+            description = "Record caching duration (in seconds)";
+          };
+        }
+        // rsubt.options;
+    };
+  in
+    (
+      if rsubt ? fromString
+      then types.either types.str
+      else lib.id
+    )
+    submodule;
+
+  # name == "@" : use unqualified domain name
+  writeRecord = name: rsubt: data: let
+    data' =
+      if isString data && rsubt ? fromString
+      then
+        # add default values for the record type
+        (recordType rsubt).merge [] [
+          {
+            file = "";
+            value = rsubt.fromString data;
+          }
+        ]
+      else data;
+    name' = let
+      fname = rsubt.nameFixup or (n: _: n) name data';
+    in
+      if name == "@"
+      then name
+      else if (hasSuffix ".@" name)
+      then removeSuffix ".@" fname
+      else "${fname}.";
+    inherit (rsubt) rtype;
+  in
+    lib.concatStringsSep " " (with data';
+      [
+        name'
+      ]
+      ++ lib.optionals (ttl != null) [
+        (toString ttl)
+      ]
+      ++ [
+        class
+        rtype
+        (rsubt.dataToString data')
+      ]);
+in {
+  inherit recordType;
+  inherit writeRecord;
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/A.nix b/modules/by-name/dn/dns/dns/types/records/A.nix
new file mode 100644
index 0000000..296943e
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/A.nix
@@ -0,0 +1,19 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{lib, ...}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "A";
+  options = {
+    address = mkOption {
+      type = types.str;
+      example = "26.3.0.103";
+      description = "IP address of the host";
+    };
+  };
+  dataToString = {address, ...}: address;
+  fromString = address: {inherit address;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/AAAA.nix b/modules/by-name/dn/dns/dns/types/records/AAAA.nix
new file mode 100644
index 0000000..4717176
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/AAAA.nix
@@ -0,0 +1,19 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{lib, ...}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "AAAA";
+  options = {
+    address = mkOption {
+      type = types.str;
+      example = "4321:0:1:2:3:4:567:89ab";
+      description = "IPv6 address of the host";
+    };
+  };
+  dataToString = {address, ...}: address;
+  fromString = address: {inherit address;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/CAA.nix b/modules/by-name/dn/dns/dns/types/records/CAA.nix
new file mode 100644
index 0000000..4b40510
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/CAA.nix
@@ -0,0 +1,42 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 8659
+{lib, ...}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "CAA";
+  options = {
+    issuerCritical = mkOption {
+      type = types.bool;
+      example = true;
+      description = ''
+        If set to '1', indicates that the corresponding property tag
+        MUST be understood if the semantics of the CAA record are to be
+        correctly interpreted by an issuer
+      '';
+    };
+    tag = mkOption {
+      type = types.enum ["issue" "issuewild" "iodef"];
+      example = "issue";
+      description = "One of the defined property tags";
+    };
+    value = mkOption {
+      type = types.str; # section 4.1.1: not limited in length
+      example = "ca.example.net";
+      description = "Value of the property";
+    };
+  };
+  dataToString = {
+    issuerCritical,
+    tag,
+    value,
+    ...
+  }: ''${
+      if issuerCritical
+      then "128"
+      else "0"
+    } ${tag} "${value}"'';
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/CNAME.nix b/modules/by-name/dn/dns/dns/types/records/CNAME.nix
new file mode 100644
index 0000000..095b078
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/CNAME.nix
@@ -0,0 +1,27 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.1
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) mkOption;
+in {
+  rtype = "CNAME";
+  options = {
+    cname = mkOption {
+      type = simple.types.domain-name;
+      example = "www.test.com";
+      description = ''
+        A <domain-name> which specifies the canonical or primary name
+        for the owner. The owner name is an alias.
+      '';
+    };
+  };
+  dataToString = {cname, ...}: "${cname}";
+  fromString = cname: {inherit cname;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/DKIM.nix b/modules/by-name/dn/dns/dns/types/records/DKIM.nix
new file mode 100644
index 0000000..31b2f67
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/DKIM.nix
@@ -0,0 +1,75 @@
+#
+# SPDX-FileCopyrightText: 2020 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# This is a “fake” record type, not actually part of DNS.
+# It gets compiled down to a TXT record.
+# RFC 6376
+{
+  lib,
+  util,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in rec {
+  rtype = "TXT";
+  options = {
+    selector = mkOption {
+      type = types.str;
+      example = "mail";
+      description = "DKIM selector name";
+    };
+    h = mkOption {
+      type = types.listOf types.str;
+      default = [];
+      example = ["sha1" "sha256"];
+      description = "Acceptable hash algorithms. Empty means all of them";
+      apply = lib.concatStringsSep ":";
+    };
+    k = mkOption {
+      type = types.nullOr types.str;
+      default = "rsa";
+      example = "rsa";
+      description = "Key type";
+    };
+    n = mkOption {
+      type = types.str;
+      default = "";
+      example = "Just any kind of arbitrary notes.";
+      description = "Notes that might be of interest to a human";
+    };
+    p = mkOption {
+      type = types.str;
+      example = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB";
+      description = "Public-key data (base64)";
+    };
+    s = mkOption {
+      type = types.listOf (types.enum ["*" "email"]);
+      default = ["*"];
+      example = ["email"];
+      description = "Service Type";
+      apply = lib.concatStringsSep ":";
+    };
+    t = mkOption {
+      type = types.listOf (types.enum ["y" "s"]);
+      default = [];
+      example = ["y"];
+      description = "Flags";
+      apply = lib.concatStringsSep ":";
+    };
+  };
+  dataToString = data: let
+    items =
+      ["v=DKIM1"]
+      ++ lib.pipe data [
+        (builtins.intersectAttrs options) # remove garbage list `_module`
+        (lib.filterAttrs (_k: v: v != null && v != ""))
+        (lib.filterAttrs (k: _v: k != "selector"))
+        (lib.mapAttrsToList (k: v: "${k}=${v}"))
+      ];
+    result = lib.concatStringsSep "; " items + ";";
+  in
+    util.writeCharacterString result;
+  nameFixup = name: self: "${self.selector}._domainkey.${name}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/DMARC.nix b/modules/by-name/dn/dns/dns/types/records/DMARC.nix
new file mode 100644
index 0000000..e67dd12
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/DMARC.nix
@@ -0,0 +1,108 @@
+#
+# SPDX-FileCopyrightText: 2020 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# This is a “fake” record type, not actually part of DNS.
+# It gets compiled down to a TXT record.
+# RFC 7208
+{
+  lib,
+  util,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in rec {
+  rtype = "TXT";
+  options = {
+    adkim = mkOption {
+      type = types.enum ["relaxed" "strict"];
+      default = "relaxed";
+      example = "strict";
+      description = "DKIM Identifier Alignment mode";
+      apply = builtins.substring 0 1;
+    };
+    aspf = mkOption {
+      type = types.enum ["relaxed" "strict"];
+      default = "relaxed";
+      example = "strict";
+      description = "SPF Identifier Alignment mode";
+      apply = builtins.substring 0 1;
+    };
+    fo = mkOption {
+      type = types.listOf (types.enum ["0" "1" "d" "s"]);
+      default = ["0"];
+      example = ["0" "1" "s"];
+      description = "Failure reporting options";
+      apply = lib.concatStringsSep ":";
+    };
+    p = mkOption {
+      type = types.enum ["none" "quarantine" "reject"];
+      example = "quarantine";
+      description = "Requested Mail Receiver policy";
+    };
+    pct = mkOption {
+      type = types.ints.between 0 100;
+      default = 100;
+      example = 30;
+      description = "Percentage of messages to which the DMARC policy is to be applied";
+      apply = builtins.toString;
+    };
+    rf = mkOption {
+      type = types.listOf (types.enum ["afrf"]);
+      default = ["afrf"];
+      example = ["afrf"];
+      description = "Format to be used for message-specific failure reports";
+      apply = lib.concatStringsSep ":";
+    };
+    ri = mkOption {
+      type = types.ints.unsigned; # FIXME: u32
+      default = 86400;
+      example = 12345;
+      description = "Interval requested between aggregate reports";
+      apply = builtins.toString;
+    };
+    rua = mkOption {
+      type = types.oneOf [types.str (types.listOf types.str)];
+      default = [];
+      example = "mailto:dmarc+rua@example.com";
+      description = "Addresses to which aggregate feedback is to be sent";
+      apply = val:
+      # FIXME: need to encode commas in URIs
+        if builtins.isList val
+        then lib.concatStringsSep "," val
+        else val;
+    };
+    ruf = mkOption {
+      type = types.listOf types.str;
+      default = [];
+      example = ["mailto:dmarc+ruf@example.com" "mailto:another+ruf@example.com"];
+      description = "Addresses to which message-specific failure information is to be reported";
+      apply = val:
+      # FIXME: need to encode commas in URIs
+        if builtins.isList val
+        then lib.concatStringsSep "," val
+        else val;
+    };
+    sp = mkOption {
+      type = types.nullOr (types.enum ["none" "quarantine" "reject"]);
+      default = null;
+      example = "quarantine";
+      description = "Requested Mail Receiver policy for all subdomains";
+    };
+  };
+  dataToString = data: let
+    # The specification could be more clear on this, but `v` and `p` MUST
+    # be the first two tags in the record.
+    items =
+      ["v=DMARC1; p=${data.p}"]
+      ++ lib.pipe data [
+        (builtins.intersectAttrs options) # remove garbage list `_module`
+        (lib.filterAttrs (k: v: v != null && v != "" && k != "p"))
+        (lib.mapAttrsToList (k: v: "${k}=${v}"))
+      ];
+    result = lib.concatStringsSep "; " items + ";";
+  in
+    util.writeCharacterString result;
+  nameFixup = name: _self: "_dmarc.${name}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/DNAME.nix b/modules/by-name/dn/dns/dns/types/records/DNAME.nix
new file mode 100644
index 0000000..042ce95
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/DNAME.nix
@@ -0,0 +1,15 @@
+# RFC 6672
+{lib, ...}: let
+  inherit (lib) dns mkOption;
+in {
+  rtype = "DNAME";
+  options = {
+    dname = mkOption {
+      type = dns.types.domain-name;
+      example = "www.test.com";
+      description = "A <domain-name> which provides redirection from a part of the DNS name tree to another part of the DNS name tree";
+    };
+  };
+  dataToString = {dname, ...}: "${dname}";
+  fromString = dname: {inherit dname;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/DNSKEY.nix b/modules/by-name/dn/dns/dns/types/records/DNSKEY.nix
new file mode 100644
index 0000000..86ce3a1
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/DNSKEY.nix
@@ -0,0 +1,63 @@
+# SPDX-FileCopyrightText: 2020 Aluísio Augusto Silva Gonçalves <https://aasg.name>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+# RFC 4034, 2
+{lib, ...}: let
+  inherit (builtins) isInt split;
+  inherit (lib) concatStrings flatten mkOption types;
+
+  dnssecOptions = import ./dnssec.nix {inherit lib;};
+  inherit (dnssecOptions) mkDNSSECAlgorithmOption;
+in {
+  rtype = "DNSKEY";
+  options = {
+    flags = mkOption {
+      description = "Flags pertaining to this RR.";
+      type = types.either types.ints.u16 (types.submodule {
+        options = {
+          zoneSigningKey = mkOption {
+            description = "Whether this RR holds a zone signing key (ZSK).";
+            type = types.bool;
+            default = false;
+          };
+          secureEntryPoint = mkOption {
+            type = types.bool;
+            description = ''
+              Whether this RR holds a secure entry point.
+              In general, this means the key is a key-signing key (KSK), as opposed to a zone-signing key.
+            '';
+            default = false;
+          };
+        };
+      });
+      apply = value:
+        if isInt value
+        then value
+        else
+          (
+            if value.zoneSigningKey
+            then 256
+            else 0
+          )
+          + (
+            if value.secureEntryPoint
+            then 1
+            else 0
+          );
+    };
+    algorithm = mkDNSSECAlgorithmOption {
+      description = "Algorithm of the key referenced by this RR.";
+    };
+    publicKey = mkOption {
+      type = types.str;
+      description = "Base64-encoded public key.";
+      apply = value: concatStrings (flatten (split "[[:space:]]" value));
+    };
+  };
+  dataToString = {
+    flags,
+    algorithm,
+    publicKey,
+    ...
+  }: "${toString flags} 3 ${toString algorithm} ${publicKey}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/DS.nix b/modules/by-name/dn/dns/dns/types/records/DS.nix
new file mode 100644
index 0000000..76fac9a
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/DS.nix
@@ -0,0 +1,48 @@
+# SPDX-FileCopyrightText: 2020 Aluísio Augusto Silva Gonçalves <https://aasg.name>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+# RFC 4034, 5
+{lib, ...}: let
+  inherit (lib) mkOption types;
+
+  dnssecOptions = import ./dnssec.nix {inherit lib;};
+  inherit (dnssecOptions) mkRegisteredNumberOption mkDNSSECAlgorithmOption;
+
+  mkDSDigestTypeOption = args:
+    mkRegisteredNumberOption {
+      registryName = "Delegation Signer (DS) Resource Record (RR) Type Digest Algorithms";
+      numberType = types.ints.u8;
+      # These mnemonics are unofficial, unlike the DNSSEC algorithm ones.
+      mnemonics = {
+        "sha-1" = 1;
+        "sha-256" = 2;
+        "gost" = 3;
+        "sha-384" = 4;
+      };
+    };
+in {
+  rtype = "DS";
+  options = {
+    keyTag = mkOption {
+      description = "Tag computed over the DNSKEY referenced by this RR to identify it.";
+      type = types.ints.u16;
+    };
+    algorithm = mkDNSSECAlgorithmOption {
+      description = "Algorithm of the key referenced by this RR.";
+    };
+    digestType = mkDSDigestTypeOption {
+      description = "Type of the digest given in the `digest` attribute.";
+    };
+    digest = mkOption {
+      description = "Digest of the DNSKEY referenced by this RR.";
+      type = types.strMatching "[[:xdigit:]]+";
+    };
+  };
+  dataToString = {
+    keyTag,
+    algorithm,
+    digestType,
+    digest,
+    ...
+  }: "${toString keyTag} ${toString algorithm} ${toString digestType} ${digest}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/HTTPS.nix b/modules/by-name/dn/dns/dns/types/records/HTTPS.nix
new file mode 100644
index 0000000..6e2ef3d
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/HTTPS.nix
@@ -0,0 +1,5 @@
+args:
+import ./SVCB.nix args
+// {
+  rtype = "HTTPS";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/MX.nix b/modules/by-name/dn/dns/dns/types/records/MX.nix
new file mode 100644
index 0000000..c25b89c
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/MX.nix
@@ -0,0 +1,32 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.9
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "MX";
+  options = {
+    preference = mkOption {
+      type = types.ints.u16;
+      example = 10;
+      description = "The preference given to this RR among others at the same owner. Lower values are preferred";
+    };
+    exchange = mkOption {
+      type = simple.types.domain-name;
+      example = "smtp.example.com.";
+      description = "A <domain-name> which specifies a host willing to act as a mail exchange for the owner name";
+    };
+  };
+  dataToString = {
+    preference,
+    exchange,
+    ...
+  }: "${toString preference} ${exchange}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/NS.nix b/modules/by-name/dn/dns/dns/types/records/NS.nix
new file mode 100644
index 0000000..ea60a91
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/NS.nix
@@ -0,0 +1,24 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.11
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) mkOption;
+in {
+  rtype = "NS";
+  options = {
+    nsdname = mkOption {
+      type = simple.types.domain-name;
+      example = "ns2.example.com";
+      description = "A <domain-name> which specifies a host which should be authoritative for the specified class and domain";
+    };
+  };
+  dataToString = {nsdname, ...}: "${nsdname}";
+  fromString = nsdname: {inherit nsdname;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/OPENPGPKEY.nix b/modules/by-name/dn/dns/dns/types/records/OPENPGPKEY.nix
new file mode 100644
index 0000000..1f39cb9
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/OPENPGPKEY.nix
@@ -0,0 +1,18 @@
+# RFC7929
+{
+  lib,
+  util,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "OPENPGPKEY";
+  options = {
+    data = mkOption {
+      type = types.str;
+    };
+  };
+
+  dataToString = {data, ...}: util.writeCharacterString data;
+  fromString = data: {inherit data;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/PTR.nix b/modules/by-name/dn/dns/dns/types/records/PTR.nix
new file mode 100644
index 0000000..32a1913
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/PTR.nix
@@ -0,0 +1,24 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.12
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) mkOption;
+in {
+  rtype = "PTR";
+  options = {
+    ptrdname = mkOption {
+      type = simple.types.domain-name;
+      example = "4-3-2-1.dynamic.example.com.";
+      description = "A <domain-name> which points to some location in the domain name space";
+    };
+  };
+  dataToString = {ptrdname, ...}: "${ptrdname}";
+  fromString = ptrdname: {inherit ptrdname;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/SOA.nix b/modules/by-name/dn/dns/dns/types/records/SOA.nix
new file mode 100644
index 0000000..db7436e
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/SOA.nix
@@ -0,0 +1,65 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.13
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) concatStringsSep removeSuffix replaceStrings;
+  inherit (lib) mkOption types;
+in {
+  rtype = "SOA";
+  options = {
+    nameServer = mkOption {
+      type = simple.types.domain-name;
+      example = "ns1.example.com";
+      description = "The <domain-name> of the name server that was the original or primary source of data for this zone. Don't forget the dot at the end!";
+    };
+    adminEmail = mkOption {
+      type = simple.types.domain-name;
+      example = "admin@example.com";
+      description = "An email address of the person responsible for this zone. (Note: in traditional zone files you are supposed to put a dot instead of `@` in your address; you can use `@` with this module and it is recommended to do so. Also don't put the dot at the end!)";
+      apply = s: replaceStrings ["@"] ["."] (removeSuffix "." s);
+    };
+    serial = mkOption {
+      type = types.ints.unsigned; # TODO: u32
+      example = 20;
+      description = "Version number of the original copy of the zone";
+    };
+    refresh = mkOption {
+      type = types.ints.unsigned; # TODO: u32
+      default = 24 * 60 * 60;
+      example = 7200;
+      description = "Time interval before the zone should be refreshed";
+    };
+    retry = mkOption {
+      type = types.ints.unsigned; # TODO: u32
+      default = 10 * 60;
+      example = 600;
+      description = "Time interval that should elapse before a failed refresh should be retried";
+    };
+    expire = mkOption {
+      type = types.ints.unsigned; # TODO: u32
+      default = 10 * 24 * 60 * 60;
+      example = 3600000;
+      description = "Time value that specifies the upper limit on the time interval that can elapse before the zone is no longer authoritative";
+    };
+    minimum = mkOption {
+      type = types.ints.unsigned; # TODO: u32
+      default = 60;
+      example = 60;
+      description = "Minimum TTL field that should be exported with any RR from this zone";
+    };
+  };
+  dataToString = data @ {
+    nameServer,
+    adminEmail,
+    ...
+  }: let
+    numbers = map toString (with data; [serial refresh retry expire minimum]);
+  in "${nameServer} ${adminEmail}. (${concatStringsSep " " numbers})";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/SRV.nix b/modules/by-name/dn/dns/dns/types/records/SRV.nix
new file mode 100644
index 0000000..5f558ed
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/SRV.nix
@@ -0,0 +1,51 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 2782
+{
+  lib,
+  simple,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "SRV";
+  options = {
+    service = mkOption {
+      type = types.str;
+      example = "foobar";
+      description = "The symbolic name of the desired service. Do not add the underscore!";
+    };
+    proto = mkOption {
+      type = types.str;
+      example = "tcp";
+      description = "The symbolic name of the desired protocol. Do not add the underscore!";
+    };
+    priority = mkOption {
+      type = types.ints.u16;
+      default = 0;
+      example = 0;
+      description = "The priority of this target host";
+    };
+    weight = mkOption {
+      type = types.ints.u16;
+      default = 100;
+      example = 20;
+      description = "The weight field specifies a relative weight for entries with the same priority. Larger weights SHOULD be given a proportionately higher probability of being selected";
+    };
+    port = mkOption {
+      type = types.ints.u16;
+      example = 9;
+      description = "The port on this target host of this service";
+    };
+    target = mkOption {
+      type = simple.types.domain-name;
+      example = "";
+      description = "The domain name of the target host";
+    };
+  };
+  dataToString = data: with data; "${toString priority} ${toString weight} ${toString port} ${target}";
+  nameFixup = name: self: "_${self.service}._${self.proto}.${name}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/SSHFP.nix b/modules/by-name/dn/dns/dns/types/records/SSHFP.nix
new file mode 100644
index 0000000..1409860
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/SSHFP.nix
@@ -0,0 +1,39 @@
+# RFC 4255
+{lib, ...}: let
+  inherit (lib) mkOption types;
+  inherit (builtins) attrNames;
+  algorithm = {
+    "rsa" = 1;
+    "dsa" = 2;
+    "ecdsa" = 3; # RFC 6594
+    "ed25519" = 4; # RFC 7479 / RFC 8709
+    "ed448" = 6; # RFC 8709
+  };
+  mode = {
+    "sha1" = 1;
+    "sha256" = 2; # RFC 6594
+  };
+in {
+  rtype = "SSHFP";
+  options = {
+    algorithm = mkOption {
+      example = "ed25519";
+      type = types.enum (attrNames algorithm);
+      apply = value: algorithm.${value};
+    };
+    fingerprintType = mkOption {
+      example = "sha256";
+      type = types.enum (attrNames mode);
+      apply = value: mode.${value};
+    };
+    fingerprint = mkOption {
+      type = types.str;
+    };
+  };
+  dataToString = {
+    algorithm,
+    fingerprintType,
+    fingerprint,
+    ...
+  }: "${toString algorithm} ${toString fingerprintType} ${fingerprint}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/SVCB.nix b/modules/by-name/dn/dns/dns/types/records/SVCB.nix
new file mode 100644
index 0000000..62cbc3d
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/SVCB.nix
@@ -0,0 +1,100 @@
+# rfc9460
+{lib, ...}: let
+  inherit
+    (lib)
+    concatStringsSep
+    filter
+    isInt
+    isList
+    mapAttrsToList
+    mkOption
+    types
+    ;
+
+  mkSvcParams = params:
+    concatStringsSep " " (
+      filter (s: s != "") (
+        mapAttrsToList (
+          name: value:
+            if value
+            then name
+            else if isList value
+            then "${name}=${concatStringsSep "," value}"
+            else if isInt value
+            then "${name}=${builtins.toString value}"
+            else ""
+        )
+        params
+      )
+    );
+in {
+  rtype = "SVCB";
+  options = {
+    svcPriority = mkOption {
+      example = 1;
+      type = types.ints.u16;
+    };
+    targetName = mkOption {
+      example = ".";
+      type = types.str;
+    };
+    mandatory = mkOption {
+      example = ["ipv4hint"];
+      default = null;
+      type = types.nullOr (types.nonEmptyListOf types.str);
+    };
+    alpn = mkOption {
+      example = ["h2"];
+      default = null;
+      type = types.nullOr (types.nonEmptyListOf types.str);
+    };
+    no-default-alpn = mkOption {
+      example = true;
+      default = false;
+      type = types.bool;
+    };
+    port = mkOption {
+      example = 443;
+      default = null;
+      type = types.nullOr types.port;
+    };
+    ipv4hint = mkOption {
+      example = ["127.0.0.1"];
+      default = null;
+      type = types.nullOr (types.nonEmptyListOf types.str);
+    };
+    ipv6hint = mkOption {
+      example = ["::1"];
+      default = null;
+      type = types.nullOr (types.nonEmptyListOf types.str);
+    };
+    ech = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+    };
+  };
+  dataToString = {
+    svcPriority,
+    targetName,
+    mandatory ? null,
+    alpn ? null,
+    no-default-alpn ? null,
+    port ? null,
+    ipv4hint ? null,
+    ipv6hint ? null,
+    ech ? null,
+    ...
+  }: "${toString svcPriority} ${targetName} ${
+    mkSvcParams {
+      inherit
+        alpn
+        ech
+        ipv4hint
+        ipv6hint
+        mandatory
+        no-default-alpn
+        port
+        ;
+    }
+  }";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/TLSA.nix b/modules/by-name/dn/dns/dns/types/records/TLSA.nix
new file mode 100644
index 0000000..d92a29b
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/TLSA.nix
@@ -0,0 +1,50 @@
+# RFC 6698
+{lib, ...}: let
+  inherit (lib) mkOption types;
+  inherit (builtins) attrNames;
+
+  certUsage = {
+    "pkix-ta" = 0;
+    "pkix-ee" = 1;
+    "dane-ta" = 2;
+    "dane-ee" = 3;
+  };
+  selectors = {
+    "cert" = 0;
+    "spki" = 1;
+  };
+  match = {
+    "exact" = 0;
+    "sha256" = 1;
+    "sha512" = 2;
+  };
+in {
+  rtype = "TLSA";
+  options = {
+    certUsage = mkOption {
+      example = "dane-ee";
+      type = types.enum (attrNames certUsage);
+      apply = value: certUsage.${value};
+    };
+    selector = mkOption {
+      example = "spki";
+      type = types.enum (attrNames selectors);
+      apply = value: selectors.${value};
+    };
+    matchingType = mkOption {
+      example = "sha256";
+      type = types.enum (attrNames match);
+      apply = value: match.${value};
+    };
+    certificate = mkOption {
+      type = types.str;
+    };
+  };
+  dataToString = {
+    certUsage,
+    selector,
+    matchingType,
+    certificate,
+    ...
+  }: "${toString certUsage} ${toString selector} ${toString matchingType} ${certificate}";
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/TXT.nix b/modules/by-name/dn/dns/dns/types/records/TXT.nix
new file mode 100644
index 0000000..d605ce8
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/TXT.nix
@@ -0,0 +1,24 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+# RFC 1035, 3.3.14
+{
+  lib,
+  util,
+  ...
+}: let
+  inherit (lib) mkOption types;
+in {
+  rtype = "TXT";
+  options = {
+    data = mkOption {
+      type = types.str;
+      example = "favorite drink=orange juice";
+      description = "Arbitrary information";
+    };
+  };
+  dataToString = {data, ...}: util.writeCharacterString data;
+  fromString = data: {inherit data;};
+}
diff --git a/modules/by-name/dn/dns/dns/types/records/default.nix b/modules/by-name/dn/dns/dns/types/records/default.nix
new file mode 100644
index 0000000..b6f6270
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/default.nix
@@ -0,0 +1,42 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{
+  lib,
+  util,
+  simple,
+}: let
+  inherit (lib.attrsets) genAttrs;
+
+  types = [
+    "A"
+    "AAAA"
+    "CAA"
+    "CNAME"
+    "DNAME"
+    "MX"
+    "NS"
+    "SOA"
+    "SRV"
+    "TXT"
+    "PTR"
+
+    # DNSSEC types
+    "DNSKEY"
+    "DS"
+
+    # DANE types
+    "SSHFP"
+    "TLSA"
+    "OPENPGPKEY"
+    "SVCB"
+    "HTTPS"
+
+    # Pseudo types
+    "DKIM"
+    "DMARC"
+  ];
+in
+  genAttrs types (t: import (./. + "/${t}.nix") {inherit lib simple util;})
diff --git a/modules/by-name/dn/dns/dns/types/records/dnssec.nix b/modules/by-name/dn/dns/dns/types/records/dnssec.nix
new file mode 100644
index 0000000..648f676
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/records/dnssec.nix
@@ -0,0 +1,48 @@
+# SPDX-FileCopyrightText: 2020 Aluísio Augusto Silva Gonçalves <https://aasg.name>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+{lib}: let
+  inherit (builtins) attrNames isInt removeAttrs;
+  inherit (lib) mkOption types;
+in rec {
+  mkRegisteredNumberOption = {
+    registryName,
+    numberType,
+    mnemonics,
+  } @ args:
+    mkOption
+    {
+      type =
+        types.either numberType (types.enum (attrNames mnemonics))
+        // {
+          name = "registeredNumber";
+          description = "number in IANA registry '${registryName}'";
+        };
+      apply = value:
+        if isInt value
+        then value
+        else mnemonics.${value};
+    }
+    // removeAttrs args ["registryName" "numberType" "mnemonics"];
+
+  mkDNSSECAlgorithmOption = args:
+    mkRegisteredNumberOption {
+      registryName = "Domain Name System Security (DNSSEC) Algorithm Numbers";
+      numberType = types.ints.u8;
+      mnemonics = {
+        "dsa" = 3;
+        "rsasha1" = 5;
+        "dsa-nsec3-sha1" = 6;
+        "rsasha1-nsec3-sha1" = 7;
+        "rsasha256" = 8;
+        "rsasha512" = 10;
+        "ecc-gost" = 12;
+        "ecdsap256sha256" = 13;
+        "ecdsap384sha384" = 14;
+        "ed25519" = 15;
+        "ed448" = 16;
+        "privatedns" = 253;
+        "privateoid" = 254;
+      };
+    };
+}
diff --git a/modules/by-name/dn/dns/dns/types/simple.nix b/modules/by-name/dn/dns/dns/types/simple.nix
new file mode 100644
index 0000000..fece2c9
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/simple.nix
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: 2021 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+{lib}: let
+  inherit (builtins) stringLength;
+in {
+  # RFC 1035, 3.1
+  domain-name = lib.types.addCheck lib.types.str (s: stringLength s <= 255);
+}
diff --git a/modules/by-name/dn/dns/dns/types/zone.nix b/modules/by-name/dn/dns/dns/types/zone.nix
new file mode 100644
index 0000000..44ccb15
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/types/zone.nix
@@ -0,0 +1,119 @@
+#
+# SPDX-FileCopyrightText: 2019 Kirill Elagin <https://kir.elagin.me/>
+# SPDX-FileCopyrightText: 2021 Naïm Favier <n@monade.li>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+#
+{
+  lib,
+  util,
+  simple,
+}: let
+  inherit (builtins) filter removeAttrs;
+  inherit
+    (lib)
+    concatMapStringsSep
+    concatStringsSep
+    mapAttrs
+    mapAttrsToList
+    optionalString
+    ;
+  inherit (lib) mkOption literalExample types;
+
+  inherit (import ./record.nix {inherit lib;}) recordType writeRecord;
+
+  rsubtypes = import ./records {inherit lib util simple;};
+  rsubtypes' = removeAttrs rsubtypes ["SOA"];
+
+  subzoneOptions =
+    {
+      subdomains = mkOption {
+        type = types.attrsOf subzone;
+        default = {};
+        example = {
+          www = {
+            A = [{address = "1.1.1.1";}];
+          };
+          staging = {
+            A = [{address = "1.0.0.1";}];
+          };
+        };
+        description = "Records for subdomains of the domain";
+      };
+    }
+    // mapAttrs (n: t:
+      mkOption {
+        type = types.listOf (recordType t);
+        default = [];
+        # example = [ t.example ];  # TODO: any way to auto-generate an example for submodule?
+        description = "List of ${n} records for this zone/subzone";
+      })
+    rsubtypes';
+
+  subzone = types.submodule {
+    options = subzoneOptions;
+  };
+
+  writeSubzone = name: zone: let
+    groupToString = pseudo: subt:
+      concatMapStringsSep "\n" (writeRecord name subt) zone."${pseudo}";
+    groups = mapAttrsToList groupToString rsubtypes';
+    groups' = filter (s: s != "") groups;
+
+    writeSubzone' = subname: writeSubzone "${subname}.${name}";
+    sub = concatStringsSep "\n\n" (mapAttrsToList writeSubzone' zone.subdomains);
+  in
+    concatStringsSep "\n\n" groups'
+    + optionalString (sub != "") ("\n\n" + sub);
+  zone = types.submodule ({name, ...}: {
+    options =
+      {
+        useOrigin = mkOption {
+          type = types.bool;
+          default = false;
+          description = "Wether to use $ORIGIN and unqualified name or fqdn when exporting the zone.";
+        };
+
+        TTL = mkOption {
+          type = types.ints.unsigned;
+          default = 24 * 60 * 60;
+          example = literalExample "60 * 60";
+          description = "Default record caching duration. Sets the $TTL variable";
+        };
+        SOA = mkOption rec {
+          type = recordType rsubtypes.SOA;
+          example =
+            {
+              ttl = 24 * 60 * 60;
+            }
+            // type.example;
+          description = "SOA record";
+        };
+      }
+      // subzoneOptions;
+  });
+  renderToString = name: {
+    useOrigin,
+    TTL,
+    SOA,
+    ...
+  } @ zone:
+    if useOrigin
+    then ''
+      $ORIGIN ${name}.
+      $TTL ${toString TTL}
+
+      ${writeRecord "@" rsubtypes.SOA SOA}
+
+      ${writeSubzone "@" zone}
+    ''
+    else ''
+      $TTL ${toString TTL}
+
+      ${writeRecord name rsubtypes.SOA SOA}
+
+      ${writeSubzone name zone}
+    '';
+in {
+  inherit zone subzone renderToString;
+}
diff --git a/modules/by-name/dn/dns/dns/util/default.nix b/modules/by-name/dn/dns/dns/util/default.nix
new file mode 100644
index 0000000..59e661d
--- /dev/null
+++ b/modules/by-name/dn/dns/dns/util/default.nix
@@ -0,0 +1,76 @@
+# SPDX-FileCopyrightText: 2021 Kirill Elagin <https://kir.elagin.me/>
+#
+# SPDX-License-Identifier: MPL-2.0 or MIT
+{lib}: let
+  inherit
+    (builtins)
+    concatStringsSep
+    genList
+    stringLength
+    substring
+    ;
+  inherit
+    (lib.strings)
+    concatMapStrings
+    concatMapStringsSep
+    fixedWidthString
+    splitString
+    stringToCharacters
+    ;
+  inherit (lib.lists) filter reverseList;
+
+  /*
+  Split a string into byte chunks, such that each output String is less then or equal to
+  `n` bytes.
+
+  # Type
+
+  splitInGroupsOf :: Integer -> String -> [String]
+
+  # Arguments
+
+    n
+    : The number of bytes to put into each String.
+
+    s
+    : The String to split.
+  */
+  splitInGroupsOf = n: s: let
+    groupCount = (stringLength s - 1) / n + 1;
+  in
+    genList (i: substring (i * n) n s) groupCount;
+
+  # : str -> str
+  # Prepares a Nix string to be written to a zone file as a character-string
+  # literal: breaks it into chunks of 255 (per RFC 1035, 3.3) and encloses
+  # each chunk in quotation marks.
+  writeCharacterString = s:
+    if stringLength s <= 255
+    then ''"${s}"''
+    else concatMapStringsSep " " (x: ''"${x}"'') (splitInGroupsOf 255 s);
+
+  # : str -> str, with length 4 (zeros are padded to the left)
+  align4Bytes = fixedWidthString 4 "0";
+
+  # : int -> str -> str
+  # Expands "" to 4n zeros and aligns the rest on 4 bytes
+  align4BytesOrExpand = n: v:
+    if v == ""
+    then (fixedWidthString (4 * n) "0" "")
+    else align4Bytes v;
+
+  # : str -> [ str ]
+  # Returns the record of the ipv6 as a list
+  mkRecordAux = v6: let
+    splitted = splitString ":" v6;
+    n = 8 - builtins.length (filter (x: x != "") splitted);
+  in
+    stringToCharacters (concatMapStrings (align4BytesOrExpand n) splitted);
+
+  # : str -> str
+  # Returns the reversed record of the ipv6
+  mkReverseRecord = v6:
+    concatStringsSep "." (reverseList (mkRecordAux v6)) + ".ip6.arpa";
+in {
+  inherit writeCharacterString mkReverseRecord;
+}
diff --git a/modules/by-name/dn/dns/module.nix b/modules/by-name/dn/dns/module.nix
new file mode 100644
index 0000000..6611f8c
--- /dev/null
+++ b/modules/by-name/dn/dns/module.nix
@@ -0,0 +1,45 @@
+{
+  config,
+  lib,
+  ...
+}: let
+  cfg = config.vhack.dns;
+
+  zones = lib.debug.traceValSeqN 2 (
+    builtins.mapAttrs (name: value: {
+      data =
+        dns.types.zone.renderToString name value;
+    })
+    (lib.debug.traceValSeqN 4 cfg.zones)
+  );
+
+  dns = import ./dns {inherit lib;};
+in {
+  options.vhack.dns = {
+    enable = lib.mkEnableOption "custom dns server";
+
+    interfaces = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      description = ''
+        A list of the interfaces to bind to.
+      '';
+      example = [
+        "192.168.1.3"
+        "2001:db8:1::3"
+      ];
+    };
+
+    zones = lib.mkOption {
+      type = lib.types.attrsOf dns.types.zone.zone;
+      description = "DNS zones";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    services.nsd = {
+      enable = true;
+      inherit (cfg) interfaces;
+      inherit zones;
+    };
+  };
+}