Switch everything to Python

This commit is contained in:
2025-08-27 23:51:18 +02:00
parent f38b2f35ff
commit f57890f6c9
15 changed files with 1470 additions and 1094 deletions

42
dns-confgen Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import argparse
import dnsmgr
import sys
def main():
parser = argparse.ArgumentParser(description='Generate Bind config files.')
parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
args = parser.parse_args()
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
try:
views = sorted(set([zone.view for zone in manager.zones]))
for view in views:
print(f'Generate config of view \'{view}\'... ', end='')
manager.generate_config(view)
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
print('Reloading named... ', end='')
try:
manager.named_reload()
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(170)
if __name__ == '__main__':
main()

71
dns-list Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import argparse
import dnsmgr
import sys
from json import dumps
def main():
parser = argparse.ArgumentParser(
description='List DNS zones.',
formatter_class=lambda prog: argparse.HelpFormatter(
prog, max_help_position=45, width=140))
parser.add_argument('-a', '--all-zones', help='do not ignore zones that are not managed', action='store_true')
parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
output = parser.add_mutually_exclusive_group()
output.add_argument('-j', '--json', help='print json format', action='store_true')
output.add_argument('-J', '--json-pretty', help='print pretty json format', action='store_true')
output.add_argument('-r', '--raw', help='print raw format', action='store_true')
args = parser.parse_args()
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
try:
zones = manager.all_zones if args.all_zones else manager.zones
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
zones.sort(key=lambda z: z.origin.to_text())
if args.raw:
for zone in zones:
name = zone.origin.to_text()
managed = zone.cfgfile is not None
print(f'{name}\t{zone.view}\t{zone.status}\t{managed}')
elif args.json or args.json_pretty:
json_output = [{
'zone': zone.origin.to_text(),
'view': zone.view,
'status': zone.status,
'managed': zone.cfgfile is not None} for zone in zones]
if args.json_pretty:
print(dumps(json_output, indent=2))
else:
print(dumps(json_output))
else:
field_names = ['Zone', 'View', 'Status']
if args.all_zones:
field_names.append('Managed')
rows = []
for zone in zones:
name = zone.origin.to_unicode(omit_final_dot=True)
row = [name, zone.view, zone.status]
if args.all_zones:
row.append(zone.cfgfile is not None)
rows.append(row)
print(dnsmgr.prettytable(field_names, rows))
print()
if __name__ == '__main__':
main()

View File

@@ -1,89 +0,0 @@
#!/usr/bin/env bash
SCRIPT_PATH=$(realpath -s "${0}")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]...
List available DNS zones.
Options:
-c, --config path to config file
-h, --help print this help message
-j, --json print json format
-J, --json-pretty print pretty json format (implies -j)
-r, --raw print raw format
EOF
exit
}
config_file="/etc/dns-manager/config.sh"
json=false
json_pretty=false
raw=false
while [ -n "$1" ]; do
opt=$1
shift
case "$opt" in
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env bash
SCRIPT_PATH=$(realpath -s "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... COMMAND [COMMAND OPTIONS]
Manage DNS records.
Commands:
* help [COMMAND] show help message of commands
* add add record to zone
* delete delete record from zone
Options:
-c, --config path to config file
-h, --help print this help message
EOF
exit
}
config_file="/etc/dns-manager/config.sh"
cmd=""
while [ -n "$1" ]; do
opt=$1
shift
case "$opt" in
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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

View File

@@ -1,152 +1,96 @@
#!/usr/bin/env bash #!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "$0") import argparse
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") import dns.rdataclass
SCRIPT=$(basename "$SCRIPT_PATH") import dns.rdataset
import dnsmgr
import sys
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW] NAME TTL TYPE VALUE
Add new records to a DNS zone. def main():
preparser = argparse.ArgumentParser(add_help=False)
preparser.add_argument('-b', '--batch', action='store_true')
preargs, args = preparser.parse_known_args()
nargs = None if preargs.batch else '?'
nvalueargs = '+' if preargs.batch else '*'
Options: parser = argparse.ArgumentParser(description='Add DNS records.')
-c, --config path to config file parser.add_argument('-a', '--all-zones', help='allow zones that are not managed', action='store_true')
-h, --help print this help message parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
-f, --force add records without confirmation prompt parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
-i, --interactive interactively ask for missing arguments parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
EOF parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
exit parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name', default=None)
} parser.add_argument('ttl', metavar='TTL', nargs=nargs, help='DNS record TTL in seconds', type=int, default=None)
parser.add_argument('type', metavar='TYPE', nargs=nargs, help='DNS record type', default=None)
parser.add_argument('value', metavar='VALUE', nargs=nvalueargs, help='DNS record value, multiple values are choined by a space character', default=None)
args = parser.parse_args()
config_file="/etc/dns-manager/config.sh" try:
force=false manager = dnsmgr.DNSManager(cfgfile=args.config)
interactive=false except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
declare -a args=() try:
while [ -n "$1" ]; do if args.zone is None:
opt=$1 zones = manager.select_zones(args.all_zones)
shift else:
case "$opt" in zones = manager.get_zones(args.zone, args.all_zones)
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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 origin = zones[0].origin
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} if args.name is None:
source "$LIB_DIR"/dns.sh || exit 3 name = dnsmgr.input_name(origin, prompt='Record name')
else:
name = dnsmgr.name_from_text(args.name, origin)
set -- "${args[@]}" if args.ttl is None:
ttl = dnsmgr.input_ttl()
else:
ttl = dnsmgr.ttl_from_text(args.ttl)
zone=$1 if args.type is None:
if shift; then rdtype = dnsmgr.select_type(args.all_types)
dns_check_zone_view "$zone" zone view || exit 10 else:
elif $interactive; then rdtype = dnsmgr.type_from_text(args.type, args.all_types)
dns_select_zone zone view || exit 11
else
echo "$SCRIPT: missing argument -- ZONE[@VIEW]" >&2
exit 1
fi
name=$1 if args.value is None:
if shift; then rdata = dnsmgr.input_rdata(rdtype, origin)
dns_check_record_name "$name" name || exit 21 else:
elif $interactive; then rdata = dnsmgr.rdata_from_text(rdtype, ' '.join(args.value), origin)
dns_read_record_name name || exit 10
else
echo "$SCRIPT: missing argument -- NAME" >&2
exit 1
fi
ttl=$1 except RuntimeError as e:
if shift; then dnsmgr.printe(e)
dns_check_ttl "$ttl" || exit 22 sys.exit(150)
elif $interactive; then except KeyboardInterrupt:
dns_read_ttl ttl || exit 10 sys.exit(0)
else
echo "$SCRIPT: missing argument -- TTL" >&2
exit 1
fi
rtype=$1 rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, rdtype, ttl=ttl)
if shift; then rdataset.add(rdata)
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 not args.batch:
if shift; then for zone in zones:
dns_check_record_value "$rtype" "$value" value || exit 24 text = rdataset.to_text(origin=zone.origin, relativize=False)
elif $interactive; then print(f'View: {zone.view}')
dns_read_record_value "$rtype" value || exit 10 print(f'\033[32m+ {name} {text}\033[0m\n')
else
echo "$SCRIPT: missing argument -- VALUE" >&2
exit 1
fi
if [ "${view}" == "*" ]; then if not dnsmgr.input_yes_no():
json_array_to_bash views < <(dns_zone_views "$zone") sys.exit(0)
else
views=("$view")
fi
if ! $force; then for zone in zones:
for view in "${views[@]}"; do origin = zone.origin.to_text(omit_final_dot=True)
echo "View: $view" if len(zones) > 1 or zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
output=$(dns_record_add "true" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1) origin = f'{origin}@{zone.view}'
if (( $? == 0 )); then print(f"Sending DDNS updates for '{origin}'... ", end='')
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)... " try:
for view in "${views[@]}"; do manager.add_zone_record(zone, name, rdataset)
output=$(dns_record_add "false" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1) print('OK')
if (( $? != 0 )); then except RuntimeError as e:
echo -e "ERROR\n" >&2 dnsmgr.printe(e)
echo "$output" >&2 sys.exit(160)
exit 31
fi
done if __name__ == '__main__':
echo "OK" main()

View File

@@ -1,203 +1,138 @@
#!/usr/bin/env bash #!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "$0") import argparse
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") import dns.rdataclass
SCRIPT=$(basename "$SCRIPT_PATH") import dnsmgr
import sys
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW] NAME TYPE [VALUE]
Delete DNS records. def main():
preparser = argparse.ArgumentParser(add_help=False)
preparser.add_argument('-b', '--batch', action='store_true')
preargs, args = preparser.parse_known_args()
nargs = None if preargs.batch else '?'
Options: parser = argparse.ArgumentParser(description='Delete DNS records.')
-c, --config path to config file parser.add_argument('-a', '--all-zones', help='allow zones that are not managed', action='store_true')
-h, --help print this help message parser.add_argument('-A', '--all-types', help='allow unsupported record types', action='store_true')
-f, --force delete records without confirmation prompt parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
-i, --interactive interactively ask for missing arguments parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
parser.add_argument('name', metavar='NAME', nargs=nargs, help='DNS record name', default=None)
parser.add_argument('type', metavar='TYPE', nargs=nargs, help='DNS record type', default=None)
parser.add_argument('value', metavar='VALUE', nargs='*', help='DNS record value, multiple values are choined by a space character', default=None)
args = parser.parse_args()
EOF try:
exit manager = dnsmgr.DNSManager(cfgfile=args.config)
} except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
config_file="/etc/dns-manager/config.sh" try:
force=false if args.zone is None:
interactive=false zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
declare -a args=() for zone in zones:
while [ -n "$1" ]; do manager.get_zone_content(zone)
opt=$1
shift
case "$opt" in
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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 origin = zones[0].origin
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} if args.name is None:
source "$LIB_DIR"/dns.sh || exit 3 names = sorted(set([name.to_unicode() for name in zone for zone in zones]))
rows = [[name] for name in names]
index = dnsmgr.prettyselect(['Record name'], rows, prompt='Select record name')
args.name = names[index]
set -- "${args[@]}" name = dnsmgr.name_from_text(args.name, origin)
zone=$1 for zone in zones:
if shift; then zone.filter_by_name(name, origin)
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 zones = list(filter(lambda zone: zone.nodes, zones))
if not zones:
raise RuntimeError(f"No such DNS record -- '{name.to_text(True)}'")
if [ "${view}" == "*" ]; then if args.type is None:
json_array_to_bash views < <(dns_zone_views "$zone") rdtypes = sorted(set([rdataset.rdtype for rdataset in zone.get_node(name) for zone in zones]))
else if not args.all_types:
views=("$view") rdtypes = list(filter(lambda rdtype: rdtype in dnsmgr.RECORD_TYPES, rdtypes))
fi rdtypes = [rdtype.to_text(rdtype) for rdtype in rdtypes]
rows = [[rdtype] for rdtype in rdtypes]
index = dnsmgr.prettyselect(['Record type'], rows, prompt='Select record type')
args.type = rdtypes[index]
records=$( rdtype = dnsmgr.type_from_text(args.type, args.all_types)
for view in "${views[@]}"; do
dns_zone "$zone" "$view" || exit 22
done | "$JQ" --compact-output --slurp 'add'
)
name=$1 for zone in zones:
if shift; then zone.filter_by_rdtype(rdtype)
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") zones = list(filter(lambda zone: zone.nodes, zones))
[ "$records" == "[]" ] && echo "ERROR: no such record -- '$name'" >&2 && exit 5 if not zones:
raise RuntimeError(f"No such {rdtype.to_text(rdtype)} record -- '{name.to_text(True)}'")
json_array_to_bash rtypes < <("$JQ" --compact-output '[ .[] | .type ] | sort | unique' <<<"$records") rdata = None
if not args.value and not args.batch and not dnsmgr.input_yes_no(f'Delete all {rdtype.to_text(rdtype)}-records?'):
values = []
for zone in zones:
for rdataset in zone.get_node(name):
for rdata in rdataset:
values.append(rdata.to_text(origin=zone.origin, relativize=False))
values = sorted(set(values))
rows = [[value] for value in values]
index = dnsmgr.prettyselect(['Record value'], rows, prompt='Select record value', truncate=True)
args.value = [values[index]]
rtype=${1^^} if args.value:
if shift; then value = ' '.join(args.value)
dns_check_record_type "$rtype" || exit 23 rdata = dnsmgr.rdata_from_text(rdtype, value, origin)
elif $interactive; then for zone in zones:
if [ "$view" == "*" ]; then zone.filter_by_rdata(rdata)
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") zones = list(filter(lambda zone: zone.nodes, zones))
[ "$records" == "[]" ] && echo "ERROR: no ${rtype} record found" >&2 && exit 5 if not zones:
raise RuntimeError(f"No such DNS record found -- {name.to_text(True)} IN {rdtype.to_text(rdtype)} {value}")
json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records") except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
value=$1 zones.sort(key=lambda zone: zone.view)
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 if not args.batch:
decoded_value=$(dns_decode_txt_value "$value") for zone in zones:
records=$("$JQ" --compact-output --arg value "$decoded_value" '[ .[] | select(.decoded_value == $value) ]' <<<"$records") print(f'View: {zone.view}')
[ "$records" == "[]" ] && echo "ERROR: no $rtype record matches value" >&2 && exit 6 node = zone.find_node(name)
fi rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
rdclassstr = rdataset.rdclass.to_text(rdataset.rdclass)
rdtypestr = rdataset.rdtype.to_text(rdataset.rdtype)
for rdata in rdataset:
text = rdata.to_text(origin=zone.origin, relativize=False)
print(f'\033[31m- {name} {rdataset.ttl} {rdclassstr} {rdtypestr} {text}\033[0m\n')
json_array_to_bash views < <("$JQ" --compact-output '[ .[] | .view ] | sort | unique' <<<"$records") if not dnsmgr.input_yes_no():
sys.exit(0)
if ! $force; then for zone in zones:
for view in "${views[@]}"; do origin = zone.origin.to_text(omit_final_dot=True)
echo "View: $view" if len(zones) > 1 or zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records") origin = f'{origin}@{zone.view}'
for value in "${values[@]}"; do print(f"Sending DDNS updates for '{origin}'... ", end='')
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)... " node = zone.find_node(name)
for view in "${views[@]}"; do rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records")
for value in "${values[@]}"; do try:
output=$(dns_record_delete "false" "$zone" "$view" "$name" "$rtype" "$value" 2>&1) manager.delete_zone_record(zone, name, rdataset)
if (( $? != 0 )); then print('OK')
echo -e "ERROR updating view -- '$view'\n" >&2 except RuntimeError as e:
echo "$output" >&2 dnsmgr.printe(e)
exit 31 sys.exit(160)
fi
done
done if __name__ == '__main__':
echo "OK" main()

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env bash
SCRIPT_PATH=$(realpath -s "${0}")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... COMMAND [COMMAND OPTIONS]
Manage DNS zones.
Commands:
* add add new zone
* delete delete zone
* help [COMMAND] show help message of commands
* list show zone content
Options:
-c, --config path to config file
-h, --help print this help message
EOF
exit
}
config_file="/etc/dns-manager/config.sh"
cmd=""
while [ -n "$1" ]; do
opt=$1
shift
case "$opt" in
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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
add|delete|list)
"$SCRIPT_DIR"/dns-zone-$cmd $params "$@"
;;
"")
usage
;;
*)
echo "$SCRIPT: invalid command -- '$cmd'" >&2
exit 5
;;
esac

View File

@@ -1,175 +1,133 @@
#!/usr/bin/env bash #!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "${0}") import argparse
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") import dns.rdataclass
SCRIPT=$(basename "$SCRIPT_PATH") import dns.rdataset
import dns.rdatatype
import dnsmgr
import sys
usage() { from time import sleep
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW]
Add new DNS zones.
Options: def main():
-c, --config path to config file preparser = argparse.ArgumentParser(add_help=False)
-h, --help print this help message preparser.add_argument('-b', '--batch', action='store_true')
-f, --force add zones without confirmation prompt preargs, args = preparser.parse_known_args()
-i, --interactive interactively ask for missing arguments nargs = None if preargs.batch else '?'
-t, --config-template config file/template (overrides value set in ZONE_TEMPLATES config option)
-z, --zone-template zone file/template (overrides value set in ZONE_TEMPLATES config option)
EOF
exit
}
config_file="/etc/dns-manager/config.sh" parser = argparse.ArgumentParser(description='Add DNS zones.')
config_template="" parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
force=false parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
interactive=false parser.add_argument('-t', '--config-template', help='config file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
zone="" parser.add_argument('-z', '--zone-template', help='zone file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
zone_template="" parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
args = parser.parse_args()
declare -a args=() try:
while [ -n "$1" ]; do manager = dnsmgr.DNSManager(cfgfile=args.config)
opt=$1 except RuntimeError as e:
shift dnsmgr.printe(f'config: {e}')
case "$opt" in sys.exit(100)
-c|--config)
config_file=$1
if ! shift; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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 managed_views = sorted(manager.config.zones_config.keys())
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} try:
source "$LIB_DIR"/dns.sh || exit 3 if args.zone is None:
source "$LIB_DIR"/output.sh || exit 3 name = dnsmgr.input_name()
rows = [[view] for view in managed_views]
index = dnsmgr.prettyselect(['View'], rows, prompt='Select view', also_valid=['*'])
views = managed_views if index == '*' else [managed_views[index]]
else:
(name, views) = dnsmgr.name_views_from_text(args.zone)
if views is None:
if len(managed_views) > 1:
raise RuntimeError('multiple managed views configured but none specified')
elif managed_views[0] != dnsmgr.NAMED_DEFAULT_VIEW:
raise RuntimeError('the default view is not managed')
views = managed_views
elif views == '*':
views = managed_views
else:
for view in views:
if view not in managed_views:
raise RuntimeError(f'managed view does not exist -- \'{view}\'')
set -- "${args[@]}" existing_views = [zone.view for zone in filter(lambda zone: zone.origin == name and zone.view in views, manager.all_zones)]
if existing_views:
views = 'and '.join(existing_views)
raise RuntimeError(f'zone already exists in view {views}')
zone=$1 except RuntimeError as e:
if shift; then dnsmgr.printe(e)
dns_check_zone_name_view "$zone" zone view || exit 10 sys.exit(150)
elif $interactive; then except KeyboardInterrupt:
dns_read_zone_view zone view || exit 11 sys.exit(0)
else
echo "$SCRIPT: missing argument -- ZONE[@VIEW]" >&2
exit 1
fi
declare -A output if not args.batch:
if [ "${view}" == "*" ]; then for view in views:
json_array_to_bash views < <(dns_zone_views) origin = name.to_text(omit_final_dot=True)
else print(f'View: {view}')
views=("$view") print(f'\033[32m+ {origin}\033[0m\n')
fi
for view in "${views[@]}"; do if not dnsmgr.input_yes_no():
dns_get_base_config "$view" zone_dir conf_dir || exit 13 sys.exit(0)
zone_conf_file="$conf_dir/$zone.conf" zones = []
[ -f "$zone_conf_file" ] && echo "ERROR: config file already exists -- '$zone_conf_file'" >&2 && exit 14 for view in views:
origin = name.to_text(omit_final_dot=True)
if len(views) > 1 or view != dnsmgr.NAMED_DEFAULT_VIEW:
origin = f'{origin}@{view}'
print(f"Adding zone '{origin}'... ", end='')
zone_file="$zone_dir/$zone.zone" try:
[ -f "$zone_file" ] && echo "ERROR: zone file already exists -- '$zone_file'" >&2 && exit 15 zone = manager.add_zone(name, view, args.config_template, args.zone_template)
manager.generate_config(view)
print('OK')
if manager.config.zones_config[view].catalog_zone:
zones.append(zone)
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(160)
IFS=":" read -r cfg_zone_template cfg_config_template <<<"${ZONE_TEMPLATES["$view"]}" try:
print('Reloading named... ', end='')
manager.named_reload()
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(170)
conf_template=${config_template:-${cfg_config_template}} if zones:
[ -z "$conf_template" ] && echo "ERROR: config template not configured nor specified by '-t' option" >&2 && exit 16 sleep(2)
! [ -f "$conf_template" ] && echo "ERROR: zone config template: no such file -- '$conf_template'" >&2 && exit 17
zone_template=${zone_template:-${cfg_zone_template}} for zone in zones:
[ -z "$zone_template" ] && echo "ERROR: zone template not configured nor specified by '-z' option" >&2 && exit 18 catalog_zone_name = manager.config.zones_config[zone.view].catalog_zone
! [ -f "$zone_template" ] && echo "ERROR: zone template: no such file -- '$zone_template'" >&2 && exit 19 try:
catalog_zones = manager.get_zones(catalog_zone_name, all_zones=True)
except RuntimeError as e:
raise RuntimeError(f'catalog zone of view \'{zone.view}\': {e}')
dns_check_zone_view "$zone@$view" &>/dev/null && echo "ERROR: non-managed zone already exists in DNS -- '$zone@$view'" >&2 && exit 16 origin = zone.origin.to_text(omit_final_dot=True)
done if len(zones) > 1 or zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
if ! $force; then for catalog_zone in catalog_zones:
for view in "${views[@]}"; do rdata = dnsmgr.rdata_from_text(dns.rdatatype.PTR, zone.origin.to_text(), catalog_zone.origin)
echo "View: $view" rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.PTR, ttl=3600)
echo -e "\e[32m+ $TAB$zone\e[0m" rdataset.add(rdata)
done rdname = dns.name.from_text(zone.nfz() + '.zones', catalog_zone.origin)
echo catalog_zone_origin = catalog_zone.origin.to_text(omit_final_dot=True)
! yes_no "Proceed?" && echo -e "Aborted" && exit if catalog_zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
echo catalog_zone_origin += f'@{catalog_zone.view}'
fi
echo -n "Adding zone to config... " try:
for view in "${views[@]}"; do print(f'Adding zone \'{origin}\' to catalog zone \'{catalog_zone_origin}\'... ', end='')
dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 13 manager.add_zone_record(catalog_zone, rdname, rdataset)
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
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"]}" if __name__ == '__main__':
conf_template=${config_template:-${cfg_config_template}} main()
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" <<EOF
/*
* This file was generated by DNS-Manager.
* DO NOT EDIT, YOUR CHANGES WILL BE OVERWRITTEN!
*/
EOF
while IFS=$NEWLINE read -r file; do
if ! cat "$file" >>"$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

View File

@@ -1,156 +1,111 @@
#!/usr/bin/env bash #!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "${0}") import argparse
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") import dns.rdataclass
SCRIPT=$(basename "$SCRIPT_PATH") import dns.rdataset
import dns.rdatatype
usage() { import dnsmgr
cat <<EOF import sys
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW]
Delete DNS zones.
Options:
-c, --config path to config file
-h, --help print this help message
-f, --force delete zones without confirmation prompt
-i, --interactive interactively ask for missing arguments
EOF
exit
}
config_file="/etc/dns-manager/config.sh"
force=false
interactive=false
zone=""
declare -a args=()
while [ -n "$1" ]; do
opt=$1
shift
case "$opt" in
-c|--config)
config_file=$1
if ! shift; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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 def main():
for view in "${views[@]}"; do preparser = argparse.ArgumentParser(add_help=False)
echo "View: $view" preparser.add_argument('-b', '--batch', action='store_true')
echo -e "\e[31m- $TAB$zone\e[0m" preargs, args = preparser.parse_known_args()
done nargs = None if preargs.batch else '?'
echo
! yes_no "Proceed?" && echo -e "Aborted" && exit
echo
fi
echo -n "Deleting zone from config... " parser = argparse.ArgumentParser(description='Delete DNS zones.')
for view in "${views[@]}"; do parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 10 parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
args = parser.parse_args()
zone_conf_file="$conf_dir/$zone.conf" try:
! rm "$zone_conf_file" && echo "ERROR: unable to delete config file -- '$zone_conf_file'" >&2 && exit 14 manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
tmp=$(mktemp) try:
cat >"$tmp" <<EOF if args.zone is None:
/* zones = manager.select_zones()
* This file was generated by DNS-Manager. else:
* DO NOT EDIT, YOUR CHANGES WILL BE OVERWRITTEN! zones = manager.get_zones(args.zone)
*/ except RuntimeError as e:
EOF dnsmgr.printe(e)
while IFS=$NEWLINE read -r file; do sys.exit(150)
if ! cat "$file" >>"$tmp"; then except KeyboardInterrupt:
echo "ERROR: unable to write to temp file -- '$tmp'" >&2 sys.exit(0)
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... " if not args.batch:
rndc_args="" for zone in zones:
[ -n "$CONTROL_KEY" ] && rndc_args="-k $CONTROL_KEY" origin = zone.origin.to_text(omit_final_dot=True)
! "$RNDC" $rndc_args reconfig && echo "ERROR: rndc reconfig failed" >&2 && exit 25 print(f'View: {zone.view}')
echo "Ok" print(f'\033[31m- {origin}\033[0m\n')
error=false if not dnsmgr.input_yes_no():
echo -n "Deleting zone files... " sys.exit(0)
for view in "${views[@]}"; do
dns_get_base_config "$view" zone_dir || exit 10
zone_file="$zone_dir/$zone.zone" for zone in zones:
! rm "$zone_file" && echo "ERROR: unable to delete zone file -- '$zone_file'" >&2 && error=true origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
while IFS=$NEWLINE read -r file; do try:
! rm "$file" && echo "ERROR: unable to delete zone related file -- '$zone_file'" >&2 && error=true catalog_zone_name = manager.config.zones_config[zone.view].catalog_zone
done < <(find "$zone_dir" -maxdepth 1 -type f -name "$zone.zone.*") if catalog_zone_name:
done try:
! $error && echo "Ok" catalog_zones = manager.get_zones(catalog_zone_name, all_zones=True)
except RuntimeError as e:
raise RuntimeError(f'catalog zone of view \'{zone.view}\': {e}')
for catalog_zone in catalog_zones:
manager.get_zone_content(catalog_zone)
rdname = dns.name.from_text(zone.nfz() + '.zones', catalog_zone.origin)
node = catalog_zone.get_node(rdname)
if not node:
continue
rdataset = node.get_rdataset(dns.rdataclass.IN, dns.rdatatype.PTR)
if not rdataset:
continue
catalog_zone_origin = catalog_zone.origin.to_text(omit_final_dot=True)
if catalog_zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
catalog_zone_origin += f'@{catalog_zone.view}'
print(f'Removing zone \'{origin}\' from catalog zone \'{catalog_zone_origin}\'... ', end='')
manager.delete_zone_record(catalog_zone, rdname, rdataset)
print('OK')
print(f"Deleting config of zone '{origin}'... ", end='')
manager.delete_zone(zone)
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(160)
try:
print('Reloading named... ', end='')
manager.named_reload()
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(170)
for zone in zones:
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != dnsmgr.NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
print(f"Cleanup zone files of zone '{origin}'... ", end='')
try:
manager.cleanup_zone(zone)
print('OK')
except Exception as e:
dnsmgr.printe(e)
sys.exit(180)
if __name__ == '__main__':
main()

View File

@@ -1,156 +1,106 @@
#!/usr/bin/env bash #!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "${0}") import argparse
SCRIPT_DIR=$(dirname "$SCRIPT_PATH") import dnsmgr
SCRIPT=$(basename "$SCRIPT_PATH") import sys
usage() { from json import dumps
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW]
Show DNS zone content.
Options: def main():
-a, --all do not ignore unsupported record types preparser = argparse.ArgumentParser(add_help=False)
-c, --config path to config file preparser.add_argument('-b', '--batch', action='store_true')
-h, --help print this help message preargs, args = preparser.parse_known_args()
-i, --interactive interactively ask for missing arguments nargs = None if preargs.batch else '?'
-j, --json print json format
-J, --json-pretty print pretty json format (implies -j)
-r, --raw print raw format
EOF parser = argparse.ArgumentParser(description='Show DNS zone records.')
exit parser.add_argument('-a', '--all-zones', help='do not ignore zones that are not managed', action='store_true')
} parser.add_argument('-A', '--all-records', help='do not ignore unsupported record types', action='store_true')
parser.add_argument('-b', '--batch', help='run in batch mode (no user input)', action='store_true')
parser.add_argument('-c', '--config', help='path to config file', default=dnsmgr.DEFAULT_CFGFILE)
parser.add_argument('-d', '--decode', help='decode internationalized domain names (IDN)', action='store_true')
parser.add_argument('zone', metavar='ZONE[@VIEWS]', nargs=nargs, help='DNS zone name and optional list of views (comma separated or asterisk to select all views)', default=None)
output = parser.add_mutually_exclusive_group()
output.add_argument('-j', '--json', help='print json format', action='store_true')
output.add_argument('-J', '--json-pretty', help='print pretty json format', action='store_true')
output.add_argument('-r', '--raw', help='print raw format', action='store_true')
args = parser.parse_args()
all=false try:
config_file="/etc/dns-manager/config.sh" manager = dnsmgr.DNSManager(cfgfile=args.config)
interactive=false except RuntimeError as e:
json=false dnsmgr.printe(f'config: {e}')
json_pretty=false sys.exit(100)
raw=false
zone=""
declare -a args=() try:
while [ -n "$1" ]; do if args.zone is None:
opt=$1 zones = manager.select_zones(args.all_zones)
shift else:
case "$opt" in zones = manager.get_zones(args.zone, args.all_zones)
-a|--all)
all=true
;;
-c|--config)
if [ -z "$1" ]; then
echo "$SCRIPT: missing argument to option -- '$opt'" >&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
;;
-*)
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 except RuntimeError as e:
echo "$SCRIPT: invalid options: --raw and --json are mutually exclusive" >&2 dnsmgr.printe(e)
exit 1 sys.exit(150)
fi except KeyboardInterrupt:
sys.exit(0)
source "$config_file" || exit 2 zones.sort(key=lambda zone: zone.view)
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib} zone_records = {}
source "$LIB_DIR"/dns.sh || exit 3 for zone in zones:
source "$LIB_DIR"/output.sh || exit 3 try:
manager.get_zone_content(zone)
except RuntimeError as e:
dnsmgr.printe(f"zone transfer of '{zone.origin.to_text(True)}@{zone.view}': {e}")
sys.exit(160)
set -- "${args[@]}" records = []
for name, node in zone.items():
for rdataset in node:
if not args.all_records and rdataset.rdtype not in dnsmgr.RECORD_TYPES:
continue
for value in rdataset:
records.append({
'name': name.to_text(),
'name_unicode': name.to_unicode(),
'ttl': str(rdataset.ttl),
'type': str(rdataset.rdtype.to_text(rdataset.rdtype)),
'value': value.to_text(origin=zone.origin, relativize=False)})
zone=$1 records.sort(key=lambda r: f'{r["name_unicode"]}{r["type"]}')
if shift; then zone_records[zone.view] = records
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 views = sorted(zone_records.keys())
if [ "${view}" == "*" ]; then
json_array_to_bash views < <(dns_zone_views "$zone")
else
views=("$view")
fi
for view in "${views[@]}"; do if args.raw:
output["$view"]=$(dns_zone "$zone" "$view" "$all") || exit 12 for view in views:
done print(f';\n; View: {view}\n;\n')
for record in zone_records[view]:
print('\t'.join([record['name'], record['ttl'], record['type'], record['value']]))
print(f'\n;\n; End of view: {view}\n;\n')
elif args.json or args.json_pretty:
json_output = []
for view in views:
json_output.append({'view': view, 'records': zone_records[view]})
if args.json_pretty:
print(dumps(json_output, indent=2))
else:
print(dumps(json_output))
if $raw; then else:
n=0 field_names = ['Name', 'TTL', 'Type', 'Value']
for view in $(printf "%s\n" "${!output[@]}" | sort); do for view in views:
if (( ${#output[@]} > 1 )) || [ "$view" != "$NAMED_DEFAULT_VIEW" ]; then rows = []
cat <<EOF for record in zone_records[view]:
# name = record['name_unicode'] if args.decode else record['name']
# VIEW: $view row = [name, record['ttl'], record['type'], record['value']]
# rows.append(row)
EOF if len(views) > 1 or view != dnsmgr.NAMED_DEFAULT_VIEW:
fi print(f'View: {view}')
echo "${output["$view"]}" print(dnsmgr.prettytable(field_names, rows, truncate=True))
"$JQ" --raw-output '.[] | "\(.name)\t\(.ttl)\t\(.type)\t\(.value)"' <<<"${output["$view"]}" print()
((n++))
(( $n < ${#output[@]} )) && echo
done
exit
fi
if $json; then
jq_opts=""
$json_pretty || jq_opts="--compact-output"
for view in $(printf "%s\n" "${!output[@]}" | sort); do
jq --compact-output --slurp "{ view: \"$view\", records: .[] }" <<<"${output["$view"]}"
done | jq --slurp $jq_opts
exit
fi
max_value_len=$(( ${TERMINAL_WITH} / 2 - 15)) if __name__ == '__main__':
n=0 main()
for view in $(printf "%s\n" "${!output[@]}" | sort); do
(( ${#output[@]} > 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

771
dnsmgr/__init__.py Normal file
View File

@@ -0,0 +1,771 @@
#!/usr/bin/env python3
import dns.name
import dns.tsig
import dns.rcode
import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import dns.query
import dns.update
import dns.zone
import dns.xfr
import os
import re
import shutil
import subprocess
import yaml
from hashlib import sha1
from prettytable import PrettyTable
from shutil import chown
DEFAULT_CFGFILE = '/etc/dns-manager/config.yml'
NAMED_DEFAULT_VIEW = '_default'
RECORD_TYPES = (
dns.rdatatype.A,
dns.rdatatype.AAAA,
dns.rdatatype.CAA,
dns.rdatatype.CDS,
dns.rdatatype.CNAME,
dns.rdatatype.DNAME,
dns.rdatatype.DS,
dns.rdatatype.MX,
dns.rdatatype.NS,
dns.rdatatype.PTR,
dns.rdatatype.SRV,
dns.rdatatype.TLSA,
dns.rdatatype.TXT)
ALL_RECORD_TYPES = (dns.rdatatype.from_text(rdtype.name) for rdtype in dns.rdatatype.RdataType)
def printe(msg):
print(f'ERROR: {msg}')
def prettytable(field_names, rows, truncate=False):
t = PrettyTable()
t.field_names = field_names
for field in t.field_names:
t.align[field] = 'l'
if truncate:
max_total_len = 0
max_name_val_ttl_len = 0
for row in rows:
max_total_len = max(max_total_len, sum(len(col) for col in row))
max_name_val_ttl_len = max(max_name_val_ttl_len, sum(len(s) for s in row[0:-1]))
overhead_len = len(row) * 4
terminal_width = os.get_terminal_size().columns
if max_total_len + overhead_len >= terminal_width:
max_value_len = terminal_width - max_name_val_ttl_len - overhead_len
if max_value_len < 5:
raise RuntimeError('terminal is too small')
for i in range(len(rows)):
value = rows[i][-1]
if len(value) > max_value_len:
rows[i][-1] = f'{value[:max_value_len - 3]}...'
for row in rows:
t.add_row(row)
return t
def prettyselect(field_names, rows, prompt='Select entry', also_valid=[], truncate=False):
length = len(rows)
if length < 1:
raise RuntimeError('no entries to select from')
field_names.insert(0, '#')
for index in range(length):
rows[index].insert(0, str(index + 1))
print(prettytable(field_names, rows, truncate))
print()
valid_str = f'1 - {length}'
if also_valid:
also_valid_str = ', '.join(also_valid)
valid_str = f'{valid_str}, {also_valid_str}'
index = None
while index is None:
try:
index = input(f'{prompt} ({valid_str}): ')
except KeyboardInterrupt:
print('\nAborted ...')
raise KeyboardInterrupt
if index in also_valid:
print()
return index
try:
index = int(index) - 1
if index < 0 or index > length - 1:
raise ValueError
except ValueError:
index = None
print()
return index
def name_from_text(txt, origin=None):
if not txt:
raise RuntimeError('empty value')
if origin is None:
txt = txt.lower()
if not txt.endswith('.'):
txt += '.'
elif txt.endswith('.'):
raise RuntimeError('record name is absolute (ends with dot)')
try:
name = dns.name.from_text(txt, origin)
except Exception as e:
raise RuntimeError(f'{e}')
return name
def name_views_from_text(txt):
try:
(name, view_txt) = txt.split('@', maxsplit=1)
if not view_txt:
view_txt = None
except ValueError:
name = txt
view_txt = None
name = name_from_text(name)
if view_txt is None:
views = None
else:
views = list(set([view.strip() for view in view_txt.split(',')]))
if '*' in views:
views = '*'
return name, views
def input_name(origin=None, prompt='Zone name'):
name = None
while name is None:
try:
value = input(f'{prompt}: ')
if not value:
continue
name = name_from_text(value, origin)
print()
except KeyboardInterrupt:
print('\nAborted ...')
raise KeyboardInterrupt
except RuntimeError as e:
print(f'ERROR: {e}\n')
name = None
return name
def ttl_from_text(txt):
try:
ttl = int(txt)
if ttl < 5:
raise RuntimeError('TTL is too low (<5 seconds)')
if ttl > 604800:
raise RuntimeError('TTL is too high (>604800 seconds)')
except ValueError as e:
raise RuntimeError(f'{e}')
return ttl
def input_ttl():
ttl = None
while ttl is None:
try:
value = input('TTL (5 - 604800): ')
if not value:
continue
ttl = ttl_from_text(value)
print()
except KeyboardInterrupt:
print('\nAborted ...')
raise KeyboardInterrupt
except RuntimeError as e:
print(f'ERROR: {e}\n')
ttl = None
return ttl
def type_from_text(txt, all_types=False):
try:
rdtype = dns.rdatatype.from_text(txt)
if not all_types and rdtype not in RECORD_TYPES:
raise RuntimeError('record type is not supported')
except Exception as e:
raise RuntimeError(f'{e}')
return rdtype
def select_type(all_types=False):
rdtypes = ALL_RECORD_TYPES if all_types else RECORD_TYPES
rows = sorted([[rdtype.to_text(rdtype)] for rdtype in rdtypes])
index = prettyselect(['Record type'], rows, prompt='Select record type')
return rdtypes[index]
def encode_txt_value(txt):
if '"' in txt:
return txt
txt = '" "'.join([txt[0+i:255+i] for i in range(0, len(txt), 255)])
return f'"{txt}"'
def rdata_from_text(rdtype, txt, origin):
if rdtype == dns.rdatatype.TXT:
txt = encode_txt_value(txt)
try:
rdata = dns.rdata.from_text(dns.rdataclass.IN, rdtype, txt, origin)
except Exception as e:
raise RuntimeError(f'{e}')
return rdata
def input_rdata(rdtype, origin):
rdata = None
while rdata is None:
try:
value = input('Record value: ')
if not value:
continue
rdata = rdata_from_text(rdtype, value, origin)
print()
except KeyboardInterrupt:
print('\nAborted ...')
raise KeyboardInterrupt
except RuntimeError as e:
print(f'ERROR: {e}\n')
rdata = None
return rdata
def input_yes_no(prompt='Confirm?'):
confirm = None
while confirm is None:
try:
value = input(f'{prompt} (yes/no): ').lower()
if value == 'yes':
confirm = True
print()
elif value == 'no':
confirm = False
else:
confirm = None
except KeyboardInterrupt:
print('\nAborted ...')
confirm = False
return confirm
class DNSViewConfig:
def __init__(self, name, config, config_dir):
if not isinstance(config, dict):
raise RuntimeError(f'views: {name}: value is not an associative array')
self.config_dir = config.get('config_dir', os.path.join(config_dir, f'{name}.zones'))
if not isinstance(self.config_dir, str):
raise RuntimeError(f'views: {name}: config_dir: value is not a string')
self.config_file = config.get('config_file')
if self.config_file is None:
raise RuntimeError(f'views: {name}: missing mandatory parameter: config_file')
if not isinstance(self.config_file, str):
raise RuntimeError(f'views: {name}: config_file: value is not a string')
self.zone_dir = config.get('zone_dir')
if self.zone_dir is None:
raise RuntimeError(f'views: {name}: missing mandatory parameter: zone_dir')
if not isinstance(self.zone_dir, str):
raise RuntimeError(f'views: {name}: zone_dir: value is not a string')
self.catalog_zone = config.get('catalog_zone')
if not isinstance(self.catalog_zone, (str, type(None))):
raise RuntimeError(f'views: {name}: catalog_zone: value is not a string')
self.config_template = None
self.zone_template = None
templates = config.get('templates')
if templates is not None:
if not isinstance(templates, dict):
raise RuntimeError(f'views: {name}: templates: value is not an associative array')
self.config_template = templates.get('config')
if not isinstance(self.config_template, (str, type(None))):
raise RuntimeError(f'views: {name}: templates: config: value is not a string')
self.zone_template = templates.get('zone')
if not isinstance(self.zone_template, (str, type(None))):
raise RuntimeError(f'views: {name}: templates: zone: value is not a string')
class DNSManagerConfig:
def __init__(self, cfgfile):
with open(cfgfile, 'r') as file:
config = yaml.safe_load(file)
if not isinstance(config, dict):
raise RuntimeError('config is not an associative array')
self.etc_dir = config.get('etc_dir', '/etc/dns-manager')
if not isinstance(self.etc_dir, str):
raise RuntimeError('etc_dir: value is not a string')
self.control_key = config.get('control_key')
if not isinstance(self.control_key, (str, type(None))):
raise RuntimeError('control_key: value is not a string')
self.dns_ip = config.get('dns_ip', '127.0.0.1')
if not isinstance(self.dns_ip, str):
raise RuntimeError('dns_ip value: is not a string')
self.named_checkconf = config.get('named_checkconf', None)
if self.named_checkconf is None:
self.named_checkconf = shutil.which('named-checkconf')
if self.named_checkconf is None:
raise RuntimeError('named-checkconf: executable not found')
elif not isinstance(self.named_checkconf, str):
raise RuntimeError('named_checkconf: value is not a string')
self.named_conf = config.get('named_conf')
if not isinstance(self.named_conf, (str, type(None))):
raise RuntimeError('named_conf: value is not a string')
self.rndc = config.get('rndc')
if not isinstance(self.rndc, (str, type(None))):
raise RuntimeError('rndc: value is not a string')
self.dns_keyfiles = config.get('dns_keyfiles')
if self.dns_keyfiles is None:
self.dns_keyfiles = {}
elif not isinstance(self.dns_keyfiles, (dict)):
raise RuntimeError('dns_keyfiles: value is not an associative array')
for view in self.dns_keyfiles:
if not isinstance(self.dns_keyfiles[view], str):
raise RuntimeError(f'dns_keyfiles: {view}: value is not a string')
self.zones_config = {}
zones_config = config.get('zones_config')
if zones_config is not None:
if not isinstance(zones_config, dict):
raise RuntimeError('zones_config: value is not a dictionary')
for name, view_config in zones_config.items():
self.zones_config[name] = DNSViewConfig(name, view_config, self.etc_dir)
class Zone(dns.zone.Zone):
def __init__(self, view=NAMED_DEFAULT_VIEW, status='unknown', cfgfile=None, zonefile=None, **kwargs):
self.view = view
self.status = status
self.cfgfile = cfgfile
self.zonefile = zonefile
super().__init__(**kwargs)
def filter_by_name(self, name, origin):
name_relative = name.relativize(origin)
self.nodes = dict((rdname, node) for rdname, node in self.items() if rdname == name_relative)
def filter_by_rdtype(self, rdtype):
for name, node in self.items():
rdataset = node.get_rdataset(dns.rdataclass.IN, rdtype)
node.rdatasets.clear()
if rdataset is not None:
node.rdatasets.append(rdataset)
self.nodes = dict((rdname, node) for rdname, node in self.items() if node.rdatasets)
def filter_by_rdata(self, rdata):
for name, node in self.items():
for rdataset in node:
rdataset.items = list(filter(lambda item: rdata == item, rdataset.items))
node.rdatasets = list(filter(lambda rdataset: rdataset.items, node.rdatasets))
self.nodes = dict((rdname, node) for rdname, node in self.items() if node.rdatasets)
def nfz(self):
return sha1(self.origin.to_wire()).hexdigest()
def named_zones(named_checkconf, named_conf=None):
cmd = [named_checkconf, '-l']
if named_conf is not None:
cmd.append(named_conf)
output = subprocess.run(cmd, stdout=subprocess.PIPE).stdout.decode()
zones = []
for line in output.splitlines():
try:
(name, zclass, view, status) = line.split()
except ValueError:
raise RuntimeError(f"named-checkconf returned invalid line: '{line}'")
if zclass.upper() == 'IN' and status.lower() in ('master', 'slave'):
zone = Zone(origin=name, view=view, status=status)
zones.append(zone)
return zones
def managed_zones(config, bind_zones=[]):
config_dirs = []
zone_dirs = []
zones = []
for view, cfg in config.items():
if cfg.config_dir in config_dirs:
raise RuntimeError(f'config directory used in multiple views: {cfg.config_dir}')
config_dirs.append(cfg.config_dir)
if cfg.zone_dir in zone_dirs:
raise RuntimeError(f'zone directory used in multiple views: {cfg.zone_dir}')
zone_dirs.append(cfg.zone_dir)
for file in os.listdir(cfg.config_dir):
cfgfile = os.path.join(cfg.config_dir, file)
if not file.endswith('.conf') or not os.path.isfile(cfgfile):
continue
name = file.removesuffix('.conf')
status = None
if bind_zones:
dns_name = dns.name.from_text(name)
try:
status = next(z.status for z in bind_zones if z.origin == dns_name)
except StopIteration:
status = None
zonefile = os.path.join(cfg.zone_dir, f'{name}.zone')
if not os.path.isfile(zonefile):
raise RuntimeError(f'missing zone file: {zonefile}')
zone = Zone(origin=name, view=view, status=status, cfgfile=cfgfile, zonefile=zonefile)
zones.append(zone)
# self.zones.append(ManagedZone("test.ch", "testview", "master", "/tmp/test.conf", "/tmp/test.zone"))
return zones
def keys_from_file(path):
with open(path, 'r') as f:
content = f.read()
key_block_re = re.compile(r'^\s*key\s+"?(?P<name>[^"]+?)"?\s*{(?P<config>(.|\n)*?)}\s*;', re.MULTILINE)
secret_re = re.compile(r'(.|\n)*secret\s+"?(?P<secret>[^"]+?)"?\s*;', re.MULTILINE)
algorithm_re = re.compile(r'(.|\n)*algorithm\s+"?(?P<algorithm>[^"]+?)"?\s*;', re.MULTILINE)
matches = key_block_re.finditer(content)
if not matches:
raise RuntimeError(f'no key section found in config file: {path}')
keys = []
for match in matches:
groupdict = match.groupdict()
name = groupdict['name']
match = secret_re.match(groupdict['config'])
if not match:
raise RuntimeError(f"missing secret in config of key '{name}'")
secret = match.groupdict()['secret']
match = algorithm_re.match(groupdict['config'])
if match:
algorithm = match.groupdict()['algorithm']
else:
algorithm = None
keys.append(dns.tsig.Key(name, secret, algorithm=algorithm))
return keys
class DNSManager:
def __init__(self, cfgfile=DEFAULT_CFGFILE):
self.config = DNSManagerConfig(cfgfile)
self._bind_zones = None
self._zones = None
self._all_zones = None
@property
def bind_zones(self):
if self._bind_zones is None:
self._bind_zones = named_zones(named_checkconf=self.config.named_checkconf, named_conf=self.config.named_conf)
return self._bind_zones
@property
def zones(self):
if self._zones is None:
self._zones = managed_zones(self.config.zones_config, self.bind_zones)
return self._zones
@property
def all_zones(self):
if self._all_zones is None:
self._all_zones = self.zones
for zone in self.bind_zones:
if next((z for z in self.zones if z.origin == zone.origin and z.view == zone.view), None) is None:
self._all_zones.append(zone)
return self._all_zones
def get_zones(self, name_view, all_zones=False):
(dns_name, views) = name_views_from_text(name_view)
zone_base = self.all_zones if all_zones else self.zones
zones = list(filter(lambda z: z.origin == dns_name, zone_base))
if not zones:
raise RuntimeError('zone not found')
if views is None:
if len(zones) > 1:
raise RuntimeError('zone is part of multiple views')
elif zones[0].view != NAMED_DEFAULT_VIEW:
raise RuntimeError('zone is not part of the default view')
elif views != '*':
all_views = set([zone.view for zone in zone_base])
zone_views = [zone.view for zone in zones]
for view in views:
if view not in all_views:
raise RuntimeError(f"view does not exist -- '{view}'")
if view not in zone_views:
raise RuntimeError(f"zone is not part of view -- '{view}'")
zones = list(filter(lambda z: z.view in views, zones))
return zones
def select_zones(self, all_zones=False):
zones = self.all_zones if all_zones else self.zones
names = sorted(set([zone.origin.to_unicode(omit_final_dot=True) for zone in zones]))
rows = [[name] for name in names]
index = prettyselect(['Zone'], rows, prompt='Select zone')
name = names[index]
try:
selected_zones = self.get_zones(name, all_zones)
except ValueError:
dns_name = dns.name.from_text(name)
zones = list(filter(lambda z: z.origin == dns_name, zones))
views = [view for view in sorted(set([zone.view for zone in zones]))]
rows = [[view] for view in sorted(set([zone.view for zone in zones]))]
index = prettyselect(['View'], rows, prompt='Select view', also_valid=['*'])
if index == '*':
selected_zones = zones
else:
view = views[index]
selected_zones = list(filter(lambda z: z.view == view, zones))
return selected_zones
def generate_config(self, view):
view_cfg = self.config.zones_config[view]
try:
with open(view_cfg.config_file, 'w') as cfh:
for file in os.listdir(view_cfg.config_dir):
cfgfile = os.path.join(view_cfg.config_dir, file)
if not file.endswith('.conf') or not os.path.isfile(cfgfile):
continue
with open(cfgfile, 'r') as fh:
cfh.write(fh.read())
cfh.write('\n')
except Exception as e:
raise RuntimeError(f'unable to generate view config: {e}')
def named_reload(self):
rndc = self.config.rndc if self.config.rndc else shutil.which('rndc')
if rndc is None:
raise RuntimeError('rndc executable not found')
cmd = [rndc]
if self.config.control_key:
cmd.extend(['-k', self.config.control_key])
cmd.append('reconfig')
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if res.returncode != 0:
raise RuntimeError(f'error reloading named config: {res.stderr.decode()}')
return res.stdout.decode()
def get_keyfile(self, zone):
name = zone.origin.to_text(omit_final_dot=True)
keyfile = None
for key_id in (f'{name}@{zone.view}', f'{zone.view}'):
try:
keyfile = self.config.dns_keyfiles[key_id]
break
except KeyError:
pass
if keyfile is None and zone.view != NAMED_DEFAULT_VIEW:
raise RuntimeError(f"no key configured for zone '{name}' in view '{zone.view}'")
return keyfile
def get_zone_content(self, zone):
keyfile = self.get_keyfile(zone)
keys = keys_from_file(keyfile)
key = keys[0] if keys else None
query, _ = dns.xfr.make_query(zone, keyring=key)
try:
dns.query.inbound_xfr(where=self.config.dns_ip, txn_manager=zone, query=query)
except Exception as e:
raise RuntimeError(e)
def add_zone_record(self, zone, rdname, rdataset):
keyfile = self.get_keyfile(zone)
keys = keys_from_file(keyfile)
key = keys[0] if keys else None
update = dns.update.Update(zone.origin, keyring=key)
update.add(rdname, rdataset)
try:
response = dns.query.tcp(update, self.config.dns_ip, timeout=10)
except Exception as e:
raise RuntimeError(e)
if response.rcode() != dns.rcode.NOERROR:
raise RuntimeError(response.to_text())
return response
def delete_zone_record(self, zone, rdname, rdataset):
keyfile = self.get_keyfile(zone)
keys = keys_from_file(keyfile)
key = keys[0] if keys else None
update = dns.update.Update(zone.origin, keyring=key)
update.delete(rdname, rdataset)
try:
response = dns.query.tcp(update, self.config.dns_ip, timeout=10)
except Exception as e:
raise RuntimeError(e)
if response.rcode() != dns.rcode.NOERROR:
raise RuntimeError(response.to_text())
return response
def add_zone(self, name, view, config_template=None, zone_template=None):
config = self.config.zones_config[view]
origin = name.to_text(omit_final_dot=True)
cfgfile = os.path.join(config.config_dir, f'{origin}.conf')
zonefile = os.path.join(config.zone_dir, f'{origin}.zone')
zone = Zone(origin=name, view=view, status=None, cfgfile=cfgfile, zonefile=zonefile)
if os.path.exists(cfgfile):
raise RuntimeError(f'config file already exists: {cfgfile}')
if os.path.exists(zonefile):
raise RuntimeError(f'zone file already exists: {zonefile}')
if config_template is None:
config_template = config.config_template
if config_template is None:
raise RuntimeError('no config template file configured')
if zone_template is None:
zone_template = config.zone_template
if zone_template is None:
raise RuntimeError('no zone template file configured')
try:
with open(config_template, 'r') as f:
zone_config = f.read()
except Exception as e:
raise RuntimeError(f'unable to open/read config template: {e}')
zone_config = zone_config.replace('%ZONE%', origin) \
.replace('%ZONE_FILE%', zonefile) \
.replace('%ZONE_FILENAME%', f'{origin}.zone')
try:
with open(cfgfile, 'w') as f:
f.write(zone_config)
except Exception as e:
raise RuntimeError(f'unable to open/write config file: {e}')
try:
with open(zone_template, 'r') as f:
zone_content = f.read()
except Exception as e:
os.remove(cfgfile)
raise RuntimeError(f'unable to open/read zone template: {e}')
zone_content = zone_content.replace('%ZONE%', origin)
try:
with open(zonefile, 'w') as f:
f.write(zone_content)
except Exception as e:
os.remove(cfgfile)
raise RuntimeError(f'unable to open/write zone file: {e}')
try:
chown(zonefile, 'named', 'named')
except Exception as e:
os.remove(cfgfile)
os.remove(zonefile)
raise RuntimeError(f'unable to change ownership of zone file: {e}')
return zone
def delete_zone(self, zone):
try:
os.remove(zone.cfgfile)
except Exception as e:
raise RuntimeError(f'unable to delete zone config file: {e}')
self.generate_config(zone.view)
def cleanup_zone(self, zone):
try:
os.remove(zone.zonefile)
zone_dir = self.config.zones_config[zone.view].zone_dir
for file in os.listdir(zone_dir):
file = os.path.join(zone_dir, file)
if not file.startswith(zone.zonefile + '.') or not os.path.isfile(file):
continue
os.remove(file)
except Exception as e:
raise RuntimeError(f'unable to delete zone file: {e}')

View File

@@ -1,82 +0,0 @@
#!/usr/bin/env bash
####################
## Global options ##
####################
#
# Paths to external binaries
#
#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
#
#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)
##############################
## Options for DDNS updates ##
##############################
#
# IP address of the DNS server (default is 127.0.0.1)
#
#DNS_IP="127.0.0.1"
#
# Associative array of paths to key files (TSIG) per view used for zone transfers
# and DDNS updates. This option mandatory when using views other than the default view.
# The keys have to be in one of these forms:
# - VIEW
# - ZONE@VIEW
#
#declare -A DNS_KEYS=(
# [_default]="/etc/bind/rndc.key"
#)
#######################################
## Options for adding/removing zones ##
#######################################
#
# Optional control key used to run "rndc reconfig" after adding or deleting zones.
# Otherwise the rndc default key is used.
#
#CONTROL_KEY="/etc/bind/rndc.key"
#
# 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
#
# Zone files are stored in ZONEDIR and config files are stored in CONFDIR.
# The content of all config files in CONFDIR are concatenated and written to CFGFILE which
# has to be included by "named.conf".
# The detour via CONFDIR is necessary because Bind does not support wildcards when
# including config files.
#
#declare -A BASE_CONFIG=(
# [_default]="/etc/bind/dyn:/etc/dns-manager/default.zones:/etc/bind/default_zones.conf"
#)
#
# Optional associative array of templates per view used when adding new zones.
# The syntax of the value is:
# ZONE_TEMPLATE:CONF_TEMPLATE
#
#declare -A ZONE_TEMPLATES=(
# [_default]="/etc/dns-manager/templates/zone.template:/etc/dns-manager/templates/zone.config.template"
#)

88
etc/config.yml Normal file
View File

@@ -0,0 +1,88 @@
#
# Optional path to config directory (default: /etc/dns-manager).
#
#etc_dir: /etc/dns-manager
#
# Optional paths to named_checkconf and rndc binaries.
#
#named_checkconf: /usr/bin/named-checkconf
#rndc: /usr/bin/rndc
#
# Optional path to named.conf.
#
#named_conf: /etc/bind/named.conf
#
# Optional IP address of the DNS server (default: 127.0.0.1).
#
#dns_ip: 127.0.0.1
#
# Dictionary of paths to key files (TSIG) per view used for zone transfers and DDNS updates.
# This option is mandatory when using views other than the default (_default) or if the usage of a key
# is mandatory to transfer and/or update zones. The keys of this dictionary are either the name of a view
# or a specific zone in a view (zone@view).
# Keep in mind that the first key found in the key files is used and all other keys are ignored.
#
#dns_keyfiles:
# _default: /etc/bind/rndc.key
# example.tld@_default: /etc/bind/another.key
#
# Optional path to key file passed to rndc (-k option), otherwise rndc uses its default key file.
# This key is used to reconfigure Bind when adding or deleting zones.
#
#control_key: /etc/bind/rndc.key
#
# Dictionary of configuration options per view.
#
zones_config:
_default:
#
# Optional path to directory where config files per zone are stored.
# Default: <etc_dir>/<view>.zones
#
#config_dir: /etc/dns-manager/_default.zones
#
# Path to config file that has to be included in named.conf.
# All configs stored in <config_dir> will be concatenated and written to it. The detour via this file is necessary
# because Bind does not support wildcards when including config files.
#
config_file: /etc/bind/default_zones.conf
#
# Path to the directory where the Bind zone files of this view are stored.
# This is typically "/etc/bind/dyn" in an single view environment.
#
zone_dir: /etc/bind/dyn
#
# Optional Bind catalog zone that will be managed automatically when adding or deleting zones.
# The view may also be specified if necessary (zone@view). It is
#
# IMPORTANT: It is recommended to manually add catalog zones to Bind to prevent accidental deletion.
#
#catalog_zone: catalog.example.tld
#
# Optional paths to config and zone default templates used when adding zones.
#
#templates:
# config: /etc/dns-manager/templates/zone.config.template
# zone: /etc/dns-manager/templates/zone.template

View File

@@ -2,10 +2,10 @@ zone "%ZONE%" {
type master; type master;
file "%ZONE_FILE%"; file "%ZONE_FILE%";
update-policy { update-policy {
grant dns-manager-key zonesub any; grant rndc-key zonesub any;
}; };
allow-transfer { allow-transfer {
key dns-manager-key; key rndc-key;
}; };
}; };

View File

@@ -1,5 +1,5 @@
$TTL 86400 ; 1 day $TTL 86400 ; 1 day
@ IN SOA dns1.%ZONE%. hostmaster.%ZONE%. ( %ZONE%. IN SOA dns1.%ZONE%. hostmaster.%ZONE%. (
1 ; serial 1 ; serial
10800 ; refresh (3 hours) 10800 ; refresh (3 hours)
3600 ; retry (1 hour) 3600 ; retry (1 hour)
@@ -8,3 +8,4 @@ $TTL 86400 ; 1 day
) )
NS dns1.%ZONE%. NS dns1.%ZONE%.
NS dns2.%ZONE%. NS dns2.%ZONE%.
$ORIGIN %ZONE%.