Initial commit of source

This commit is contained in:
2025-08-04 19:52:36 +02:00
parent 60b3a3f6f5
commit 3c82ad6936
9 changed files with 1488 additions and 0 deletions

592
lib/dns.sh Normal file
View File

@@ -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("(?<name>.+)\\.\($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 $?
}