{ lib, pkgs, config, ... }: let inherit (builtins) isList; inherit (lib) mkMerge mkIf mkDefault mapAttrsToList concatMapStringsSep concatStringsSep; inherit (lib.my) mkBoolOpt' mkOpt'; # Yoinked from nixos/modules/services/networking/pdns-recursor.nix oneOrMore = type: with lib.types; either type (listOf type); valueType = with lib.types; oneOf [ int str bool path ]; configType = with lib.types; attrsOf (nullOr (oneOrMore valueType)); toBool = val: if val then "yes" else "no"; serialize = val: with lib.types; if str.check val then val else if int.check val then toString val else if path.check val then toString val else if bool.check val then toBool val else if isList val then (concatMapStringsSep "," serialize val) else ""; settingsToLines = s: (concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${serialize v}") s)) + "\n"; bindList = l: "{ ${concatStringsSep "; " l} }"; bindAlsoNotify = with lib.types; mkOpt' (listOf str) [ ] "List of additional address to send DNS NOTIFY messages to."; bindZoneOpts = with lib.types; { name, config, ... }: { options = { type = mkOpt' (enum [ "master" "slave" "native" ]) "native" "Zone type."; masters = mkOpt' (listOf str) [ ] "List of masters to retrieve data from (as slave)."; also-notify = bindAlsoNotify; template = mkBoolOpt' true "Whether to run the zone contents through a template for post-processing."; text = mkOpt' (nullOr lines) null "Inline content of the zone file."; path = mkOpt' path null "Path to zone file."; }; config.path = mkIf (config.text != null) (pkgs.writeText "${name}.zone" config.text); }; namedZone = n: o: '' zone "${n}" IN { file "/run/pdns/bind-zones/${n}.zone"; type ${o.type}; masters ${bindList o.masters}; also-notify ${bindList o.also-notify}; }; ''; staticZonePath = "/etc/pdns-bind-zones"; loadZonesCommon = pkgs.writeShellScript "pdns-bind-load-common.sh" '' loadZones() { for z in ${staticZonePath}/*.zone; do zoneName="$(echo "$z" | ${pkgs.gnused}/bin/sed -rn 's|${staticZonePath}/(.*)\.zone|\1|p')" zDat="/var/lib/pdns/bind-zones/"$zoneName".dat" newZonePath="$(readlink -f "$z")" if [ ! -e "$zDat" ]; then echo "zonePath=\"$newZonePath\"" > "$zDat" echo "serial=$(date +%Y%m%d00)" >> "$zDat" fi source "$zDat" subSerial() { ${pkgs.gnused}/bin/sed "s/@@SERIAL@@/$serial/g" < "$z" > /run/pdns/bind-zones/"$zoneName".zone } # Zone in /run won't have changed if it didn't exist if [ "$newZonePath" != "$zonePath" ]; then echo "$zoneName has changed; incrementing serial..." ((serial++)) echo "zonePath=\"$newZonePath\"" > "$zDat" echo "serial=$serial" >> "$zDat" subSerial if [ "$1" = reload ]; then echo "Reloading $zoneName" ${pkgs.pdns}/bin/pdns_control bind-reload-now "$zoneName" fi elif [ "$1" != reload ]; then subSerial fi done } ''; pdns-file-record = pkgs.writeShellApplication { name = "pdns-file-record"; runtimeInputs = with pkgs; [ gnused moreutils pdns ]; text = '' die() { echo "$@" >&2 exit 1 } usage() { die "usage: $0 [content]" } add() { if [ $# -lt 2 ]; then usage fi file="$dir"/"$1"txt shift echo "$@" >> "$file" } del() { if [ $# -lt 1 ]; then usage fi file="$dir"/"$1"txt if [ $# -eq 1 ]; then rm "$file" else shift sed -i "/^""$*""$/!{q1}; /^""$*""$/d" "$file" exit $? fi } dir=/run/pdns/file-records mkdir -p "$dir" if [ $# -lt 2 ]; then usage fi zone="$1" shift cmd="$1" shift case "$cmd" in add) add "$@";; del) del "$@";; *) usage;; esac # TODO: This feels pretty hacky? zDat=/var/lib/pdns/bind-zones/"$zone".dat # shellcheck disable=SC1090 source "$zDat" ((serial++)) # Use sponge instead of `sed -i` because that actually uses a temporary file and clobbers ownership... sed "s/^serial=.*$/serial=$serial/g" "$zDat" | sponge "$zDat" sed "s/@@SERIAL@@/$serial/g" < ${staticZonePath}/"$zone".zone > /run/pdns/bind-zones/"$zone".zone pdns_control bind-reload-now "$zone" ''; }; fileRecScript = pkgs.writeText "file-record.lua" '' local path = "/run/pdns/file-records/" .. string.lower(qname:toStringNoDot()) .. ".txt" if not os.execute("test -e " .. path) then return {} end local values = {} for line in io.lines(path) do table.insert(values, line) end return values ''; cfg = config.my.pdns; extraSettingsOpt = with lib.types; mkOpt' (nullOr str) null "Path to extra settings (e.g. for secrets)."; baseAuthSettings = pkgs.writeText "pdns.conf" (settingsToLines cfg.auth.settings); baseRecursorSettings = pkgs.writeText "pdns-recursor.conf" (settingsToLines config.services.pdns-recursor.settings); generateSettings = type: base: dst: if (cfg."${type}".extraSettingsFile != null) then '' oldUmask="$(umask)" umask 006 cat "${base}" "${cfg."${type}".extraSettingsFile}" > "${dst}" umask "$oldUmask" '' else '' cp "${base}" "${dst}" ''; namedConf = pkgs.writeText "pdns-named.conf" '' options { directory "/run/pdns/bind-zones"; also-notify ${bindList cfg.auth.bind.options.also-notify}; }; ${concatStringsSep "\n" (mapAttrsToList namedZone cfg.auth.bind.zones)} ''; templateZone = n: s: pkgs.runCommand "${n}.zone" { passAsFile = [ "script" ]; script = '' import re import ipaddress import sys def ptr(m): ip = ipaddress.ip_address(m.group(1)) return '.'.join(ip.reverse_pointer.split('.')[:int(m.group(2))]) ex_ptr = re.compile(r'@@PTR:(.+):(\d+)@@') fr = '"dofile(\'${fileRecScript}\')"' ex_fr = re.compile(r'@@FILE@@') for line in sys.stdin: print(ex_fr.sub(fr, ex_ptr.sub(ptr, line)), end=''') ''; } '' ${pkgs.python3}/bin/python "$scriptPath" < "${s}" > "$out" ''; zones = pkgs.linkFarm "pdns-bind-zones" (mapAttrsToList (n: o: rec { name = "${n}.zone"; path = if o.template then templateZone n o.path else o.path; }) cfg.auth.bind.zones); enableFileRecSSH = cfg.auth.bind.file-records.sshKey != null; in { options.my.pdns = with lib.types; { auth = { enable = mkBoolOpt' false "Whether to enable PowerDNS authoritative nameserver."; settings = mkOpt' configType { } "Authoritative server settings."; extraSettingsFile = extraSettingsOpt; bind = { options = { also-notify = bindAlsoNotify; }; zones = mkOpt' (attrsOf (submodule bindZoneOpts)) { } "BIND-style zones definitions."; file-records = { sshKey = mkOpt' (nullOr str) null "SSH public key for file record update user."; }; }; }; recursor = { enable = mkBoolOpt' false "Whether to enable PowerDNS recursive nameserver."; extraSettingsFile = extraSettingsOpt; }; }; config = mkMerge [ (mkIf cfg.auth.enable { my = { tmproot.persistence.config.directories = [ "/var/lib/pdns" ]; pdns.auth.settings = { launch = [ "bind" ]; socket-dir = "/run/pdns"; bind-config = namedConf; expand-alias = mkDefault true; }; }; users.users."pdns-file-records" = let script = pkgs.writeShellScript "pdns-file-records-ssh.sh" '' read -r -a args <<< "$SSH_ORIGINAL_COMMAND" exec ${pdns-file-record}/bin/pdns-file-record "''${args[@]}" ''; in (mkIf enableFileRecSSH { group = "pdns"; isSystemUser = true; shell = pkgs.bashInteractive; openssh.authorizedKeys.keys = [ ''command="${script}" ${cfg.auth.bind.file-records.sshKey}'' ]; }); environment = { # For pdns_control etc systemPackages = with pkgs; [ pdns (pkgs.writeShellScriptBin "pu" '' ${pdns}/bin/pdnsutil --config-dir /run/pdns "$@" '') pdns-file-record ]; etc."pdns-bind-zones".source = "${zones}/*"; }; systemd.services.pdns = { preStart = '' ${generateSettings "auth" baseAuthSettings "/run/pdns/pdns.conf"} source ${loadZonesCommon} mkdir /run/pdns/{bind-zones,file-records} mkdir -p /var/lib/pdns/bind-zones loadZones start ''; postStart = '' chmod -R g+w /run/pdns /var/lib/pdns ''; # pdns reloads existing zones, so the only trigger will be if the zone files themselves change. If any new zones # are added or removed, named.conf will change, in turn changing the overall pdns settings and causing pdns to # get fully restarted reload = '' source ${loadZonesCommon} loadZones reload ''; reloadTriggers = [ zones ]; serviceConfig = { ExecStart = [ "" "${pkgs.pdns}/bin/pdns_server --config-dir=/run/pdns --guardian=no --daemon=no --disable-syslog --log-timestamp=no --write-pid=no" ]; RuntimeDirectory = "pdns"; StateDirectory = "pdns"; }; }; services.powerdns = { enable = true; }; }) (mkIf cfg.recursor.enable { systemd.services.pdns-recursor = { preStart = '' ${generateSettings "recursor" baseRecursorSettings "/run/pdns-recursor/recursor.conf"} ''; serviceConfig.ExecStart = [ "" "${pkgs.pdns-recursor}/bin/pdns_recursor --config-dir=/run/pdns-recursor" ]; }; services.pdns-recursor = { enable = true; }; }) ]; }