From 0b1f1bf777ab9001b1021f994075ec107d4365a0 Mon Sep 17 00:00:00 2001 From: Michael Raitza Date: Wed, 2 Dec 2020 22:16:35 +0100 Subject: [PATCH] Add tinydns module --- flake.lock | 24 ++++ flake.nix | 16 +++ tinydns/default.nix | 282 ++++++++++++++++++++++++++++++++++++++++++++ tinydns/lib.nix | 125 ++++++++++++++++++++ 4 files changed, 447 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tinydns/default.nix create mode 100644 tinydns/lib.nix 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