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 @@ +
+ + dns_servers.description + + text + Description for this DNS server entry + + + dns_servers.ip_address + + text + DNS server IP address (IPv4 or IPv6) + + + dns_servers.port + + text + DNS server port, default 53 + + + dns_servers.key_name + + dropdown + Select an optional TSIG key for updates sent to this server. When set, this server-specific key overrides any TSIG key configured on the domain/zone. + +
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 @@ +
+ + ddns_domains.name + + text + Forward DDNS domain (e.g. example.com.) + + + ddns_domains.key_name + + dropdown + Select an optional TSIG key to authenticate updates for this domain. Note: if a DNS server has a TSIG key configured, that server-specific key takes precedence over the domain key. + + + ddns_domains.dns_servers + + select_multiple + Select one or more shared DNS servers to update for this domain. + +
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 @@ +
+ + ddns_domains.name + + text + Reverse DDNS domain (e.g. 0.168.192.in-addr.arpa. or ip6.arpa. variant) + + + ddns_domains.key_name + + dropdown + Select an optional TSIG key to authenticate updates for this reverse zone. Note: if a DNS server has a TSIG key configured, that server-specific key takes precedence over the domain key. + + + ddns_domains.dns_servers + + select_multiple + Select one or more shared DNS servers to update for this reverse zone. + +
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 @@ +
+ + tsig_keys.name + + text + TSIG key name. + + + tsig_keys.algorithm + + dropdown + Select the TSIG hash algorithm. + + + tsig_keys.secret + + text + Base64-encoded shared secret. + +
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 @@ +
+ + header + + + + dhcp_ddns.general.enabled + + checkbox + Enable DHCP-DDNS service. + + + dhcp_ddns.general.manual_config + + checkbox + true + Disable configuration file generation and manage the file (/usr/local/etc/kea/kea-dhcp-ddns.conf) manually. + +
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 %}