#!/usr/bin/env bash #################### # config variables # #################### if [ -z "${!DNS_KEYS[*]}" ]; then declare -A DNS_KEYS=() fi DNS_IP=${DNS_IP:-127.0.0.1} DIG=${DIG:-/usr/bin/dig} IDN2=${IDN2:-/usr/bin/idn2} JQ=${JQ:-/usr/bin/jq} NAMED_CHECKCONF=${NAMED_CHECKCONF:-/usr/bin/named-checkconf} NSUPDATE=${NSUPDATE:-/usr/bin/nsupdate} TERMINAL_WITH=${MAX_TERMINAL_WITH:-$(/usr/bin/tput cols)} #################### # global variables # #################### # List of managable DNS record types declare -a DNS_RECORD_TYPES=("A" "AAAA" "CAA" "CDS" "CNAME" "DS" "MX" "NS" "PTR" "SRV" "TLSA" "TXT") # Global variables NEWLINE=$'\n' TAB=$'\t' NAMED_DEFAULT_VIEW="_default" ######################################## # bash array and JSON helper functions # ######################################## in_array() { local search=$1 shift local value for value in "$@"; do [ "$search" == "$value" ] && return 0 done return 1 } join_by() { local d=${1-} f=${2-} if shift 2; then printf %s "$f" "${@/#/$d}" fi } raw_to_json_obj() { local delim=$1 shift || return 1 local fields=() local name index while (( $# > 0 )); do IFS=":" read -r name index <<<"$1" shift fields+=("$name:.[$index]") done local jq_cmd=". | split(\$delim) | {$(join_by "," "${fields[@]}")}" "$JQ" --raw-input --compact-output --arg delim "$delim" "$jq_cmd" < <(cat -) return $? } json_array_to_bash() { local retvar=${1:-REPLY} local __values=() while IFS=$NEWLINE read -r line; do __values+=("$("$JQ" --raw-output --compact-output '.[] | @sh' <<<"$line")") done < <(cat -) declare -g -a $retvar eval "$retvar=(${__values[@]})" } bash_array_to_json() { "$JQ" --raw-input --compact-output '.' < <(cat -) | "$JQ" --compact-output --slurp } ############################# # internal helper functions # ############################# _get_keyfile() { local retvar=$1 local zone=$2 local view=$3 local key_id for key_id in "$zone@$view" "$view"; do keyfile=${DNS_KEYS["$key_id"]} if [ -n "$keyfile" ]; then if ! test -r "$keyfile" -a -f "$keyfile"; then echo "ERROR: key file does not exist or is not accessible -- '$keyfile'" >&2 return 1 fi declare -g $retvar="$keyfile" return 0 fi done if [ "$view" == "$NAMED_DEFAULT_VIEW" ]; then declare -g $retvar="" return 0 fi echo "ERROR: config: no key found for '$zone@$view' or '$view'" >&2 return 1 } _nsupdate() { local zone=$1 shift || return 1 local view=$1 shift || return 1 local update_script=$1 shift || return 1 local pretend=${1:-true} _get_keyfile keyfile "$zone" "$view" || return $? local nsupdate_args="" [ -n "$keyfile" ] && nsupdate_args="-k ${keyfile}" "$NSUPDATE" -v $nsupdate_args < <( echo "server $DNS_IP" echo "$update_script" if $pretend; then echo "show" else echo "send" echo "answer" fi ) return $? } ############################# # data generation functions # ############################# _DNS_ZONES_DATA="" dns_zones() { local zone=$1 if [ -z "$_DNS_ZONES_DATA" ]; then local raw_data=$("$NAMED_CHECKCONF" -l | grep -P -i '^[^.][^ ]* IN ' | sort) || return 1 local json_data=$(raw_to_json_obj " " "zone:0" "view:2" "status:3" <<<"$raw_data") || return 2 declare -g _DNS_ZONES_DATA=$("$JQ" --compact-output --slurp 'sort_by(.zone)' <<<"$json_data") || return 3 fi [ -z "$zone" ] && echo "$_DNS_ZONES_DATA" && return 0 "$JQ" --compact-output --arg zone "$zone" '[ .[] | select(.zone == $zone) ]' <<<"$_DNS_ZONES_DATA" } dns_zone_views() { local zone=$1 local zones=$(dns_zones "$zone") || return $? "$JQ" --compact-output '[ .[] | .view ] | sort | unique' <<<"$zones" return $? } dns_zone() { local zone=$1 local view=$2 local unfiltered=${3:-false} local keyfile _get_keyfile keyfile "$zone" "$view" || return $? local dig_args [ -n "$keyfile" ] && dig_args="-k ${keyfile}" local raw_data=$("$DIG" @${DNS_IP} $dig_args +nocmd +onesoa +nostats AXFR "$zone" | \ sed -r 's#^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)#\1\t\2\t\3\t\4\t\5#g' | \ grep -P -i '^[^\t]+\t[0-9]+\tIN\t') raw_data=$( local name ttl _type rtype value while read -r name ttl _type rtype value; do if [ "${rtype^^}" == "TXT" ]; then decoded_value=$(dns_decode_txt_value "$value") else decoded_value=$value fi printf "%s\t" "$name" "$ttl" "$rtype" "$value" "$decoded_value" echo done <<<"$raw_data" ) local query='.' local rtypes="[]" if ! $unfiltered; then query='[ .[] | select(.type | IN($rtypes[])) ]' rtypes=$(printf "%s\n" "${DNS_RECORD_TYPES[@]}" | bash_array_to_json) fi query+='| [ .[] | .view += "\($view)" | if .name == "\($zone)." then .name = "@" else .name = "\(.name | capture("(?.+)\\.\($zone)").name )" end ] | sort_by(.name)' raw_to_json_obj "$TAB" "name:0" "ttl:1" "type:2" "value:3" "decoded_value:4" <<<"$raw_data" | \ jq --compact-output --slurp --arg view "$view" --argjson rtypes "$rtypes" --arg zone "$zone" "$query" } ############################## # input validation functions # ############################## dns_check_zone() { local zone=$1 shift || return 1 local retvar=${1:-REPLY} zone=$("$IDN2" <<<"$zone") local zones=$(dns_zones "$zone") || return 1 local query='if . | length > 0 then "true" else "false" end' local found=$("$JQ" --raw-output --arg zone "$zone" "$query" <<<"$zones") $found && declare -g $retvar="$zone" && return 0 declare -g $retvar="" echo "ERROR: zone does not exist -- '$zone'" >&2 return 2 } dns_check_view() { local view=$1 shift || return 1 local query='. as $list | $view | IN($list[])' $("$JQ" --raw-output --arg view "$view" "$query" < <(dns_zone_views)) && return 0 echo "ERROR: view does not exist -- '$view'" >&2 return 1 } dns_check_zone_view() { local zone_view=$1 local zone_retvar=$2 local view_retvar=$3 local zone view IFS='@' read -r zone view <<<"$zone_view" dns_check_zone "$zone" zone || return 1 local -a views json_array_to_bash views < <(dns_zone_views "$zone") if [ -z "$view" ]; then if (( ${#views[@]} > 1 )); then echo "ERROR: zone is member of multiple views -- '$zone'" >&2 return 2 fi if [ "${views[0]}" != "$NAMED_DEFAULT_VIEW" ]; then echo "ERROR: zone is not a member of the default view -- '$zone'" >&2 return 3 fi view=$NAMED_DEFAULT_VIEW elif [ "${view}" != "*" ]; then dns_check_view "$view" || return 4 if ! in_array "$view" "${views[@]}"; then echo "ERROR: zone '$zone' is not part of view '$view'" >&2 return 5 fi fi [ -n "$zone_retvar" ] && declare -g $zone_retvar="$zone" [ -n "$view_retvar" ] && declare -g $view_retvar="$view" return 0 } dns_check_zone_name() { local name=${1,,} [[ "$name" =~ ^[a-z0-9_][a-z0-9_.-]*$ ]] && [[ "$name" != *"." ]] && return 0 echo "ERROR: invalid zone name -- '$name'" >&2 return 1 } dns_check_record_name() { local name=${1,,} local retvar=${2:-REPLY} [ "$name" == "@" ] && return 0 name=$("$IDN2" <<<"$name") local LC_ALL=C if [[ "$name" =~ ^[a-z0-9_][a-z0-9_.-]*$ ]] && [[ "$name" != *"." ]]; then declare -g $retvar="$name" return 0 fi echo "ERROR: invalid record name -- '$name'" >&2 return 1 } dns_check_ttl() { local ttl=$1 [[ "$ttl" =~ ^[1-9][0-9]*$ ]] && (( $ttl <= 2147483647 )) && return 0 echo "ERROR: invalid TTL -- '$ttl'" >&2 return 1 } dns_check_record_type() { local rtype=$1 [ -z "$rtype" ] && return 1 if ! in_array "${rtype^^}" "${DNS_RECORD_TYPES[@]}"; then echo "ERROR: invalid record type -- '$rtype'" >&2 return 1 fi return 0 } dns_check_record_value() { local rtype=$1 local value=$2 local retvar=${3:-REPLY} [ -z "$rtype" ] && return 1 [ -z "$value" ] && return 1 case "${rtype^^}" in A) local digit_re='(0|1[0-9]{0,2}|2([0-9]|[0-4][0-9]|5[0-5])?)' local ipv4_re="^$digit_re\.$digit_re\.$digit_re\.$digit_re\$" local LC_ALL=C if ! [[ "$value" =~ $ipv4_re ]]; then echo "ERROR: invalid IPv4 address -- '$value'" 2>&1 return 1 fi ;; AAAA) local ipv6_re='^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$' local LC_ALL=C if ! [[ "$value" =~ $ipv6_re ]]; then echo "ERROR: invalid IPv6 address -- '$value'" 2>&1 return 1 fi ;; CNAME|PTR|NS) dns_check_zone_name "$value" || return 1 ;; MX) local prio mx read -r prio mx <<<"$value" [ -z "$rio" -a -z "$mx" ] && echo "ERROR: invalid MX record format -- '$value'" >&2 && return 1 local LC_ALL=C ! [[ "$prio" =~ ^[1-9][0-9]*$ ]] && echo "ERROR: invalid priority -- '$prio'" >&2 && return 1 dns_check_zone_name "$mx" || return 1 ;; TXT) if [ "${value:0:1}" != '"' ]; then local -a parts=() local index=0 while (( ${#value} - $index > 0 )); do parts+=("${value:$index:255}") ((index+=255)) done value="\"$(join_by '" "' "${parts[@]}")\"" fi ;; esac declare -g $retvar="$value" return 0 } ######################## # user input functions # ######################## yes_no() { local yn="" while :; do read -p "$* (yes/no): " yn case "$yn" in [Yy]*) return 0 ;; [Nn]*) return 1 ;; esac done } dns_select_zone() { local zone_retvar=$1 local view_retvar=$2 local -a zones=() local zones=$(dns_zones) || return $? json_array_to_bash zones < <("$JQ" --compact-output '[ .[] | .zone ] | unique' <<<"$zones") (( ${#zones[@]} == 0 )) && echo "ERROR: no zones found" >&2 && return 1 local COLUMNS=30 echo -e "Select zone:\n" select zone in "${zones[@]}"; do [ -n "$zone" ] && break done echo [ -z "$zone" ] && echo "ERROR: zone selection failed" >&2 && return 1 local views=() json_array_to_bash views < <(dns_zone_views "$zone") local view="" if (( ${#views[@]} == 1 )) && [ "${views[0]}" == "$NAMED_DEFAULT_VIEW" ]; then view=$NAMED_DEFAULT_VIEW else echo -e "Select view:\n" select view in "${views[@]}"; do [ -n "$view" ] && break done echo [ -z "$view" ] && echo "ERROR: view selection failed" >&2 && return 1 fi declare -g $zone_retvar="$zone" declare -g $view_retvar="$view" } dns_select_record_type() { local retvar=${1:-REPLY} echo "Select record type:" local rtype select rtype in "${DNS_RECORD_TYPES[@]}"; do [ -n "$rtype" ] && break done echo [ -z "$rtype" ] && echo "ERROR: record type selection failed" >&2 && return 1 declare -g $retvar="$rtype" } dns_read_zone_name() { # TODO exit #if [ -n "$zone" ]; then # if ! in_array "$zone" "${zones[@]}"; then # echo "ERROR: unknown zone '$zone'" >&2 # return 1 # fi #fi #if [ -n "$view" ]; then # if ! in_array "$view" "${views[@]}" ]]; then # echo "ERROR: zone '$zone' is not part of view '$view'" >&2 # return 2 # fi #fi } dns_read_record_name() { local retvar=${1:-REPLY} local name="" while [ -z "$name" ]; do read -e -p "Record name (e.g. www or mail): " name [ -n "$name" ] && ! dns_check_record_name "$name" name && name="" done echo declare -g $retvar="$name" } dns_read_ttl() { local retvar=${1:-REPLY} local ttl while [ -z "$ttl" ]; do read -e -p "TTL (seconds): " -i 7200 ttl [ -n "$ttl" ] && ! dns_check_ttl "$ttl" && ttl="" done echo declare -g $retvar="$ttl" } dns_read_record_value() { local rtype=$1 local retvar=${2:-REPLY} local value while [ -z "$value" ]; do read -e -p "Value: " value [ -n "$value" ] && ! dns_check_record_value "$rtype" "$value" value && value="" done echo declare -g $retvar="$value" } dns_decode_txt_value() { local value=$1 [ "${value:0:1}" != '"' ] && echo "$value" && return value=${value#\"} value=${value%\"} sed -r 's#([^\])" "#\1#g;s#\\"#"#g' <<<"$value" } #################### # action functions # #################### dns_record_add() { local pretend=$1 local zone=$2 local view=$3 local name=$4 local ttl=$5 local rtype=$6 local value=$7 [ -z "$pretend" ] && echo "ERROR: missing argument -- PRETEND" >&2 && return 1 ! in_array "$pretend" "true" "false" && echo "ERROR: invalid argument PRETEND -- '$pretend'" >&2 && return 1 [ -z "$zone" ] && echo "ERROR: missing argument -- ZONE" >&2 && return 1 [ -z "$view" ] && echo "ERROR: missing argument -- VIEW" >&2 && return 1 [ -z "$name" ] && echo "ERROR: missing argument -- NAME" >&2 && return 1 [ -z "$ttl" ] && echo "ERROR: missing argument -- TTL" >&2 && return 1 [ -z "$rtype" ] && echo "ERROR: missing argument -- TYPE" >&2 && return 1 [ -z "$value" ] && echo "ERROR: missing argument -- VALUE" >&2 && return 1 local fqdn [ "$name" == "@" ] && fqdn=$zone || fqdn="$name.$zone" update_script="" if [ "${rtype^^}" == "CNAME" ]; then update_script="prereq nxdomain $fqdn$NEWLINE" fi update_script+="update add $fqdn $ttl $rtype $value" _nsupdate "$zone" "$view" "$update_script" "$pretend" return $? } dns_record_delete() { local pretend=$1 local zone=$2 local view=$3 local name=$4 local rtype=$5 local value=$6 [ -z "$pretend" ] && echo "ERROR: missing argument -- PRETEND" >&2 && return 1 ! in_array "$pretend" "true" "false" && echo "ERROR: invalid argument PRETEND -- '$pretend'" >&2 && return 1 [ -z "$zone" ] && echo "ERROR: missing argument -- ZONE" >&2 && return 1 [ -z "$view" ] && echo "ERROR: missing argument -- VIEW" >&2 && return 1 [ -z "$name" ] && echo "ERROR: missing argument -- NAME" >&2 && return 1 [ -z "$rtype" ] && echo "ERROR: missing argument -- TYPE" >&2 && return 1 [ -z "$value" ] && echo "ERROR: missing argument -- VALUE" >&2 && return 1 local fqdn [ "$name" == "@" ] && fqdn=$zone || fqdn="$name.$zone" [ -n "$value" ] && value=" $value" update_script="update delete $fqdn $rtype$value" _nsupdate "$zone" "$view" "$update_script" "$pretend" return $? }