From 33ff828b834fefa6154ca54515dfe950ad31cf4d Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sun, 19 Jul 2026 02:30:26 +0200 Subject: [PATCH] Add hook system --- README.md | 47 ++++++++++++++ distribution/gentoo/dns-manager-9999.ebuild | 1 + files/config.yml | 21 +++++++ src/dnsmgr/__init__.py | 68 +++++++++++++++++++++ src/dnsmgr/record_add.py | 14 +++++ src/dnsmgr/record_delete.py | 20 +++++- src/dnsmgr/zone_add.py | 15 +++++ src/dnsmgr/zone_delete.py | 14 +++++ 8 files changed, 197 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 868a326..f5e4c19 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,50 @@ dns-zone-list example.tld # Add an A record dns-record-add example.tld www 3600 A 192.0.2.1 ``` + +## Hooks + +Executable scripts placed in the hooks base directory (`hooks_dir`, default +`/hooks`) are run around zone and record mutations. The tree narrows +progressively and every level is optional: + +``` +hooks/ -> every action and phase (zone+record, add+delete, pre+post) + zone/ -> all zone actions/phases + add/ -> zone add, both phases + pre/ -> zone add, pre only + post/ -> zone add, post only + delete/{pre,post}/ + record/ + add/{pre,post}/ + delete/{pre,post}/ +``` + +For a given event `(kind, action, phase)` all matching hooks across every +existing level along that event's path (`hooks/` → `hooks//` → +`hooks///` → `hooks////`) are collected and +run sorted by filename (run-parts style), so a numeric prefix (e.g. `50-`) +controls execution order regardless of directory depth. A hook placed shallower +in the tree fires for more events; distinguish the concrete event via the +environment variables. Each hook inherits the terminal, so its stdout and stderr +appear live and in order, and a hook may be interactive: it can print prompts and +read the user's input from stdin. In batch mode (`-b`) stdin is redirected to +`/dev/null`, so a hook that reads input gets an immediate EOF instead of blocking +(hooks that prompt should guard on a tty/EOF). +Only executable regular files are run; everything else (including the +`zone`/`record`/`add`/`delete`/`pre`/`post` subdirectories) is skipped, and +missing directories are no-ops. + +Each hook receives context via environment variables: + +* Common env: `DNSMGR_KIND` (zone|record), `DNSMGR_ACTION` (add|delete), + `DNSMGR_PHASE` (pre|post), `DNSMGR_ZONE` (origin, no trailing dot), `DNSMGR_VIEW`. +* Record-only env: `DNSMGR_RECORD_NAME`, `DNSMGR_RECORD_TTL`, + `DNSMGR_RECORD_TYPE`, `DNSMGR_RECORD_VALUE` (values joined by newlines). +* `DNSMGR_ZONE_FILE` (zone file path): set only for managed zones and not for zone add pre-hooks and not for zone delete post-hooks. + +A `pre` hook exiting non-zero aborts the operation before any change is applied +(exit code 190); a `post` hook failure only prints an `ERROR:` and continues. + +Pass `-n`/`--no-hooks` to `dns-zone-add`, `dns-zone-delete`, `dns-record-add` or +`dns-record-delete` to skip all hook execution for that run. diff --git a/distribution/gentoo/dns-manager-9999.ebuild b/distribution/gentoo/dns-manager-9999.ebuild index 58b02be..9947b3e 100644 --- a/distribution/gentoo/dns-manager-9999.ebuild +++ b/distribution/gentoo/dns-manager-9999.ebuild @@ -45,6 +45,7 @@ python_install_all() { doins files/templates/*.template keepdir /etc/${PN}/default.zones + keepdir /etc/${PN}/hooks distutils-r1_python_install_all } diff --git a/files/config.yml b/files/config.yml index 6ca9733..48cc995 100644 --- a/files/config.yml +++ b/files/config.yml @@ -23,6 +23,27 @@ #dns_ip: 127.0.0.1 +# +# Optional path to the hooks base directory (default: /hooks). +# Executable files placed in this tree are run around zone and record mutations. The tree narrows +# progressively; every level is optional and a hook fires for every event along its path: +# +# hooks/ -> every action and phase (zone+record, add+delete, pre+post) +# hooks// -> all actions/phases of (zone|record) +# hooks/// -> both phases of (add|delete) +# hooks//// -> single phase only (pre|post) +# +# All matching hooks across all levels are collected and run sorted by filename (run-parts style); +# only executable files are run. Each hook inherits the terminal: stdout/stderr appear live and it +# may be interactive (prompt and read stdin). In batch mode (-b) stdin is /dev/null, so reads get EOF. +# A pre-hook exiting non-zero aborts the operation (exit code 190); a post-hook failure prints an ERROR and continues. +# Hooks receive context via env vars (DNSMGR_KIND/ACTION/PHASE/ZONE/VIEW and, for records, +# DNSMGR_RECORD_NAME/TTL/TYPE/VALUE). +# DNSMGR_ZONE_FILE (zone file path) is set only for managed zones and and not for zone add pre-hooks nor for zone delete post-hooks. +# + +#hooks_dir: /etc/dns-manager/hooks + # # 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 diff --git a/src/dnsmgr/__init__.py b/src/dnsmgr/__init__.py index 3a51bb7..a9132cc 100644 --- a/src/dnsmgr/__init__.py +++ b/src/dnsmgr/__init__.py @@ -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) diff --git a/src/dnsmgr/record_add.py b/src/dnsmgr/record_add.py index 9b0e10d..ed71202 100644 --- a/src/dnsmgr/record_add.py +++ b/src/dnsmgr/record_add.py @@ -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() diff --git a/src/dnsmgr/record_delete.py b/src/dnsmgr/record_delete.py index 9e3223a..746bddc 100644 --- a/src/dnsmgr/record_delete.py +++ b/src/dnsmgr/record_delete.py @@ -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() diff --git a/src/dnsmgr/zone_add.py b/src/dnsmgr/zone_add.py index 3207fcc..07bc7d6 100644 --- a/src/dnsmgr/zone_add.py +++ b/src/dnsmgr/zone_add.py @@ -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() diff --git a/src/dnsmgr/zone_delete.py b/src/dnsmgr/zone_delete.py index 9648cbd..649e4cc 100644 --- a/src/dnsmgr/zone_delete.py +++ b/src/dnsmgr/zone_delete.py @@ -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()