Re-structure for packaging

This commit is contained in:
2026-01-18 16:22:29 +01:00
parent 758d7effdc
commit ac0d54814a
13 changed files with 166 additions and 98 deletions
+781
View File
@@ -0,0 +1,781 @@
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
__version__ = "1.0.0"
__author__ = "Thomas Oettli <spacefreak@noop.ch>"
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 and rows:
n_cols = len(rows[0])
max_col_lengths = [0] * n_cols
for row in rows:
for index in range(0, len(row)):
max_col_lengths[index] = max(max_col_lengths[index], len(row[index]) + 3)
max_total_len = sum(max_col_lengths) + 2
max_pre_len = sum(max_col_lengths[0:-1]) + 2
terminal_width = os.get_terminal_size().columns
if max_total_len >= terminal_width:
max_value_len = terminal_width - max_pre_len - 3
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):
if file.startswith('.') or not file.endswith('.conf'):
continue
cfgfile = os.path.join(cfg.config_dir, file)
if 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)
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):
if file.startswith('.') or not file.endswith('.conf'):
continue
cfgfile = os.path.join(view_cfg.config_dir, file)
if 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}')
+41
View File
@@ -0,0 +1,41 @@
import argparse
import sys
from . import DEFAULT_CFGFILE, DNSManager, printe
def main():
parser = argparse.ArgumentParser(description='Generate Bind config files.')
parser.add_argument('-c', '--config', help='path to config file', default=DEFAULT_CFGFILE)
args = parser.parse_args()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
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:
printe(e)
sys.exit(150)
print('Reloading named... ', end='')
try:
manager.named_reload()
print('OK')
except RuntimeError as e:
printe(e)
sys.exit(170)
if __name__ == '__main__':
main()
+69
View File
@@ -0,0 +1,69 @@
import argparse
import sys
from . import DEFAULT_CFGFILE, DNSManager, printe, prettytable
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=DEFAULT_CFGFILE)
parser.add_argument('-d', '--decode', help='decode internationalized domain names (IDN)', action='store_true')
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 = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
try:
zones = manager.all_zones if args.all_zones else manager.zones
except RuntimeError as e:
printe(e)
sys.exit(150)
zones.sort(key=lambda zone: zone.origin.to_unicode() if args.decode else zone.origin.to_text())
if args.raw:
for zone in zones:
name = zone.origin.to_unicode(True) if args.decode else zone.origin.to_text(True)
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_unicode(True) if args.decode else zone.origin.to_text(True),
'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(True) if args.decode else zone.origin.to_text(True)
row = [name, zone.view, zone.status]
if args.all_zones:
row.append(zone.cfgfile is not None)
rows.append(row)
print(prettytable(field_names, rows))
print(f'\nTotal: {len(rows)}\n')
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
import argparse
import dns.rdataclass
import dns.rdataset
import sys
from . import (
DEFAULT_CFGFILE,
NAMED_DEFAULT_VIEW,
DNSManager,
input_name,
input_rdata,
input_ttl,
input_yes_no,
name_from_text,
printe,
rdata_from_text,
select_type,
ttl_from_text,
type_from_text
)
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 '*'
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=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()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
origin = zones[0].origin
if args.name is None:
name = input_name(origin, prompt='Record name')
else:
name = name_from_text(args.name, origin)
if args.ttl is None:
ttl = input_ttl()
else:
ttl = ttl_from_text(args.ttl)
if args.type is None:
rdtype = select_type(args.all_types)
else:
rdtype = type_from_text(args.type, args.all_types)
if not args.value:
rdata = input_rdata(rdtype, origin)
else:
rdata = rdata_from_text(rdtype, ' '.join(args.value), origin)
except RuntimeError as e:
printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
rdataset = dns.rdataset.Rdataset(dns.rdataclass.IN, rdtype, ttl=ttl)
rdataset.add(rdata)
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 not input_yes_no():
sys.exit(0)
for zone in zones:
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
print(f"Sending DDNS updates for '{origin}'... ", end='')
try:
manager.add_zone_record(zone, name, rdataset)
print('OK')
except RuntimeError as e:
printe(e)
sys.exit(160)
if __name__ == '__main__':
main()
+149
View File
@@ -0,0 +1,149 @@
import argparse
import dns.rdataclass
import sys
from . import (
DEFAULT_CFGFILE,
NAMED_DEFAULT_VIEW,
RECORD_TYPES,
DNSManager,
input_yes_no,
name_from_text,
prettyselect,
printe,
rdata_from_text,
type_from_text,
)
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 '?'
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=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()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
for zone in zones:
manager.get_zone_content(zone)
origin = zones[0].origin
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 = prettyselect(['Record name'], rows, prompt='Select record name')
args.name = names[index]
name = name_from_text(args.name, origin)
for zone in zones:
zone.filter_by_name(name, origin)
zones = list(filter(lambda zone: zone.nodes, zones))
if not zones:
raise RuntimeError(f"No such DNS record -- '{name.to_text(True)}'")
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 RECORD_TYPES, rdtypes))
rdtypes = [rdtype.to_text(rdtype) for rdtype in rdtypes]
rows = [[rdtype] for rdtype in rdtypes]
index = prettyselect(['Record type'], rows, prompt='Select record type')
args.type = rdtypes[index]
rdtype = type_from_text(args.type, args.all_types)
for zone in zones:
zone.filter_by_rdtype(rdtype)
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)}'")
rdata = None
if not args.value and not args.batch and not 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 = prettyselect(['Record value'], rows, prompt='Select record value', truncate=True)
args.value = [values[index]]
if args.value:
value = ' '.join(args.value)
rdata = rdata_from_text(rdtype, value, origin)
for zone in zones:
zone.filter_by_rdata(rdata)
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}")
except RuntimeError as e:
printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
zones.sort(key=lambda zone: zone.view)
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')
print()
if not input_yes_no():
sys.exit(0)
for zone in zones:
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
print(f"Sending DDNS updates for '{origin}'... ", end='')
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:
printe(e)
sys.exit(160)
if __name__ == '__main__':
main()
+141
View File
@@ -0,0 +1,141 @@
import argparse
import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import sys
from . import (
DEFAULT_CFGFILE,
NAMED_DEFAULT_VIEW,
DNSManager,
input_name,
input_yes_no,
name_views_from_text,
prettyselect,
printe,
rdata_from_text,
)
from time import sleep
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 '?'
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=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()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
managed_views = sorted(manager.config.zones_config.keys())
try:
if args.zone is None:
name = input_name()
rows = [[view] for view in managed_views]
index = prettyselect(['View'], rows, prompt='Select view', also_valid=['*'])
views = managed_views if index == '*' else [managed_views[index]]
else:
(name, views) = 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] != 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}\'')
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}')
except RuntimeError as e:
printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
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')
if not input_yes_no():
sys.exit(0)
zones = []
for view in views:
origin = name.to_text(omit_final_dot=True)
if len(views) > 1 or view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{view}'
print(f"Adding zone '{origin}'... ", end='')
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:
printe(e)
sys.exit(160)
try:
print('Reloading named... ', end='')
manager.named_reload()
print('OK')
except RuntimeError as e:
printe(e)
sys.exit(170)
if zones:
sleep(2)
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}')
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
for catalog_zone in catalog_zones:
rdata = 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 != NAMED_DEFAULT_VIEW:
catalog_zone_origin += f'@{catalog_zone.view}'
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:
printe(e)
if __name__ == '__main__':
main()
+110
View File
@@ -0,0 +1,110 @@
import argparse
import dns.rdataclass
import dns.rdataset
import dns.rdatatype
import sys
from . import DEFAULT_CFGFILE, NAMED_DEFAULT_VIEW, DNSManager, printe, input_yes_no
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 '?'
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=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()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
try:
if args.zone is None:
zones = manager.select_zones()
else:
zones = manager.get_zones(args.zone)
except RuntimeError as e:
printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
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')
if not input_yes_no():
sys.exit(0)
for zone in zones:
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != 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 != 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:
printe(e)
sys.exit(160)
try:
print('Reloading named... ', end='')
manager.named_reload()
print('OK')
except RuntimeError as e:
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 != 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:
printe(e)
sys.exit(180)
if __name__ == '__main__':
main()
+109
View File
@@ -0,0 +1,109 @@
import argparse
import re
import sys
from . import NAMED_DEFAULT_VIEW, RECORD_TYPES, DEFAULT_CFGFILE, DNSManager, printe, prettytable
from dns.reversename import ipv4_reverse_domain, ipv6_reverse_domain
from json import dumps
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 '?'
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=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()
try:
manager = DNSManager(cfgfile=args.config)
except RuntimeError as e:
printe(f'config: {e}')
sys.exit(100)
try:
if args.zone is None:
zones = manager.select_zones(args.all_zones)
else:
zones = manager.get_zones(args.zone, args.all_zones)
except RuntimeError as e:
printe(e)
sys.exit(150)
except KeyboardInterrupt:
sys.exit(0)
zones.sort(key=lambda zone: zone.view)
zone_records = {}
for zone in zones:
try:
manager.get_zone_content(zone)
except RuntimeError as e:
printe(f"zone transfer of '{zone.origin.to_text(True)}@{zone.view}': {e}")
sys.exit(160)
records = []
for name, node in zone.items():
for rdataset in node:
if not args.all_records and rdataset.rdtype not in RECORD_TYPES:
continue
for value in rdataset:
records.append({
'name': name.to_unicode() if args.decode else name.to_text(),
'ttl': str(rdataset.ttl),
'type': str(rdataset.rdtype.to_text(rdataset.rdtype)),
'value': value.to_text(origin=zone.origin, relativize=False)})
if zone.origin.is_subdomain(ipv4_reverse_domain):
records.sort(key=lambda r: int(re.findall(r'\d+|$', r['name'])[0] or 0))
elif zone.origin.is_subdomain(ipv6_reverse_domain):
records.sort(key=lambda r: int(''.join(re.findall(r'\d+', r['name'])) or 0))
else:
records.sort(key=lambda r: f'{r["name"]}{r["type"]}')
zone_records[zone.view] = records
views = sorted(zone_records.keys())
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))
else:
field_names = ['Name', 'TTL', 'Type', 'Value']
for view in views:
rows = []
for record in zone_records[view]:
row = [record['name'], record['ttl'], record['type'], record['value']]
rows.append(row)
if len(views) > 1 or view != NAMED_DEFAULT_VIEW:
print(f'View: {view}')
print(prettytable(field_names, rows, truncate=True))
print()
if __name__ == '__main__':
main()