From 90a49bd10d3ffa8f08f071c74f4b7821a4bb9778 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Sun, 7 Dec 2025 03:02:42 -0800 Subject: [PATCH 01/15] Add dnsmasq-to-unbound plugin Registers dnsmasq DHCP leases and static hosts in Unbound DNS. Features: - TXT marker records for tracking managed entries - Batch operations for efficiency - Periodic reconciliation to handle orphaned records - kqueue-based file watching on FreeBSD --- dns/dnsmasq-to-unbound/Makefile | 8 + dns/dnsmasq-to-unbound/pkg-descr | 8 + .../src/etc/rc.d/dnsmasq_watcher | 83 ++ .../Api/ServiceController.php | 130 +++ .../Api/SettingsController.php | 39 + .../DnsmasqToUnbound/IndexController.php | 40 + .../DnsmasqToUnbound/forms/settings.xml | 28 + .../OPNsense/DnsmasqToUnbound/ACL/ACL.xml | 9 + .../DnsmasqToUnbound/DnsmasqToUnbound.php | 37 + .../DnsmasqToUnbound/DnsmasqToUnbound.xml | 25 + .../OPNsense/DnsmasqToUnbound/Menu/Menu.xml | 5 + .../OPNsense/DnsmasqToUnbound/index.volt | 197 ++++ .../scripts/unbound/dnsmasq_watcher.py | 925 ++++++++++++++++++ .../scripts/unbound/list_dnsmasq_records.py | 282 ++++++ .../actions.d/actions_dnsmasqtounbound.conf | 34 + .../OPNsense/DnsmasqToUnbound/+TARGETS | 1 + .../OPNsense/DnsmasqToUnbound/dnsmasq_watcher | 5 + 17 files changed, 1856 insertions(+) create mode 100644 dns/dnsmasq-to-unbound/Makefile create mode 100644 dns/dnsmasq-to-unbound/pkg-descr create mode 100644 dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/SettingsController.php create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/IndexController.php create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/ACL/ACL.xml create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/Menu/Menu.xml create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/views/OPNsense/DnsmasqToUnbound/index.volt create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/service/conf/actions.d/actions_dnsmasqtounbound.conf create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/+TARGETS create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher diff --git a/dns/dnsmasq-to-unbound/Makefile b/dns/dnsmasq-to-unbound/Makefile new file mode 100644 index 0000000000..092bfaf3ec --- /dev/null +++ b/dns/dnsmasq-to-unbound/Makefile @@ -0,0 +1,8 @@ +PLUGIN_NAME= dnsmasq-to-unbound +PLUGIN_VERSION= 1.0 +PLUGIN_REVISION= 0 +PLUGIN_DEPENDS= dnsmasq +PLUGIN_COMMENT= Register dnsmasq DHCP leases and static hosts in Unbound DNS +PLUGIN_MAINTAINER= chall37@users.noreply.github.com + +.include "../../Mk/plugins.mk" diff --git a/dns/dnsmasq-to-unbound/pkg-descr b/dns/dnsmasq-to-unbound/pkg-descr new file mode 100644 index 0000000000..e18f4c73ba --- /dev/null +++ b/dns/dnsmasq-to-unbound/pkg-descr @@ -0,0 +1,8 @@ +Enables Unbound DNS to automatically register hostnames from +dnsmasq DHCP leases and static reservations. + +Watches /var/db/dnsmasq.leases for changes and updates Unbound +DNS records via unbound-control, allowing DHCP clients to be +resolved by hostname without running dnsmasq's DNS server. + +WWW: https://github.com/opnsense/plugins diff --git a/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher b/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher new file mode 100644 index 0000000000..f3bcbf82e9 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher @@ -0,0 +1,83 @@ +#!/bin/sh + +# Copyright (c) 2024 C. Hall (chall37@users.noreply.github.com) +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# +# PROVIDE: dnsmasq_watcher +# REQUIRE: DAEMON unbound +# KEYWORD: shutdown +# + +. /etc/rc.subr + +name=dnsmasq_watcher +rcvar=dnsmasq_watcher_enable +command=/usr/local/opnsense/scripts/unbound/dnsmasq_watcher.py +command_interpreter=/usr/local/bin/python3 +pidfile="/var/run/${name}.pid" + +load_rc_config $name + +: ${dnsmasq_watcher_enable:=NO} + +start_postcmd=dnsmasq_watcher_poststart +stop_cmd=dnsmasq_watcher_stop + +dnsmasq_watcher_poststart() +{ + # Give the daemon time to initialize + for i in 1 2 3 4 5; do + sleep 1 + if [ -s ${pidfile} ]; then + break + fi + done +} + +dnsmasq_watcher_stop() +{ + if [ -z "$rc_pid" ]; then + [ -n "$rc_fast" ] && return 0 + _run_rc_notrunning + return 1 + fi + echo -n "Stopping ${name}." + kill -15 ${rc_pid} + # Wait max 2 seconds for graceful exit + for i in $(seq 1 20); do + if [ -z "`/bin/ps -p ${rc_pid} -o pid=`" ]; then + break + fi + sleep 0.1 + done + # Force kill if still running + if [ -n "`/bin/ps -p ${rc_pid} -o pid=`" ]; then + kill -9 ${rc_pid} >/dev/null 2>&1 + fi + rm -f ${pidfile} + echo "done." +} + +run_rc_command $1 diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php new file mode 100644 index 0000000000..dc7971147b --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php @@ -0,0 +1,130 @@ +configdRun('dnsmasqtounbound listrecords'); + $data = json_decode($response, true); + if ($data === null || !isset($data['rows'])) { + return ['total' => 0, 'rowCount' => 0, 'current' => 1, 'rows' => []]; + } + + $rows = $data['rows']; + + // Handle sorting from bootgrid + if ($this->request->isPost()) { + $sortColumn = null; + $sortOrder = 'asc'; + $post = $this->request->getPost(); + if (isset($post['sort']) && is_array($post['sort'])) { + foreach ($post['sort'] as $col => $order) { + $sortColumn = $col; + $sortOrder = strtolower($order) === 'desc' ? 'desc' : 'asc'; + break; + } + } + + if ($sortColumn !== null) { + usort($rows, function ($a, $b) use ($sortColumn, $sortOrder) { + $valA = isset($a[$sortColumn]) ? (string)$a[$sortColumn] : ''; + $valB = isset($b[$sortColumn]) ? (string)$b[$sortColumn] : ''; + + // Check for empty/null values (treat '-' as empty) + $emptyA = ($valA === '' || $valA === '-'); + $emptyB = ($valB === '' || $valB === '-'); + + // Empty values go to end on asc, beginning on desc + if ($emptyA && !$emptyB) { + return $sortOrder === 'asc' ? 1 : -1; + } + if (!$emptyA && $emptyB) { + return $sortOrder === 'asc' ? -1 : 1; + } + if ($emptyA && $emptyB) { + return 0; + } + + // IP address sorting + if ($sortColumn === 'ip') { + $ipA = ip2long($valA); + $ipB = ip2long($valB); + if ($ipA !== false && $ipB !== false) { + $cmp = $ipA - $ipB; + return $sortOrder === 'desc' ? -$cmp : $cmp; + } + } + + // Default string comparison + $cmp = strcmp(strtolower($valA), strtolower($valB)); + return $sortOrder === 'desc' ? -$cmp : $cmp; + }); + } + } + + return [ + 'total' => count($rows), + 'rowCount' => count($rows), + 'current' => 1, + 'rows' => $rows + ]; + } + + /** + * Get hash of current DNS records for change detection + * @return array + */ + public function recordshashAction() + { + $backend = new Backend(); + $response = $backend->configdRun('dnsmasqtounbound recordshash'); + $data = json_decode($response, true); + if ($data === null) { + return ['hash' => '']; + } + return $data; + } +} diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/SettingsController.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/SettingsController.php new file mode 100644 index 0000000000..bf94bcfecb --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/SettingsController.php @@ -0,0 +1,39 @@ +view->settings = $this->getForm("settings"); + $this->view->pick('OPNsense/DnsmasqToUnbound/index'); + } +} diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml new file mode 100644 index 0000000000..cc68cd0467 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml @@ -0,0 +1,28 @@ +
+ + unbounddnsmasq.enabled + + checkbox + Enable the dnsmasq lease watcher service. + + + unbounddnsmasq.watchleases + + checkbox + Register DHCP leases from dnsmasq in Unbound DNS. + + + unbounddnsmasq.watchstatic + + checkbox + Register static host reservations from dnsmasq in Unbound DNS. + + + unbounddnsmasq.domains + + select_multiple + + true + Leave empty to register all domains. Specifying domains will exclude hosts from unlisted domains. + +
diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/ACL/ACL.xml b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/ACL/ACL.xml new file mode 100644 index 0000000000..e1d5b2a970 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/ACL/ACL.xml @@ -0,0 +1,9 @@ + + + Services: Dnsmasq to Unbound + + ui/dnsmasqtounbound/* + api/dnsmasqtounbound/* + + + diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php new file mode 100644 index 0000000000..1ae6032fb3 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php @@ -0,0 +1,37 @@ + + //OPNsense/DnsmasqToUnbound + 1.0.0 + Unbound DNS registration for dnsmasq DHCP leases + + + 0 + Y + + + 1 + Y + + + 1 + Y + + + N + , + /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/ + Invalid domain format + + + diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/Menu/Menu.xml b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/Menu/Menu.xml new file mode 100644 index 0000000000..3bba37bf4b --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/Menu/Menu.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/views/OPNsense/DnsmasqToUnbound/index.volt b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/views/OPNsense/DnsmasqToUnbound/index.volt new file mode 100644 index 0000000000..cc1e8b3278 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/views/OPNsense/DnsmasqToUnbound/index.volt @@ -0,0 +1,197 @@ +{# + Copyright (c) 2024 C. Hall (chall37@users.noreply.github.com) + All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +#} + + + + + +
+ +
+
+ {{ partial("layout_partials/base_form", ['fields': settings, 'id': 'frm_Settings']) }} +
+
+ + +
+
+
+ + + + + + + + + + + + +
{{ lang._('FQDN') }}{{ lang._('IP Address') }}{{ lang._('Source') }}{{ lang._('MAC') }}{{ lang._('Expiry') }}
+
+ {{ lang._('Table updates automatically when records change (polling every 5 seconds).') }} +
+
+
+
+
+ +
+
+
+
+ + + +

+
+
+
diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py new file mode 100644 index 0000000000..9aaef90ea1 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py @@ -0,0 +1,925 @@ +#!/usr/local/bin/python3 + +""" + Copyright (c) 2024 C. Hall (chall37@users.noreply.github.com) + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + -------------------------------------------------------------------------------------- + + Watch dnsmasq DHCP leases and static hosts, register them in Unbound DNS. + Uses kqueue for efficient file watching on FreeBSD. + Reads configuration from OPNsense config.xml. + + Failure Handling: + - Pre-flight checks verify dependencies before starting + - On critical failure, enters idle state (running but doing nothing) + - Logs failure once, waits for restart + - Does not modify config or spam logs on failure +""" + +import argparse +import os +import signal +import select +import subprocess +import syslog +import time +import sys +import xml.etree.ElementTree as ET + +sys.path.insert(0, "/usr/local/opnsense/site-python") +from daemonize import Daemonize + +LEASE_FILE = '/var/db/dnsmasq.leases' +STATIC_HOSTS_FILE = '/var/etc/dnsmasq-hosts' +UNBOUND_CONTROL = '/usr/local/sbin/unbound-control' +UNBOUND_CONF = '/var/unbound/unbound.conf' +OPNSENSE_CONFIG = '/conf/config.xml' + +# Maximum consecutive failures before entering permanent idle +MAX_CONSECUTIVE_FAILURES = 5 +# How long to wait between retry attempts (seconds) +FAILURE_RETRY_DELAY = 30 +# How often to run full reconciliation (seconds) +RECONCILE_INTERVAL = 300 # 5 minutes +# Marker to identify our managed records +MANAGED_MARKER = 'managed-by=unbounddnsmasq' +# Delay before verifying added records (seconds) +VERIFICATION_DELAY = 5 + + +class FailureReason: + """Constants for failure reasons.""" + NONE = None + DISABLED = 'disabled' + NO_KQUEUE = 'no_kqueue' + UNBOUND_NOT_RUNNING = 'unbound_not_running' + UNBOUND_CONTROL_MISSING = 'unbound_control_missing' + UNBOUND_CONTROL_DISABLED = 'unbound_control_disabled' + CONFIG_PARSE_ERROR = 'config_parse_error' + MAX_FAILURES_EXCEEDED = 'max_failures_exceeded' + + +class DnsmasqLeaseWatcher: + def __init__(self, lease_file=LEASE_FILE, static_hosts_file=STATIC_HOSTS_FILE): + self.lease_file = lease_file + self.static_hosts_file = static_hosts_file + # registered_records: fqdn -> {'ip': str, 'source': str, 'expiry': int or None} + self.registered_records = {} + # pending_verification: fqdn -> (record, added_time) - records to verify after delay + self.pending_verification = {} + self.kq = None + self.watched_fds = {} # fd -> filepath + # Config values (loaded from config.xml) + self.enabled = True + self.watch_leases = True + self.watch_static = True + self.domain_filter = set() # Empty = all domains + # Failure tracking + self.failed = False + self.failure_reason = FailureReason.NONE + self.consecutive_failures = 0 + self.running = True + + def log(self, message, priority=syslog.LOG_INFO): + syslog.syslog(priority, f"dnsmasq_watcher: {message}") + + def enter_failed_state(self, reason, message): + """Enter failed/idle state. Log once, then wait for restart.""" + self.failed = True + self.failure_reason = reason + self.log(f"FAILED: {message} - entering idle state (restart to retry)", syslog.LOG_ERR) + + def preflight_checks(self): + """ + Verify all dependencies are available before starting. + Returns True if all checks pass, False otherwise. + """ + # Check 1: kqueue availability (FreeBSD-specific) + if not hasattr(select, 'kqueue'): + self.enter_failed_state( + FailureReason.NO_KQUEUE, + "kqueue not available (requires FreeBSD)" + ) + return False + + # Check 2: unbound-control executable exists + if not os.path.isfile(UNBOUND_CONTROL): + self.enter_failed_state( + FailureReason.UNBOUND_CONTROL_MISSING, + f"unbound-control not found at {UNBOUND_CONTROL}" + ) + return False + + if not os.access(UNBOUND_CONTROL, os.X_OK): + self.enter_failed_state( + FailureReason.UNBOUND_CONTROL_MISSING, + f"unbound-control not executable at {UNBOUND_CONTROL}" + ) + return False + + # Check 3: unbound is running and controllable + try: + result = subprocess.run( + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'status'], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if 'control-enable' in stderr or 'Connection refused' in stderr: + self.enter_failed_state( + FailureReason.UNBOUND_CONTROL_DISABLED, + "Unbound remote control not enabled. " + "Enable 'Remote Control' in Services > Unbound DNS > General" + ) + return False + else: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + f"Unbound not responding: {stderr or result.stdout.strip()}" + ) + return False + except subprocess.TimeoutExpired: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + "Unbound control timeout - service may be unresponsive" + ) + return False + except FileNotFoundError: + self.enter_failed_state( + FailureReason.UNBOUND_CONTROL_MISSING, + f"unbound-control not found: {UNBOUND_CONTROL}" + ) + return False + except Exception as e: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + f"Error checking Unbound status: {e}" + ) + return False + + # Check 4: At least one watch source should exist or be expected + if not self.watch_leases and not self.watch_static: + self.log("Warning: Both lease and static watching disabled", syslog.LOG_WARNING) + + self.log("Pre-flight checks passed") + return True + + def load_config(self): + """ + Load configuration from OPNsense config.xml. + Returns True on success, False on critical failure. + """ + if not os.path.exists(OPNSENSE_CONFIG): + self.log("Config file not found, using defaults", syslog.LOG_WARNING) + return True # Not critical, use defaults + + try: + tree = ET.parse(OPNSENSE_CONFIG) + root = tree.getroot() + + config = root.find('.//OPNsense/DnsmasqToUnbound') + if config is None: + self.log("No DnsmasqToUnbound config found, using defaults") + return True # Not critical, use defaults + + enabled = config.find('enabled') + if enabled is not None: + self.enabled = enabled.text == '1' + + watch_leases = config.find('watchleases') + if watch_leases is not None: + self.watch_leases = watch_leases.text == '1' + + watch_static = config.find('watchstatic') + if watch_static is not None: + self.watch_static = watch_static.text == '1' + + domains = config.find('domains') + if domains is not None and domains.text: + # Parse comma-separated domains, normalize (strip whitespace and leading dots) + raw_domains = domains.text.split(',') + self.domain_filter = set() + for d in raw_domains: + d = d.strip().lstrip('.') + if d: + self.domain_filter.add(d) + + self.log(f"Config loaded: enabled={self.enabled}, leases={self.watch_leases}, " + f"static={self.watch_static}, domains={self.domain_filter or 'all'}") + return True + + except ET.ParseError as e: + self.enter_failed_state( + FailureReason.CONFIG_PARSE_ERROR, + f"Error parsing config.xml: {e}" + ) + return False + except Exception as e: + self.log(f"Error loading config: {e}", syslog.LOG_ERR) + return True # Non-critical, continue with defaults + + def parse_lease_line(self, line): + """ + Parse a dnsmasq lease line. + Format: + """ + parts = line.strip().split() + if len(parts) < 4: + return None + + try: + expiry = int(parts[0]) + except ValueError: + return None + + ip = parts[2] + hostname = parts[3] if parts[3] != '*' else None + + if not hostname: + return None + + return { + 'expiry': expiry, + 'ip': ip, + 'hostname': hostname + } + + def parse_hosts_line(self, line): + """ + Parse a hosts file line. + Format: [aliases...] + Returns hostname and any domain suffix found. + """ + line = line.strip() + if not line or line.startswith('#'): + return None + + parts = line.split() + if len(parts) < 2: + return None + + ip = parts[0] + hostname = parts[1] + domain = None + + # Extract domain if present + if '.' in hostname: + parts_name = hostname.split('.', 1) + hostname = parts_name[0] + domain = parts_name[1] + + return { + 'ip': ip, + 'hostname': hostname, + 'domain': domain + } + + def get_domains_to_register(self, source_domain=None): + """ + Determine which domains to register a host under. + If domain_filter is set, only register under filtered domains. + If empty, register under all detected domains (or 'lan' as fallback). + """ + if self.domain_filter: + # Filter mode: only register if source domain matches filter + if source_domain and source_domain in self.domain_filter: + return [source_domain] + elif not source_domain: + # No source domain, register under all filtered domains + return list(self.domain_filter) + else: + # Source domain doesn't match filter, skip + return [] + else: + # No filter: register under source domain or 'lan' fallback + return [source_domain] if source_domain else ['lan'] + + def read_leases(self): + """ + Read and parse all leases from the lease file. + Returns dict keyed by FQDN with full record metadata. + """ + records = {} + if not self.watch_leases: + return records + if not os.path.exists(self.lease_file): + return records + + current_time = int(time.time()) + try: + with open(self.lease_file, 'r') as f: + for line in f: + lease = self.parse_lease_line(line) + if lease: + # Skip expired leases (expiry of 0 means infinite) + if lease['expiry'] != 0 and lease['expiry'] < current_time: + continue + # Leases don't have domain suffix, use configured domains + for domain in self.get_domains_to_register(None): + fqdn = f"{lease['hostname']}.{domain}" + new_record = { + 'ip': lease['ip'], + 'source': 'lease', + 'expiry': lease['expiry'], + 'hostname': lease['hostname'], + 'domain': domain + } + # Handle duplicates within leases: prefer later expiry + if fqdn in records: + existing = records[fqdn] + if self._should_replace(existing, new_record): + records[fqdn] = new_record + else: + records[fqdn] = new_record + except IOError as e: + self.log(f"Error reading lease file: {e}", syslog.LOG_ERR) + + return records + + def read_static_hosts(self): + """ + Read and parse static hosts file. + Returns dict keyed by FQDN with full record metadata. + """ + records = {} + if not self.watch_static: + return records + if not os.path.exists(self.static_hosts_file): + return records + + try: + with open(self.static_hosts_file, 'r') as f: + for line in f: + host = self.parse_hosts_line(line) + if host: + # Register under appropriate domains + for domain in self.get_domains_to_register(host['domain']): + fqdn = f"{host['hostname']}.{domain}" + new_record = { + 'ip': host['ip'], + 'source': 'static', + 'expiry': None, # Static entries have no expiry + 'hostname': host['hostname'], + 'domain': domain + } + # For static duplicates, first one wins (earlier in file) + if fqdn not in records: + records[fqdn] = new_record + except IOError as e: + self.log(f"Error reading static hosts file: {e}", syslog.LOG_ERR) + + return records + + def _should_replace(self, existing, new): + """ + Determine if new record should replace existing record for same FQDN. + + Rules: + 1. If both have expiry timestamps, prefer later expiry (newer lease) + 2. Otherwise, static entries take precedence over leases + 3. If both are same type with no expiry info, keep existing + """ + existing_expiry = existing.get('expiry') + new_expiry = new.get('expiry') + existing_source = existing.get('source') + new_source = new.get('source') + + # Both have expiry - prefer later expiry (newer) + if existing_expiry is not None and new_expiry is not None: + # expiry=0 means infinite, treat as very far future + existing_cmp = existing_expiry if existing_expiry != 0 else float('inf') + new_cmp = new_expiry if new_expiry != 0 else float('inf') + return new_cmp > existing_cmp + + # Static takes precedence over lease when we can't compare timestamps + if existing_source == 'static' and new_source == 'lease': + return False + if existing_source == 'lease' and new_source == 'static': + return True + + # Same source type, keep existing + return False + + def _merge_records(self, static_records, lease_records): + """ + Merge static and lease records, deduplicating by FQDN. + Returns dict keyed by FQDN with winning record for each. + """ + merged = {} + + # Add all static records first + for fqdn, record in static_records.items(): + merged[fqdn] = record + + # Add lease records, applying conflict resolution + for fqdn, record in lease_records.items(): + if fqdn in merged: + if self._should_replace(merged[fqdn], record): + self.log(f"Lease overriding existing record for {fqdn}", syslog.LOG_DEBUG) + merged[fqdn] = record + # else: keep existing (static wins or existing is newer) + else: + merged[fqdn] = record + + return merged + + def unbound_control(self, *args): + """ + Execute unbound-control command. + Tracks consecutive failures and enters failed state if threshold exceeded. + """ + cmd = [UNBOUND_CONTROL, '-c', UNBOUND_CONF] + list(args) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + stderr = result.stderr.strip() + self.consecutive_failures += 1 + if 'control-enable' in stderr or 'Connection refused' in stderr: + if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + self.enter_failed_state( + FailureReason.UNBOUND_CONTROL_DISABLED, + "Unbound remote control not enabled or Unbound stopped" + ) + else: + self.log(f"unbound-control failed ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {stderr}", syslog.LOG_WARNING) + else: + self.log(f"unbound-control error: {stderr}", syslog.LOG_WARNING) + return False + # Success - reset failure counter + self.consecutive_failures = 0 + return True + except subprocess.TimeoutExpired: + self.consecutive_failures += 1 + if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + "Unbound repeatedly timing out" + ) + else: + self.log(f"unbound-control timeout ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})", syslog.LOG_WARNING) + return False + except Exception as e: + self.consecutive_failures += 1 + if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + f"Repeated unbound-control failures: {e}" + ) + else: + self.log(f"unbound-control exception ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR) + return False + + def add_dns_record(self, fqdn, record): + """Add TXT marker, A, and PTR records to Unbound in a single batch call.""" + ip = record['ip'] + source = record['source'] + ttl = 3600 if source == 'static' else 300 + ptr_name = '.'.join(reversed(ip.split('.'))) + '.in-addr.arpa' + + # Batch all records: TXT first (marker), then A, then PTR + # Note: trailing newline required - without it, EOF gets interpreted as part of the record + txt_value = f"{MANAGED_MARKER};source={source}" + records = '\n'.join([ + f'{fqdn}. {ttl} IN TXT "{txt_value}"', + f'{fqdn}. {ttl} IN A {ip}', + f'{ptr_name}. {ttl} IN PTR {fqdn}.' + ]) + '\n' + + try: + result = subprocess.run( + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'local_datas'], + input=records, text=True, capture_output=True, timeout=10 + ) + if result.returncode != 0: + self.log(f"Failed to add records for {fqdn}: {result.stderr.strip()}", syslog.LOG_ERR) + self.consecutive_failures += 1 + return False + except Exception as e: + self.log(f"Exception adding records for {fqdn}: {e}", syslog.LOG_ERR) + self.consecutive_failures += 1 + return False + + self.consecutive_failures = 0 + self.log(f"Added record ({source}): {fqdn} -> {ip}") + self.registered_records[fqdn] = record + # Queue for verification after delay + self.pending_verification[fqdn] = (record, time.time()) + return True + + def verify_pending_records(self): + """Verify records that were added VERIFICATION_DELAY seconds ago.""" + if not self.pending_verification: + return + + now = time.time() + verified = [] + failed = [] + + for fqdn, (record, added_time) in list(self.pending_verification.items()): + if now - added_time < VERIFICATION_DELAY: + continue # Not ready for verification yet + + # Query Unbound for this record + try: + result = subprocess.run( + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'lookup', fqdn], + capture_output=True, text=True, timeout=5 + ) + # Check if our IP is in the response + if record['ip'] in result.stdout: + verified.append(fqdn) + else: + failed.append((fqdn, record)) + except Exception as e: + self.log(f"Verification lookup failed for {fqdn}: {e}", syslog.LOG_WARNING) + failed.append((fqdn, record)) + + # Remove verified from pending + for fqdn in verified: + del self.pending_verification[fqdn] + + # Re-add failed records + for fqdn, record in failed: + del self.pending_verification[fqdn] + self.log(f"Verification failed for {fqdn}, re-adding", syslog.LOG_WARNING) + # Remove from registered so add_dns_record can re-add + self.registered_records.pop(fqdn, None) + self.add_dns_record(fqdn, record) + + def get_managed_fqdns_from_unbound(self): + """ + Query Unbound for all FQDNs we manage (identified by TXT marker). + Returns dict of fqdn -> {'ip': str, 'has_ptr': bool} for records with our marker. + """ + managed = {} + try: + result = subprocess.run( + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'list_local_data'], + capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + self.log(f"Failed to list local data: {result.stderr.strip()}", syslog.LOG_ERR) + return managed + + # Parse output: "name. TTL IN TYPE value" + txt_fqdns = set() # FQDNs with our marker + a_records = {} # fqdn -> ip + ptr_targets = set() # Set of FQDNs that have PTR records pointing to them + + for line in result.stdout.strip().split('\n'): + if not line: + continue + parts = line.split() + if len(parts) < 5: + continue + + name = parts[0].rstrip('.') + rtype = parts[3] + + if rtype == 'TXT' and MANAGED_MARKER in line: + txt_fqdns.add(name) + elif rtype == 'A': + a_records[name] = parts[4] + elif rtype == 'PTR': + # PTR value is the FQDN it points to (strip trailing dot) + ptr_target = parts[4].rstrip('.') + ptr_targets.add(ptr_target) + + # Return only A records that have our TXT marker, including PTR status + for fqdn in txt_fqdns: + if fqdn in a_records: + managed[fqdn] = { + 'ip': a_records[fqdn], + 'has_ptr': fqdn in ptr_targets + } + + except Exception as e: + self.log(f"Exception querying Unbound: {e}", syslog.LOG_ERR) + + return managed + + def remove_dns_record(self, fqdn, record): + """Remove A/TXT and PTR records from Unbound in a single batch call.""" + ip = record['ip'] + ptr_name = '.'.join(reversed(ip.split('.'))) + '.in-addr.arpa' + + # Batch removal of both names (trailing newline required) + names = '\n'.join([f'{fqdn}.', f'{ptr_name}.']) + '\n' + + try: + subprocess.run( + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'local_datas_remove'], + input=names, text=True, capture_output=True, timeout=10 + ) + except Exception as e: + self.log(f"Exception removing records for {fqdn}: {e}", syslog.LOG_ERR) + + self.log(f"Removed record: {fqdn} -> {ip}") + self.registered_records.pop(fqdn, None) + + def sync_records(self): + """Sync DNS records with current lease and static host state.""" + # Gather all current records from both sources with deduplication + static_records = self.read_static_hosts() + lease_records = self.read_leases() + current_records = self._merge_records(static_records, lease_records) + + # Add new records or update changed records + for fqdn, record in current_records.items(): + existing = self.registered_records.get(fqdn) + if existing is None: + # New record + self.add_dns_record(fqdn, record) + elif existing['ip'] != record['ip'] or existing['source'] != record['source']: + # Record changed (different IP or source) - remove old, add new + self.log(f"Updating record for {fqdn}: {existing['ip']} -> {record['ip']}") + self.remove_dns_record(fqdn, existing) + self.add_dns_record(fqdn, record) + + # Remove stale records + for fqdn in list(self.registered_records.keys()): + if fqdn not in current_records: + self.remove_dns_record(fqdn, self.registered_records[fqdn]) + + def reconcile(self): + """ + Full reconciliation: compare Unbound state with dnsmasq state. + Handles orphans from crashes, Unbound restarts, etc. + """ + self.log("Running reconciliation") + + # Get what should exist (from dnsmasq) + static_records = self.read_static_hosts() + lease_records = self.read_leases() + expected = self._merge_records(static_records, lease_records) + + # Get what actually exists in Unbound (with our marker) + # Returns dict of fqdn -> {'ip': str, 'has_ptr': bool} + actual = self.get_managed_fqdns_from_unbound() + + # Find orphans (in Unbound but not in dnsmasq) + orphan_count = 0 + for fqdn, info in actual.items(): + if fqdn not in expected: + self.log(f"Removing orphan: {fqdn} -> {info['ip']}") + # Create minimal record for removal + self.remove_dns_record(fqdn, {'ip': info['ip']}) + orphan_count += 1 + + # Find missing or incomplete records (in dnsmasq but not fully in Unbound) + missing_count = 0 + ptr_repair_count = 0 + for fqdn, record in expected.items(): + if fqdn not in actual: + # Completely missing - add all records + self.log(f"Adding missing: {fqdn} -> {record['ip']}") + self.add_dns_record(fqdn, record) + missing_count += 1 + else: + actual_info = actual[fqdn] + if actual_info['ip'] != record['ip']: + # IP mismatch - update + self.log(f"Fixing IP mismatch: {fqdn} {actual_info['ip']} -> {record['ip']}") + self.remove_dns_record(fqdn, {'ip': actual_info['ip']}) + self.add_dns_record(fqdn, record) + missing_count += 1 + elif not actual_info['has_ptr']: + # A record exists but PTR is missing - re-add all records + # (local_datas is idempotent, so TXT and A will just be updated) + self.log(f"Repairing missing PTR for: {fqdn}") + self.add_dns_record(fqdn, record) + ptr_repair_count += 1 + + # Rebuild registered_records from expected + self.registered_records = {fqdn: record for fqdn, record in expected.items()} + + if orphan_count or missing_count or ptr_repair_count: + self.log(f"Reconciliation complete: removed {orphan_count} orphans, " + f"added {missing_count} missing, repaired {ptr_repair_count} PTRs") + else: + self.log("Reconciliation complete: no changes needed") + + def setup_kqueue(self): + """Set up kqueue watchers for lease and static hosts files.""" + self.kq = select.kqueue() + self.watched_fds = {} + + for filepath in [self.lease_file, self.static_hosts_file]: + self._watch_file(filepath) + + def _watch_file(self, filepath): + """Add a file to kqueue watch list.""" + if not os.path.exists(filepath): + return + + try: + fd = os.open(filepath, os.O_RDONLY) + ev = select.kevent( + fd, + filter=select.KQ_FILTER_VNODE, + flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR, + fflags=select.KQ_NOTE_WRITE | select.KQ_NOTE_DELETE | select.KQ_NOTE_RENAME + ) + self.kq.control([ev], 0) + self.watched_fds[fd] = filepath + self.log(f"Watching {filepath} (fd={fd})") + except OSError as e: + self.log(f"Error watching {filepath}: {e}", syslog.LOG_ERR) + + def _rewatch_file(self, filepath): + """Re-establish watch on a file (after delete/rename).""" + # Remove old fd if exists + for fd, path in list(self.watched_fds.items()): + if path == filepath: + try: + os.close(fd) + except OSError: + pass + del self.watched_fds[fd] + break + + # Re-add watch + self._watch_file(filepath) + + def idle_loop(self): + """ + Idle loop for failed state. + Stays running but does nothing until terminated. + """ + self.log(f"Entering idle mode (reason: {self.failure_reason})") + while self.running: + # Sleep in chunks to respond to signals promptly + time.sleep(60) + + def handle_signal(self, signum, frame): + """Handle termination signals gracefully.""" + sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum) + self.log(f"Received signal {sig_name}, shutting down") + self.running = False + + def run(self): + """Main entry point with pre-flight checks and failure handling.""" + syslog.openlog('dnsmasq_watcher', syslog.LOG_PID, syslog.LOG_DAEMON) + self.log("Starting dnsmasq lease watcher") + + # Set up signal handlers + signal.signal(signal.SIGTERM, self.handle_signal) + signal.signal(signal.SIGINT, self.handle_signal) + + # Load configuration first + if not self.load_config(): + self.idle_loop() + return + + # Check if service is disabled + if not self.enabled: + self.enter_failed_state(FailureReason.DISABLED, "Service disabled in configuration") + self.idle_loop() + return + + # Run pre-flight checks + if not self.preflight_checks(): + self.idle_loop() + return + + # Initial reconciliation (cleans up orphans from previous runs, adds current records) + try: + self.reconcile() + self.log(f"Initial reconciliation complete: {len(self.registered_records)} records") + last_reconcile = time.time() + except Exception as e: + self.enter_failed_state( + FailureReason.UNBOUND_NOT_RUNNING, + f"Initial reconciliation failed: {e}" + ) + self.idle_loop() + return + + # Check if we entered failed state during initial sync + if self.failed: + self.idle_loop() + return + + # Set up file watchers + try: + self.setup_kqueue() + except Exception as e: + self.enter_failed_state( + FailureReason.NO_KQUEUE, + f"Failed to set up file watchers: {e}" + ) + self.idle_loop() + return + + self.log("Entering main watch loop") + + # Main watch loop + while self.running and not self.failed: + try: + # Wait for events (timeout every 60s to check for new files) + events = self.kq.control(None, 10, 60) + + files_changed = set() + for ev in events: + filepath = self.watched_fds.get(ev.ident) + if filepath: + files_changed.add(filepath) + # Handle file deletion/rename - need to rewatch + if ev.fflags & (select.KQ_NOTE_DELETE | select.KQ_NOTE_RENAME): + self.log(f"File {filepath} deleted/renamed, re-establishing watch") + time.sleep(0.5) # Brief wait for file to be recreated + self._rewatch_file(filepath) + + if files_changed: + self.log(f"Files changed: {files_changed}") + self.sync_records() + + # Periodically check for files that may not exist yet + for filepath in [self.lease_file, self.static_hosts_file]: + if filepath not in self.watched_fds.values() and os.path.exists(filepath): + self._watch_file(filepath) + + # Verify pending records (non-blocking, checks after VERIFICATION_DELAY) + self.verify_pending_records() + + # Periodic reconciliation (handles Unbound restarts, missed events, etc.) + if time.time() - last_reconcile >= RECONCILE_INTERVAL: + self.reconcile() + last_reconcile = time.time() + + # Check if we entered failed state during sync + if self.failed: + break + + except Exception as e: + self.consecutive_failures += 1 + if self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES: + self.enter_failed_state( + FailureReason.MAX_FAILURES_EXCEEDED, + f"Too many errors in watch loop: {e}" + ) + break + else: + self.log(f"Error in watch loop ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR) + time.sleep(FAILURE_RETRY_DELAY) + + # If we exited due to failure, enter idle loop + if self.failed: + self.idle_loop() + + self.log("Shutting down") + + +def main(): + parser = argparse.ArgumentParser(description='Watch dnsmasq leases and register in Unbound') + parser.add_argument('-l', '--lease-file', default=LEASE_FILE, + help=f'Path to dnsmasq lease file (default: {LEASE_FILE})') + parser.add_argument('-s', '--static-hosts', default=STATIC_HOSTS_FILE, + help=f'Path to static hosts file (default: {STATIC_HOSTS_FILE})') + parser.add_argument('-f', '--foreground', action='store_true', + help='Run in foreground (do not daemonize)') + parser.add_argument('-p', '--pid', default='/var/run/dnsmasq_watcher.pid', + help='PID file location') + args = parser.parse_args() + + watcher = DnsmasqLeaseWatcher( + lease_file=args.lease_file, + static_hosts_file=args.static_hosts + ) + + if args.foreground: + watcher.run() + else: + daemon = Daemonize( + app="dnsmasq_watcher", + pid=args.pid, + action=watcher.run, + foreground=False + ) + daemon.start() + + +if __name__ == '__main__': + main() diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py new file mode 100644 index 0000000000..f2c5a2d2d7 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py @@ -0,0 +1,282 @@ +#!/usr/local/bin/python3 + +""" + Copyright (c) 2024 C. Hall (chall37@users.noreply.github.com) + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + -------------------------------------------------------------------------------------- + + List current DNS records registered from dnsmasq in Unbound. + Outputs JSON for API consumption. +""" + +import argparse +import hashlib +import json +import os +import time +import xml.etree.ElementTree as ET + +LEASE_FILE = '/var/db/dnsmasq.leases' +STATIC_HOSTS_FILE = '/var/etc/dnsmasq-hosts' +DNSMASQ_CONF = '/usr/local/etc/dnsmasq.conf' +OPNSENSE_CONFIG = '/conf/config.xml' + + +def get_config(): + """Load configuration from OPNsense config.xml.""" + config = { + 'enabled': True, + 'watchleases': True, + 'watchstatic': True, + 'domains': [] + } + + if not os.path.exists(OPNSENSE_CONFIG): + return config + + try: + tree = ET.parse(OPNSENSE_CONFIG) + root = tree.getroot() + node = root.find('.//OPNsense/DnsmasqToUnbound') + if node is not None: + for key in ['enabled', 'watchleases', 'watchstatic']: + elem = node.find(key) + if elem is not None: + config[key] = elem.text == '1' + domains = node.find('domains') + if domains is not None and domains.text: + config['domains'] = [d.strip().lstrip('.') for d in domains.text.split(',') if d.strip()] + except Exception: + pass + + return config + + +def parse_lease_line(line): + """Parse a dnsmasq lease line.""" + parts = line.strip().split() + if len(parts) < 4: + return None + try: + expiry = int(parts[0]) + except ValueError: + return None + hostname = parts[3] if parts[3] != '*' else None + if not hostname: + return None + return { + 'expiry': expiry, + 'mac': parts[1], + 'ip': parts[2], + 'hostname': hostname + } + + +def parse_hosts_line(line): + """Parse a hosts file line.""" + line = line.strip() + if not line or line.startswith('#'): + return None + parts = line.split() + if len(parts) < 2: + return None + ip = parts[0] + hostname = parts[1] + domain = None + if '.' in hostname: + parts_name = hostname.split('.', 1) + hostname = parts_name[0] + domain = parts_name[1] + return {'ip': ip, 'hostname': hostname, 'domain': domain} + + +def get_dhcp_host_macs(): + """Parse dhcp-host entries from dnsmasq.conf to get MAC addresses by IP.""" + mac_by_ip = {} + if not os.path.exists(DNSMASQ_CONF): + return mac_by_ip + try: + with open(DNSMASQ_CONF, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('dhcp-host='): + # Format: dhcp-host=MAC,IP,hostname or dhcp-host=MAC,IP + value = line[10:] # Remove 'dhcp-host=' + parts = value.split(',') + if len(parts) >= 2: + mac = parts[0].strip() + ip = parts[1].strip() + if mac and ip: + mac_by_ip[ip] = mac + except IOError: + pass + return mac_by_ip + + +def get_domains_to_register(domain_filter, source_domain=None): + """Determine which domains to register a host under.""" + if domain_filter: + if source_domain and source_domain in domain_filter: + return [source_domain] + elif not source_domain: + return domain_filter + else: + return [] + else: + return [source_domain] if source_domain else ['lan'] + + +def should_replace(existing, new): + """ + Determine if new record should replace existing record for same FQDN. + + Rules: + 1. If both have expiry timestamps, prefer later expiry (newer lease) + 2. Otherwise, static entries take precedence over leases + 3. If both are same type with no expiry info, keep existing + """ + existing_expiry = existing.get('expiry_ts') + new_expiry = new.get('expiry_ts') + existing_type = existing.get('type') + new_type = new.get('type') + + # Both have expiry - prefer later expiry (newer) + if existing_expiry is not None and new_expiry is not None: + # expiry=0 means infinite, treat as very far future + existing_cmp = existing_expiry if existing_expiry != 0 else float('inf') + new_cmp = new_expiry if new_expiry != 0 else float('inf') + return new_cmp > existing_cmp + + # Static takes precedence over lease when we can't compare timestamps + if existing_type == 'static' and new_type == 'lease': + return False + if existing_type == 'lease' and new_type == 'static': + return True + + # Same source type, keep existing + return False + + +def get_records(): + """Fetch and return deduplicated records.""" + config = get_config() + domain_filter = config['domains'] + records_by_fqdn = {} # Deduplicate by FQDN + current_time = int(time.time()) + + # Get MAC addresses from dhcp-host entries + mac_by_ip = get_dhcp_host_macs() + + # Read static hosts first (they have priority by default) + if config['watchstatic'] and os.path.exists(STATIC_HOSTS_FILE): + try: + with open(STATIC_HOSTS_FILE, 'r') as f: + for line in f: + host = parse_hosts_line(line) + if host: + for domain in get_domains_to_register(domain_filter, host['domain']): + fqdn = f"{host['hostname']}.{domain}" + mac = mac_by_ip.get(host['ip'], '-') + new_record = { + 'hostname': host['hostname'], + 'fqdn': fqdn, + 'ip': host['ip'], + 'type': 'static', + 'mac': mac, + 'expiry': '-', + 'expiry_ts': None # For comparison + } + # For static duplicates, first one wins + if fqdn not in records_by_fqdn: + records_by_fqdn[fqdn] = new_record + except IOError: + pass + + # Read leases + if config['watchleases'] and os.path.exists(LEASE_FILE): + try: + with open(LEASE_FILE, 'r') as f: + for line in f: + lease = parse_lease_line(line) + if lease: + if lease['expiry'] != 0 and lease['expiry'] < current_time: + continue + for domain in get_domains_to_register(domain_filter, None): + fqdn = f"{lease['hostname']}.{domain}" + new_record = { + 'hostname': lease['hostname'], + 'fqdn': fqdn, + 'ip': lease['ip'], + 'type': 'lease', + 'mac': lease['mac'], + 'expiry': 'infinite' if lease['expiry'] == 0 else time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(lease['expiry'])), + 'expiry_ts': lease['expiry'] # For comparison + } + # Handle duplicates with conflict resolution + if fqdn in records_by_fqdn: + if should_replace(records_by_fqdn[fqdn], new_record): + records_by_fqdn[fqdn] = new_record + else: + records_by_fqdn[fqdn] = new_record + except IOError: + pass + + # Convert to list and remove internal expiry_ts field + records = [] + for record in records_by_fqdn.values(): + record.pop('expiry_ts', None) + records.append(record) + + # Sort by FQDN + records.sort(key=lambda x: (x['fqdn'].lower(), x['ip'])) + + return records + + +def main(): + parser = argparse.ArgumentParser(description='List dnsmasq DNS records') + parser.add_argument('--hash', action='store_true', + help='Output only a hash of the records for change detection') + args = parser.parse_args() + + records = get_records() + + if args.hash: + # Generate hash from sorted FQDN list for quick comparison + fqdns = sorted([r['fqdn'] + ':' + r['ip'] for r in records]) + hash_input = '|'.join(fqdns) + hash_value = hashlib.md5(hash_input.encode()).hexdigest() + print(json.dumps({'hash': hash_value})) + else: + print(json.dumps({ + 'total': len(records), + 'rowCount': len(records), + 'current': 1, + 'rows': records + }, sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/dns/dnsmasq-to-unbound/src/opnsense/service/conf/actions.d/actions_dnsmasqtounbound.conf b/dns/dnsmasq-to-unbound/src/opnsense/service/conf/actions.d/actions_dnsmasqtounbound.conf new file mode 100644 index 0000000000..3335ce5b56 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/service/conf/actions.d/actions_dnsmasqtounbound.conf @@ -0,0 +1,34 @@ +[start] +command:service dnsmasq_watcher start +type:script +message:Starting dnsmasq watcher + +[stop] +command:service dnsmasq_watcher stop +type:script +message:Stopping dnsmasq watcher + +[restart] +command:service dnsmasq_watcher restart +type:script +message:Restarting dnsmasq watcher + +[status] +command:service dnsmasq_watcher status; exit 0 +type:script_output +message:Checking dnsmasq watcher status + +[reconfigure] +command:/usr/local/bin/configctl template reload OPNsense/DnsmasqToUnbound && service dnsmasq_watcher restart +type:script +message:Reconfiguring dnsmasq watcher + +[listrecords] +command:/usr/local/opnsense/scripts/unbound/list_dnsmasq_records.py +type:script_output +message:Listing dnsmasq DNS records + +[recordshash] +command:/usr/local/opnsense/scripts/unbound/list_dnsmasq_records.py --hash +type:script_output +message:Getting dnsmasq DNS records hash diff --git a/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/+TARGETS b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/+TARGETS new file mode 100644 index 0000000000..b15672fd86 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/+TARGETS @@ -0,0 +1 @@ +dnsmasq_watcher:/etc/rc.conf.d/dnsmasq_watcher diff --git a/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher new file mode 100644 index 0000000000..7e7a718043 --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher @@ -0,0 +1,5 @@ +{% if helpers.exists('OPNsense.UnboundDnsmasq.enabled') and OPNsense.UnboundDnsmasq.enabled == '1' %} +dnsmasq_watcher_enable="YES" +{% else %} +dnsmasq_watcher_enable="NO" +{% endif %} From b2916fa79ec4df35b0e082c9f276495340a8781f Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Sun, 7 Dec 2025 14:23:59 -0800 Subject: [PATCH 02/15] Fix template to use renamed config path DnsmasqToUnbound --- .../service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher index 7e7a718043..1691313b9e 100644 --- a/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher +++ b/dns/dnsmasq-to-unbound/src/opnsense/service/templates/OPNsense/DnsmasqToUnbound/dnsmasq_watcher @@ -1,4 +1,4 @@ -{% if helpers.exists('OPNsense.UnboundDnsmasq.enabled') and OPNsense.UnboundDnsmasq.enabled == '1' %} +{% if helpers.exists('OPNsense.DnsmasqToUnbound.enabled') and OPNsense.DnsmasqToUnbound.enabled == '1' %} dnsmasq_watcher_enable="YES" {% else %} dnsmasq_watcher_enable="NO" From 7d63220de92c2e0234f6c5a535a74d3d21bd82ba Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Sun, 7 Dec 2025 14:29:32 -0800 Subject: [PATCH 03/15] Fix rc.d script permissions --- dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher diff --git a/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher b/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher old mode 100644 new mode 100755 From e083d6ca95115bf1ff1388514418fd85241a5b12 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Sun, 7 Dec 2025 14:35:42 -0800 Subject: [PATCH 04/15] Fix form field IDs and default values - Update form field IDs from unbounddnsmasq to dnsmasqtounbound to match the renamed model - Change enabled default from 0 to 1 so plugin is enabled by default on fresh install --- .../OPNsense/DnsmasqToUnbound/forms/settings.xml | 8 ++++---- .../models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml index cc68cd0467..7056fd7eb8 100644 --- a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/forms/settings.xml @@ -1,24 +1,24 @@
- unbounddnsmasq.enabled + dnsmasqtounbound.enabled checkbox Enable the dnsmasq lease watcher service. - unbounddnsmasq.watchleases + dnsmasqtounbound.watchleases checkbox Register DHCP leases from dnsmasq in Unbound DNS. - unbounddnsmasq.watchstatic + dnsmasqtounbound.watchstatic checkbox Register static host reservations from dnsmasq in Unbound DNS. - unbounddnsmasq.domains + dnsmasqtounbound.domains select_multiple diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml index d09ff3c3c5..a7e0da9935 100644 --- a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.xml @@ -4,7 +4,7 @@ Unbound DNS registration for dnsmasq DHCP leases - 0 + 1 Y From 45bcd10833e79f79b22b86bb974bf11bda24847a Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Sun, 7 Dec 2025 14:37:11 -0800 Subject: [PATCH 05/15] Make Python scripts executable --- .../src/opnsense/scripts/unbound/dnsmasq_watcher.py | 0 .../src/opnsense/scripts/unbound/list_dnsmasq_records.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py mode change 100644 => 100755 dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py old mode 100644 new mode 100755 diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py old mode 100644 new mode 100755 From 9c4fb12eb103c3c0b3558e82b50d400a73255295 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 10:59:01 -0800 Subject: [PATCH 06/15] Prepare plugin for community submission - Fix file permissions (644 for Makefile/pkg-descr, 755 for scripts) - Add .gitignore for work/ and __pycache__/ - Add plugins.inc.d integration for service widget, HA sync, syslog - Add PHPDoc class comments to all PHP classes - Update copyright year to 2025 --- dns/dnsmasq-to-unbound/.gitignore | 3 + .../inc/plugins.inc.d/dnsmasqtounbound.inc | 90 +++++++++++++++++++ .../Api/ServiceController.php | 6 +- .../Api/SettingsController.php | 6 +- .../DnsmasqToUnbound/IndexController.php | 10 ++- .../DnsmasqToUnbound/DnsmasqToUnbound.php | 6 +- .../OPNsense/DnsmasqToUnbound/index.volt | 2 +- .../scripts/unbound/dnsmasq_watcher.py | 2 +- .../scripts/unbound/list_dnsmasq_records.py | 2 +- 9 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 dns/dnsmasq-to-unbound/.gitignore create mode 100644 dns/dnsmasq-to-unbound/src/etc/inc/plugins.inc.d/dnsmasqtounbound.inc diff --git a/dns/dnsmasq-to-unbound/.gitignore b/dns/dnsmasq-to-unbound/.gitignore new file mode 100644 index 0000000000..0f5f902bc2 --- /dev/null +++ b/dns/dnsmasq-to-unbound/.gitignore @@ -0,0 +1,3 @@ +work/ +__pycache__/ +*.pyc diff --git a/dns/dnsmasq-to-unbound/src/etc/inc/plugins.inc.d/dnsmasqtounbound.inc b/dns/dnsmasq-to-unbound/src/etc/inc/plugins.inc.d/dnsmasqtounbound.inc new file mode 100644 index 0000000000..6d9ec4512b --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/etc/inc/plugins.inc.d/dnsmasqtounbound.inc @@ -0,0 +1,90 @@ +enabled == '1'; +} + +/** + * Register dnsmasq to unbound service for the dashboard widget + * @return array + */ +function dnsmasqtounbound_services() +{ + $services = []; + + if (!dnsmasqtounbound_enabled()) { + return $services; + } + + $services[] = [ + 'description' => gettext('Dnsmasq to Unbound Watcher'), + 'configd' => [ + 'restart' => ['dnsmasqtounbound restart'], + 'start' => ['dnsmasqtounbound start'], + 'stop' => ['dnsmasqtounbound stop'], + ], + 'pidfile' => '/var/run/dnsmasq_watcher.pid', + 'name' => 'dnsmasq_watcher', + ]; + + return $services; +} + +/** + * Register configuration sections for HA sync + * @return array + */ +function dnsmasqtounbound_xmlrpc_sync() +{ + $result = []; + $result['id'] = 'dnsmasqtounbound'; + $result['section'] = 'OPNsense.DnsmasqToUnbound'; + $result['description'] = gettext('Dnsmasq to Unbound'); + $result['services'] = ['dnsmasq_watcher']; + return [$result]; +} + +/** + * Register syslog facility + * @return array + */ +function dnsmasqtounbound_syslog() +{ + $syslogconf = []; + $syslogconf['dnsmasq_watcher'] = ['facility' => ['dnsmasq_watcher']]; + return $syslogconf; +} diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php index dc7971147b..d2f51e8f84 100644 --- a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/controllers/OPNsense/DnsmasqToUnbound/Api/ServiceController.php @@ -1,7 +1,7 @@ view->settings = $this->getForm("settings"); diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php index 1ae6032fb3..781ea71cd1 100644 --- a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/models/OPNsense/DnsmasqToUnbound/DnsmasqToUnbound.php @@ -1,7 +1,7 @@ Date: Mon, 8 Dec 2025 11:11:47 -0800 Subject: [PATCH 07/15] Make FQDN deduplication case-insensitive DNS is case-insensitive, so OPNsense.lan and opnsense.lan should be treated as the same hostname for deduplication purposes. --- .../scripts/unbound/dnsmasq_watcher.py | 103 ++++++++++-------- .../scripts/unbound/list_dnsmasq_records.py | 16 +-- 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py index 70fecfa602..2861c9dfc0 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py @@ -339,20 +339,22 @@ def read_leases(self): # Leases don't have domain suffix, use configured domains for domain in self.get_domains_to_register(None): fqdn = f"{lease['hostname']}.{domain}" + fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { 'ip': lease['ip'], 'source': 'lease', 'expiry': lease['expiry'], 'hostname': lease['hostname'], - 'domain': domain + 'domain': domain, + 'fqdn': fqdn # Preserve original case for display } # Handle duplicates within leases: prefer later expiry - if fqdn in records: - existing = records[fqdn] + if fqdn_lower in records: + existing = records[fqdn_lower] if self._should_replace(existing, new_record): - records[fqdn] = new_record + records[fqdn_lower] = new_record else: - records[fqdn] = new_record + records[fqdn_lower] = new_record except IOError as e: self.log(f"Error reading lease file: {e}", syslog.LOG_ERR) @@ -377,16 +379,18 @@ def read_static_hosts(self): # Register under appropriate domains for domain in self.get_domains_to_register(host['domain']): fqdn = f"{host['hostname']}.{domain}" + fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { 'ip': host['ip'], 'source': 'static', 'expiry': None, # Static entries have no expiry 'hostname': host['hostname'], - 'domain': domain + 'domain': domain, + 'fqdn': fqdn # Preserve original case for display } # For static duplicates, first one wins (earlier in file) - if fqdn not in records: - records[fqdn] = new_record + if fqdn_lower not in records: + records[fqdn_lower] = new_record except IOError as e: self.log(f"Error reading static hosts file: {e}", syslog.LOG_ERR) @@ -424,24 +428,24 @@ def _should_replace(self, existing, new): def _merge_records(self, static_records, lease_records): """ - Merge static and lease records, deduplicating by FQDN. - Returns dict keyed by FQDN with winning record for each. + Merge static and lease records, deduplicating by FQDN (case-insensitive). + Returns dict keyed by lowercase FQDN with winning record for each. """ merged = {} - # Add all static records first - for fqdn, record in static_records.items(): - merged[fqdn] = record + # Add all static records first (keys are already lowercase) + for fqdn_lower, record in static_records.items(): + merged[fqdn_lower] = record # Add lease records, applying conflict resolution - for fqdn, record in lease_records.items(): - if fqdn in merged: - if self._should_replace(merged[fqdn], record): - self.log(f"Lease overriding existing record for {fqdn}", syslog.LOG_DEBUG) - merged[fqdn] = record + for fqdn_lower, record in lease_records.items(): + if fqdn_lower in merged: + if self._should_replace(merged[fqdn_lower], record): + self.log(f"Lease overriding existing record for {record.get('fqdn', fqdn_lower)}", syslog.LOG_DEBUG) + merged[fqdn_lower] = record # else: keep existing (static wins or existing is newer) else: - merged[fqdn] = record + merged[fqdn_lower] = record return merged @@ -491,10 +495,13 @@ def unbound_control(self, *args): self.log(f"unbound-control exception ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR) return False - def add_dns_record(self, fqdn, record): + def add_dns_record(self, fqdn_key, record): """Add TXT marker, A, and PTR records to Unbound in a single batch call.""" ip = record['ip'] source = record['source'] + # Use lowercase fqdn for DNS (case-insensitive) + fqdn = fqdn_key.lower() + display_fqdn = record.get('fqdn', fqdn) # Original case for logging ttl = 3600 if source == 'static' else 300 ptr_name = '.'.join(reversed(ip.split('.'))) + '.in-addr.arpa' @@ -513,19 +520,19 @@ def add_dns_record(self, fqdn, record): input=records, text=True, capture_output=True, timeout=10 ) if result.returncode != 0: - self.log(f"Failed to add records for {fqdn}: {result.stderr.strip()}", syslog.LOG_ERR) + self.log(f"Failed to add records for {display_fqdn}: {result.stderr.strip()}", syslog.LOG_ERR) self.consecutive_failures += 1 return False except Exception as e: - self.log(f"Exception adding records for {fqdn}: {e}", syslog.LOG_ERR) + self.log(f"Exception adding records for {display_fqdn}: {e}", syslog.LOG_ERR) self.consecutive_failures += 1 return False self.consecutive_failures = 0 - self.log(f"Added record ({source}): {fqdn} -> {ip}") - self.registered_records[fqdn] = record + self.log(f"Added record ({source}): {display_fqdn} -> {ip}") + self.registered_records[fqdn_key] = record # Queue for verification after delay - self.pending_verification[fqdn] = (record, time.time()) + self.pending_verification[fqdn_key] = (record, time.time()) return True def verify_pending_records(self): @@ -537,36 +544,38 @@ def verify_pending_records(self): verified = [] failed = [] - for fqdn, (record, added_time) in list(self.pending_verification.items()): + for fqdn_key, (record, added_time) in list(self.pending_verification.items()): if now - added_time < VERIFICATION_DELAY: continue # Not ready for verification yet - # Query Unbound for this record + display_fqdn = record.get('fqdn', fqdn_key) + # Query Unbound for this record (use lowercase) try: result = subprocess.run( - [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'lookup', fqdn], + [UNBOUND_CONTROL, '-c', UNBOUND_CONF, 'lookup', fqdn_key.lower()], capture_output=True, text=True, timeout=5 ) # Check if our IP is in the response if record['ip'] in result.stdout: - verified.append(fqdn) + verified.append(fqdn_key) else: - failed.append((fqdn, record)) + failed.append((fqdn_key, record)) except Exception as e: - self.log(f"Verification lookup failed for {fqdn}: {e}", syslog.LOG_WARNING) - failed.append((fqdn, record)) + self.log(f"Verification lookup failed for {display_fqdn}: {e}", syslog.LOG_WARNING) + failed.append((fqdn_key, record)) # Remove verified from pending - for fqdn in verified: - del self.pending_verification[fqdn] + for fqdn_key in verified: + del self.pending_verification[fqdn_key] # Re-add failed records - for fqdn, record in failed: - del self.pending_verification[fqdn] - self.log(f"Verification failed for {fqdn}, re-adding", syslog.LOG_WARNING) + for fqdn_key, record in failed: + del self.pending_verification[fqdn_key] + display_fqdn = record.get('fqdn', fqdn_key) + self.log(f"Verification failed for {display_fqdn}, re-adding", syslog.LOG_WARNING) # Remove from registered so add_dns_record can re-add - self.registered_records.pop(fqdn, None) - self.add_dns_record(fqdn, record) + self.registered_records.pop(fqdn_key, None) + self.add_dns_record(fqdn_key, record) def get_managed_fqdns_from_unbound(self): """ @@ -595,7 +604,8 @@ def get_managed_fqdns_from_unbound(self): if len(parts) < 5: continue - name = parts[0].rstrip('.') + # Normalize to lowercase for case-insensitive comparison + name = parts[0].rstrip('.').lower() rtype = parts[3] if rtype == 'TXT' and MANAGED_MARKER in line: @@ -604,7 +614,7 @@ def get_managed_fqdns_from_unbound(self): a_records[name] = parts[4] elif rtype == 'PTR': # PTR value is the FQDN it points to (strip trailing dot) - ptr_target = parts[4].rstrip('.') + ptr_target = parts[4].rstrip('.').lower() ptr_targets.add(ptr_target) # Return only A records that have our TXT marker, including PTR status @@ -620,9 +630,12 @@ def get_managed_fqdns_from_unbound(self): return managed - def remove_dns_record(self, fqdn, record): + def remove_dns_record(self, fqdn_key, record): """Remove A/TXT and PTR records from Unbound in a single batch call.""" ip = record['ip'] + # Use lowercase fqdn for DNS (case-insensitive) + fqdn = fqdn_key.lower() + display_fqdn = record.get('fqdn', fqdn) # Original case for logging ptr_name = '.'.join(reversed(ip.split('.'))) + '.in-addr.arpa' # Batch removal of both names (trailing newline required) @@ -634,10 +647,10 @@ def remove_dns_record(self, fqdn, record): input=names, text=True, capture_output=True, timeout=10 ) except Exception as e: - self.log(f"Exception removing records for {fqdn}: {e}", syslog.LOG_ERR) + self.log(f"Exception removing records for {display_fqdn}: {e}", syslog.LOG_ERR) - self.log(f"Removed record: {fqdn} -> {ip}") - self.registered_records.pop(fqdn, None) + self.log(f"Removed record: {display_fqdn} -> {ip}") + self.registered_records.pop(fqdn_key, None) def sync_records(self): """Sync DNS records with current lease and static host state.""" diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py index 13267e2de8..de52c10760 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py @@ -183,7 +183,7 @@ def get_records(): """Fetch and return deduplicated records.""" config = get_config() domain_filter = config['domains'] - records_by_fqdn = {} # Deduplicate by FQDN + records_by_fqdn = {} # Deduplicate by FQDN (case-insensitive) current_time = int(time.time()) # Get MAC addresses from dhcp-host entries @@ -198,6 +198,7 @@ def get_records(): if host: for domain in get_domains_to_register(domain_filter, host['domain']): fqdn = f"{host['hostname']}.{domain}" + fqdn_lower = fqdn.lower() # DNS is case-insensitive mac = mac_by_ip.get(host['ip'], '-') new_record = { 'hostname': host['hostname'], @@ -209,8 +210,8 @@ def get_records(): 'expiry_ts': None # For comparison } # For static duplicates, first one wins - if fqdn not in records_by_fqdn: - records_by_fqdn[fqdn] = new_record + if fqdn_lower not in records_by_fqdn: + records_by_fqdn[fqdn_lower] = new_record except IOError: pass @@ -225,6 +226,7 @@ def get_records(): continue for domain in get_domains_to_register(domain_filter, None): fqdn = f"{lease['hostname']}.{domain}" + fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { 'hostname': lease['hostname'], 'fqdn': fqdn, @@ -235,11 +237,11 @@ def get_records(): 'expiry_ts': lease['expiry'] # For comparison } # Handle duplicates with conflict resolution - if fqdn in records_by_fqdn: - if should_replace(records_by_fqdn[fqdn], new_record): - records_by_fqdn[fqdn] = new_record + if fqdn_lower in records_by_fqdn: + if should_replace(records_by_fqdn[fqdn_lower], new_record): + records_by_fqdn[fqdn_lower] = new_record else: - records_by_fqdn[fqdn] = new_record + records_by_fqdn[fqdn_lower] = new_record except IOError: pass From f13b9b1c9fcdf892ec906ede0e40c3369b879d4e Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 12:13:55 -0800 Subject: [PATCH 08/15] Add domain config from dnsmasq.conf and system status notifications - Parse domain= lines from dnsmasq.conf for global and range-specific domains - Use IP-based domain lookup instead of hardcoded 'lan' fallback - Watch dnsmasq.conf for changes and trigger reconciliation - Add system status notifications via OPNsense SystemStatus API - Write status to /var/run/dnsmasq_watcher_status.json - Add PHP status class to display notifications in web UI - Log skipped records when IP not in any configured domain range - Status levels: OK, WARNING (skipped records), ERROR (config/service issues) --- .../System/Status/DnsmasqToUnboundStatus.php | 88 +++++++ .../scripts/unbound/dnsmasq_watcher.py | 227 ++++++++++++++++-- .../scripts/unbound/list_dnsmasq_records.py | 115 ++++++++- 3 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 dns/dnsmasq-to-unbound/src/opnsense/mvc/app/library/OPNsense/System/Status/DnsmasqToUnboundStatus.php diff --git a/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/library/OPNsense/System/Status/DnsmasqToUnboundStatus.php b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/library/OPNsense/System/Status/DnsmasqToUnboundStatus.php new file mode 100644 index 0000000000..f5bb5a535d --- /dev/null +++ b/dns/dnsmasq-to-unbound/src/opnsense/mvc/app/library/OPNsense/System/Status/DnsmasqToUnboundStatus.php @@ -0,0 +1,88 @@ +internalPriority = 5; + $this->internalPersistent = false; + $this->internalTitle = gettext('Dnsmasq to Unbound'); + $this->internalLocation = '/ui/dnsmasqtounbound/settings'; + } + + public function collectStatus() + { + if (!file_exists(self::STATUS_FILE)) { + // No status file means service is not running or disabled + return; + } + + $content = @file_get_contents(self::STATUS_FILE); + if ($content === false) { + return; + } + + $status = @json_decode($content, true); + if (!is_array($status) || !isset($status['level'])) { + return; + } + + // Map Python StatusLevel values to OPNsense SystemStatusCode + // Python: OK=2, NOTICE=1, WARNING=0, ERROR=-1 + // PHP: OK=2, NOTICE=1, WARNING=0, ERROR=-1 + switch ($status['level']) { + case -1: + $this->internalStatus = SystemStatusCode::ERROR; + break; + case 0: + $this->internalStatus = SystemStatusCode::WARNING; + break; + case 1: + $this->internalStatus = SystemStatusCode::NOTICE; + break; + default: + // OK or unknown - don't set status (no notification) + return; + } + + $this->internalMessage = $status['message'] ?? gettext('Check system log for details.'); + $this->internalTimestamp = $status['timestamp'] ?? time(); + } +} diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py index 2861c9dfc0..fd8d3a6c47 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py @@ -39,6 +39,7 @@ """ import argparse +import json import os import signal import select @@ -46,6 +47,7 @@ import syslog import time import sys +import ipaddress import xml.etree.ElementTree as ET sys.path.insert(0, "/usr/local/opnsense/site-python") @@ -53,6 +55,7 @@ LEASE_FILE = '/var/db/dnsmasq.leases' STATIC_HOSTS_FILE = '/var/etc/dnsmasq-hosts' +DNSMASQ_CONF = '/usr/local/etc/dnsmasq.conf' UNBOUND_CONTROL = '/usr/local/sbin/unbound-control' UNBOUND_CONF = '/var/unbound/unbound.conf' OPNSENSE_CONFIG = '/conf/config.xml' @@ -67,6 +70,16 @@ MANAGED_MARKER = 'managed-by=unbounddnsmasq' # Delay before verifying added records (seconds) VERIFICATION_DELAY = 5 +# Status file for UI notifications +STATUS_FILE = '/var/run/dnsmasq_watcher_status.json' + + +class StatusLevel: + """Status levels matching OPNsense SystemStatusCode.""" + OK = 2 + NOTICE = 1 + WARNING = 0 + ERROR = -1 class FailureReason: @@ -82,9 +95,11 @@ class FailureReason: class DnsmasqLeaseWatcher: - def __init__(self, lease_file=LEASE_FILE, static_hosts_file=STATIC_HOSTS_FILE): + def __init__(self, lease_file=LEASE_FILE, static_hosts_file=STATIC_HOSTS_FILE, + dnsmasq_conf=DNSMASQ_CONF): self.lease_file = lease_file self.static_hosts_file = static_hosts_file + self.dnsmasq_conf = dnsmasq_conf # registered_records: fqdn -> {'ip': str, 'source': str, 'expiry': int or None} self.registered_records = {} # pending_verification: fqdn -> (record, added_time) - records to verify after delay @@ -96,20 +111,56 @@ def __init__(self, lease_file=LEASE_FILE, static_hosts_file=STATIC_HOSTS_FILE): self.watch_leases = True self.watch_static = True self.domain_filter = set() # Empty = all domains + # Dnsmasq domain config (loaded from dnsmasq.conf) + self.global_domain = None # Global default domain + self.domain_ranges = [] # List of (start_ip, end_ip, domain) tuples # Failure tracking self.failed = False self.failure_reason = FailureReason.NONE self.consecutive_failures = 0 self.running = True + # Status tracking for UI notifications + self.status_level = StatusLevel.OK + self.status_message = None + self.skipped_records_notified = False # Only notify once per run def log(self, message, priority=syslog.LOG_INFO): syslog.syslog(priority, f"dnsmasq_watcher: {message}") + def set_status(self, level, message): + """Set current status and write to status file for UI consumption.""" + self.status_level = level + self.status_message = message + self.write_status_file() + + def write_status_file(self): + """Write current status to file for PHP status class to read.""" + status = { + 'level': self.status_level, + 'message': self.status_message, + 'timestamp': int(time.time()), + 'registered_count': len(self.registered_records) + } + try: + with open(STATUS_FILE, 'w') as f: + json.dump(status, f) + except IOError as e: + self.log(f"Failed to write status file: {e}", syslog.LOG_WARNING) + + def clear_status_file(self): + """Remove status file (service stopping or OK status).""" + try: + if os.path.exists(STATUS_FILE): + os.unlink(STATUS_FILE) + except IOError: + pass + def enter_failed_state(self, reason, message): """Enter failed/idle state. Log once, then wait for restart.""" self.failed = True self.failure_reason = reason self.log(f"FAILED: {message} - entering idle state (restart to retry)", syslog.LOG_ERR) + self.set_status(StatusLevel.ERROR, message) def preflight_checks(self): """ @@ -240,6 +291,82 @@ def load_config(self): self.log(f"Error loading config: {e}", syslog.LOG_ERR) return True # Non-critical, continue with defaults + def load_dnsmasq_config(self): + """ + Load domain configuration from dnsmasq.conf. + Parses 'domain=' lines to extract global domain and IP-range-specific domains. + Returns True if a domain is configured, False otherwise. + """ + self.global_domain = None + self.domain_ranges = [] + + if not os.path.exists(self.dnsmasq_conf): + msg = f"dnsmasq.conf not found at {self.dnsmasq_conf}" + self.log(msg, syslog.LOG_ERR) + self.set_status(StatusLevel.ERROR, msg) + return False + + try: + with open(self.dnsmasq_conf, 'r') as f: + for line in f: + line = line.strip() + if not line.startswith('domain='): + continue + + # Parse domain= line + # Format: domain= or domain=,, + value = line[7:] # Strip 'domain=' + parts = value.split(',') + + if len(parts) == 1: + # Global domain (first one wins if multiple) + if self.global_domain is None: + self.global_domain = parts[0].strip() + elif len(parts) >= 3: + # Range-specific domain + domain = parts[0].strip() + try: + start_ip = ipaddress.ip_address(parts[1].strip()) + end_ip = ipaddress.ip_address(parts[2].strip()) + self.domain_ranges.append((start_ip, end_ip, domain)) + except ValueError as e: + self.log(f"Invalid IP in domain range: {line} ({e})", syslog.LOG_WARNING) + + if self.global_domain or self.domain_ranges: + self.log(f"Dnsmasq config: global_domain={self.global_domain}, " + f"ranges={len(self.domain_ranges)}") + return True + else: + msg = "No domain configured in dnsmasq.conf - cannot register DHCP leases" + self.log(msg, syslog.LOG_ERR) + self.set_status(StatusLevel.ERROR, msg) + return False + + except IOError as e: + msg = f"Error reading dnsmasq.conf: {e}" + self.log(msg, syslog.LOG_ERR) + self.set_status(StatusLevel.ERROR, msg) + return False + + def get_domain_for_ip(self, ip_str): + """ + Get the domain for an IP address based on dnsmasq config. + Checks range-specific domains first, then falls back to global domain. + Returns None if no domain can be determined. + """ + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return None + + # Check range-specific domains first + for start_ip, end_ip, domain in self.domain_ranges: + if start_ip <= ip <= end_ip: + return domain + + # Fall back to global domain + return self.global_domain + def parse_lease_line(self, line): """ Parse a dnsmasq lease line. @@ -296,25 +423,39 @@ def parse_hosts_line(self, line): 'domain': domain } - def get_domains_to_register(self, source_domain=None): + def get_domains_to_register(self, source_domain=None, ip=None): """ Determine which domains to register a host under. - If domain_filter is set, only register under filtered domains. - If empty, register under all detected domains (or 'lan' as fallback). + + Args: + source_domain: Domain from the source record (e.g., from static host entry) + ip: IP address (used to look up domain from dnsmasq config if no source_domain) + + Returns: + List of domains to register under, or empty list if none can be determined. """ + # Determine the effective domain + if source_domain: + effective_domain = source_domain + elif ip: + effective_domain = self.get_domain_for_ip(ip) + else: + effective_domain = self.global_domain + + if not effective_domain: + # No domain can be determined - don't register + return [] + if self.domain_filter: - # Filter mode: only register if source domain matches filter - if source_domain and source_domain in self.domain_filter: - return [source_domain] - elif not source_domain: - # No source domain, register under all filtered domains - return list(self.domain_filter) + # Filter mode: only register if domain matches filter + if effective_domain in self.domain_filter: + return [effective_domain] else: - # Source domain doesn't match filter, skip + # Domain doesn't match filter, skip return [] else: - # No filter: register under source domain or 'lan' fallback - return [source_domain] if source_domain else ['lan'] + # No filter: register under the effective domain + return [effective_domain] def read_leases(self): """ @@ -336,8 +477,20 @@ def read_leases(self): # Skip expired leases (expiry of 0 means infinite) if lease['expiry'] != 0 and lease['expiry'] < current_time: continue - # Leases don't have domain suffix, use configured domains - for domain in self.get_domains_to_register(None): + # Leases don't have domain suffix, determine from IP + domains = self.get_domains_to_register(None, lease['ip']) + if not domains: + self.log(f"Skipping lease {lease['hostname']} ({lease['ip']}): " + "no domain configured for this IP range", syslog.LOG_WARNING) + if not self.skipped_records_notified: + self.skipped_records_notified = True + self.set_status( + StatusLevel.WARNING, + "Some records skipped - IP not in any configured domain range. " + "Check system log for details." + ) + continue + for domain in domains: fqdn = f"{lease['hostname']}.{domain}" fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { @@ -376,8 +529,20 @@ def read_static_hosts(self): for line in f: host = self.parse_hosts_line(line) if host: - # Register under appropriate domains - for domain in self.get_domains_to_register(host['domain']): + # Register under appropriate domains (use IP lookup if no explicit domain) + domains = self.get_domains_to_register(host['domain'], host['ip']) + if not domains: + self.log(f"Skipping static host {host['hostname']} ({host['ip']}): " + "no domain configured for this IP range", syslog.LOG_WARNING) + if not self.skipped_records_notified: + self.skipped_records_notified = True + self.set_status( + StatusLevel.WARNING, + "Some records skipped - IP not in any configured domain range. " + "Check system log for details." + ) + continue + for domain in domains: fqdn = f"{host['hostname']}.{domain}" fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { @@ -735,11 +900,11 @@ def reconcile(self): self.log("Reconciliation complete: no changes needed") def setup_kqueue(self): - """Set up kqueue watchers for lease and static hosts files.""" + """Set up kqueue watchers for lease, static hosts, and dnsmasq config files.""" self.kq = select.kqueue() self.watched_fds = {} - for filepath in [self.lease_file, self.static_hosts_file]: + for filepath in [self.lease_file, self.static_hosts_file, self.dnsmasq_conf]: self._watch_file(filepath) def _watch_file(self, filepath): @@ -806,10 +971,16 @@ def run(self): self.idle_loop() return + # Load dnsmasq domain configuration + if not self.load_dnsmasq_config(): + self.failed = True + self.idle_loop() + return + # Check if service is disabled if not self.enabled: - self.enter_failed_state(FailureReason.DISABLED, "Service disabled in configuration") - self.idle_loop() + self.log("Service disabled in configuration") + self.clear_status_file() return # Run pre-flight checks @@ -848,6 +1019,10 @@ def run(self): self.log("Entering main watch loop") + # Set OK status if no warnings/errors were set during init + if self.status_level == StatusLevel.OK: + self.set_status(StatusLevel.OK, None) + # Main watch loop while self.running and not self.failed: try: @@ -867,7 +1042,14 @@ def run(self): if files_changed: self.log(f"Files changed: {files_changed}") - self.sync_records() + # If dnsmasq.conf changed, reload domain config and do full reconcile + if self.dnsmasq_conf in files_changed: + self.log("Dnsmasq config changed, reloading domain configuration") + self.load_dnsmasq_config() + self.reconcile() + last_reconcile = time.time() + else: + self.sync_records() # Periodically check for files that may not exist yet for filepath in [self.lease_file, self.static_hosts_file]: @@ -903,6 +1085,7 @@ def run(self): self.idle_loop() self.log("Shutting down") + self.clear_status_file() def main(): diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py index de52c10760..45e139a5c3 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py @@ -33,6 +33,7 @@ import argparse import hashlib +import ipaddress import json import os import time @@ -135,17 +136,106 @@ def get_dhcp_host_macs(): return mac_by_ip -def get_domains_to_register(domain_filter, source_domain=None): - """Determine which domains to register a host under.""" +def get_dnsmasq_domain_config(): + """ + Load domain configuration from dnsmasq.conf. + Returns (global_domain, domain_ranges) where domain_ranges is a list of + (start_ip, end_ip, domain) tuples. + """ + global_domain = None + domain_ranges = [] + + if not os.path.exists(DNSMASQ_CONF): + return global_domain, domain_ranges + + try: + with open(DNSMASQ_CONF, 'r') as f: + for line in f: + line = line.strip() + if not line.startswith('domain='): + continue + + # Parse domain= line + # Format: domain= or domain=,, + value = line[7:] # Strip 'domain=' + parts = value.split(',') + + if len(parts) == 1: + # Global domain (first one wins if multiple) + if global_domain is None: + global_domain = parts[0].strip() + elif len(parts) >= 3: + # Range-specific domain + domain = parts[0].strip() + try: + start_ip = ipaddress.ip_address(parts[1].strip()) + end_ip = ipaddress.ip_address(parts[2].strip()) + domain_ranges.append((start_ip, end_ip, domain)) + except ValueError: + pass # Invalid IP, skip + except IOError: + pass + + return global_domain, domain_ranges + + +def get_domain_for_ip(ip_str, global_domain, domain_ranges): + """ + Get the domain for an IP address based on dnsmasq config. + Checks range-specific domains first, then falls back to global domain. + Returns None if no domain can be determined. + """ + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return None + + # Check range-specific domains first + for start_ip, end_ip, domain in domain_ranges: + if start_ip <= ip <= end_ip: + return domain + + # Fall back to global domain + return global_domain + + +def get_domains_to_register(domain_filter, source_domain=None, ip=None, + global_domain=None, domain_ranges=None): + """ + Determine which domains to register a host under. + + Args: + domain_filter: List of allowed domains from plugin config (empty = all) + source_domain: Domain from the source record (e.g., from static host entry) + ip: IP address (used to look up domain from dnsmasq config if no source_domain) + global_domain: Global domain from dnsmasq.conf + domain_ranges: List of (start_ip, end_ip, domain) tuples from dnsmasq.conf + + Returns: + List of domains to register under, or empty list if none can be determined. + """ + # Determine the effective domain + if source_domain: + effective_domain = source_domain + elif ip and (global_domain or domain_ranges): + effective_domain = get_domain_for_ip(ip, global_domain, domain_ranges or []) + else: + effective_domain = global_domain + + if not effective_domain: + # No domain can be determined - don't register + return [] + if domain_filter: - if source_domain and source_domain in domain_filter: - return [source_domain] - elif not source_domain: - return domain_filter + # Filter mode: only register if domain matches filter + if effective_domain in domain_filter: + return [effective_domain] else: + # Domain doesn't match filter, skip return [] else: - return [source_domain] if source_domain else ['lan'] + # No filter: register under the effective domain + return [effective_domain] def should_replace(existing, new): @@ -189,6 +279,9 @@ def get_records(): # Get MAC addresses from dhcp-host entries mac_by_ip = get_dhcp_host_macs() + # Get domain configuration from dnsmasq.conf + global_domain, domain_ranges = get_dnsmasq_domain_config() + # Read static hosts first (they have priority by default) if config['watchstatic'] and os.path.exists(STATIC_HOSTS_FILE): try: @@ -196,7 +289,9 @@ def get_records(): for line in f: host = parse_hosts_line(line) if host: - for domain in get_domains_to_register(domain_filter, host['domain']): + for domain in get_domains_to_register( + domain_filter, host['domain'], host['ip'], + global_domain, domain_ranges): fqdn = f"{host['hostname']}.{domain}" fqdn_lower = fqdn.lower() # DNS is case-insensitive mac = mac_by_ip.get(host['ip'], '-') @@ -224,7 +319,9 @@ def get_records(): if lease: if lease['expiry'] != 0 and lease['expiry'] < current_time: continue - for domain in get_domains_to_register(domain_filter, None): + for domain in get_domains_to_register( + domain_filter, None, lease['ip'], + global_domain, domain_ranges): fqdn = f"{lease['hostname']}.{domain}" fqdn_lower = fqdn.lower() # DNS is case-insensitive new_record = { From 65126eb179297580d3ba68c62ae81b5a0e869a3b Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 12:20:23 -0800 Subject: [PATCH 09/15] Fix plugin submission compliance issues - Update copyright year to 2025 in rc.d script - Add README.md with installation and usage documentation --- dns/dnsmasq-to-unbound/README.md | 105 ++++++++++++++++++ .../src/etc/rc.d/dnsmasq_watcher | 2 +- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 dns/dnsmasq-to-unbound/README.md diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md new file mode 100644 index 0000000000..e4c2010809 --- /dev/null +++ b/dns/dnsmasq-to-unbound/README.md @@ -0,0 +1,105 @@ +# Dnsmasq to Unbound DNS Registration + +This OPNsense plugin automatically registers dnsmasq DHCP leases and static host entries in Unbound DNS, enabling local hostname resolution for DHCP clients. + +## Features + +- Watches dnsmasq lease file and static hosts for changes +- Registers A and PTR records in Unbound DNS +- Supports multiple domains via dnsmasq's IP-range-to-domain mapping +- Deduplicates records (static entries take precedence over leases) +- Automatic cleanup of stale records +- System status notifications in OPNsense web UI +- Periodic reconciliation to handle Unbound restarts + +## Requirements + +- OPNsense with Unbound DNS resolver enabled +- **Unbound Remote Control must be enabled**: Services > Unbound DNS > General > Enable Remote Control +- dnsmasq plugin installed and configured with DHCP + +## Installation + +Install via the OPNsense plugin system or manually: + +``` +pkg install os-dnsmasq-to-unbound +``` + +## Configuration + +Navigate to **Services > Dnsmasq to Unbound** in the OPNsense web UI. + +### Settings + +| Option | Description | +|--------|-------------| +| Enable | Enable/disable the DNS registration service | +| Watch Leases | Register DNS entries for DHCP leases | +| Watch Static | Register DNS entries for static host mappings | +| Domain Filter | Limit registration to specific domains (comma-separated) | + +### Domain Configuration + +The plugin reads domain configuration from dnsmasq's configuration: + +- **Global domain**: `domain=lan` in dnsmasq.conf +- **Range-specific domains**: `domain=guest,192.168.20.1,192.168.20.254` + +If no domain is configured in dnsmasq, DHCP leases cannot be registered (static hosts with explicit domains will still work). + +## How It Works + +1. The daemon watches `/var/db/dnsmasq.leases` and `/var/etc/dnsmasq-hosts` for changes +2. When changes are detected, it parses the files and compares with current Unbound state +3. New records are added, changed records are updated, and stale records are removed +4. Records are marked with a TXT record (`managed-by=unbounddnsmasq`) for identification +5. Every 5 minutes, a full reconciliation runs to catch any missed changes + +## Troubleshooting + +### Service Status + +Check service status via CLI: +``` +configctl dnsmasqtounbound status +``` + +View registered records: +``` +configctl dnsmasqtounbound listrecords +``` + +### System Logs + +Check system logs for errors: +``` +grep dnsmasq_watcher /var/log/system/latest.log +``` + +### Common Issues + +**"Unbound remote control not enabled"** +- Enable Remote Control in Services > Unbound DNS > General + +**"No domain configured in dnsmasq.conf"** +- Add `domain=lan` (or your domain) to dnsmasq configuration + +**Records not appearing** +- Verify the service is running +- Check that Unbound is running and controllable +- Ensure domains match the domain filter (if configured) + +### Status Notifications + +The plugin reports status via OPNsense's system status indicator: + +| Status | Meaning | +|--------|---------| +| OK (green) | Service running normally | +| Warning (yellow) | Some records skipped (check logs) | +| Error (red) | Service failed (check logs for details) | + +## License + +BSD 2-Clause License. See source files for full license text. diff --git a/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher b/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher index f3bcbf82e9..1d43bd9b57 100755 --- a/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher +++ b/dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher @@ -1,6 +1,6 @@ #!/bin/sh -# Copyright (c) 2024 C. Hall (chall37@users.noreply.github.com) +# Copyright (c) 2025 C. Hall (chall37@users.noreply.github.com) # All rights reserved. # # Redistribution and use in source and binary forms, with or without From fc8785d2d3a3b757f63265eb9f3bab12e7018a00 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 12:49:48 -0800 Subject: [PATCH 10/15] Rename TXT marker to managed-by=dnsmasq-to-unbound --- dns/dnsmasq-to-unbound/README.md | 2 +- .../src/opnsense/scripts/unbound/dnsmasq_watcher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md index e4c2010809..cd7b838aef 100644 --- a/dns/dnsmasq-to-unbound/README.md +++ b/dns/dnsmasq-to-unbound/README.md @@ -53,7 +53,7 @@ If no domain is configured in dnsmasq, DHCP leases cannot be registered (static 1. The daemon watches `/var/db/dnsmasq.leases` and `/var/etc/dnsmasq-hosts` for changes 2. When changes are detected, it parses the files and compares with current Unbound state 3. New records are added, changed records are updated, and stale records are removed -4. Records are marked with a TXT record (`managed-by=unbounddnsmasq`) for identification +4. Records are marked with a TXT record (`managed-by=dnsmasq-to-unbound`) for identification 5. Every 5 minutes, a full reconciliation runs to catch any missed changes ## Troubleshooting diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py index fd8d3a6c47..e73dcf7409 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py @@ -67,7 +67,7 @@ # How often to run full reconciliation (seconds) RECONCILE_INTERVAL = 300 # 5 minutes # Marker to identify our managed records -MANAGED_MARKER = 'managed-by=unbounddnsmasq' +MANAGED_MARKER = 'managed-by=dnsmasq-to-unbound' # Delay before verifying added records (seconds) VERIFICATION_DELAY = 5 # Status file for UI notifications From b8ee2b3a2ffbcea39df7d72b65faffdeec5f2784 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 12:58:10 -0800 Subject: [PATCH 11/15] dns/dnsmasq-to-unbound: fix README - remote control enabled by default Unbound remote control is always enabled by default in OPNsense, there is no UI setting to enable it. Updated README to reflect this. --- dns/dnsmasq-to-unbound/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md index cd7b838aef..85480fc972 100644 --- a/dns/dnsmasq-to-unbound/README.md +++ b/dns/dnsmasq-to-unbound/README.md @@ -14,8 +14,7 @@ This OPNsense plugin automatically registers dnsmasq DHCP leases and static host ## Requirements -- OPNsense with Unbound DNS resolver enabled -- **Unbound Remote Control must be enabled**: Services > Unbound DNS > General > Enable Remote Control +- OPNsense with Unbound DNS resolver enabled (remote control is enabled by default) - dnsmasq plugin installed and configured with DHCP ## Installation @@ -80,7 +79,8 @@ grep dnsmasq_watcher /var/log/system/latest.log ### Common Issues **"Unbound remote control not enabled"** -- Enable Remote Control in Services > Unbound DNS > General +- This should not normally occur as OPNsense enables remote control by default +- Check that Unbound is running and restart if necessary **"No domain configured in dnsmasq.conf"** - Add `domain=lan` (or your domain) to dnsmasq configuration From a50798bf686008c44bc4428b5a466b0da0e41bc3 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 13:01:57 -0800 Subject: [PATCH 12/15] dns/dnsmasq-to-unbound: note stopgap nature of plugin --- dns/dnsmasq-to-unbound/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md index 85480fc972..3b968fbfbc 100644 --- a/dns/dnsmasq-to-unbound/README.md +++ b/dns/dnsmasq-to-unbound/README.md @@ -2,6 +2,8 @@ This OPNsense plugin automatically registers dnsmasq DHCP leases and static host entries in Unbound DNS, enabling local hostname resolution for DHCP clients. +> **Note:** This plugin is intended as a stopgap solution until native integration between Unbound and a supported DHCP service is implemented in OPNsense core. + ## Features - Watches dnsmasq lease file and static hosts for changes From 9145c56130543800a97e988261f512eb9ce19d16 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 13:07:32 -0800 Subject: [PATCH 13/15] dns/dnsmasq-to-unbound: fix flake8 linting issues --- .../scripts/unbound/dnsmasq_watcher.py | 22 ++++++++++++++----- .../scripts/unbound/list_dnsmasq_records.py | 4 +++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py index e73dcf7409..6783220d0d 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py @@ -51,7 +51,7 @@ import xml.etree.ElementTree as ET sys.path.insert(0, "/usr/local/opnsense/site-python") -from daemonize import Daemonize +from daemonize import Daemonize # noqa: E402 LEASE_FILE = '/var/db/dnsmasq.leases' STATIC_HOSTS_FILE = '/var/etc/dnsmasq-hosts' @@ -632,7 +632,10 @@ def unbound_control(self, *args): "Unbound remote control not enabled or Unbound stopped" ) else: - self.log(f"unbound-control failed ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {stderr}", syslog.LOG_WARNING) + self.log( + f"unbound-control failed ({self.consecutive_failures}/" + f"{MAX_CONSECUTIVE_FAILURES}): {stderr}", syslog.LOG_WARNING + ) else: self.log(f"unbound-control error: {stderr}", syslog.LOG_WARNING) return False @@ -647,7 +650,10 @@ def unbound_control(self, *args): "Unbound repeatedly timing out" ) else: - self.log(f"unbound-control timeout ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES})", syslog.LOG_WARNING) + self.log( + f"unbound-control timeout ({self.consecutive_failures}/" + f"{MAX_CONSECUTIVE_FAILURES})", syslog.LOG_WARNING + ) return False except Exception as e: self.consecutive_failures += 1 @@ -657,7 +663,10 @@ def unbound_control(self, *args): f"Repeated unbound-control failures: {e}" ) else: - self.log(f"unbound-control exception ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR) + self.log( + f"unbound-control exception ({self.consecutive_failures}/" + f"{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR + ) return False def add_dns_record(self, fqdn_key, record): @@ -1077,7 +1086,10 @@ def run(self): ) break else: - self.log(f"Error in watch loop ({self.consecutive_failures}/{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR) + self.log( + f"Error in watch loop ({self.consecutive_failures}/" + f"{MAX_CONSECUTIVE_FAILURES}): {e}", syslog.LOG_ERR + ) time.sleep(FAILURE_RETRY_DELAY) # If we exited due to failure, enter idle loop diff --git a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py index 45e139a5c3..f78be9ed8c 100755 --- a/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py +++ b/dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py @@ -330,7 +330,9 @@ def get_records(): 'ip': lease['ip'], 'type': 'lease', 'mac': lease['mac'], - 'expiry': 'infinite' if lease['expiry'] == 0 else time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(lease['expiry'])), + 'expiry': 'infinite' if lease['expiry'] == 0 else time.strftime( + '%Y-%m-%d %H:%M:%S', time.localtime(lease['expiry']) + ), 'expiry_ts': lease['expiry'] # For comparison } # Handle duplicates with conflict resolution From 171a77e397db06ff45de76721fc7f9ba4b2afec0 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 13:20:54 -0800 Subject: [PATCH 14/15] dns/dnsmasq-to-unbound: add background section on DHCP/DNS limitations Document the shortcomings of each OPNsense DHCP option for Unbound DNS integration: - ISC DHCP: deprecated, watcher daemon crashes on bad hostnames - Kea DHCP: no dynamic lease registration, static requires restart - dnsmasq: built-in DNS only, forwarding has domain issues References: opnsense/core#7475, #8075, #8612, #9277 --- dns/dnsmasq-to-unbound/README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md index 3b968fbfbc..4804b09b01 100644 --- a/dns/dnsmasq-to-unbound/README.md +++ b/dns/dnsmasq-to-unbound/README.md @@ -4,6 +4,36 @@ This OPNsense plugin automatically registers dnsmasq DHCP leases and static host > **Note:** This plugin is intended as a stopgap solution until native integration between Unbound and a supported DHCP service is implemented in OPNsense core. +## Background + +OPNsense offers three DHCP server options, each with limitations for Unbound DNS integration: + +| DHCP Server | Dynamic Lease DNS | Static Reservation DNS | Status | +|-------------|-------------------|------------------------|--------| +| ISC DHCP | Buggy | Yes | End-of-life, deprecated | +| Kea DHCP | **No** | Yes (requires Unbound restart) | Active, DNS integration deprioritized | +| dnsmasq | Built-in DNS only | Built-in DNS only | Active | + +### ISC DHCP (Deprecated) + +ISC DHCP had Unbound integration via `unbound_watcher.py`, but it suffers from reliability issues where the [watcher daemon silently crashes](https://github.com/opnsense/core/issues/8075) when encountering malformed hostnames, stopping all subsequent DNS registration until Unbound is restarted. ISC DHCP reached end-of-life in 2022 and is being phased out of OPNsense. + +### Kea DHCP + +Kea is ISC's strategic replacement but currently only supports static reservation DNS registration in Unbound - dynamic leases are [not registered](https://github.com/opnsense/core/issues/7475). Static reservations also require an Unbound restart to take effect. This limitation is acknowledged but deprioritized by the OPNsense team due to architectural complexity concerns. + +### dnsmasq + +dnsmasq includes its own DNS server with automatic lease registration, but many users prefer Unbound for its DNSSEC validation, DNS-over-TLS support, and advanced caching. When using Unbound as the primary resolver: + +- dnsmasq's internal DNS registrations are not accessible to Unbound +- Query forwarding from Unbound to dnsmasq is possible but [has issues](https://github.com/opnsense/core/issues/8612) where static reservations don't inherit the system domain +- Domain overrides [may not apply consistently](https://github.com/opnsense/core/issues/9277) to static mappings vs dynamic leases + +### This Plugin + +This plugin bridges the gap by directly registering dnsmasq DHCP data into Unbound via `unbound-control`, providing the simplicity of dnsmasq DHCP with the features of Unbound DNS. It avoids the reliability issues of the ISC DHCP watcher by using a more robust file-watching mechanism and graceful error handling. + ## Features - Watches dnsmasq lease file and static hosts for changes From 8a1ab569c30f9e761c20a185bf55f422a4eb4f32 Mon Sep 17 00:00:00 2001 From: Courtney Hall Date: Mon, 8 Dec 2025 13:32:40 -0800 Subject: [PATCH 15/15] dns/dnsmasq-to-unbound: expand on query forwarding limitations Detail why Unbound-to-dnsmasq query forwarding is problematic: - Brittle config sync or performance penalty (no fallback behavior) - Requires private-domain and domain-insecure exemptions - Known bugs with static reservations and domain overrides --- dns/dnsmasq-to-unbound/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dns/dnsmasq-to-unbound/README.md b/dns/dnsmasq-to-unbound/README.md index 4804b09b01..70cd716242 100644 --- a/dns/dnsmasq-to-unbound/README.md +++ b/dns/dnsmasq-to-unbound/README.md @@ -24,11 +24,14 @@ Kea is ISC's strategic replacement but currently only supports static reservatio ### dnsmasq -dnsmasq includes its own DNS server with automatic lease registration, but many users prefer Unbound for its DNSSEC validation, DNS-over-TLS support, and advanced caching. When using Unbound as the primary resolver: +dnsmasq includes its own DNS server with automatic lease registration, but many users prefer Unbound for its DNSSEC validation, DNS-over-TLS support, and advanced caching. When using Unbound as the primary resolver, dnsmasq's internal DNS registrations are not directly accessible. -- dnsmasq's internal DNS registrations are not accessible to Unbound -- Query forwarding from Unbound to dnsmasq is possible but [has issues](https://github.com/opnsense/core/issues/8612) where static reservations don't inherit the system domain -- Domain overrides [may not apply consistently](https://github.com/opnsense/core/issues/9277) to static mappings vs dynamic leases +**Query forwarding** from Unbound to dnsmasq is possible but problematic: + +- Forwarding is either brittle or incurs a performance penalty: Unbound either needs explicit knowledge of every domain served by dnsmasq (requiring configuration to stay in sync), or all queries must be routed through dnsmasq first, adding latency to every DNS lookup and negating Unbound's direct recursive resolution capabilities. +- Static reservations [don't inherit the system domain](https://github.com/opnsense/core/issues/8612) - each must have the domain manually specified or queries fail. +- Domain overrides [may not apply consistently](https://github.com/opnsense/core/issues/9277) to static mappings vs dynamic leases. +- Requires additional configuration for `private-domain` (rebind protection exemption) and `domain-insecure` (DNSSEC exemption) for each local domain. ### This Plugin