mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
Merge 8a1ab569c3 into cb9a5d6d69
This commit is contained in:
commit
9f7a6f44ee
21 changed files with 2506 additions and 0 deletions
3
dns/dnsmasq-to-unbound/.gitignore
vendored
Normal file
3
dns/dnsmasq-to-unbound/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
work/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
8
dns/dnsmasq-to-unbound/Makefile
Normal file
8
dns/dnsmasq-to-unbound/Makefile
Normal 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"
|
||||
140
dns/dnsmasq-to-unbound/README.md
Normal file
140
dns/dnsmasq-to-unbound/README.md
Normal 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.
|
||||
8
dns/dnsmasq-to-unbound/pkg-descr
Normal file
8
dns/dnsmasq-to-unbound/pkg-descr
Normal 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
83
dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher
Executable file
83
dns/dnsmasq-to-unbound/src/etc/rc.d/dnsmasq_watcher
Executable 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
{
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<menu>
|
||||
<Services>
|
||||
<DnsmasqToUnbound VisibleName="Dnsmasq to Unbound" cssClass="fa fa-exchange fa-fw" url="/ui/DnsmasqToUnbound"/>
|
||||
</Services>
|
||||
</menu>
|
||||
|
|
@ -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>
|
||||
1133
dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py
Executable file
1133
dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/dnsmasq_watcher.py
Executable file
File diff suppressed because it is too large
Load diff
383
dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py
Executable file
383
dns/dnsmasq-to-unbound/src/opnsense/scripts/unbound/list_dnsmasq_records.py
Executable 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
dnsmasq_watcher:/etc/rc.conf.d/dnsmasq_watcher
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in a new issue