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")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
import argparse
import dns.rdataclass
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:
-c, --config path to config file
-h, --help print this help message
-f, --force add records without confirmation prompt
-i, --interactive interactively ask for missing arguments
EOF
exit
}
parser = argparse.ArgumentParser(description='Add DNS records.')
parser.add_argument('-a', '--all-zones', help='allow zones that are not managed', action='store_true')
parser.add_argument('-A', '--all-types', help='allow 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('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('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"
force=false
interactive=false
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
declare -a args=()
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
;;
-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
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
source "$config_file" || exit 2
origin = zones[0].origin
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib}
source "$LIB_DIR"/dns.sh || exit 3
if args.name is None:
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 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
if args.type is None:
rdtype = dnsmgr.select_type(args.all_types)
else:
rdtype = dnsmgr.type_from_text(args.type, args.all_types)
name=$1
if shift; then
dns_check_record_name "$name" name || exit 21
elif $interactive; then
dns_read_record_name name || exit 10
else
echo "$SCRIPT: missing argument -- NAME" >&2
exit 1
fi
if args.value is None:
rdata = dnsmgr.input_rdata(rdtype, origin)
else:
rdata = dnsmgr.rdata_from_text(rdtype, ' '.join(args.value), origin)
ttl=$1
if shift; then
dns_check_ttl "$ttl" || exit 22
elif $interactive; then
dns_read_ttl ttl || exit 10
else
echo "$SCRIPT: missing argument -- TTL" >&2
exit 1
fi
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
rtype=$1
if shift; then
dns_check_record_type "$rtype" || exit 23
elif $interactive; then
dns_select_record_type rtype || exit 10
else
echo "$SCRIPT: missing argument -- TYPE" >&2
exit 1
fi
rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, rdtype, ttl=ttl)
rdataset.add(rdata)
value=$1
if shift; then
dns_check_record_value "$rtype" "$value" value || exit 24
elif $interactive; then
dns_read_record_value "$rtype" value || exit 10
else
echo "$SCRIPT: missing argument -- VALUE" >&2
exit 1
fi
if not args.batch:
for zone in zones:
text = rdataset.to_text(origin=zone.origin, relativize=False)
print(f'View: {zone.view}')
print(f'\033[32m+ {name} {text}\033[0m\n')
if [ "${view}" == "*" ]; then
json_array_to_bash views < <(dns_zone_views "$zone")
else
views=("$view")
fi
if not dnsmgr.input_yes_no():
sys.exit(0)
if ! $force; then
for view in "${views[@]}"; do
echo "View: $view"
output=$(dns_record_add "true" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1)
if (( $? == 0 )); then
echo -n -e "\e[32m+ $TAB"
echo -n -e "$output\e[0m" | grep --color=never -v -E '^(Outgoing update query:|;.*)?$'
else
echo -e "\e[31mERROR:\n" >&2
echo -e "$output\e[0m" >&2
exit 30
fi
done
echo
! yes_no "Proceed?" && echo -e "Aborted" && exit
echo
fi
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"Sending DDNS updates for '{origin}'... ", end='')
echo -n "Sending DDNS update(s)... "
for view in "${views[@]}"; do
output=$(dns_record_add "false" "$zone" "$view" "$name" "$ttl" "$rtype" "$value" 2>&1)
if (( $? != 0 )); then
echo -e "ERROR\n" >&2
echo "$output" >&2
exit 31
fi
done
echo "OK"
try:
manager.add_zone_record(zone, name, rdataset)
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(160)
if __name__ == '__main__':
main()

View File

@@ -1,203 +1,138 @@
#!/usr/bin/env bash
#!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
import argparse
import dns.rdataclass
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:
-c, --config path to config file
-h, --help print this help message
-f, --force delete records without confirmation prompt
-i, --interactive interactively ask for missing arguments
parser = argparse.ArgumentParser(description='Delete DNS records.')
parser.add_argument('-a', '--all-zones', help='allow zones that are not managed', action='store_true')
parser.add_argument('-A', '--all-types', help='allow 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('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
exit
}
try:
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"
force=false
interactive=false
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
declare -a args=()
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
;;
-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
for zone in zones:
manager.get_zone_content(zone)
source "$config_file" || exit 2
origin = zones[0].origin
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib}
source "$LIB_DIR"/dns.sh || exit 3
if args.name is None:
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
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
for zone in zones:
zone.filter_by_name(name, origin)
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
json_array_to_bash views < <(dns_zone_views "$zone")
else
views=("$view")
fi
if args.type is None:
rdtypes = sorted(set([rdataset.rdtype for rdataset in zone.get_node(name) for zone in zones]))
if not args.all_types:
rdtypes = list(filter(lambda rdtype: rdtype in dnsmgr.RECORD_TYPES, rdtypes))
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=$(
for view in "${views[@]}"; do
dns_zone "$zone" "$view" || exit 22
done | "$JQ" --compact-output --slurp 'add'
)
rdtype = dnsmgr.type_from_text(args.type, args.all_types)
name=$1
if shift; then
dns_check_record_name "$name" name || exit 21
elif $interactive; then
if (( ${#zone_data[@]} > 1 )); then
dns_read_record_name name || exit 10
else
json_array_to_bash names < <("$JQ" --compact-output '[ .[] | .name ] | sort | unique' <<<"$records")
COLUMNS=30
echo -e "Select record name:\n"
select name in "${names[@]}"; do
[ -n "$name" ] && break
done
[ -z "$name" ] && echo "ERROR: record name selection failed" >&2 && exit 11
echo
fi
else
echo "$SCRIPT: missing argument -- NAME" >&2
exit 1
fi
for zone in zones:
zone.filter_by_rdtype(rdtype)
records=$("$JQ" --compact-output --arg name "$name" '[ .[] | select(.name == $name) ]' <<<"$records")
[ "$records" == "[]" ] && echo "ERROR: no such record -- '$name'" >&2 && exit 5
zones = list(filter(lambda zone: zone.nodes, zones))
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 shift; then
dns_check_record_type "$rtype" || exit 23
elif $interactive; then
if [ "$view" == "*" ]; then
dns_select_record_type rtype || exit 10
else
echo -e "Select record type:\n"
select rtype in "${rtypes[@]}"; do
[ -n "$rtype" ] && break
done
[ -z "$name" ] && echo "ERROR: record type selection failed" >&2 && exit 11
echo
fi
else
echo "$SCRIPT: missing argument -- TYPE" >&2
exit 1
fi
if args.value:
value = ' '.join(args.value)
rdata = dnsmgr.rdata_from_text(rdtype, value, origin)
for zone in zones:
zone.filter_by_rdata(rdata)
records=$("$JQ" --compact-output --arg rtype "$rtype" '[ .[] | select(.type == $rtype) ]' <<<"$records")
[ "$records" == "[]" ] && echo "ERROR: no ${rtype} record found" >&2 && exit 5
zones = list(filter(lambda zone: zone.nodes, zones))
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
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
zones.sort(key=lambda zone: zone.view)
if [ -n "$value" ]; then
decoded_value=$(dns_decode_txt_value "$value")
records=$("$JQ" --compact-output --arg value "$decoded_value" '[ .[] | select(.decoded_value == $value) ]' <<<"$records")
[ "$records" == "[]" ] && echo "ERROR: no $rtype record matches value" >&2 && exit 6
fi
if not args.batch:
for zone in zones:
print(f'View: {zone.view}')
node = zone.find_node(name)
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 view in "${views[@]}"; do
echo "View: $view"
json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records")
for value in "${values[@]}"; do
output=$(dns_record_delete "true" "$zone" "$view" "$name" "$rtype" "$value" 2>&1)
if (( $? == 0 )); then
echo -n -e "\e[31m- $TAB"
echo -n -e "$output\e[0m" | grep --color=never -v -E '^(Outgoing update query:|;.*)?$'
else
echo -e "\e[31mERROR:\n" >&2
echo -e "$output\e[0m" >&2
exit 30
fi
done
done
echo
! yes_no "Proceed?" && echo -e "Aborted" && exit
echo
fi
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"Sending DDNS updates for '{origin}'... ", end='')
echo -n "Sending DDNS update(s)... "
for view in "${views[@]}"; do
json_array_to_bash values < <("$JQ" --compact-output '[ .[] | .value ] | sort | unique' <<<"$records")
for value in "${values[@]}"; do
output=$(dns_record_delete "false" "$zone" "$view" "$name" "$rtype" "$value" 2>&1)
if (( $? != 0 )); then
echo -e "ERROR updating view -- '$view'\n" >&2
echo "$output" >&2
exit 31
fi
done
done
echo "OK"
node = zone.find_node(name)
rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
try:
manager.delete_zone_record(zone, name, rdataset)
print('OK')
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(160)
if __name__ == '__main__':
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}")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
import argparse
import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import dnsmgr
import sys
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW]
from time import sleep
Add new DNS zones.
Options:
-c, --config path to config file
-h, --help print this help message
-f, --force add zones without confirmation prompt
-i, --interactive interactively ask for missing arguments
-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
}
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 '?'
config_file="/etc/dns-manager/config.sh"
config_template=""
force=false
interactive=false
zone=""
zone_template=""
parser = argparse.ArgumentParser(description='Add DNS zones.')
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('-t', '--config-template', help='config file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
parser.add_argument('-z', '--zone-template', help='zone file/template (overrides value set in ZONE_TEMPLATES config option)', default=None)
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=()
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
;;
-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
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
source "$config_file" || exit 2
managed_views = sorted(manager.config.zones_config.keys())
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib}
source "$LIB_DIR"/dns.sh || exit 3
source "$LIB_DIR"/output.sh || exit 3
try:
if args.zone is None:
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
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
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
declare -A output
if [ "${view}" == "*" ]; then
json_array_to_bash views < <(dns_zone_views)
else
views=("$view")
fi
if not args.batch:
for view in views:
origin = name.to_text(omit_final_dot=True)
print(f'View: {view}')
print(f'\033[32m+ {origin}\033[0m\n')
for view in "${views[@]}"; do
dns_get_base_config "$view" zone_dir conf_dir || exit 13
if not dnsmgr.input_yes_no():
sys.exit(0)
zone_conf_file="$conf_dir/$zone.conf"
[ -f "$zone_conf_file" ] && echo "ERROR: config file already exists -- '$zone_conf_file'" >&2 && exit 14
zones = []
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"
[ -f "$zone_file" ] && echo "ERROR: zone file already exists -- '$zone_file'" >&2 && exit 15
try:
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}}
[ -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
if zones:
sleep(2)
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
for zone in zones:
catalog_zone_name = manager.config.zones_config[zone.view].catalog_zone
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
done
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}'
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
for catalog_zone in catalog_zones:
rdata = dnsmgr.rdata_from_text(dns.rdatatype.PTR, zone.origin.to_text(), catalog_zone.origin)
rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, dns.rdatatype.PTR, ttl=3600)
rdataset.add(rdata)
rdname = dns.name.from_text(zone.nfz() + '.zones', catalog_zone.origin)
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}'
echo -n "Adding zone to config... "
for view in "${views[@]}"; do
dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 13
try:
print(f'Adding zone \'{origin}\' to catalog zone \'{catalog_zone_origin}\'... ', end='')
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"]}"
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" <<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
if __name__ == '__main__':
main()

View File

@@ -1,156 +1,111 @@
#!/usr/bin/env bash
#!/usr/bin/env python3
SCRIPT_PATH=$(realpath -s "${0}")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
usage() {
cat <<EOF
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
import argparse
import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import dnsmgr
import sys
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
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 '?'
echo -n "Deleting zone from config... "
for view in "${views[@]}"; do
dns_get_base_config "$view" zone_dir conf_dir conf_file || exit 10
parser = argparse.ArgumentParser(description='Delete DNS zones.')
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('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"
! rm "$zone_conf_file" && echo "ERROR: unable to delete config file -- '$zone_conf_file'" >&2 && exit 14
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"
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
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"
try:
if args.zone is None:
zones = manager.select_zones()
else:
zones = manager.get_zones(args.zone)
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
error=false
echo -n "Deleting zone files... "
for view in "${views[@]}"; do
dns_get_base_config "$view" zone_dir || exit 10
if not args.batch:
for zone in zones:
origin = zone.origin.to_text(omit_final_dot=True)
print(f'View: {zone.view}')
print(f'\033[31m- {origin}\033[0m\n')
zone_file="$zone_dir/$zone.zone"
! rm "$zone_file" && echo "ERROR: unable to delete zone file -- '$zone_file'" >&2 && error=true
if not dnsmgr.input_yes_no():
sys.exit(0)
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"
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}'
try:
catalog_zone_name = manager.config.zones_config[zone.view].catalog_zone
if catalog_zone_name:
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}')
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}")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT=$(basename "$SCRIPT_PATH")
import argparse
import dnsmgr
import sys
usage() {
cat <<EOF
Usage: $SCRIPT [OPTIONS]... ZONE[@VIEW]
from json import dumps
Show DNS zone content.
Options:
-a, --all do not ignore unsupported record types
-c, --config path to config file
-h, --help print this help message
-i, --interactive interactively ask for missing arguments
-j, --json print json format
-J, --json-pretty print pretty json format (implies -j)
-r, --raw print raw format
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 '?'
EOF
exit
}
parser = argparse.ArgumentParser(description='Show DNS zone records.')
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
config_file="/etc/dns-manager/config.sh"
interactive=false
json=false
json_pretty=false
raw=false
zone=""
try:
manager = dnsmgr.DNSManager(cfgfile=args.config)
except RuntimeError as e:
dnsmgr.printe(f'config: {e}')
sys.exit(100)
declare -a args=()
while [ -n "$1" ]; do
opt=$1
shift
case "$opt" in
-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
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
if $raw && $json; then
echo "$SCRIPT: invalid options: --raw and --json are mutually exclusive" >&2
exit 1
fi
except RuntimeError as e:
dnsmgr.printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
source "$config_file" || exit 2
zones.sort(key=lambda zone: zone.view)
LIB_DIR=${LIB_DIR:-$SCRIPT_DIR/lib}
source "$LIB_DIR"/dns.sh || exit 3
source "$LIB_DIR"/output.sh || exit 3
zone_records = {}
for zone in zones:
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
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
records.sort(key=lambda r: f'{r["name_unicode"]}{r["type"]}')
zone_records[zone.view] = records
declare -A output
if [ "${view}" == "*" ]; then
json_array_to_bash views < <(dns_zone_views "$zone")
else
views=("$view")
fi
views = sorted(zone_records.keys())
for view in "${views[@]}"; do
output["$view"]=$(dns_zone "$zone" "$view" "$all") || exit 12
done
if args.raw:
for view in views:
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
n=0
for view in $(printf "%s\n" "${!output[@]}" | sort); do
if (( ${#output[@]} > 1 )) || [ "$view" != "$NAMED_DEFAULT_VIEW" ]; then
cat <<EOF
#
# VIEW: $view
#
else:
field_names = ['Name', 'TTL', 'Type', 'Value']
for view in views:
rows = []
for record in zone_records[view]:
name = record['name_unicode'] if args.decode else record['name']
row = [name, record['ttl'], record['type'], record['value']]
rows.append(row)
EOF
fi
echo "${output["$view"]}"
"$JQ" --raw-output '.[] | "\(.name)\t\(.ttl)\t\(.type)\t\(.value)"' <<<"${output["$view"]}"
((n++))
(( $n < ${#output[@]} )) && echo
done
exit
fi
if len(views) > 1 or view != dnsmgr.NAMED_DEFAULT_VIEW:
print(f'View: {view}')
print(dnsmgr.prettytable(field_names, rows, truncate=True))
print()
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))
n=0
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
if __name__ == '__main__':
main()

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;
file "%ZONE_FILE%";
update-policy {
grant dns-manager-key zonesub any;
grant rndc-key zonesub any;
};
allow-transfer {
key dns-manager-key;
key rndc-key;
};
};

View File

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