diff --git a/plist b/plist
index 15cdc7011d0..4f5b632cb41 100644
--- a/plist
+++ b/plist
@@ -399,6 +399,7 @@
/usr/local/opnsense/mvc/app/controllers/OPNsense/Interfaces/forms/dialogVlan.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Interfaces/forms/dialogVxlan.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/CtrlAgentController.php
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/DhcpddnsController.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv4Controller.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Dhcpv6Controller.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/Leases4Controller.php
@@ -407,6 +408,10 @@
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/Api/ServiceController.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/agentSettings.xml
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsDnsServer.xml
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsForwardDomain.xml
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsReverseDomain.xml
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsTsigKey.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPeer4.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPeer6.xml
@@ -416,6 +421,7 @@
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettings4.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettings6.xml
+/usr/local/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettingsDdns.xml
/usr/local/opnsense/mvc/app/controllers/OPNsense/Monit/Api/ServiceController.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Monit/Api/SettingsController.php
/usr/local/opnsense/mvc/app/controllers/OPNsense/Monit/Api/StatusController.php
@@ -822,6 +828,8 @@
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/FieldTypes/KeaStaticRoutesField.php
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaCtrlAgent.php
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaCtrlAgent.xml
+/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.php
+/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml
/usr/local/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php
@@ -973,6 +981,7 @@
/usr/local/opnsense/mvc/app/views/OPNsense/Interface/vlan.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Interface/vxlan.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Kea/ctrl_agent.volt
+/usr/local/opnsense/mvc/app/views/OPNsense/Kea/dhcp_ddns.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Kea/dhcpv4.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Kea/dhcpv6.volt
/usr/local/opnsense/mvc/app/views/OPNsense/Kea/leases4.volt
diff --git a/src/etc/inc/plugins.inc.d/kea.inc b/src/etc/inc/plugins.inc.d/kea.inc
index f5fa4bc24f3..b98d9cb5213 100644
--- a/src/etc/inc/plugins.inc.d/kea.inc
+++ b/src/etc/inc/plugins.inc.d/kea.inc
@@ -62,6 +62,14 @@ function kea_services()
$services[] = $service;
}
+ if (!(new \OPNsense\Kea\KeaDhcpDdns())->general->enabled->isEmpty()) {
+ $service = $template;
+ $service['pidfile'] = '/var/run/kea/kea-dhcp-ddns.kea-dhcp-ddns.pid';
+ $service['description'] = gettext('KEA DHCP DDNS server');
+ $service['id'] = 'ddns';
+ $services[] = $service;
+ }
+
return $services;
}
@@ -146,6 +154,7 @@ function kea_configure_do($verbose = false)
{
$keaDhcpv4 = new \OPNsense\Kea\KeaDhcpv4();
$keaDhcpv6 = new \OPNsense\Kea\KeaDhcpv6();
+ $keaDhcpDdns = new \OPNsense\Kea\KeaDhcpDdns();
killbypid('/var/run/kea_prefix_watcher.pid');
@@ -159,6 +168,9 @@ function kea_configure_do($verbose = false)
/* skip kea-dhcp6.conf when configured manually */
$keaDhcpv6->generateConfig();
}
+ if ($keaDhcpDdns->isEnabled() && $keaDhcpDdns->general->manual_config->isEmpty()) {
+ $keaDhcpDdns->generateConfig();
+ }
(new \OPNsense\Kea\KeaCtrlAgent())->generateConfig();
if ($keaDhcpv6->isEnabled()) {
mwexecfb(
@@ -175,7 +187,7 @@ function kea_configure_do($verbose = false)
function kea_syslog()
{
$logfacilities = [];
- $logfacilities['kea'] = ['facility' => ['kea-dhcp4', 'kea-dhcp6', 'kea-ctrl-agent']];
+ $logfacilities['kea'] = ['facility' => ['kea-dhcp4', 'kea-dhcp6', 'kea-ctrl-agent', 'kea-dhcp-ddns']];
return $logfacilities;
}
@@ -289,7 +301,7 @@ function kea_xmlrpc_sync()
'description' => gettext('Kea DHCP'),
'section' => 'OPNsense.Kea',
'id' => 'kea',
- 'services' => ["kea-dhcpv4", "kea-dhcpv6"],
+ 'services' => ["kea-dhcpv4", "kea-dhcpv6", "kea-dhcp-ddns"],
];
return $result;
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/DhcpddnsController.php b/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/DhcpddnsController.php
new file mode 100644
index 00000000000..4e47963cdf9
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/Api/DhcpddnsController.php
@@ -0,0 +1,140 @@
+
+ *
+ * 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\Kea\Api;
+
+use OPNsense\Base\ApiMutableModelControllerBase;
+
+class DhcpddnsController extends ApiMutableModelControllerBase
+{
+ protected static $internalModelName = 'dhcp_ddns';
+ protected static $internalModelClass = 'OPNsense\Kea\KeaDhcpDdns';
+
+ /* Forward DDNS domains */
+ public function searchForwardDomainAction()
+ {
+ return $this->searchBase('forward_ddns.ddns_domains', null, 'name');
+ }
+
+ public function getForwardDomainAction($uuid = null)
+ {
+ return $this->getBase('ddns_domains', 'forward_ddns.ddns_domains', $uuid);
+ }
+
+ public function addForwardDomainAction()
+ {
+ return $this->addBase('ddns_domains', 'forward_ddns.ddns_domains');
+ }
+
+ public function setForwardDomainAction($uuid)
+ {
+ return $this->setBase('ddns_domains', 'forward_ddns.ddns_domains', $uuid);
+ }
+
+ public function delForwardDomainAction($uuid)
+ {
+ return $this->delBase('forward_ddns.ddns_domains', $uuid);
+ }
+
+ /* Reverse DDNS domains */
+ public function searchReverseDomainAction()
+ {
+ return $this->searchBase('reverse_ddns.ddns_domains', null, 'name');
+ }
+
+ public function getReverseDomainAction($uuid = null)
+ {
+ return $this->getBase('ddns_domains', 'reverse_ddns.ddns_domains', $uuid);
+ }
+
+ public function addReverseDomainAction()
+ {
+ return $this->addBase('ddns_domains', 'reverse_ddns.ddns_domains');
+ }
+
+ public function setReverseDomainAction($uuid)
+ {
+ return $this->setBase('ddns_domains', 'reverse_ddns.ddns_domains', $uuid);
+ }
+
+ public function delReverseDomainAction($uuid)
+ {
+ return $this->delBase('reverse_ddns.ddns_domains', $uuid);
+ }
+
+ /* TSIG keys */
+ public function searchTsigKeyAction()
+ {
+ return $this->searchBase('tsig_keys', null, 'name');
+ }
+
+ public function getTsigKeyAction($uuid = null)
+ {
+ return $this->getBase('tsig_keys', 'tsig_keys', $uuid);
+ }
+
+ public function addTsigKeyAction()
+ {
+ return $this->addBase('tsig_keys', 'tsig_keys');
+ }
+
+ public function setTsigKeyAction($uuid)
+ {
+ return $this->setBase('tsig_keys', 'tsig_keys', $uuid);
+ }
+
+ public function delTsigKeyAction($uuid)
+ {
+ return $this->delBase('tsig_keys', $uuid);
+ }
+
+ /* Shared DNS servers */
+ public function searchDnsServerAction()
+ {
+ return $this->searchBase('dns_servers', null, 'ip_address');
+ }
+
+ public function getDnsServerAction($uuid = null)
+ {
+ return $this->getBase('dns_servers', 'dns_servers', $uuid);
+ }
+
+ public function addDnsServerAction()
+ {
+ return $this->addBase('dns_servers', 'dns_servers');
+ }
+
+ public function setDnsServerAction($uuid)
+ {
+ return $this->setBase('dns_servers', 'dns_servers', $uuid);
+ }
+
+ public function delDnsServerAction($uuid)
+ {
+ return $this->delBase('dns_servers', $uuid);
+ }
+}
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php b/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php
index 9e3f115ec9a..950e2ea8ddc 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/DhcpController.php
@@ -88,4 +88,26 @@ public function leases6Action()
{
$this->view->pick('OPNsense/Kea/leases6');
}
+
+ public function ddnsAction()
+ {
+ $this->view->pick('OPNsense/Kea/dhcp_ddns');
+ $this->view->formGeneralSettings = $this->getForm("generalSettingsDdns");
+
+ // Forward zones (domains)
+ $this->view->formDialogForwardDomain = $this->getForm("dialogDdnsForwardDomain");
+ $this->view->formGridForwardDomain = $this->getFormGrid("dialogDdnsForwardDomain");
+
+ // Reverse zones (domains)
+ $this->view->formDialogReverseDomain = $this->getForm("dialogDdnsReverseDomain");
+ $this->view->formGridReverseDomain = $this->getFormGrid("dialogDdnsReverseDomain");
+
+ // Shared DNS servers
+ $this->view->formDialogDnsServer = $this->getForm("dialogDdnsDnsServer");
+ $this->view->formGridDnsServer = $this->getFormGrid("dialogDdnsDnsServer");
+
+ // TSIG keys
+ $this->view->formDialogTsigKey = $this->getForm("dialogDdnsTsigKey");
+ $this->view->formGridTsigKey = $this->getFormGrid("dialogDdnsTsigKey");
+ }
}
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsDnsServer.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsDnsServer.xml
new file mode 100644
index 00000000000..947a6b81aa3
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsDnsServer.xml
@@ -0,0 +1,26 @@
+
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsForwardDomain.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsForwardDomain.xml
new file mode 100644
index 00000000000..f9d29901fe2
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsForwardDomain.xml
@@ -0,0 +1,20 @@
+
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsReverseDomain.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsReverseDomain.xml
new file mode 100644
index 00000000000..eaa47754b64
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsReverseDomain.xml
@@ -0,0 +1,20 @@
+
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsTsigKey.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsTsigKey.xml
new file mode 100644
index 00000000000..38a2d5a6d72
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogDdnsTsigKey.xml
@@ -0,0 +1,20 @@
+
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml
index 4a7aba4922a..2dd2b6b5f59 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet4.xml
@@ -28,6 +28,47 @@
false
+
+ header
+
+ true
+
+
+ subnet4.ddns_options.send_updates
+
+ checkbox
+ Enable Dynamic DNS updates for this subnet.
+
+ false
+
+
+
+ subnet4.ddns_options.update_on_renew
+
+ checkbox
+ Send DNS updates when leases are renewed.
+
+ false
+
+
+
+ subnet4.ddns_options.qualifying_suffix
+
+ text
+ Suffix appended to hostnames to form FQDNs. Must end with a dot.
+
+ false
+
+
+
+ subnet4.ddns_options.conflict_resolution_mode
+
+ dropdown
+ How to resolve DNS conflicts.
+
+ false
+
+
header
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml
index 63d29de2bee..6c95ba19808 100644
--- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml
@@ -37,6 +37,47 @@
textbox
List of pools, one per line in range or subnet format (e.g. 2001:db8:1::-2001:db8:1::100, 2001:db8:1::/80
+
+ header
+
+ true
+
+
+ subnet6.ddns_options.send_updates
+
+ checkbox
+ Enable Dynamic DNS updates for this subnet.
+
+ false
+
+
+
+ subnet6.ddns_options.update_on_renew
+
+ checkbox
+ Send DNS updates when leases are renewed.
+
+ false
+
+
+
+ subnet6.ddns_options.qualifying_suffix
+
+ text
+ Suffix appended to hostnames to form FQDNs. Must end with a dot.
+
+ false
+
+
+
+ subnet6.ddns_options.conflict_resolution_mode
+
+ dropdown
+ How to resolve DNS conflicts.
+
+ false
+
+
header
diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettingsDdns.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettingsDdns.xml
new file mode 100644
index 00000000000..e6e4c53c9b1
--- /dev/null
+++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/generalSettingsDdns.xml
@@ -0,0 +1,19 @@
+
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.php b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.php
new file mode 100644
index 00000000000..3d9960d1b68
--- /dev/null
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.php
@@ -0,0 +1,226 @@
+
+ *
+ * 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\Kea;
+
+use OPNsense\Core\File;
+use OPNsense\Base\BaseModel;
+use OPNsense\Base\Messages\Message;
+
+class KeaDhcpDdns extends BaseModel
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function performValidation($validateFullModel = false)
+ {
+ // Run default field-level validators first
+ $messages = parent::performValidation($validateFullModel);
+
+ // Explicitly validate that forward and reverse domain names end with a dot (FQDN)
+ foreach ($this->forward_ddns->ddns_domains->iterateItems() as $domain) {
+ if (!$validateFullModel && !$domain->isFieldChanged()) {
+ continue;
+ }
+ if (!($domain->name->isEmpty()) && !str_ends_with($domain->name->getValue(), '.')) {
+ $messages->appendMessage(
+ new Message(
+ gettext('Domain must be a fully qualified domain name ending with a dot.'),
+ $domain->__reference . '.name'
+ )
+ );
+ }
+ }
+ foreach ($this->reverse_ddns->ddns_domains->iterateItems() as $domain) {
+ if (!$validateFullModel && !$domain->isFieldChanged()) {
+ continue;
+ }
+ if (!($domain->name->isEmpty()) && !str_ends_with($domain->name->getValue(), '.')) {
+ $messages->appendMessage(
+ new Message(
+ gettext('Domain must be a fully qualified domain name ending with a dot.'),
+ $domain->__reference . '.name'
+ )
+ );
+ }
+ }
+
+ return $messages;
+ }
+
+ public function isEnabled()
+ {
+ return $this->general->enabled->isEqual('1');
+ }
+
+ /**
+ * Build a map of shared DNS servers defined at root level (dns_servers)
+ * keyed by their UUID for quick lookup.
+ * @return array
+ */
+ private function getSharedDnsServersMap()
+ {
+ $map = [];
+ $tsigNameMap = $this->getTsigKeyNameMap();
+ foreach ($this->dns_servers->iterateItems() as $uuid => $srv) {
+ $item = [];
+ if (!($srv->ip_address->isEmpty())) {
+ $item['ip-address'] = $srv->ip_address->getValue();
+ }
+ if (!($srv->port->isEmpty())) {
+ $item['port'] = $srv->port->asInt();
+ }
+ if (!($srv->key_name->isEmpty())) {
+ $kn = $srv->key_name->getValue();
+ // key_name is a ModelRelationField (UUID). Resolve to TSIG key name.
+ if (!empty($tsigNameMap[$kn])) {
+ $item['key-name'] = $tsigNameMap[$kn];
+ }
+ }
+ if (!empty($item)) {
+ $map[$uuid] = $item;
+ }
+ }
+ return $map;
+ }
+
+ /**
+ * Build a map uuid => tsig key name for quick lookup when resolving relations.
+ * @return array
+ */
+ private function getTsigKeyNameMap()
+ {
+ $map = [];
+ foreach ($this->tsig_keys->iterateItems() as $uuid => $key) {
+ if (!($key->name->isEmpty())) {
+ $map[$uuid] = $key->name->getValue();
+ }
+ }
+ return $map;
+ }
+
+ private function getTsigKeys() {
+ $tsig_keys = [];
+ foreach ($this->tsig_keys->iterateItems() as $key) {
+ $item = [];
+ if (!($key->name->isEmpty())) {
+ $item['name'] = $key->name->getValue();
+ }
+ if (!($key->algorithm->isEmpty())) {
+ $item['algorithm'] = $key->algorithm->getValue();
+ }
+ if (!($key->secret->isEmpty())) {
+ $item['secret'] = $key->secret->getValue();
+ }
+ if (!empty($item)) {
+ $tsig_keys[] = $item;
+ }
+ }
+ return $tsig_keys;
+ }
+
+ private function buildDomains ($domainsNode) {
+ $domains = [];
+ $serversMap = $this->getSharedDnsServersMap();
+ $tsigNameMap = $this->getTsigKeyNameMap();
+ foreach ($domainsNode->iterateItems() as $domain) {
+ $entry = [];
+ if (!($domain->name->isEmpty())) {
+ // emit stored value as-is; validation ensures FQDN (trailing dot)
+ $entry['name'] = $domain->name->getValue();
+ }
+ if (!($domain->key_name->isEmpty())) {
+ $kn = $domain->key_name->getValue(); // UUID from ModelRelationField
+ if (!empty($tsigNameMap[$kn])) {
+ $entry['key-name'] = $tsigNameMap[$kn];
+ }
+ }
+
+ // dns-servers referenced via ModelRelationField (comma-separated UUIDs)
+ $servers = [];
+ $refs = !($domain->dns_servers->isEmpty()) ? $domain->dns_servers->getValue() : '';
+ if (!empty($refs)) {
+ foreach (array_filter(explode(',', $refs)) as $uuid) {
+ if (empty($uuid) || empty($serversMap[$uuid])) {
+ continue;
+ }
+ $servers[] = $serversMap[$uuid];
+ }
+ }
+ if (!empty($servers)) {
+ $entry['dns-servers'] = $servers;
+ }
+
+ if (!empty($entry)) {
+ $domains[] = $entry;
+ }
+ }
+ return $domains;
+ }
+
+ private function getForwardDomains() {
+ return $this->buildDomains($this->forward_ddns->ddns_domains);
+ }
+
+ private function getReverseDomains() {
+ return $this->buildDomains($this->reverse_ddns->ddns_domains);
+ }
+
+ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp-ddns.conf')
+ {
+ $result = [
+ 'DhcpDdns' => [
+ 'ip-address' => '127.0.0.1',
+ 'port' => 53001,
+ 'control-socket' => [
+ 'socket-type' => 'unix',
+ 'socket-name' => '/var/run/kea/kea-ddns-ctrl-socket'
+ ],
+ 'loggers' => [
+ [
+ 'name' => 'kea-dhcp-ddns',
+ 'output_options' => [
+ [
+ 'output' => 'syslog'
+ ]
+ ],
+ 'severity' => 'INFO',
+ ]
+ ],
+ 'tsig-keys' => $this->getTsigKeys(),
+ 'forward-ddns' => [
+ 'ddns-domains' => $this->getForwardDomains()
+ ],
+ 'reverse-ddns' => [
+ 'ddns-domains' => $this->getReverseDomains()
+ ]
+ ]
+ ];
+
+ File::file_put_contents($target, json_encode($result, JSON_PRETTY_PRINT), 0600);
+ }
+}
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.xml
new file mode 100644
index 00000000000..1f74b3e77cc
--- /dev/null
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpDdns.xml
@@ -0,0 +1,132 @@
+
+ //OPNsense/Kea/dhcp_ddns
+ 1.0.0
+ Kea DHCP-DDNS Configuration
+
+
+
+ 0
+ Y
+
+
+
+
+
+
+ Y
+
+
+ N
+ Y
+
+
+ 53
+ Y
+
+
+
+
+ OPNsense.Kea.KeaDhcpDdns
+ tsig_keys
+ name
+
+
+
+
+
+
+
+ N
+ Please specify a valid domain name.
+
+
+ UniqueConstraint
+ Duplicate forward domain exists.
+
+
+
+
+
+
+ OPNsense.Kea.KeaDhcpDdns
+ tsig_keys
+ name
+
+
+
+
+
+
+ OPNsense.Kea.KeaDhcpDdns
+ dns_servers
+ description
+
+
+ Y
+ Y
+ Please select at least one DNS server.
+
+
+
+
+
+
+ N
+ Please specify a valid domain name.
+
+
+ UniqueConstraint
+ Duplicate reverse domain exists.
+
+
+
+
+
+
+ OPNsense.Kea.KeaDhcpDdns
+ tsig_keys
+ name
+
+
+
+
+
+
+ OPNsense.Kea.KeaDhcpDdns
+ dns_servers
+ description
+
+
+ Y
+ Y
+ Please select at least one DNS server.
+
+
+
+
+
+ Y
+
+
+ Duplicate TSIG key name exists.
+ UniqueConstraint
+
+
+
+
+ Y
+
+ HMAC-MD5
+ HMAC-SHA1
+ HMAC-SHA224
+ HMAC-SHA256
+ HMAC-SHA384
+ HMAC-SHA512
+
+
+
+ Y
+
+
+
+
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php
index c0dff19e6fc..5381243913e 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.php
@@ -90,6 +90,24 @@ public function performValidation($validateFullModel = false)
}
}
+ // Enforce that ddns qualifying suffix ends with a dot when set
+ foreach ($this->subnets->subnet4->iterateItems() as $subnet) {
+ if (!$validateFullModel && !$subnet->isFieldChanged()) {
+ continue;
+ }
+
+ $suffix = $subnet->ddns_options->qualifying_suffix;
+ if (!($subnet->ddns_options->send_updates->isEqual('1')) &&
+ !($suffix->isEmpty()) && !str_ends_with($suffix->getValue(), '.')) {
+ $messages->appendMessage(
+ new Message(
+ gettext('DDNS qualifying suffix must end with a dot.'),
+ $subnet->__reference . '.ddns_options.qualifying_suffix'
+ )
+ );
+ }
+ }
+
return $messages;
}
@@ -175,6 +193,21 @@ private function getConfigSubnets()
'pools' => [],
'reservations' => []
];
+
+ // Conditionally include DDNS settings only when send-updates is enabled,
+ // and only include fields that have meaningful values.;
+ if ($subnet->ddns_options->send_updates->isEqual('1')) {
+ $record['ddns-send-updates'] = true;
+ if (!($subnet->ddns_options->qualifying_suffix->isEmpty())) {
+ $record['ddns-qualifying-suffix'] = $subnet->ddns_options->qualifying_suffix->getValue();
+ }
+ if ($subnet->ddns_options->update_on_renew->isEqual('1')) {
+ $record['ddns-update-on-renew'] = true;
+ }
+ if (!($subnet->ddns_options->conflict_resolution_mode->isEmpty())) {
+ $record['ddns-conflict-resolution-mode'] = $subnet->ddns_options->conflict_resolution_mode->getValue();
+ }
+ }
/* add pools */
foreach (array_filter(explode("\n", $subnet->pools->getValue())) as $pool) {
$record['pools'][] = ['pool' => $pool];
@@ -237,6 +270,11 @@ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp4.conf')
'socket-type' => 'unix',
'socket-name' => '/var/run/kea/kea4-ctrl-socket'
],
+ 'dhcp-ddns' => [
+ 'enable-updates' => false,
+ 'server-ip' => '127.0.0.1',
+ 'server-port' => 53001,
+ ],
'loggers' => [
[
'name' => 'kea-dhcp4',
@@ -255,6 +293,14 @@ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp4.conf')
if ($expiredLeasesConfig !== null) {
$cnf['Dhcp4']['expired-leases-processing'] = $expiredLeasesConfig;
}
+
+ foreach ($this->subnets->subnet4->iterateItems() as $subnet) {
+ if ($subnet->ddns_options->send_updates->isEqual('1')) {
+ $cnf['Dhcp4']['dhcp-ddns']['enable-updates'] = true;
+ break;
+ }
+ }
+
if (!(new KeaCtrlAgent())->general->enabled->isEmpty()) {
$cnf['Dhcp4']['hooks-libraries'] = [];
$cnf['Dhcp4']['hooks-libraries'][] = [
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml
index a5139f3012d..cb230369691 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv4.xml
@@ -1,6 +1,6 @@
//OPNsense/Kea/dhcp4
- 1.0.4
+ 1.0.5
Kea DHCPv4 configuration
@@ -136,6 +136,21 @@
+
+
+
+
+
+ check-with-dhcid
+ Y
+
+ check-with-dhcid
+ no-check-with-dhcid
+ check-exists-with-dhcid
+ no-check-without-dhcid
+
+
+
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php
index d8d5a746e5d..0a4a9a981ff 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php
@@ -72,6 +72,23 @@ public function performValidation($validateFullModel = false)
}
}
+ // Enforce that ddns qualifying suffix ends with a dot when set
+ foreach ($this->subnets->subnet6->iterateItems() as $subnet) {
+ if (!$validateFullModel && !$subnet->isFieldChanged()) {
+ continue;
+ }
+ $suffix = $subnet->ddns_options->qualifying_suffix;
+ if ($subnet->ddns_options->send_updates->isEqual('1') &&
+ !($suffix->isEmpty()) && !str_ends_with($suffix->getValue(), '.')) {
+ $messages->appendMessage(
+ new Message(
+ gettext('DDNS qualifying suffix must end with a dot.'),
+ $subnet->__reference . '.ddns_options.qualifying_suffix'
+ )
+ );
+ }
+ }
+
return $messages;
}
@@ -129,6 +146,21 @@ private function getConfigSubnets()
'pd-pools' => [],
'reservations' => []
];
+
+ // Conditionally include DDNS settings only when send-updates is enabled,
+ // and only include fields that have meaningful values.
+ if ($subnet->ddns_options->send_updates->isEqual('1')) {
+ $record['ddns-send-updates'] = true;
+ if (!($subnet->ddns_options->qualifying_suffix->isEmpty())) {
+ $record['ddns-qualifying-suffix'] = $subnet->ddns_options->qualifying_suffix->getValue();
+ }
+ if ($subnet->ddns_options->update_on_renew->isEqual('1')) {
+ $record['ddns-update-on-renew'] = true;
+ }
+ if (!($subnet->ddns_options->conflict_resolution_mode->isEmpty())) {
+ $record['ddns-conflict-resolution-mode'] = $subnet->ddns_options->conflict_resolution_mode->getValue();
+ }
+ }
$if = $subnet->interface->getValue();
if (isset($cfg->interfaces->$if) && !empty($cfg->interfaces->$if->if)) {
$record['interface'] = (string)$cfg->interfaces->$if->if;
@@ -223,6 +255,11 @@ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp6.conf')
'socket-type' => 'unix',
'socket-name' => '/var/run/kea/kea6-ctrl-socket'
],
+ 'dhcp-ddns' => [
+ 'enable-updates' => false,
+ 'server-ip' => '127.0.0.1',
+ 'server-port' => 53001,
+ ],
'loggers' => [
[
'name' => 'kea-dhcp6',
@@ -241,6 +278,14 @@ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp6.conf')
if ($expiredLeasesConfig !== null) {
$cnf['Dhcp6']['expired-leases-processing'] = $expiredLeasesConfig;
}
+
+ foreach ($this->subnets->subnet6->iterateItems() as $subnet) {
+ if ($subnet->ddns_options->send_updates->isEqual('1')) {
+ $cnf['Dhcp6']['dhcp-ddns']['enable-updates'] = true;
+ break;
+ }
+ }
+
if (!(new KeaCtrlAgent())->general->enabled->isEmpty()) {
$cnf['Dhcp6']['hooks-libraries'] = [];
$cnf['Dhcp6']['hooks-libraries'][] = [
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml
index 34ffa71e4cf..8a14c22c541 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml
@@ -1,6 +1,6 @@
//OPNsense/Kea/dhcp6
- 1.0.0
+ 1.0.1
Kea DHCPv6 configuration
@@ -105,6 +105,21 @@
Y
+
+
+
+
+
+ check-with-dhcid
+ Y
+
+ check-with-dhcid
+ no-check-with-dhcid
+ check-exists-with-dhcid
+ no-check-without-dhcid
+
+
+
diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/Menu/Menu.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/Menu/Menu.xml
index 3aa4d72fe2e..860c30b9fe5 100644
--- a/src/opnsense/mvc/app/models/OPNsense/Kea/Menu/Menu.xml
+++ b/src/opnsense/mvc/app/models/OPNsense/Kea/Menu/Menu.xml
@@ -4,6 +4,7 @@
+
diff --git a/src/opnsense/mvc/app/views/OPNsense/Kea/dhcp_ddns.volt b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcp_ddns.volt
new file mode 100644
index 00000000000..67b3a04a255
--- /dev/null
+++ b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcp_ddns.volt
@@ -0,0 +1,111 @@
+{#
+ # Copyright (C) 2025 Yip Rui Fung
+ #
+ # Redistribution and use in source and binary forms, with or without modification,
+ # are permitted provided that the following conditions are met:
+ #
+ # 1. Redistributions of source code must retain the above copyright notice,
+ # this list of conditions and the following disclaimer.
+ #
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
+ # this list of conditions and the following disclaimer in the documentation
+ # and/or other materials provided with the distribution.
+ #
+ # THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+ # AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+ # AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+ # OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ # POSSIBILITY OF SUCH DAMAGE.
+ #}
+
+
+
+
+
+
+ {{ partial("layout_partials/base_form",['fields':formGeneralSettings,'id':'frm_generalsettings'])}}
+
+
+ {{ partial('layout_partials/base_bootgrid_table', formGridForwardDomain)}}
+
+
+ {{ partial('layout_partials/base_bootgrid_table', formGridReverseDomain)}}
+
+
+ {{ partial('layout_partials/base_bootgrid_table', formGridTsigKey)}}
+
+
+ {{ partial('layout_partials/base_bootgrid_table', formGridDnsServer)}}
+
+
+
+{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/kea/service/reconfigure'}) }}
+{{ partial("layout_partials/base_dialog",['fields':formDialogForwardDomain,'id':formGridForwardDomain['edit_dialog_id'],'label':lang._('Edit Forward Domain')])}}
+{{ partial("layout_partials/base_dialog",['fields':formDialogReverseDomain,'id':formGridReverseDomain['edit_dialog_id'],'label':lang._('Edit Reverse Domain')])}}
+{{ partial("layout_partials/base_dialog",['fields':formDialogTsigKey,'id':formGridTsigKey['edit_dialog_id'],'label':lang._('Edit TSIG Key')])}}
+{{ partial("layout_partials/base_dialog",['fields':formDialogDnsServer,'id':formGridDnsServer['edit_dialog_id'],'label':lang._('Edit DNS Server')])}}
diff --git a/src/opnsense/service/templates/OPNsense/Kea/keactrl.conf b/src/opnsense/service/templates/OPNsense/Kea/keactrl.conf
index bf8bc80986b..cd2f71867cc 100644
--- a/src/opnsense/service/templates/OPNsense/Kea/keactrl.conf
+++ b/src/opnsense/service/templates/OPNsense/Kea/keactrl.conf
@@ -34,7 +34,7 @@ dhcp4={% if not helpers.empty('OPNsense.Kea.dhcp4.general.enabled') %}yes{% else
dhcp6={% if not helpers.empty('OPNsense.Kea.dhcp6.general.enabled') %}yes{% else %}no{% endif %}
# Start DHCP DDNS server?
-dhcp_ddns=no
+dhcp_ddns={% if not helpers.empty('OPNsense.Kea.dhcp_ddns.general.enabled') %}yes{% else %}no{% endif %}
# Start Control Agent?
ctrl_agent={% if not helpers.empty('OPNsense.Kea.ctrl_agent.general.enabled') %}yes{% else %}no{% endif %}