diff --git a/plist b/plist index cad0df89c33..bc47f05b2ed 100644 --- a/plist +++ b/plist @@ -555,6 +555,7 @@ /usr/local/opnsense/mvc/app/library/OPNsense/Firewall/SNatRule.php /usr/local/opnsense/mvc/app/library/OPNsense/Firewall/Util.php /usr/local/opnsense/mvc/app/library/OPNsense/Interface/Autoconf.php +/usr/local/opnsense/mvc/app/library/OPNsense/Interface/Idassoc.php /usr/local/opnsense/mvc/app/library/OPNsense/Mvc/Controller.php /usr/local/opnsense/mvc/app/library/OPNsense/Mvc/Dispatcher.php /usr/local/opnsense/mvc/app/library/OPNsense/Mvc/Exceptions/ClassNotFoundException.php @@ -1307,6 +1308,7 @@ /usr/local/opnsense/scripts/kea/del_kea_leases.py /usr/local/opnsense/scripts/kea/get_kea_leases.py /usr/local/opnsense/scripts/kea/kea_dhcp_options.py +/usr/local/opnsense/scripts/kea/kea_prefix_renew.py /usr/local/opnsense/scripts/kea/kea_prefix_watcher.py /usr/local/opnsense/scripts/kea/lib/__init__.py /usr/local/opnsense/scripts/kea/lib/kea_ctrl.py diff --git a/src/etc/inc/plugins.inc.d/kea.inc b/src/etc/inc/plugins.inc.d/kea.inc index 259a811b597..0194c948f71 100644 --- a/src/etc/inc/plugins.inc.d/kea.inc +++ b/src/etc/inc/plugins.inc.d/kea.inc @@ -146,10 +146,26 @@ function kea_staticmap($proto = null, $valid_addresses = true, $ifconfig_details function kea_configure() { return [ - 'kea_sync' => ['kea_configure_do'] + 'kea_generate_dhcpv6' => ['kea_generate_dhcpv6_do'], + 'kea_sync' => ['kea_configure_do'], + 'newwanip' => ['kea_newwanip'], ]; } +// used by kea_prefix_renew hook script to regenerate config when dynamic IPv6 prefix changes +function kea_generate_dhcpv6_do($verbose = false) +{ + kea_generate_dhcpv6(new \OPNsense\Kea\KeaDhcpv6()); +} + +function kea_generate_dhcpv6($keaDhcpv6) +{ + if ($keaDhcpv6->isEnabled() && $keaDhcpv6->general->manual_config->isEmpty()) { + /* skip kea-dhcp6.conf when configured manually */ + $keaDhcpv6->generateConfig(); + } +} + function kea_configure_do($verbose = false) { $keaDhcpv4 = new \OPNsense\Kea\KeaDhcpv4(); @@ -164,10 +180,7 @@ function kea_configure_do($verbose = false) /* skip kea-dhcp4.conf when configured manually */ $keaDhcpv4->generateConfig(); } - if ($keaDhcpv6->isEnabled() && $keaDhcpv6->general->manual_config->isEmpty()) { - /* skip kea-dhcp6.conf when configured manually */ - $keaDhcpv6->generateConfig(); - } + kea_generate_dhcpv6($keaDhcpv6); if ($keaDdns->isEnabled() && $keaDdns->general->manual_config->isEmpty()) { /* skip kea-dhcp-ddns.conf when configured manually */ $keaDdns->generateConfig(); @@ -187,6 +200,19 @@ function kea_configure_do($verbose = false) } } +function kea_newwanip($interfaces, $family) +{ + if ($family === 'inet6') { + $keaDhcpv6 = new \OPNsense\Kea\KeaDhcpv6(); + if ( + $keaDhcpv6->isEnabled() && + $keaDhcpv6->general->manual_config->isEmpty() + ) { + mwexecf('/usr/local/opnsense/scripts/kea/kea_prefix_renew.py'); + } + } +} + function kea_syslog() { return ['kea' => ['facility' => [ diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml index d43544f5120..123a882a8f9 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogPDPool6.xml @@ -9,13 +9,21 @@ pd_pool.prefix text + + + pd_pool + pd_pool.prefix_len text + + + pd_pool + pd_pool.delegated_len 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 bfbacdc343c..a46da48f8da 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml +++ b/src/opnsense/mvc/app/controllers/OPNsense/Kea/forms/dialogSubnet6.xml @@ -3,7 +3,11 @@ subnet6.subnet text + Subnet to use, should be large enough to hold the specified pools and reservations + + subnet + subnet6.interface @@ -11,12 +15,26 @@ dropdown Select which interface this subnet belongs too. + + subnet6.dynamic_prefix + + checkbox + Use the identity association prefix allocated to this interface and generate subnet and pools automatically. DHCP options or DDNS settings that use IPv6 addresses are unaffected by prefix changes, they remain static. + + boolean + boolean + false + + subnet6.allocator dropdown true Select allocator method to use when offering leases to clients. + + false + subnet6.pd-allocator @@ -24,18 +42,19 @@ dropdown true Select allocator method to use when offering prefix delegations to clients - - - subnet6.description - - text - You may enter a description here for your reference (not parsed). + + false + subnet6.pools 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 + + subnet + subnet6.valid_lifetime @@ -46,6 +65,12 @@ false + + subnet6.description + + text + You may enter a description here for your reference (not parsed). + header diff --git a/src/opnsense/mvc/app/library/OPNsense/Firewall/Util.php b/src/opnsense/mvc/app/library/OPNsense/Firewall/Util.php index b433ed779ec..af1dba38442 100644 --- a/src/opnsense/mvc/app/library/OPNsense/Firewall/Util.php +++ b/src/opnsense/mvc/app/library/OPNsense/Firewall/Util.php @@ -505,6 +505,40 @@ public static function isIPv6PrefixInPrefix(string $childPrefix, string $parentP return self::isIPInCIDR($childAddress, $parentPrefix); } + /** + * Split an IPv6 parent prefix (e.g., /56) into two child prefixes (e.g., 2x /57). + * + * @param string $prefix IPv6 CIDR prefix + * @return array two child prefixes or empty array + */ + public static function splitIPv6Prefix($prefix): array + { + if (!self::isSubnetStrict($prefix)) { + return []; + } + + [$address, $prefix_len] = explode('/', $prefix, 2); + if (!self::isIpv6Address($address)) { + return []; + } + + $child_prefix_len = (int)$prefix_len + 1; + if ($child_prefix_len > 128) { + return []; + } + + $bytes = array_values(unpack('C*', inet_pton($address))); + $second = $bytes; + + $bit = $child_prefix_len - 1; + $second[intdiv($bit, 8)] |= 1 << (7 - ($bit % 8)); + + return [ + inet_ntop(pack('C*', ...$bytes)) . '/' . $child_prefix_len, + inet_ntop(pack('C*', ...$second)) . '/' . $child_prefix_len, + ]; + } + /** * convert ipv4 cidr to netmask e.g. 24 --> 255.255.255.0 * @param int $bits ipv4 bits diff --git a/src/opnsense/mvc/app/library/OPNsense/Interface/Idassoc.php b/src/opnsense/mvc/app/library/OPNsense/Interface/Idassoc.php new file mode 100644 index 00000000000..b79692418c6 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Interface/Idassoc.php @@ -0,0 +1,204 @@ + + * [ + * [prefix_id] => 0 + * [prefix_on_link] => 2001:db8:1234::/64 + * [prefix_allocated] => 2001:db8:1234::/58 + * [prefix_associated] => 2001:db8:1234::/56 + * [prefix_valid] => true + * [prefix_source] => wan + * ] + * [opt1] => + * [ + * [prefix_id] => 68 + * [prefix_on_link] => 2001:db8:1234:68::/64 + * [prefix_allocated] => 2001:db8:1234:68::/61 + * [prefix_associated] => 2001:db8:1234::/56 + * [prefix_valid] => true + * [prefix_source] => wan + * + * ] + * ] + */ + private static function prefixes(): array + { + $result = []; + $groups = []; + $cfg = Config::getInstance()->object(); + + foreach ($cfg->interfaces->children() as $ifname => $ifcfg) { + if ((string)($ifcfg->ipaddrv6 ?? '') !== 'idassoc6') { + continue; + } + + $prefix_id = (string)($ifcfg->{'track6-prefix-id'} ?? ''); + $trackif = (string)($ifcfg->{'track6-interface'} ?? ''); + + if ($prefix_id === '' || $trackif === '' || empty($cfg->interfaces->{$trackif}->if)) { + continue; + } + + $prefix_valid = true; + $prefix_associated = self::getPrefix((string)$cfg->interfaces->{$trackif}->if, 'inet6') ?? ''; + + if ($prefix_associated === '') { + $prefix_valid = false; + $prefix_associated = self::temporaryPrefix($trackif); + } + + $groups[$prefix_associated][$ifname] = [ + 'prefix_id' => $prefix_id, + 'trackif' => $trackif, + 'prefix_valid' => $prefix_valid, + ]; + } + + foreach ($groups as $prefix_associated => $interfaces) { + /* Sort by prefix ID because prefix_allocated depends on the next higher configured ID. */ + uasort($interfaces, fn($a, $b) => hexdec($a['prefix_id']) <=> hexdec($b['prefix_id'])); + $ordered = array_keys($interfaces); + $source_prefix_len = (int)explode('/', $prefix_associated, 2)[1]; + + foreach ($ordered as $idx => $ifname) { + $prefix_id = $interfaces[$ifname]['prefix_id']; + $next_prefix_id = isset($ordered[$idx + 1]) ? $interfaces[$ordered[$idx + 1]]['prefix_id'] : null; + $prefix_usable_len = self::calculateUsablePrefixLength($source_prefix_len, $prefix_id, $next_prefix_id); + + $result[$ifname] = [ + 'prefix_id' => $prefix_id, + 'prefix_on_link' => self::calculatePrefix($prefix_associated, $prefix_id), + 'prefix_allocated' => self::calculatePrefix($prefix_associated, $prefix_id, $prefix_usable_len), + 'prefix_associated' => $prefix_associated, + 'prefix_valid' => $interfaces[$ifname]['prefix_valid'], + 'prefix_source' => $interfaces[$ifname]['trackif'], + ]; + } + } + + return $result; + } + + /** + * Return configured IPv6 identity association prefix information. + */ + public static function prefix($ifname = null): array + { + $prefixes = self::prefixes(); + + if ($ifname !== null) { + return $prefixes[$ifname] ?? []; + } + + return $prefixes; + } +} diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php index 2cd9b576fc8..30d4084ea72 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php +++ b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.php @@ -34,6 +34,7 @@ use OPNsense\Core\Backend; use OPNsense\Core\File; use OPNsense\Firewall\Util; +use OPNsense\Interface\Idassoc; class KeaDhcpv6 extends BaseModel { @@ -52,6 +53,12 @@ public function performValidation($validateFullModel = false) $subnet = ""; $subnet_node = $this->getNodeByReference("subnets.subnet6.{$reservation->subnet}"); if ($subnet_node) { + if (!$subnet_node->dynamic_prefix->isEmpty()) { + $messages->appendMessage( + new Message(gettext("Reservations cannot be assigned to dynamic prefix subnets."), $key . ".subnet") + ); + continue; + } $subnet = $subnet_node->subnet->getValue(); } if (!Util::isIPInCIDR($reservation->ip_address->getValue(), $subnet) && !$reservation->ip_address->isEmpty()) { @@ -81,11 +88,63 @@ public function performValidation($validateFullModel = false) new Message(gettext('Interface is not selected in the general settings.'), $key . ".interface") ); } - foreach ($subnet->pools->checkSubnet($subnet->subnet->getValue()) as $pool) { + if (!$subnet->dynamic_prefix->isEmpty()) { + if (!$subnet->pools->isEmpty()) { + $messages->appendMessage( + new Message(gettext('Pools cannot be configured when dynamic prefix is enabled, they are automatically generated.'), $key . ".pools") + ); + } + } else { + foreach ($subnet->pools->checkSubnet($subnet->subnet->getValue()) as $pool) { + $messages->appendMessage( + new Message(sprintf(gettext('Pool "%s" not in specified subnet.'), $pool), $key . ".pools") + ); + } + } + if ($subnet->dynamic_prefix->isEmpty() && $subnet->subnet->isEmpty()) { $messages->appendMessage( - new Message(sprintf(gettext('Pool "%s" not in specified subnet.'), $pool), $key . ".pools") + new Message(gettext('Subnet is required when dynamic prefix is disabled.'), $key . ".subnet") ); } + if (!$subnet->dynamic_prefix->isEmpty() && !$subnet->subnet->isEmpty()) { + $messages->appendMessage( + new Message(gettext('Subnet must be empty when dynamic prefix is enabled.'), $key . ".subnet") + ); + } + if (!$subnet->dynamic_prefix->isEmpty()) { + foreach ($this->subnets->subnet6->iterateItems() as $tmpsubnet) { + if ($key === $tmpsubnet->__reference) { + continue; + } + if ( + !$tmpsubnet->dynamic_prefix->isEmpty() && + $tmpsubnet->interface->isEqual($subnet->interface->getValue()) + ) { + $messages->appendMessage( + new Message(gettext('Only one dynamic prefix subnet may be configured per interface.'), $key . ".interface") + ); + break; + } + } + $dynamic_pd_pool_count = 0; + foreach ($this->pd_pools->pd_pool->iterateItems() as $tmppool) { + if ($tmppool->subnet->isEqual($subnet->getAttribute('uuid'))) { + $dynamic_pd_pool_count++; + } + } + if ($dynamic_pd_pool_count > 1) { + $messages->appendMessage( + new Message(gettext('Only one PD pool may be configured for a dynamic prefix subnet.'), $key . ".dynamic_prefix") + ); + } + // This validation is not ideal, but it prevents user error on initial dynamic subnet configuration + $idassoc = Idassoc::prefix($subnet->interface->getValue()); + if (empty($idassoc)) { + $messages->appendMessage( + new Message(gettext('Interface has no identity association prefix configuration.'), $key . ".dynamic_prefix") + ); + } + } } // validate changed pd_pools foreach ($this->pd_pools->pd_pool->iterateItems() as $pool) { @@ -93,6 +152,52 @@ public function performValidation($validateFullModel = false) continue; } $key = $pool->__reference; + // dynamic pd_pool validation + if (($subnet_node = $this->getNodeByReference("subnets.subnet6.{$pool->subnet}")) !== null && !$subnet_node->dynamic_prefix->isEmpty()) { + foreach ($this->pd_pools->pd_pool->iterateItems() as $tmppool) { + if ($key === $tmppool->__reference) { + continue; + } + if ($tmppool->subnet->isEqual($pool->subnet->getValue())) { + $messages->appendMessage( + new Message(gettext("Only one PD pool may be configured for a dynamic prefix subnet."), $key . ".subnet") + ); + break; + } + } + if (!$pool->prefix->isEmpty()) { + $messages->appendMessage( + new Message(gettext("Prefix must be empty when attached to a dynamic prefix subnet."), $key . ".prefix") + ); + } + if (!$pool->prefix_len->isEmpty()) { + $messages->appendMessage( + new Message(gettext("Prefix length must be empty when attached to a dynamic prefix subnet."), $key . ".prefix_len") + ); + } + $idassoc = Idassoc::prefix($subnet_node->interface->getValue()); + if (!empty($idassoc['prefix_allocated'])) { + $pd_prefixes = Util::splitIPv6Prefix($idassoc['prefix_allocated']); + if (empty($pd_prefixes[1])) { + $messages->appendMessage( + new Message(gettext("Dynamic prefix is too small to create a non-overlapping PD pool."), $key . ".delegated_len") + ); + } else { + $pd_prefix_len = (int)explode('/', $pd_prefixes[1], 2)[1]; + if ($pool->delegated_len->asInt() < $pd_prefix_len) { + $messages->appendMessage( + new Message(gettext("Delegated length must be longer than or equal to dynamic PD pool prefix length."), $key . ".delegated_len") + ); + } + } + } + continue; + } + // static pd_pool validation + if ($pool->prefix_len->isEmpty()) { + $messages->appendMessage(new Message(gettext("Prefix length is required."), $key . ".prefix_len")); + continue; + } if ($pool->prefix_len->asInt() > $pool->delegated_len->asInt()) { $messages->appendMessage(new Message(gettext("Delegated length must be longer than or equal to prefix length"), $key . ".delegated_len")); } @@ -108,8 +213,15 @@ public function performValidation($validateFullModel = false) if ($key === $tmppool->__reference) { continue; } + $tmpsubnet = $this->getNodeByReference("subnets.subnet6.{$tmppool->subnet}"); + if ($tmpsubnet !== null && !$tmpsubnet->dynamic_prefix->isEmpty()) { + continue; + } $osubnet = $tmppool->prefix->getValue() . "/" . $tmppool->prefix_len->getValue(); $orange = Util::cidrToRange($osubnet); + if (empty($orange)) { + continue; + } if (Util::isIPInCIDR($orange[0], $subnet) || Util::isIPInCIDR($trange[0], $osubnet)) { $messages->appendMessage(new Message(gettext("Pool overlaps with an existing one."), $key . ".prefix")); } @@ -161,9 +273,21 @@ private function getConfigSubnets($ddns_enabled = false) $result = []; $subnet_id = 1; foreach ($this->subnets->subnet6->iterateItems() as $subnet_uuid => $subnet) { + // If subnet is dynamic, seed an initial subnet value so KEA can start + $if = $subnet->interface->getValue(); + $subnet_value = $subnet->subnet->getValue(); + $idassoc = []; + if (!$subnet->dynamic_prefix->isEmpty()) { + // XXX: If a subnet has been created for an interface that does not exist anymore, + // or the interface was removed from the identity association but still exists in the KEA config, + // there won't be a prefix and KEA will fail to start. Ideally this should be validated + // in the core interface configuration, it cannot be validated inside KEA. + $idassoc = Idassoc::prefix($if); + $subnet_value = $idassoc['prefix_allocated'] ?? ''; + } $record = [ 'id' => $subnet_id++, - 'subnet' => $subnet->subnet->getValue(), + 'subnet' => $subnet_value, 'option-data' => [], 'pools' => [], 'pd-pools' => [], @@ -188,6 +312,16 @@ private function getConfigSubnets($ddns_enabled = false) if (!$subnet->description->isEmpty()) { $record['user-context']['description'] = $subnet->description->getValue(); } + if (!$subnet->dynamic_prefix->isEmpty()) { + // Used by hook script to know which subnets have a dynamic prefix, it reads the running conf from socket + $record['user-context']['dynamic_prefix'] = true; + $record['user-context']['prefix_valid'] = $idassoc['prefix_valid'] ?? false; + $record['user-context']['prefix_source'] = $idassoc['prefix_source'] ?? $if; + // If the prefix is temporary placeholder, we will not send leases to any client + if (empty($idassoc['prefix_valid'])) { + $record['client-classes'] = ['NO_LEASES_PLEASE']; + } + } /* standard option-data elements */ foreach ($subnet->option_data->iterateItems() as $key => $value) { $target_fieldname = str_replace('_', '-', $key); @@ -204,19 +338,38 @@ private function getConfigSubnets($ddns_enabled = false) } } /* add pools */ - foreach ($subnet->pools->getValues() as $pool) { - $record['pools'][] = ['pool' => $pool]; + if (!$subnet->dynamic_prefix->isEmpty()) { + if (!empty($idassoc['prefix_on_link'])) { + $record['pools'][] = ['pool' => $idassoc['prefix_on_link']]; + } + } else { + foreach ($subnet->pools->getValues() as $pool) { + $record['pools'][] = ['pool' => $pool]; + } } /* add pd-pools */ foreach ($this->pd_pools->pd_pool->iterateItems() as $key => $pdpool) { if ($pdpool->subnet != $subnet_uuid) { continue; } - $entry = [ - 'prefix' => $pdpool->prefix->getValue(), - 'prefix-len' => $pdpool->prefix_len->asInt(), - 'delegated-len' => $pdpool->delegated_len->asInt() - ]; + if (!$subnet->dynamic_prefix->isEmpty()) { + $pd_prefixes = Util::splitIPv6Prefix($record['subnet']); + if (empty($pd_prefixes[1])) { + continue; + } + [$pd_prefix, $pd_prefix_len] = explode('/', $pd_prefixes[1], 2); + $entry = [ + 'prefix' => $pd_prefix, + 'prefix-len' => (int)$pd_prefix_len, + 'delegated-len' => $pdpool->delegated_len->asInt() + ]; + } else { + $entry = [ + 'prefix' => $pdpool->prefix->getValue(), + 'prefix-len' => $pdpool->prefix_len->asInt(), + 'delegated-len' => $pdpool->delegated_len->asInt() + ]; + } /* add description and other custom keys - not parsed by KEA */ $entry['user-context'] = ['uuid' => $pdpool->getAttribute('uuid')]; if (!$pdpool->description->isEmpty()) { @@ -391,6 +544,14 @@ public function generateConfig($target = '/usr/local/etc/kea/kea-dhcp6.conf') ] ]; $client_classes = $this->getConfigClientClasses(); + + // Used by temporary dynamic-prefix placeholder subnets. + // The test can never pass, so subnets using it will not hand out leases. + $client_classes[] = [ + 'name' => 'NO_LEASES_PLEASE', + 'test' => "not member('ALL')", + ]; + if (!empty($client_classes)) { $cnf['Dhcp6']['client-classes'] = $client_classes; } diff --git a/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml b/src/opnsense/mvc/app/models/OPNsense/Kea/KeaDhcpv6.xml index ca0e3c2bccc..0da17a105a2 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.1 + 1.0.2 Kea DHCPv6 configuration @@ -84,7 +84,6 @@ Y ipv6 - Y UniqueConstraint @@ -139,6 +138,10 @@ Y + + Y + 0 + N @@ -230,9 +233,11 @@ OPNsense.Kea.KeaDhcpv6 subnets.subnet6 - subnet + interface,subnet + %s %s + Y Related subnet not found. @@ -417,9 +422,11 @@ OPNsense.Kea.KeaDhcpv6 subnets.subnet6 - subnet + interface,subnet + %s %s + Y Related subnet not found. Y @@ -429,8 +436,6 @@ 1 128 - 56 - Y 1 diff --git a/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv6.volt b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv6.volt index 72f2963c6bc..10000f0ecd7 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv6.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Kea/dhcpv6.volt @@ -82,8 +82,24 @@ }, options: { triggerEditFor: getUrlHash('edit'), - initialSearchPhrase: getUrlHash('search') - } + initialSearchPhrase: getUrlHash('search'), + formatters: { + subnet: function(column, row) { + if ((row.subnet || '') === '') { + // XXX: Could somehow dynamically insert current values from running KEA config but thats more glue than this + // Also the dialog would also need dynamic hints, there these fields are hidden for this reason. + return ' {{ lang._("dynamic") }}'; + } + return row["%" + column.id] || row[column.id] || ""; + }, + pd_pool: function(column, row) { + if ((row.prefix || '') === '' && (row.prefix_len || '') === '') { + return ' {{ lang._("dynamic") }}'; + } + return row["%" + column.id] || row[column.id] || ""; + }, + }, + }, }); // Reservation-only commands @@ -128,6 +144,33 @@ } }); + $("#subnet6\\.dynamic_prefix").change(function(){ + if ($(this).is(':checked')) { + $(".static_prefix").closest('tr').hide(); + } else { + $(".static_prefix").closest('tr').show(); + } + }); + + // Since dynamic pd_pools relate to their dynamic subnet, map them first + $("#pd_pool\\.subnet").change(function(){ + const subnet_uuid = $(this).val(); + if (subnet_uuid === '') { + $(".static_prefix").closest('tr').show(); + return; + } + ajaxGet("/api/kea/dhcpv6/search_subnet", {}, function(data) { + const subnet = (data.rows || []).find(row => row.uuid === subnet_uuid); + const is_dynamic = subnet !== undefined && + (subnet.dynamic_prefix === '1' || subnet.dynamic_prefix === true); + if (is_dynamic) { + $(".static_prefix").closest('tr').hide(); + } else { + $(".static_prefix").closest('tr').show(); + } + }); + }); + /* Manual configuration, hide all config elements except the service section*/ $("#dhcpv6\\.general\\.manual_config").change(function(){ let manual_config = $(this).is(':checked'); diff --git a/src/opnsense/scripts/kea/kea_prefix_renew.py b/src/opnsense/scripts/kea/kea_prefix_renew.py new file mode 100755 index 00000000000..6aee631eb5a --- /dev/null +++ b/src/opnsense/scripts/kea/kea_prefix_renew.py @@ -0,0 +1,70 @@ +#!/usr/local/bin/python3 + +""" + Copyright (c) 2026 Deciso B.V. + 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. +""" + +import subprocess +import syslog +import ujson + +from lib.kea_ctrl import KeaCtrl + + +if __name__ == "__main__": + syslog.openlog("kea-dhcp6", facility=syslog.LOG_LOCAL4) + result = {"status": "ok"} + + try: + config = KeaCtrl.send_command("config-get", None, "dhcp6") + subnet6 = config.get("arguments", {}).get("Dhcp6", {}).get("subnet6", []) + + for subnet in subnet6: + if subnet.get("id") is not None and subnet.get("user-context", {}).get("dynamic_prefix") is True: + subnet_id = int(subnet.get("id")) + KeaCtrl.send_command("lease6-wipe", {"subnet-id": subnet_id}, "dhcp6") + + except Exception as e: + result["status"] = "failed" + syslog.syslog(syslog.LOG_ERR, "failed wiping dynamic prefix leases: %s" % e) + + try: + failed = subprocess.run(["pluginctl", "kea_generate_dhcpv6"], check=False, capture_output=True).returncode != 0 + + except Exception as e: + failed = True + syslog.syslog(syslog.LOG_ERR, "failed generating Kea configuration: %s" % e) + + if failed: + result["status"] = "failed" + + try: + KeaCtrl.send_command("config-reload", None, "dhcp6") + + except Exception as e: + result["status"] = "failed" + syslog.syslog(syslog.LOG_ERR, "failed config-reload of Kea DHCPv6 configuration: %s" % e) + + print(ujson.dumps(result))