This commit is contained in:
Courtney Hall 2026-05-23 17:25:12 +02:00 committed by GitHub
commit 9f7a6f44ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2506 additions and 0 deletions

3
dns/dnsmasq-to-unbound/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
work/
__pycache__/
*.pyc

View file

@ -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"

View file

@ -0,0 +1,140 @@
# 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.
> **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 directly accessible.
**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
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
- 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 (remote control is enabled by default)
- 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=dnsmasq-to-unbound`) 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"**
- 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
**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.

View file

@ -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

View file

@ -0,0 +1,90 @@
<?php
/**
* Copyright (C) 2025 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.
*
*/
/**
* Check if the dnsmasq to unbound service is enabled
* @return bool
*/
function dnsmasqtounbound_enabled()
{
$model = new \OPNsense\DnsmasqToUnbound\DnsmasqToUnbound();
return (string)$model->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;
}

View file

@ -0,0 +1,83 @@
#!/bin/sh
# Copyright (c) 2025 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

View file

@ -0,0 +1,134 @@
<?php
/**
* Copyright (C) 2025 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.
*
*/
namespace OPNsense\DnsmasqToUnbound\Api;
use OPNsense\Base\ApiMutableServiceControllerBase;
use OPNsense\Core\Backend;
/**
* Class ServiceController
* @package OPNsense\DnsmasqToUnbound\Api
*/
class ServiceController extends ApiMutableServiceControllerBase
{
protected static $internalServiceClass = '\OPNsense\DnsmasqToUnbound\DnsmasqToUnbound';
protected static $internalServiceEnabled = 'enabled';
protected static $internalServiceTemplate = 'OPNsense/DnsmasqToUnbound';
protected static $internalServiceName = 'dnsmasqtounbound';
/**
* Search/list current DNS records registered from dnsmasq
* @return array
*/
public function searchrecordsAction()
{
$backend = new Backend();
$response = $backend->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;
}
}

View file

@ -0,0 +1,43 @@
<?php
/**
* Copyright (C) 2025 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.
*
*/
namespace OPNsense\DnsmasqToUnbound\Api;
use OPNsense\Base\ApiMutableModelControllerBase;
/**
* Class SettingsController
* @package OPNsense\DnsmasqToUnbound\Api
*/
class SettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelClass = '\OPNsense\DnsmasqToUnbound\DnsmasqToUnbound';
protected static $internalModelName = 'dnsmasqtounbound';
}

View file

@ -0,0 +1,48 @@
<?php
/**
* Copyright (C) 2025 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.
*
*/
namespace OPNsense\DnsmasqToUnbound;
/**
* Class IndexController
* @package OPNsense\DnsmasqToUnbound
*/
class IndexController extends \OPNsense\Base\IndexController
{
/**
* Default index action - render settings form
* @return void
*/
public function indexAction()
{
$this->view->settings = $this->getForm("settings");
$this->view->pick('OPNsense/DnsmasqToUnbound/index');
}
}

View file

@ -0,0 +1,28 @@
<form>
<field>
<id>dnsmasqtounbound.enabled</id>
<label>Enable</label>
<type>checkbox</type>
<help>Enable the dnsmasq lease watcher service.</help>
</field>
<field>
<id>dnsmasqtounbound.watchleases</id>
<label>Watch Dynamic Leases</label>
<type>checkbox</type>
<help>Register DHCP leases from dnsmasq in Unbound DNS.</help>
</field>
<field>
<id>dnsmasqtounbound.watchstatic</id>
<label>Watch Static Hosts</label>
<type>checkbox</type>
<help>Register static host reservations from dnsmasq in Unbound DNS.</help>
</field>
<field>
<id>dnsmasqtounbound.domains</id>
<label>Domain Filter</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>Leave empty to register all domains. Specifying domains will exclude hosts from unlisted domains.</help>
</field>
</form>

View file

@ -0,0 +1,88 @@
<?php
/*
* Copyright (C) 2025 C. Hall
* 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.
*/
namespace OPNsense\System\Status;
use OPNsense\System\AbstractStatus;
use OPNsense\System\SystemStatusCode;
/**
* Status provider for Dnsmasq to Unbound DNS registration service.
* Reads status from JSON file written by the Python watcher daemon.
*/
class DnsmasqToUnboundStatus extends AbstractStatus
{
private const STATUS_FILE = '/var/run/dnsmasq_watcher_status.json';
public function __construct()
{
$this->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();
}
}

View file

@ -0,0 +1,9 @@
<acl>
<page-services-dnsmasqtounbound>
<name>Services: Dnsmasq to Unbound</name>
<patterns>
<pattern>ui/dnsmasqtounbound/*</pattern>
<pattern>api/dnsmasqtounbound/*</pattern>
</patterns>
</page-services-dnsmasqtounbound>
</acl>

View file

@ -0,0 +1,41 @@
<?php
/**
* Copyright (C) 2025 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.
*
*/
namespace OPNsense\DnsmasqToUnbound;
use OPNsense\Base\BaseModel;
/**
* Class DnsmasqToUnbound
* @package OPNsense\DnsmasqToUnbound
*/
class DnsmasqToUnbound extends BaseModel
{
}

View file

@ -0,0 +1,25 @@
<model>
<mount>//OPNsense/DnsmasqToUnbound</mount>
<version>1.0.0</version>
<description>Unbound DNS registration for dnsmasq DHCP leases</description>
<items>
<enabled type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</enabled>
<watchleases type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</watchleases>
<watchstatic type="BooleanField">
<Default>1</Default>
<Required>Y</Required>
</watchstatic>
<domains type="CSVListField">
<Required>N</Required>
<FieldSeparator>,</FieldSeparator>
<Mask>/^[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])?)*$/</Mask>
<ValidationMessage>Invalid domain format</ValidationMessage>
</domains>
</items>
</model>

View file

@ -0,0 +1,5 @@
<menu>
<Services>
<DnsmasqToUnbound VisibleName="Dnsmasq to Unbound" cssClass="fa fa-exchange fa-fw" url="/ui/DnsmasqToUnbound"/>
</Services>
</menu>

View file

@ -0,0 +1,197 @@
{#
Copyright (c) 2025 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.
#}
<script>
$(document).ready(function() {
// Track last records hash for change detection
let lastRecordsHash = null;
// Load settings form
mapDataToFormUI({'frm_Settings': "/api/dnsmasqtounbound/settings/get"}).done(function() {
formatTokenizersUI();
$('.selectpicker').selectpicker('refresh');
updateServiceControlUI('dnsmasqtounbound');
});
// Save button - just save settings
$("#saveAct").click(function() {
saveFormToEndpoint("/api/dnsmasqtounbound/settings/set", 'frm_Settings', function(data, status) {
if (status === "success" && data.status === 'ok') {
$("#settingsChangeMessage").show();
}
}, true);
});
// Apply button - reconfigure service
$("#applyAct").SimpleActionButton({
onAction: function(data, status) {
if (status === "success" && data.status === 'ok') {
$("#settingsChangeMessage").hide();
updateServiceControlUI('dnsmasqtounbound');
// Refresh records table after reconfigure
setTimeout(function() {
lastRecordsHash = null; // Force refresh
refreshRecordsIfChanged();
}, 1000);
}
}
});
// Track if grid is initialized
let gridInitialized = false;
// Initialize records table with UIBootgrid
function initRecordsGrid() {
if (gridInitialized) return;
$("#grid-records").UIBootgrid({
search: '/api/dnsmasqtounbound/service/searchrecords',
options: {
selection: false,
multiSelect: false,
formatters: {
"typeFormatter": function(column, row) {
if (row.type === 'static') {
return '<span class="label label-info">static</span>';
} else {
return '<span class="label label-success">lease</span>';
}
}
}
}
});
gridInitialized = true;
}
// Reload records table
function loadRecords() {
if (gridInitialized) {
$("#grid-records").bootgrid("reload");
} else {
initRecordsGrid();
}
}
// Check hash and refresh only if changed
function refreshRecordsIfChanged() {
$.ajax({
url: '/api/dnsmasqtounbound/service/recordshash',
method: 'POST',
dataType: 'json',
success: function(data) {
const newHash = data.hash || '';
if (lastRecordsHash === null || newHash !== lastRecordsHash) {
// Data changed or first load - reload table
lastRecordsHash = newHash;
loadRecords();
}
// If hash unchanged, do nothing
}
});
}
// Auto-refresh records table every 5 seconds when tab is active (only if data changed)
let refreshInterval = null;
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
if ($(e.target).attr('href') === '#records') {
// Initial load via polling function
refreshRecordsIfChanged();
// Start polling for changes
refreshInterval = setInterval(function() {
refreshRecordsIfChanged();
}, 5000);
} else {
// Stop auto-refresh when leaving records tab
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
});
updateServiceControlUI('dnsmasqtounbound');
});
</script>
<ul class="nav nav-tabs" role="tablist" id="maintabs">
<li class="active"><a data-toggle="tab" href="#settings"><b>{{ lang._('Settings') }}</b></a></li>
<li><a data-toggle="tab" href="#records">{{ lang._('Registered Records') }}</a></li>
</ul>
<div class="content-box tab-content">
<!-- Settings Tab -->
<div id="settings" class="tab-pane fade in active">
<div class="content-box">
{{ partial("layout_partials/base_form", ['fields': settings, 'id': 'frm_Settings']) }}
</div>
</div>
<!-- Records Tab -->
<div id="records" class="tab-pane fade">
<div class="content-box" style="padding-bottom: 1.5em;">
<div class="col-sm-12">
<table id="grid-records" class="table table-condensed table-hover table-striped table-responsive">
<thead>
<tr>
<th data-column-id="fqdn" data-type="string" data-order="asc">{{ lang._('FQDN') }}</th>
<th data-column-id="ip" data-type="string">{{ lang._('IP Address') }}</th>
<th data-column-id="type" data-type="string" data-formatter="typeFormatter" data-width="8em">{{ lang._('Source') }}</th>
<th data-column-id="mac" data-type="string" data-width="12em">{{ lang._('MAC') }}</th>
<th data-column-id="expiry" data-type="string" data-width="12em">{{ lang._('Expiry') }}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="col-md-12" style="padding-top: 10px;">
<em>{{ lang._('Table updates automatically when records change (polling every 5 seconds).') }}</em>
</div>
</div>
</div>
</div>
</div>
<section class="page-content-main">
<div class="content-box">
<div class="col-md-12">
<br/>
<div id="settingsChangeMessage" class="alert alert-info" style="display: none" role="alert">
{{ lang._('After changing settings, please remember to apply them.') }}
</div>
<button class="btn btn-primary" id="saveAct" type="button">
<b>{{ lang._('Save') }}</b> <i id="saveAct_progress" class=""></i>
</button>
<button class="btn btn-primary" id="applyAct"
data-endpoint="/api/dnsmasqtounbound/service/reconfigure"
data-label="{{ lang._('Apply') }}"
data-service-widget="dnsmasqtounbound"
type="button">
{{ lang._('Apply') }}
</button>
<br/><br/>
</div>
</div>
</section>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,383 @@
#!/usr/local/bin/python3
"""
Copyright (c) 2025 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 ipaddress
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_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=<domain> or domain=<domain>,<start_ip>,<end_ip>
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:
# 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:
# No filter: register under the effective domain
return [effective_domain]
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 (case-insensitive)
current_time = int(time.time())
# 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:
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'], 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'], '-')
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_lower not in records_by_fqdn:
records_by_fqdn[fqdn_lower] = 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, lease['ip'],
global_domain, domain_ranges):
fqdn = f"{lease['hostname']}.{domain}"
fqdn_lower = fqdn.lower() # DNS is case-insensitive
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_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_lower] = 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()

View file

@ -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

View file

@ -0,0 +1 @@
dnsmasq_watcher:/etc/rc.conf.d/dnsmasq_watcher

View file

@ -0,0 +1,5 @@
{% if helpers.exists('OPNsense.DnsmasqToUnbound.enabled') and OPNsense.DnsmasqToUnbound.enabled == '1' %}
dnsmasq_watcher_enable="YES"
{% else %}
dnsmasq_watcher_enable="NO"
{% endif %}