diff --git a/config.sh b/config.sh new file mode 100644 index 0000000..e461143 --- /dev/null +++ b/config.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# +# IP address of the DNS server +# +#DNS_IP="127.0.0.1" + +# +# Associative array containing path to key files used for zone transfer and DDNS updates. +# The key has to be in on of the following forms: +# - VIEW +# - ZONE@VIEW +# +#DNS_KEYS=( +# [_default]="/etc/bind/rndc.key" +#) + +# +# Paths to external binaries +# +#DIG="/usr/bin/dig" +#IDN2="/usr/bin/idn2" +#NAMED_CHECKCONF="/usr/bin/named-checkconf" +#NSUPDATE="/usr/bin/nsupdate" + +# +# Path to library directory +# +#LIB_DIR="/usr/local/dns-manager/lib" + +# +# Color of the header text when printing tables +# +#TABLE_HEADER_COLOR="red" +#TERMINAL_WITH=$(/usr/bin/tput cols) diff --git a/dns-list-zones b/dns-list-zones new file mode 100755 index 0000000..b26478c --- /dev/null +++ b/dns-list-zones @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "${0}") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -j|--json) + json=true + ;; + -J|--json-pretty) + json=true + json_pretty=true + ;; + -r|--raw) + raw=true + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + echo "$SCRIPT: invalid argument -- '$opt'" >&2 + exit 1 + ;; + esac +done + +if $json && $raw; then + echo "$SCRIPT: invalid options: --raw and --json are mutually exclusive" >&2 + exit 1 +fi + +source "$config_file" || exit 2 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 3 +source "$LIB_DIR"/output.sh || exit 3 + +zones_data=$(dns_zones) || exit 10 + +if $raw; then + "$JQ" --raw-output '.[] | "\(.zone)\t\(.view)\t\(.status)"' <<<"$zones_data" +elif $json; then + jq_opts="" + $json_pretty || jq_opts="--compact-output" + "$JQ" $jq_opts <<<"$zones_data" +else + while read -r zone view status; do + zone=$("$IDN2" --decode <<<"$zone") + echo "$zone$TAB$view$TAB$status" + done < <("$JQ" --raw-output '.[] | "\(.zone)\t\(.view)\t\(.status)"' <<<"$zones_data") | table_output "ZONE" "VIEW" "STATUS" +fi diff --git a/dns-record b/dns-record new file mode 100755 index 0000000..602d50b --- /dev/null +++ b/dns-record @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "$0") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + cmd=$opt + break + ;; + esac +done + +cmd=${cmd,,:-help} + +source "$config_file" || exit 1 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 1 +source "$LIB_DIR"/output.sh || exit 1 + +params="--interactive" + +if [ "$cmd" == "help" ]; then + params="--help" + cmd=${1,,} + shift +fi + +case "$cmd" in + add) + "$SCRIPT_DIR"/dns-record-add $params "$@" + ;; + del|delete) + "$SCRIPT_DIR"/dns-record-delete $params "$@" + ;; + "") + usage + ;; + *) + echo "$SCRIPT: invalid command -- '$cmd'" >&2 + exit 5 + ;; +esac diff --git a/dns-record-add b/dns-record-add new file mode 100755 index 0000000..2f5d452 --- /dev/null +++ b/dns-record-add @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "$0") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -f|--force) + force=true + ;; + -i|--interactive) + interactive=true + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + args+=("$opt") + if (( ${#args[@]} > 6 )); then + echo "$SCRIPT: invalid argument -- '$opt'" >&2 + exit 1 + fi + ;; + esac +done + +source "$config_file" || exit 2 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 3 + +set -- "${args[@]}" + +zone=$1 +if shift; then + dns_check_zone_view "$zone" zone view || exit 10 +elif $interactive; then + dns_select_zone zone view || exit 11 +else + echo "$SCRIPT: missing argument -- ZONE[@VIEW]" >&2 + exit 1 +fi + +name=$1 +if shift; then + dns_check_record_name "$name" name || exit 21 +elif $interactive; then + dns_read_record_name name || exit 10 +else + echo "$SCRIPT: missing argument -- NAME" >&2 + exit 1 +fi + +ttl=$1 +if shift; then + dns_check_ttl "$ttl" || exit 22 +elif $interactive; then + dns_read_ttl ttl || exit 10 +else + echo "$SCRIPT: missing argument -- TTL" >&2 + exit 1 +fi + +rtype=$1 +if shift; then + dns_check_record_type "$rtype" || exit 23 +elif $interactive; then + dns_select_record_type rtype || exit 10 +else + echo "$SCRIPT: missing argument -- TYPE" >&2 + exit 1 +fi + +value=$1 +if shift; then + dns_check_record_value "$rtype" "$value" value || exit 24 +elif $interactive; then + dns_read_record_value "$rtype" value || exit 10 +else + echo "$SCRIPT: missing argument -- VALUE" >&2 + exit 1 +fi + +if [ "${view}" == "*" ]; then + json_array_to_bash views < <(dns_zone_views "$zone") +else + views=("$view") +fi + +if ! $force; then + for view in "${views[@]}"; do + echo "View: $view" + output=$(dns_record_add "true" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1) + if (( $? == 0 )); then + echo -n -e "\e[32m+ $TAB" + echo -n -e "$output\e[0m" | grep --color=never -v -E '^(Outgoing update query:|;.*)?$' + else + echo -e "\e[31mERROR:\n" >&2 + echo -e "$output\e[0m" >&2 + exit 30 + fi + done + echo + ! yes_no "Proceed?" && echo -e "Aborted" && exit + echo +fi + +echo -n "Sending DDNS update(s)... " +for view in "${views[@]}"; do + output=$(dns_record_add "false" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1) + if (( $? != 0 )); then + echo -e "ERROR\n" >&2 + echo "$output" >&2 + exit 31 + fi +done +echo "OK" diff --git a/dns-record-delete b/dns-record-delete new file mode 100755 index 0000000..349669d --- /dev/null +++ b/dns-record-delete @@ -0,0 +1,203 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "$0") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -i|--interactive) + interactive=true + ;; + -f|--force) + force=true + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + args+=("$opt") + if (( ${#args[@]} > 5 )); then + echo "$SCRIPT: invalid argument -- '$opt'" >&2 + exit 1 + fi + ;; + esac +done + +source "$config_file" || exit 2 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 3 + +set -- "${args[@]}" + +zone=$1 +if shift; then + dns_check_zone_view "$zone" zone view || exit 10 +elif $interactive; then + dns_select_zone zone view || exit 11 +else + echo "$SCRIPT: missing argument -- ZONE[@VIEW]" >&2 + exit 1 +fi + +declare -A zone_data + +if [ "${view}" == "*" ]; then + json_array_to_bash views < <(dns_zone_views "$zone") +else + views=("$view") +fi + +records=$( + for view in "${views[@]}"; do + dns_zone "$zone" "$view" || exit 22 + done | "$JQ" --compact-output --slurp 'add' +) + +name=$1 +if shift; then + dns_check_record_name "$name" name || exit 21 +elif $interactive; then + if (( ${#zone_data[@]} > 1 )); then + dns_read_record_name name || exit 10 + else + json_array_to_bash names < <("$JQ" --compact-output '[ .[] | .name ] | sort | unique' <<<"$records") + COLUMNS=30 + echo -e "Select record name:\n" + select name in "${names[@]}"; do + [ -n "$name" ] && break + done + [ -z "$name" ] && echo "ERROR: record name selection failed" >&2 && exit 11 + echo + fi +else + echo "$SCRIPT: missing argument -- NAME" >&2 + exit 1 +fi + +records=$("$JQ" --compact-output --arg name "$name" '[ .[] | select(.name == $name) ]' <<<"$records") +[ "$records" == "[]" ] && echo "ERROR: no such record -- '$name'" >&2 && exit 5 + +json_array_to_bash rtypes < <("$JQ" --compact-output '[ .[] | .type ] | sort | unique' <<<"$records") + +rtype=${1^^} +if shift; then + dns_check_record_type "$rtype" || exit 23 +elif $interactive; then + if [ "$view" == "*" ]; then + dns_select_record_type rtype || exit 10 + else + echo -e "Select record type:\n" + select rtype in "${rtypes[@]}"; do + [ -n "$rtype" ] && break + done + [ -z "$name" ] && echo "ERROR: record type selection failed" >&2 && exit 11 + echo + fi +else + echo "$SCRIPT: missing argument -- TYPE" >&2 + exit 1 +fi + +records=$("$JQ" --compact-output --arg rtype "$rtype" '[ .[] | select(.type == $rtype) ]' <<<"$records") +[ "$records" == "[]" ] && echo "ERROR: no ${rtype} record found" >&2 && exit 5 + +json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records") + +value=$1 +if shift; then + dns_check_record_value "$rtype" "$value" value || exit 24 +elif $interactive; then + if [ "$view" == "*" ]; then + dns_read_record_value "$rtype" value || exit 10 + else + if ! yes_no "Delete all ${rtype} records?"; then + echo -e "\nSelect value:\n" + select value in "${values[@]}"; do + [ -n "$value" ] && break + done + fi + [ -z "$name" ] && echo "ERROR: invalid answer" >&2 && exit 11 + echo + fi +fi + +if [ -n "$value" ]; then + decoded_value=$(dns_decode_txt_value "$value") + records=$("$JQ" --compact-output --arg value "$decoded_value" '[ .[] | select(.decoded_value == $value) ]' <<<"$records") + [ "$records" == "[]" ] && echo "ERROR: no $rtype record matches value" >&2 && exit 6 +fi + +json_array_to_bash views < <("$JQ" --compact-output '[ .[] | .view ] | sort | unique' <<<"$records") + +if ! $force; then + for view in "${views[@]}"; do + echo "View: $view" + json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records") + for value in "${values[@]}"; do + output=$(dns_record_delete "true" "$zone" "$view" "$name" "$rtype" "$value" 2>&1) + if (( $? == 0 )); then + echo -n -e "\e[31m- $TAB" + echo -n -e "$output\e[0m" | grep --color=never -v -E '^(Outgoing update query:|;.*)?$' + else + echo -e "\e[31mERROR:\n" >&2 + echo -e "$output\e[0m" >&2 + exit 30 + fi + done + done + echo + ! yes_no "Proceed?" && echo -e "Aborted" && exit + echo +fi + +echo -n "Sending DDNS update(s)... " +for view in "${views[@]}"; do + json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records") + for value in "${values[@]}"; do + output=$(dns_record_delete "false" "$zone" "$view" "$name" "$rtype" "$value" 2>&1) + if (( $? != 0 )); then + echo -e "ERROR updating view -- '$view'\n" >&2 + echo "$output" >&2 + exit 31 + fi + done +done +echo "OK" diff --git a/dns-zone b/dns-zone new file mode 100755 index 0000000..3c056e2 --- /dev/null +++ b/dns-zone @@ -0,0 +1,81 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "${0}") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + cmd=$opt + break + ;; + esac +done + +cmd=${cmd,,:-help} + +source "$config_file" || exit 2 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 3 +source "$LIB_DIR"/output.sh || exit 3 + +params="--interactive" + +if [ "$cmd" == "help" ]; then + params="--help" + cmd=${1,,} + shift +fi + +case "$cmd" in + list) + "$SCRIPT_DIR"/dns-zone-$cmd $params "$@" + ;; + "") + usage + ;; + *) + echo "$SCRIPT: invalid command -- '$cmd'" >&2 + exit 5 + ;; +esac diff --git a/dns-zone-list b/dns-zone-list new file mode 100755 index 0000000..667be48 --- /dev/null +++ b/dns-zone-list @@ -0,0 +1,156 @@ +#!/usr/bin/env bash + +SCRIPT_PATH=$(realpath -s "${0}") +SCRIPT_DIR=$(dirname "$SCRIPT_PATH") +SCRIPT=$(basename "$SCRIPT_PATH") + +usage() { + cat <&2 + exit 1 + fi + config_file=$1 + shift + ;; + -h|--help) + usage + ;; + -i|--interactive) + interactive=true + ;; + -j|--json) + json=true + ;; + -J|--json-pretty) + json=true + json_pretty=true + ;; + -r|--raw) + raw=true + ;; + -u|--unfiltered) + unfiltered=true + ;; + -*) + echo "$SCRIPT: invalid option -- '$opt'" >&2 + exit 1 + ;; + *) + args+=("$opt") + if (( ${#args[@]} > 1 )); then + echo "$SCRIPT: invalid argument -- '$opt'" >&2 + exit 1 + fi + ;; + esac +done + +if $raw && $json; then + echo "$SCRIPT: invalid options: --raw and --json are mutually exclusive" >&2 + exit 1 +fi + +source "$config_file" || exit 2 + +LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} +source "$LIB_DIR"/dns.sh || exit 3 +source "$LIB_DIR"/output.sh || exit 3 + +set -- "${args[@]}" + +zone=$1 +if shift; then + dns_check_zone_view "$zone" zone view || exit 10 +elif $interactive; then + dns_select_zone zone view || exit 11 +else + echo "$SCRIPT: missing argument -- ZONE[@VIEW]" >&2 + exit 1 +fi + +declare -A output +if [ "${view}" == "*" ]; then + json_array_to_bash views < <(dns_zone_views "$zone") +else + views=("$view") +fi + +for view in "${views[@]}"; do + output["$view"]=$(dns_zone "$zone" "$view" "$unfiltered") || exit 12 +done + +if $raw; then + n=0 + for view in $(printf "%s\n" "${!output[@]}" | sort); do + if (( ${#output[@]} > 1 )) || [ "$view" != "$NAMED_DEFAULT_VIEW" ]; then + cat < 1 )) || [ "$view" != "$NAMED_DEFAULT_VIEW" ] && echo "View: $view" + { + (( $max_value_len <= 0 )) && max_value_len=1 + while read -r name ttl rtype value; do + name=$("$IDN2" --decode <<<"$name") + (( ${#value} > $max_value_len )) && value="${value:0:$max_value_len} ...[TRUNCATED]" + echo "$name$TAB$ttl$TAB$rtype$TAB$value" + done < <("$JQ" --raw-output '.[] | "\(.name)\t\(.ttl)\t\(.type)\t\(.value)"' <<<"${output["$view"]}") + } | table_output "NAME" "TTL" "TYPE" "VALUE" + ((n++)) + (( $n < ${#output[@]} )) && echo +done diff --git a/lib/dns.sh b/lib/dns.sh new file mode 100644 index 0000000..47fcf14 --- /dev/null +++ b/lib/dns.sh @@ -0,0 +1,592 @@ +#!/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 $? +} diff --git a/lib/output.sh b/lib/output.sh new file mode 100644 index 0000000..57de162 --- /dev/null +++ b/lib/output.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +_prettytable_char_top_left="┌" +_prettytable_char_horizontal="─" +_prettytable_char_vertical="│" +_prettytable_char_bottom_left="└" +_prettytable_char_bottom_right="┘" +_prettytable_char_top_right="┐" +_prettytable_char_vertical_horizontal_left="├" +_prettytable_char_vertical_horizontal_right="┤" +_prettytable_char_vertical_horizontal_top="┬" +_prettytable_char_vertical_horizontal_bottom="┴" +_prettytable_char_vertical_horizontal="┼" + + +# Escape codes + +# Default colors +_prettytable_color_blue="0;34" +_prettytable_color_green="0;32" +_prettytable_color_cyan="0;36" +_prettytable_color_red="0;31" +_prettytable_color_purple="0;35" +_prettytable_color_yellow="0;33" +_prettytable_color_gray="1;30" +_prettytable_color_light_blue="1;34" +_prettytable_color_light_green="1;32" +_prettytable_color_light_cyan="1;36" +_prettytable_color_light_red="1;31" +_prettytable_color_light_purple="1;35" +_prettytable_color_light_yellow="1;33" +_prettytable_color_light_gray="0;37" + +# Somewhat special colors +_prettytable_color_black="0;30" +_prettytable_color_white="1;37" +_prettytable_color_none="0" + +function _prettytable_prettify_lines() { + cat - | sed -e "s@^@${_prettytable_char_vertical}@;s@\$@ @;s@ @ ${_prettytable_char_vertical}@g" +} + +function _prettytable_fix_border_lines() { + cat - | sed -e "1s@ @${_prettytable_char_horizontal}@g;3s@ @${_prettytable_char_horizontal}@g;\$s@ @${_prettytable_char_horizontal}@g" +} + +function _prettytable_colorize_lines() { + local color="$1" + local range="$2" + local ansicolor="$(eval "echo \${_prettytable_color_${color}}")" + + cat - | sed -e "${range}s@\\([^${_prettytable_char_vertical}]\\{1,\\}\\)@"$'\E'"[${ansicolor}m\1"$'\E'"[${_prettytable_color_none}m@g" +} + +function prettytable() { + local cols="${1}" + local color="${2:-none}" + local input="$(cat -)" + local header="$(echo -e "${input}"|head -n1)" + local body="$(echo -e "${input}"|tail -n+2)" + { + # Top border + echo -n "${_prettytable_char_top_left}" + for i in $(seq 2 ${cols}); do + echo -ne "\t${_prettytable_char_vertical_horizontal_top}" + done + echo -e "\t${_prettytable_char_top_right}" + + echo -e "${header}" | _prettytable_prettify_lines + + # Header/Body delimiter + echo -n "${_prettytable_char_vertical_horizontal_left}" + for i in $(seq 2 ${cols}); do + echo -ne "\t${_prettytable_char_vertical_horizontal}" + done + echo -e "\t${_prettytable_char_vertical_horizontal_right}" + + echo -e "${body}" | _prettytable_prettify_lines + + # Bottom border + echo -n "${_prettytable_char_bottom_left}" + for i in $(seq 2 ${cols}); do + echo -ne "\t${_prettytable_char_vertical_horizontal_bottom}" + done + echo -e "\t${_prettytable_char_bottom_right}" + } | column -t -s $'\t' | _prettytable_fix_border_lines | _prettytable_colorize_lines "${color}" "2" +} + +table_output() { + local n_cols=$# + { + printf "$(join_by "\t" "$@")\n" + cat - + } | prettytable "$n_cols" "${TABLE_HEADER_COLOR:-gray}" +}