diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..a941233
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,24 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1606600693,
+ "narHash": "sha256-Xr03i0LlCD7PoBOIUNNFJss2uITrGoLF3LiOIW6seC0=",
+ "path": "/nix/store/60394i1ywi6jahim7fq0zkfxxn3f1pxr-source",
+ "rev": "29e9c10750e2b35a0e47db55f36c685ef9219f4e",
+ "type": "path"
+ },
+ "original": {
+ "id": "nixpkgs",
+ "type": "indirect"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..f1d12fc
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,16 @@
+{
+ description = "NixOS modules too specialized to be integrated into the official nixpkgs tree";
+
+ outputs = { self, nixpkgs }: {
+
+ lib = nixpkgs.lib.extend (self: super: let
+ callLibs = file: import file { lib = self; };
+ in {
+ tinydns = callLibs ./tinydns/lib.nix;
+ });
+
+ nixosModules = {
+ tinydns = import ./tinydns;
+ };
+ };
+}
diff --git a/tinydns/default.nix b/tinydns/default.nix
new file mode 100644
index 0000000..13e32cf
--- /dev/null
+++ b/tinydns/default.nix
@@ -0,0 +1,282 @@
+{ config, lib, pkgs, ... }:
+
+let
+ inherit (lib) any concatStrings concatStringsSep getAttrFromPath getBin id
+ literalExample mapAttrs' mapAttrsToList mkRemovedOptionModule mkDefault
+ mkEnableOption mkIf mkOption nameValuePair optional optionalString singleton
+ types;
+ inherit (builtins) attrNames;
+
+in {
+ ###### interface
+ disabledModules = [ "services/networking/tinydns.nix" ];
+
+ options = {
+ services.tinydns = mkOption {
+ description = "An attribute set of tinydns service configurations. Each Attribute must name the IP address this server will listen on.";
+ example = literalExample ''
+ services.tinydns = {
+ "128.1.2.3" = {
+ enable = true;
+ data = "=host.on.network.a:128.1.2.3";
+ };
+ "10.0.0.1" = {
+ enable = true;
+ data = "=host.on.network.b:10.0.0.1";
+ };
+ }
+ '';
+ default = {};
+ type = with types; attrsOf (submodule (
+ { config, name, ... }:
+ {
+ options = {
+ enable = mkOption {
+ default = false;
+ type = types.bool;
+ description = ''
+ Whether to run the tinydns DNS server on the IP address given as
+ the submodule's name. To answer queries larger than 512 bytes,
+ make sure to also enable listenTCP.
+ '';
+ };
+
+ data = mkOption {
+ default = "";
+ type = types.lines;
+ description = ''
+ DNS records, one per line, in the format described in
+ tinydns-data8.
+ Many advanced records need special encoding when appearing inside this
+ file. Use the functions from lib.tinydns to
+ generate various kinds of records. All record-building functions
+ accept a variable amount of arguments with unspecified arguments
+ filled up with the empty string. See also the example below.
+ The following record-building functions are implemented in
+ lib.tinydns:
+
+ Flag has one mandatory argument; a DNS
+ record in the format described in
+ tinydns-data8;
+ adds up to three additional flags to the DNS record, TTL, timestamp
+ from which this record becomes valid and the location it is valid in.
+ All other record-building functions also respect these flags as their
+ last three arguments in addition to the arguments that are relevant
+ for their particular type. TTL must be an integer. If timestamp is
+ given as an integer, it is taken as a UNIX timestamp, otherwise it
+ must be a string and is taken to be a hex-encoded TAI64 timestamp.
+ Location must be a string.
+
+ AFSDB has two mandatory arguments;
+ the requested AFS cell and the database server.
+
+ SRV has five mandatory arguments; the
+ requested service, a priority (int), a weight (int), a port (int), and
+ a target.
+
+ TXT has two mandatory arguments; the
+ requested service and a data field in the format that the service
+ requires.
+
+ URI has four mandatory arguments; the
+ requested service, a priority (int), a weight (int), and a target.
+ '';
+ example = literalExample ''
+ %ex
+ %in:10.0.0
+ .example.com:127.0.0.2:b
+ =server.example.com:1.2.3.4:::ex
+ =server.example.com:10.0.0.1:3600:400000005c0f6e84:in
+ +future.example.com:127.0.0.127
+ ''${TXT [ "_kerberos.example.com" "EXAMPLE.COM" 360 ]}
+ ''${SRV [ "_kerberos._udp.example.com" 10 100 88 "current.example.com." ]}
+ ''${URIs [ [ "_kerberos.EXAMPLE.COM" 10 1 "krb5srv:m:udp:current.example.com" "" 1542812577 ]
+ [ "_kerberos.EXAMPLE.COM" 10 1 "krb5srv:m:udp:future.example.com" "" "410000005c6bd4d3" ] ]}
+ '';
+ };
+
+ listenTCP = mkOption {
+ type = types.bool;
+ default = false;
+ description = ''
+ Also answer queries via TCP. You need this to answer queries whose
+ responses are larger than 512 bytes and for zone transfers via AXFR
+ (not supported, use secondaries instead).
+ '';
+ };
+
+ rootPath = mkOption {
+ internal = true;
+ type = types.str;
+ default = "/run/tinydns-${name}";
+ description = ''
+ Set this to an on-disk location to permanently store the DNS record database.
+ '';
+ };
+
+ secondaries = mkOption {
+ type = with types; attrsOf (submodule (
+ { config, name, ... }:
+ {
+ options = {
+ uri = mkOption {
+ type = types.str;
+ example = "tinydns@secondary.example.com";
+ description = ''
+ SSH connection uri to the secondary server. On zone updates the
+ new set of DNS records is transferred to this secondary server
+ via SSH. Make sure to add the secondary's SSH host key to
+ programs.ssh.knownHosts!
+ '';
+ };
+ sshKey = mkOption {
+ type = types.str;
+ example = "/some/secure/location";
+ description = ''
+ File location of the private SSH key to authenticate zone
+ updates to the secondary server.
+ '';
+ };
+ };
+ config = {};
+ }));
+ default = {};
+ description = ''
+ Set of secondary DNS servers. Updates to this server's DNS records are
+ sent to secondaries via SSH.
+ '';
+ };
+
+ secondary.enable = mkEnableOption "Configure this tinydns instance as a secondary server receiving its data via SSH.";
+
+ secondary.sshKey = mkOption {
+ type = types.str;
+ description = "SSH key to authenticate zone transfers to the secondary DNS server.";
+ default = "";
+ };
+ };
+ }));
+ };
+ };
+
+ ###### implementation
+
+ config = mkIf (any id (mapAttrsToList (_: v: v.enable) config.services.tinydns)) {
+
+ environment.systemPackages = [ pkgs.djbdns ];
+
+ # Restrict to a single session at a time
+ services.openssh.extraConfig = ''
+ Match User tinydns
+ MaxSessions=1
+ Match All
+ '';
+
+ ids.uids.tinydns = 999;
+ ids.gids.tinydns = 999;
+ users.users.tinydns = {
+ isSystemUser = true;
+ uid = config.ids.uids.tinydns;
+ group = "tinydns";
+ shell = "/run/current-system/sw/bin/bash";
+ openssh.authorizedKeys.keys = mapAttrsToList (name: cfg: let
+ # Expects tinydns data on standard input
+ tinydnsReceive = pkgs.writeScript "tinydns-receive" ''
+ #!${pkgs.stdenv.shell} -e
+ cd ${cfg.rootPath}
+ (
+ flock -n 5 || exit 1
+ mv data data.backup
+ cat >data
+ if ${getBin pkgs.djbdns}/bin/tinydns-data ; then
+ rm data.backup
+ else
+ mv data.backup data
+ fi
+ # Do not allow zone transfers to run more often than once per second due to
+ # minimum DNS record time resolution.
+ sleep 1
+ ) 5>data-lock
+ '';
+ in optionalString (cfg.secondary.sshKey != "") ''
+ restrict,command="${tinydnsReceive}" ${cfg.secondary.sshKey}
+ '') config.services.tinydns;
+ };
+ users.groups.tinydns = {
+ gid = config.ids.gids.tinydns;
+ };
+
+ systemd.services = (mapAttrs' (ip: cfg: nameValuePair "tinydns-${ip}" {
+ enable = cfg.enable;
+ description = "djbdns tinydns server";
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ requires = [ "network-online.target" ];
+ environment = {
+ IP = ip;
+ ROOT = cfg.rootPath;
+ UID = "${toString config.users.users.tinydns.uid}";
+ GID = "${toString config.users.groups.tinydns.gid}";
+ AXFR_JOBS = "2"; # Number of concurrent zone transfers to secondary servers.
+ };
+ path = with pkgs; [ coreutils djbdns ];
+ serviceConfig = {
+ # Drops privileges and chroots to ROOT by itself
+ ExecStart = "${getBin pkgs.djbdns}/bin/tinydns";
+ LimitDATA = mkDefault "40000000";
+ RestartSec = 1;
+ Restart = "always";
+ };
+ preStart = ''
+ rm -rf $ROOT
+ mkdir -m750 -p $ROOT
+ chown ''${UID}:''${GID} $ROOT
+ cd $ROOT
+ ${if cfg.secondary.enable then "touch data" else "ln -sf ${pkgs.writeText "tinydns-data" cfg.data} data"}
+ tinydns-data
+ '';
+ reload = let
+ tinydnsSend = pkgs.writeText "tinydns-send.mk" (concatStrings (
+ (singleton ''
+ all: ${concatStringsSep " " (attrNames cfg.secondaries)}
+ '') ++ (mapAttrsToList (k: v: ''
+.PHONY: ${k}
+${k}::
+ -${getBin pkgs.openssh}/bin/ssh -a -x -S none -T -i ${v.sshKey} ${v.uri} <${cfg.rootPath}/data
+'') cfg.secondaries)));
+ in ''
+ cd $ROOT
+ ${optionalString (!cfg.secondary.enable) "ln -sf ${pkgs.writeText "tinydns-data" cfg.data} data"}
+ tinydns-data
+ ${optionalString (cfg.secondaries != {}) "${getBin pkgs.gnumake}/bin/make -j$AXFR_JOBS -f ${tinydnsSend}"}
+ '';
+ reloadIfChanged = true;
+ }) config.services.tinydns) // (mapAttrs' (ip: cfg: nameValuePair "axfrdns-${ip}" {
+ enable = cfg.enable;
+ description = "axfrdns tcp dns server";
+ after = [ "tinydns-${ip}.service" ];
+ requires = [ "tinydns-${ip}.service" ];
+ environment = {
+ IP = ip;
+ ROOT = cfg.rootPath;
+ AXFR = ""; # Do not allow any zone transfers!
+ UID = "${toString config.users.users.tinydns.uid}";
+ GID = "${toString config.users.groups.tinydns.gid}";
+ };
+ serviceConfig = {
+ StandardInput = "socket";
+ StandardError = "journal";
+ ExecStart = "${getBin pkgs.djbdns}/bin/axfrdns";
+ LimitDATA = mkDefault "15000000";
+ };
+ }) config.services.tinydns);
+
+ systemd.sockets = mapAttrs' (ip: cfg: nameValuePair "axfrdns-${ip}" {
+ enable = cfg.enable;
+ wantedBy = [ "sockets.target" ];
+ description = "axfrdns socket";
+ listenStreams = [ "${ip}:53" ];
+ socketConfig.Accept = true;
+ }) config.services.tinydns;
+ };
+}
diff --git a/tinydns/lib.nix b/tinydns/lib.nix
new file mode 100644
index 0000000..f2cc97d
--- /dev/null
+++ b/tinydns/lib.nix
@@ -0,0 +1,125 @@
+{ lib }:
+let
+ inherit (lib) concatStrings concatMapStringsSep concatStringsSep
+ getAttrFromPath getBin literalExample mapAttrsToList mkForce mkIf
+ mkChangedOptionModule mkEnableOption mkOption mod optional optionalString
+ remove singleton splitString types;
+ inherit (builtins) attrNames genList head isFunction isString length
+ removeAttrs replaceStrings stringLength substring tail;
+
+ _lib = rec {
+
+ # Convert a number base 10 to another number base k (k <= 16)
+ # l determins the 0 prefixed length of the output.
+ # Returns the output as a string
+ klconv = k: l: num:
+ let
+ snum = { base = k; d = num; c = 0; result = ""; };
+ replaceDigit = d: replaceStrings [ "10" "11" "12" "13" "14" "15" ]
+ [ "a" "b" "c" "d" "e" "f" ] d;
+ kconvDigit = { d, ... }@args:
+ args // { d = args.d / args.base;
+ result = replaceDigit (toString (d - ((d / args.base) * args.base))) + args.result;
+ };
+ kconv' = snum:
+ if snum.d == 0
+ then if stringLength snum.result < l
+ then (concatStrings (genList (_: "0") (l - stringLength snum.result))) + snum.result
+ else snum.result
+ else kconv' (kconvDigit snum);
+ in kconv' snum;
+
+ # Convert integer to octal code string of 16 or 8 bits
+ octconv16 = x: let
+ low = mod x 256;
+ high = (mod x 65536) / 256;
+ in "\\" + (substring 0 3 (klconv 8 3 high)) + "\\" + (substring 0 3 (klconv 8 3 low));
+ octconv8 = x: let s =(klconv 8 3 x); in "\\" + (substring 0 3 s);
+
+ # Convert seconds since Epoch to TAI64 into hex-coded string external format
+ epochToTAI64 = epoch: klconv 16 0 (4611686018427387904 + epoch);
+
+ # Quote special characters with octal replacements to suit tinydns-data
+ quoteDATA = s: replaceStrings [ " " "#" ":" ]
+ [ "\\040" "\\043" "\\072" ] s;
+
+ # Convert string into fixed-length string
+ # abcd -> \004abcd
+ fixedString = s: let
+ prefixShort = short: (octconv8 (stringLength short)) + (quoteDATA short);
+ fixedString' = s:
+ if stringLength s > 255
+ then (prefixShort (substring 0 255 s))+ (fixedString' (substring 255 (stringLength s) s))
+ else prefixShort s;
+ in
+ if stringLength s <= 65280 then fixedString' s
+ else abort "String payload too big, must be less than 65281 bytes";
+
+ # Split target at '.' into a length-prefixed string list suitable for tinydns-data
+ # abcd.ef. -> \004abcd\002ef\000
+ splitTarget = target:
+ concatStrings (map fixedString (splitString "." target));
+
+ # Parse DNS records defined in option records.
+ # Delivers var-args for the data* lambda type functions by supplying default
+ # values (i.e. "") for missing arguments.
+ parseRecord = f: l: if isFunction f
+ then if (length l) > 0
+ then parseRecord (f (head l)) (tail l)
+ else parseRecord (f "") []
+ else f;
+
+ # Capture the varargs parser and its varargs into a set containing all
+ # arguments as a list. Run the captured parser on the args when coercing the
+ # set to string.
+ captureParser = parser: { args = [];
+ __functor = self: arg: self // { args = self.args ++ [ arg ]; };
+ __toString = self: parser self.args;
+ };
+
+ # Make a DNS record by taking a prefabricated record and parsing the
+ # additional flags ttl, timestamp and location
+ dataFlags = record: ttl: ts: location:
+ "${record}"
+ + ":${if ttl == null then "" else if isString ttl then ttl else toString ttl}"
+ + ":${if ts == null then "" else if isString ts then ts else epochToTAI64 ts}"
+ + ":${location}";
+
+ }; # _lib
+
+ # Each parser is expected to produce a string.
+ #
+ # captureParser collects the (variable amount of) arguments and the specific
+ # parser. It sets up the result such that, when it is coerced to a string, the
+ # captured parser is called with the captured arguments.
+ #
+ # parseRecord ensures that the specialized parser is called with enough
+ # arguments eventually. Left-out arguments are replaced with the empty string.
+ #
+ # Parser-building functions are collected in lib.
+ _parsers = with _lib; {
+ lib = _lib;
+
+ # Add the flags TTL, timestamp and location to a record
+ Flag = captureParser (parseRecord dataFlags);
+
+ # Make an AFSDB DNS record
+ AFSDB = captureParser (parseRecord (cell: server: dataFlags
+ (":${cell}:18:" + (octconv16 1) + (splitTarget server))));
+
+ # Make a SRV DNS record
+ SRV = captureParser (parseRecord (service: prio: w: port: target: dataFlags
+ (":${service}:33:" + (concatStrings (map octconv16 [ prio w port ])) + (splitTarget target))));
+
+ # Make a TXT DNS record
+ TXT = captureParser (parseRecord (service: txt: dataFlags
+ (":${service}:16:" + (fixedString txt))));
+
+ # Make a URI DNS record
+ URI = captureParser (parseRecord (service: prio: weight: pl: dataFlags
+ (":${service}:256:" + "${octconv16 prio}${octconv16 weight}" + (quoteDATA pl))));
+
+ }; # _parsers
+
+# Update the documentation of services.tinydns.*.data when adding new record-building functions, here!
+in _parsers