Add hook system

This commit is contained in:
2026-07-19 02:30:26 +02:00
parent b73f303b4d
commit 33ff828b83
8 changed files with 197 additions and 3 deletions
+68
View File
@@ -12,6 +12,7 @@ import os
import re
import shutil
import subprocess
import sys
import yaml
from hashlib import sha1
@@ -47,6 +48,35 @@ def printe(msg):
print(f'ERROR: {msg}')
def run_hooks(hooks_dir, kind, action, phase, env, batch=False):
dirs = [
hooks_dir,
os.path.join(hooks_dir, kind),
os.path.join(hooks_dir, kind, action),
os.path.join(hooks_dir, kind, action, phase),
]
hooks = []
for hook_dir in dirs:
if not os.path.isdir(hook_dir):
break
for entry in os.listdir(hook_dir):
path = os.path.join(hook_dir, entry)
if not os.path.isfile(path) or not os.access(path, os.X_OK):
continue
hooks.append(path)
stdin = subprocess.DEVNULL if batch else None
for path in sorted(hooks, key=os.path.basename):
sys.stdout.flush()
sys.stderr.flush()
res = subprocess.run([path], env={**os.environ, **env}, stdin=stdin)
if res.returncode != 0:
msg = f'hook failed (exit {res.returncode}): {path}'
raise RuntimeError(msg)
def prettytable(field_names, rows, truncate=False):
t = PrettyTable()
@@ -346,6 +376,10 @@ class DNSManagerConfig:
if not isinstance(self.etc_dir, str):
raise RuntimeError('etc_dir: value is not a string')
self.hooks_dir = config.get('hooks_dir', os.path.join(self.etc_dir, 'hooks'))
if not isinstance(self.hooks_dir, str):
raise RuntimeError('hooks_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')
@@ -784,3 +818,37 @@ class DNSManager:
except Exception as e:
raise RuntimeError(f'unable to delete zone file: {e}')
def run_zone_hooks(self, action, phase, name, view, batch=False):
origin = name.to_text(omit_final_dot=True)
env = {
'DNSMGR_KIND': 'zone',
'DNSMGR_ACTION': action,
'DNSMGR_PHASE': phase,
'DNSMGR_ZONE': origin,
'DNSMGR_VIEW': view,
}
if view in self.config.zones_config and not (action == 'add' and phase == 'pre') \
and not (action == 'delete' and phase == 'post'):
env['DNSMGR_ZONE_FILE'] = os.path.join(self.config.zones_config[view].zone_dir, f'{origin}.zone')
run_hooks(self.config.hooks_dir, 'zone', action, phase, env, batch)
def run_record_hooks(self, action, phase, zone, name, rdataset, batch=False):
origin = zone.origin.to_text(omit_final_dot=True)
rdname = name.to_text(omit_final_dot=True)
rdtype = rdataset.rdtype.to_text(rdataset.rdtype)
values = [rdata.to_text(origin=zone.origin, relativize=False) for rdata in rdataset]
env = {
'DNSMGR_KIND': 'record',
'DNSMGR_ACTION': action,
'DNSMGR_PHASE': phase,
'DNSMGR_ZONE': origin,
'DNSMGR_VIEW': zone.view,
'DNSMGR_RECORD_NAME': rdname,
'DNSMGR_RECORD_TTL': str(rdataset.ttl),
'DNSMGR_RECORD_TYPE': rdtype,
'DNSMGR_RECORD_VALUE': '\n'.join(values),
}
if zone.cfgfile is not None:
env['DNSMGR_ZONE_FILE'] = zone.zonefile
run_hooks(self.config.hooks_dir, 'record', action, phase, env, batch)
+14
View File
@@ -32,6 +32,7 @@ def main():
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('-n', '--no-hooks', action='store_true', help='skip hook execution')
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, @ refers to zone apex', default=None)
parser.add_argument('ttl', metavar='TTL', nargs=nargs, help='DNS record TTL in seconds', type=int, default=None)
@@ -92,6 +93,13 @@ def main():
sys.exit(0)
for zone in zones:
if not args.no_hooks:
try:
manager.run_record_hooks('add', 'pre', zone, name, rdataset, args.batch)
except RuntimeError as e:
printe(e)
sys.exit(190)
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
@@ -104,6 +112,12 @@ def main():
printe(e)
sys.exit(160)
if not args.no_hooks:
try:
manager.run_record_hooks('add', 'post', zone, name, rdataset, args.batch)
except RuntimeError as e:
printe(e)
if __name__ == '__main__':
main()
+17 -3
View File
@@ -27,6 +27,7 @@ def main():
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('-n', '--no-hooks', action='store_true', help='skip hook execution')
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, @ refers to zone apex', default=None)
parser.add_argument('type', metavar='TYPE', nargs=nargs, help='DNS record type', default=None)
@@ -129,14 +130,21 @@ def main():
sys.exit(0)
for zone in zones:
node = zone.find_node(name)
rdataset = node.find_rdataset(dns.rdataclass.IN, rdtype)
if not args.no_hooks:
try:
manager.run_record_hooks('delete', 'pre', zone, name, rdataset, args.batch)
except RuntimeError as e:
printe(e)
sys.exit(190)
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')
@@ -144,6 +152,12 @@ def main():
printe(e)
sys.exit(160)
if not args.no_hooks:
try:
manager.run_record_hooks('delete', 'post', zone, name, rdataset, args.batch)
except RuntimeError as e:
printe(e)
if __name__ == '__main__':
main()
+15
View File
@@ -29,6 +29,7 @@ def main():
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('-n', '--no-hooks', action='store_true', help='skip hook execution')
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()
@@ -83,6 +84,13 @@ def main():
zones = []
for view in views:
if not args.no_hooks:
try:
manager.run_zone_hooks('add', 'pre', name, view, args.batch)
except RuntimeError as e:
printe(e)
sys.exit(190)
origin = name.to_text(omit_final_dot=True)
if len(views) > 1 or view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{view}'
@@ -136,6 +144,13 @@ def main():
except RuntimeError as e:
printe(e)
if not args.no_hooks:
for view in views:
try:
manager.run_zone_hooks('add', 'post', name, view, args.batch)
except RuntimeError as e:
printe(e)
if __name__ == '__main__':
main()
+14
View File
@@ -16,6 +16,7 @@ def main():
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('-n', '--no-hooks', action='store_true', help='skip hook execution')
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()
@@ -46,6 +47,13 @@ def main():
sys.exit(0)
for zone in zones:
if not args.no_hooks:
try:
manager.run_zone_hooks('delete', 'pre', zone.origin, zone.view, args.batch)
except RuntimeError as e:
printe(e)
sys.exit(190)
origin = zone.origin.to_text(omit_final_dot=True)
if len(zones) > 1 or zone.view != NAMED_DEFAULT_VIEW:
origin = f'{origin}@{zone.view}'
@@ -105,6 +113,12 @@ def main():
printe(e)
sys.exit(180)
if not args.no_hooks:
try:
manager.run_zone_hooks('delete', 'post', zone.origin, zone.view, args.batch)
except RuntimeError as e:
printe(e)
if __name__ == '__main__':
main()