From d0c42d05580ae16ba4148f2bbd0e05013747c7db Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Wed, 6 Aug 2025 23:53:08 +0200 Subject: [PATCH] Add dns-zone-add and dns-zone-delete --- config.sh | 6 +- dns-record-add | 2 +- dns-record-delete | 2 +- dns-zone | 4 +- dns-zone-add | 175 +++++++++++++++++++++++++++++++++ dns-zone-delete | 156 +++++++++++++++++++++++++++++ lib/dns.sh | 124 +++++++++++++++++------ templates/zone.config.template | 11 +++ templates/zone.template | 10 ++ 9 files changed, 457 insertions(+), 33 deletions(-) create mode 100755 dns-zone-add create mode 100755 dns-zone-delete create mode 100644 templates/zone.config.template create mode 100644 templates/zone.template diff --git a/config.sh b/config.sh index be35e04..269721c 100644 --- a/config.sh +++ b/config.sh @@ -9,8 +9,10 @@ # #DIG="/usr/bin/dig" #IDN2="/usr/bin/idn2" +#JQ="/usr/bin/jq" #NAMED_CHECKCONF="/usr/bin/named-checkconf" #NSUPDATE="/usr/bin/nsupdate" +#RNDC="/usr/sbin/rndc" # # Path to library directory @@ -56,7 +58,7 @@ #CONTROL_KEY="/etc/bind/rndc.key" # -# Associative array of config values per view. This option is mandatory when adding +# Associative array of directories and config files per view. This option is mandatory when adding # or removing zones. The syntax of the value is: # ZONEDIR:CONFDIR:CFGFILE # @@ -66,7 +68,7 @@ # The detour via CONFDIR is necessary because Bind does not support wildcards when # including config files. # -#ZONE_DIRS=( +#BASE_CONFIG=( # [_default]="/etc/bind/dyn:/etc/dns-manager/default.zones:/etc/bind/default_zones.conf" #) diff --git a/dns-record-add b/dns-record-add index 5eb4f09..3e885b0 100755 --- a/dns-record-add +++ b/dns-record-add @@ -8,7 +8,7 @@ usage() { cat <&2 + exit 1 + fi + ;; + -h|--help) + usage + ;; + -f|--force) + force=true + ;; + -i|--interactive) + interactive=true + ;; + -t|--config-template) + config_template=$1 + if ! shift; then + echo "$SCRIPT: missing argument to option -- '$opt'" >&2 + exit 1 + fi + ;; + -z|--zone-template) + zone_template=$1 + if ! shift; then + echo "$SCRIPT: missing argument to option -- '$opt'" >&2 + exit 1 + fi + ;; + -*) + 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 + +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_name_view "$zone" zone view || exit 10 +elif $interactive; then + dns_read_zone_view 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) +else + views=("$view") +fi + +for view in "${views[@]}"; do + dns_get_base_config "$view" zone_dir conf_dir || exit 13 + + zone_conf_file="$conf_dir/$zone.conf" + [ -f "$zone_conf_file" ] && echo "ERROR: config file already exists -- '$zone_conf_file'" >&2 && exit 14 + + zone_file="$zone_dir/$zone.zone" + [ -f "$zone_file" ] && echo "ERROR: zone file already exists -- '$zone_file'" >&2 && exit 15 + + IFS=":" read -r cfg_zone_template cfg_config_template <<<"${ZONE_TEMPLATES["$view"]}" + + conf_template=${config_template:-${cfg_config_template}} + [ -z "$conf_template" ] && echo "ERROR: config template not configured nor specified by '-t' option" >&2 && exit 16 + ! [ -f "$conf_template" ] && echo "ERROR: zone config template: no such file -- '$conf_template'" >&2 && exit 17 + + zone_template=${zone_template:-${cfg_zone_template}} + [ -z "$zone_template" ] && echo "ERROR: zone template not configured nor specified by '-z' option" >&2 && exit 18 + ! [ -f "$zone_template" ] && echo "ERROR: zone template: no such file -- '$zone_template'" >&2 && exit 19 + + dns_check_zone_view "$zone@$view" &>/dev/null && echo "ERROR: non-managed zone already exists in DNS -- '$zone@$view'" >&2 && exit 16 +done + +if ! $force; then + for view in "${views[@]}"; do + echo "View: $view" + echo -e "\e[32m+ $TAB$zone\e[0m" + done + echo + ! yes_no "Proceed?" && echo -e "Aborted" && exit + echo +fi + +echo -n "Adding zone to config... " +for view in "${views[@]}"; do + dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 13 + + zone_conf_file="$conf_dir/$zone.conf" + zone_file="$zone_dir/$zone.zone" + + IFS=":" read -r cfg_zone_template cfg_config_template <<<"${ZONE_TEMPLATES["$view"]}" + conf_template=${config_template:-${cfg_config_template}} + zone_template=${zone_template:-${cfg_zone_template}} + + ! sed "s#%ZONE%#$zone#g;s#%ZONE_FILE%#$zone_file#g" "$conf_template" >"$zone_conf_file" && echo "ERROR: unable to write to config file -- '$zone_conf_file'" >&2 && exit 20 + ! sed "s#%ZONE%#$zone#g" "$zone_template" >"$zone_file" && echo "ERROR: unable to write to zone file -- '$zone_file'" >&2 && exit 21 + ! chown named:named "$zone_file" && echo "ERROR: unable to set ownership of zone file to 'named:named' -- '$zone_file'" >&2 && exit 22 + + tmp=$(mktemp) + cat >"$tmp" <>"$tmp"; then + echo "ERROR: unable to write to temp file -- '$tmp'" >&2 + rm "$tmp" + exit 23 + fi + done < <(find "$conf_dir" -maxdepth 1 -type f -name '*.conf') + if ! cat "$tmp" > "$conf_file"; then + echo "ERROR: unable to write config file -- '$conf_file'" >&2 + rm "$tmp" + exit 24 + fi + rm "$tmp" +done +echo "Ok" + +dns_reload_config || exit 25 diff --git a/dns-zone-delete b/dns-zone-delete new file mode 100755 index 0000000..ef100ee --- /dev/null +++ b/dns-zone-delete @@ -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 + ;; + -h|--help) + usage + ;; + -f|--force) + force=true + ;; + -i|--interactive) + interactive=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 + +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) +else + views=("$view") +fi + +for view in "${views[@]}"; do + dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 10 + + zone_conf_file="$conf_dir/$zone.conf" + ! [ -f "$zone_conf_file" ] && echo "ERROR: zone exists in DNS but no config file found, zone not managed by DNS-Manager -- '$zone_conf_file'" >&2 && exit 14 + + zone_file="$zone_dir/$zone.zone" + ! [ -f "$zone_file" ] && echo "ERROR: zone exists in DNS but no zone file found, zone not managed by DNS-Manager -- '$zone_file'" >&2 && exit 15 +done + + +if ! $force; then + for view in "${views[@]}"; do + echo "View: $view" + echo -e "\e[31m- $TAB$zone\e[0m" + done + echo + ! yes_no "Proceed?" && echo -e "Aborted" && exit + echo +fi + +echo -n "Deleting zone from config... " +for view in "${views[@]}"; do + dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 10 + + zone_conf_file="$conf_dir/$zone.conf" + ! rm "$zone_conf_file" && echo "ERROR: unable to delete config file -- '$zone_conf_file'" >&2 && exit 14 + + tmp=$(mktemp) + cat >"$tmp" <>"$tmp"; then + echo "ERROR: unable to write to temp file -- '$tmp'" >&2 + rm "$tmp" + exit 23 + fi + done < <(find "$conf_dir" -maxdepth 1 -type f -name '*.conf') + if ! cat "$tmp" > "$conf_file"; then + echo "ERROR: unable to write config file -- '$conf_file'" >&2 + rm "$tmp" + exit 24 + fi + rm "$tmp" +done +echo "Ok" + +echo -n "Reload Bind config... " +rndc_args="" +[ -n "$CONTROL_KEY" ] && rndc_args="-k $CONTROL_KEY" +! "$RNDC" $rndc_args reconfig && echo "ERROR: rndc reconfig failed" >&2 && exit 25 +echo "Ok" + +error=false +echo -n "Deleting zone files... " +for view in "${views[@]}"; do + dns_get_base_config "$view" zone_dir || exit 10 + + zone_file="$zone_dir/$zone.zone" + ! rm "$zone_file" && echo "ERROR: unable to delete zone file -- '$zone_file'" >&2 && error=true + + while IFS=$NEWLINE read -r file; do + ! rm "$file" && echo "ERROR: unable to delete zone related file -- '$zone_file'" >&2 && error=true + done < <(find "$zone_dir" -maxdepth 1 -type f -name "$zone.zone.*") +done +! $error && echo "Ok" diff --git a/lib/dns.sh b/lib/dns.sh index a5f0956..f087cfa 100644 --- a/lib/dns.sh +++ b/lib/dns.sh @@ -4,25 +4,27 @@ # config variables # #################### -if [ -z "${!DNS_KEYS[*]}" ]; then - declare -A DNS_KEYS=() -fi +[ -z "${!DNS_KEYS[*]}" ] && declare -A DNS_KEYS=() +[ -z "${!BASE_CONFIG[*]}" ] && declare -A BASE_CONFIG=() +[ -z "${!ZONE_TEMPLATES[*]}" ] && declare -A ZONE_TEMPLATES=() DNS_IP=${DNS_IP:-127.0.0.1} + DIG=${DIG:-$(which dig)} || exit 1 IDN2=${IDN2:-$(which idn2)} || exit 1 JQ=${JQ:-$(which jq)} || exit 1 NAMED_CHECKCONF=${NAMED_CHECKCONF:-$(which named-checkconf)} || exit 1 NSUPDATE=${NSUPDATE:-$(which nsupdate)} || exit 1 -TERMINAL_WITH=${MAX_TERMINAL_WITH:-$($(which stty) size | cut -d " " -f 2)} || exit 1 +RNDC=${RNDC:-$(which rndc)} || exit 1 +TERMINAL_WITH=${MAX_TERMINAL_WITH:-$($(which stty) size | cut -d " " -f 2)} || exit 1 #################### # 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") +declare -a DNS_RECORD_TYPES=("A" "AAAA" "CAA" "CDS" "CNAME" "DNAME" "DS" "MX" "NS" "PTR" "SRV" "TLSA" "TXT") # Global variables NEWLINE=$'\n' @@ -116,7 +118,7 @@ _get_keyfile() { return 0 fi - echo "ERROR: config: no key found for '$zone@$view' or '$view'" >&2 + echo "ERROR: no key configured for '$zone@$view' or '$view'" >&2 return 1 } @@ -149,6 +151,31 @@ _nsupdate() { } +##################### +# general functions # +##################### + +dns_get_base_config() { + local view=$1 + local zone_dir_retvar=${2:-REPLY} + local conf_dir_retvar=${3:-REPLY} + local conf_file_retvar=${4:-REPLY} + + local base_config=${BASE_CONFIG[$view]} + [ -z "$base_config" ] && echo "ERROR: no base config found for view -- '$view'" >&2 && return 1 + + local __zone_dir __conf_dir __conf_file + IFS=":" read -r __zone_dir __conf_dir __conf_file <<<"$base_config" + [ -z "$__zone_dir" -o -z "$__conf_dir" -o -z "$__conf_file" ] && echo "ERROR: invalid BASE_CONFIG for view -- '$view'" >&2 && return 2 + ! [ -d "$__conf_dir" ] && echo "ERROR: conf dir: no such directory -- '$__conf_dir'" >&2 && return 3 + ! [ -d "$__zone_dir" ] && echo "ERROR: zone dir: no such directory -- '$__zone_dir'" >&2 && return 4 + + declare -g $zone_dir_retvar="$__zone_dir" + declare -g $conf_dir_retvar="$__conf_dir" + declare -g $conf_file_retvar="$__conf_file" +} + + ############################# # data generation functions # ############################# @@ -237,7 +264,6 @@ dns_check_zone() { $found && declare -g $retvar="$zone" && return 0 - declare -g $retvar="" echo "ERROR: zone does not exist -- '$zone'" >&2 return 2 } @@ -257,7 +283,7 @@ 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" @@ -291,12 +317,49 @@ dns_check_zone_view() { } dns_check_zone_name() { - local name=${1,,} + local zone=${1,,} + local retvar=${2:-REPLY} - [[ "$name" =~ ^[a-z0-9_][a-z0-9_.-]*$ ]] && [[ "$name" != *"." ]] && return 0 + zone=$("$IDN2" <<<"$zone") + if ! [[ "$zone" =~ ^[a-z0-9_][a-z0-9_.-]*$ ]] || [[ "$zone" == *"." ]]; then + echo "ERROR: invalid zone name -- '$zone'" >&2 + return 1 + fi - echo "ERROR: invalid zone name -- '$name'" >&2 - return 1 + declare -g $retvar="$zone" +} + +dns_check_zone_name_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_name "$zone" zone || return 1 + + if [ -z "$view" ]; then + view=$NAMED_DEFAULT_VIEW + elif [ "$view" != "*" ]; then + dns_check_view "$view" || return 2 + fi + + local -a views=() + json_array_to_bash views < <(dns_zone_views "$zone") + + if (( ${#views[@]} > 0 )); then + if [ "${view}" == "*" ]; then + echo "ERROR: zone '$zone' already exists in these views -- '$(join_by ", " "${views[@]}")'" >&2 && return 4 + else + in_array "$view" "${views[@]}" && echo "ERROR: zone '$zone' already exists in 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_record_name() { @@ -308,7 +371,7 @@ dns_check_record_name() { name=$("$IDN2" <<<"$name") local LC_ALL=C - if [[ "$name" =~ ^[a-z0-9_][a-z0-9_.-]*$ ]] && [[ "$name" != *"." ]]; then + if [[ "$name" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$ ]] && [[ "$name" != *"." ]]; then declare -g $retvar="$name" return 0 fi @@ -464,22 +527,19 @@ dns_select_record_type() { declare -g $retvar="$rtype" } -dns_read_zone_name() { - # TODO - exit +dns_read_zone_view() { + local zone_retvar=$1 + local view_retvar=$2 + + local zone view + while [ -z "$zone" ]; do + read -e -p "Zone name (ZONE or ZONE@VIEW): " zone + [ -n "$zone" ] && ! dns_check_zone_name_view "$zone" zone view && zone="" + done + echo - #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 + declare -g $zone_retvar="$zone" + declare -g $view_retvar="$view" } dns_read_record_name() { @@ -591,3 +651,11 @@ dns_record_delete() { _nsupdate "$zone" "$view" "$update_script" "$pretend" return $? } + +dns_reload_config() { + echo -n "Reload Bind config... " + rndc_args="" + [ -n "$CONTROL_KEY" ] && rndc_args="-k $CONTROL_KEY" + ! "$RNDC" $rndc_args reconfig && echo "ERROR: rndc reconfig failed" >&2 && return 1 + echo "Ok" +} diff --git a/templates/zone.config.template b/templates/zone.config.template new file mode 100644 index 0000000..5a92580 --- /dev/null +++ b/templates/zone.config.template @@ -0,0 +1,11 @@ +zone "%ZONE%" { + type master; + file "%ZONE_FILE%"; + update-policy { + grant dns-manager-key zonesub any; + }; + allow-transfer { + key dns-manager-key; + }; +}; + diff --git a/templates/zone.template b/templates/zone.template new file mode 100644 index 0000000..38db7da --- /dev/null +++ b/templates/zone.template @@ -0,0 +1,10 @@ +$TTL 86400 ; 1 day +@ IN SOA dns1.%ZONE%. hostmaster.%ZONE%. ( + 1 ; serial + 10800 ; refresh (3 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 3600 ; minimum (1 hour) + ) + NS dns1.%ZONE%. + NS dns2.%ZONE%.