--- a/etc/inc/openvpn.inc 2026-05-02 20:17:54.664708000 +0000 +++ b/etc/inc/openvpn.inc 2026-05-02 20:38:26.015835975 +0000 @@ -1028,7 +1028,15 @@ if ($mode == 'server') { list($ip, $cidr) = openvpn_gen_tunnel_network($settings['tunnel_network']); - list($ipv6, $prefix) = openvpn_gen_tunnel_network($settings['tunnel_networkv6']); + if (!empty($settings['tunnel_networkv6_type']) && $settings['tunnel_networkv6_type'] === 'pd6') { + $pd6_subnet = openvpn_get_dhcp6pd_subnet( + $settings['tunnel_track6_interface'] ?? 'wan', + (int)($settings['tunnel_track6_prefix_id'] ?? 0) + ); + list($ipv6, $prefix) = $pd6_subnet ? explode('/', $pd6_subnet) : ['', '']; + } else { + list($ipv6, $prefix) = openvpn_gen_tunnel_network($settings['tunnel_networkv6']); + } $mask = gen_subnet_mask($cidr); // configure tls modes @@ -2419,6 +2427,116 @@ return array($ipv6_1, $ipv6_2); } +/** + * Given a WAN interface (friendly name, e.g. "wan") and a DHCPv6 PD + * prefix ID integer, return the corresponding /64 subnet carved from the + * delegated prefix currently on that interface. + * + * pfSense stores the delegation size in interfaces//dhcp6-ia-pd-len as + * the number of subnet bits (e.g. 4 for a /60 delegation), NOT the prefix + * length itself. So the actual prefix length is (64 - dhcp6-ia-pd-len). + * + * Discovers the delegated base prefix by finding an existing track6 LAN + * interface that tracks the same upstream WAN, reading its live IPv6 address, + * and masking it down to the delegation prefix length. The prefix_id is + * then OR'd into the lower subnet_bits of the 4th 16-bit group. + * + * Returns a string like "2001:db8:1:3::/64", or false on failure (logged). + */ +function openvpn_get_dhcp6pd_subnet($wan_iface, $prefix_id) { + $subnet_bits = (int)config_get_path("interfaces/{$wan_iface}/dhcp6-ia-pd-len", 0); + if ($subnet_bits < 1 || $subnet_bits > 16) { + log_error("openvpn_get_dhcp6pd_subnet: invalid or missing dhcp6-ia-pd-len ({$subnet_bits}) for {$wan_iface}"); + return false; + } + + $pd_len = 64 - $subnet_bits; + $max_id = (1 << $subnet_bits) - 1; + if ((int)$prefix_id < 0 || (int)$prefix_id > $max_id) { + log_error("openvpn_get_dhcp6pd_subnet: prefix_id {$prefix_id} out of range 0-{$max_id} for /{$pd_len} delegation on {$wan_iface}"); + return false; + } + + foreach (config_get_path('interfaces', []) as $if => $ifcfg) { + if (!isset($ifcfg['ipaddrv6']) || $ifcfg['ipaddrv6'] !== 'track6') { + continue; + } + if (!isset($ifcfg['track6-interface']) || $ifcfg['track6-interface'] !== $wan_iface) { + continue; + } + $if_ipv6 = get_interface_ipv6($if); + if (!is_ipaddrv6($if_ipv6)) { + continue; + } + // Mask down to the /$pd_len delegation base, zeroing subnet+IID bits. + $base = gen_subnetv6($if_ipv6, $pd_len); + if (!is_ipaddrv6($base)) { + continue; + } + // Expand to 8 colon-separated 16-bit groups. + // The prefix_id occupies the lower $subnet_bits of group index 3 + // (bits 49-64). gen_subnetv6 has already zeroed those bits, so + // we simply OR in $prefix_id. + $expanded = Net_IPv6::uncompress($base, true); + $groups = explode(':', $expanded); + $groups[3] = sprintf('%04x', hexdec($groups[3]) | (int)$prefix_id); + $subnet = Net_IPv6::compress(implode(':', $groups)) . '/64'; + return $subnet; + } + + log_error("openvpn_get_dhcp6pd_subnet: no active track6 interface found on {$wan_iface}"); + return false; +} + +/** + * Build a list of WAN interfaces that have DHCPv6 prefix delegation configured. + * Used to populate the UI dropdown for pd6 tunnel network type selection. + * Returns array of [ 'friendly_name' => 'Description (/ PD)' ]. + */ +function openvpn_build_dhcp6pd_wan_list() { + $list = []; + foreach (config_get_path('interfaces', []) as $if => $ifcfg) { + if (!isset($ifcfg['ipaddrv6']) || $ifcfg['ipaddrv6'] !== 'dhcp6') { + continue; + } + $subnet_bits = (int)($ifcfg['dhcp6-ia-pd-len'] ?? 0); + if ($subnet_bits < 1 || $subnet_bits > 16) { + continue; + } + $pd_len = 64 - $subnet_bits; + $descr = !empty($ifcfg['descr']) ? $ifcfg['descr'] : strtoupper($if); + $list[$if] = "{$descr} (/{$pd_len} PD)"; + } + return $list; +} + +/** + * Resync all OpenVPN instances that use DHCPv6 PD tracking on $wan_iface, + * regardless of which interface the instance is bound to. Called from + * rc.newwanipv6 to catch instances bound to "any" or a LAN interface that + * openvpn_resync_all() would not otherwise restart. + */ +function openvpn_resync_dhcp6pd_wan($wan_iface) { + foreach (array("server", "client") as $type) { + foreach (config_get_path("openvpn/openvpn-{$type}", []) as $settings) { + if (isset($settings['disable'])) { + continue; + } + if (($settings['tunnel_networkv6_type'] ?? '') !== 'pd6') { + continue; + } + if (($settings['tunnel_track6_interface'] ?? '') !== $wan_iface) { + continue; + } + // Skip if already handled by openvpn_resync_all (bound to this WAN). + if ($settings['interface'] === $wan_iface) { + continue; + } + openvpn_resync($type, $settings); + } + } +} + function openvpn_clear_route($mode, $settings) { if (empty($settings['tunnel_network'])) { return; --- a/usr/local/www/vpn_openvpn_server.php 2026-05-02 20:17:54.655450000 +0000 +++ b/usr/local/www/vpn_openvpn_server.php 2026-05-02 20:39:07.864719743 +0000 @@ -174,6 +174,9 @@ $pconfig['tunnel_network'] = $this_server_config['tunnel_network']; $pconfig['tunnel_networkv6'] = $this_server_config['tunnel_networkv6']; + $pconfig['tunnel_networkv6_type'] = $this_server_config['tunnel_networkv6_type'] ?? 'static'; + $pconfig['tunnel_track6_interface'] = $this_server_config['tunnel_track6_interface'] ?? ''; + $pconfig['tunnel_track6_prefix_id'] = $this_server_config['tunnel_track6_prefix_id'] ?? 0; $pconfig['remote_network'] = $this_server_config['remote_network']; $pconfig['remote_networkv6'] = $this_server_config['remote_networkv6']; @@ -449,15 +452,36 @@ $input_errors[] = gettext("The submitted IPv4 Tunnel Network is already in use."); } - if (!empty($pconfig['tunnel_networkv6']) && !openvpn_validate_tunnel_network($pconfig['tunnel_networkv6'], 'ipv6')) { - $input_errors[] = gettext("The field 'IPv6 Tunnel Network' must contain a valid IPv6 prefix or an alias with a single IPv6 prefix."); - } + if (($pconfig['tunnel_networkv6_type'] ?? 'static') === 'static') { + if (!empty($pconfig['tunnel_networkv6']) && !openvpn_validate_tunnel_network($pconfig['tunnel_networkv6'], 'ipv6')) { + $input_errors[] = gettext("The field 'IPv6 Tunnel Network' must contain a valid IPv6 prefix or an alias with a single IPv6 prefix."); + } - if (!empty($pconfig['tunnel_networkv6']) && - (!isset($this_server_config) || - ($this_server_config['tunnel_networkv6'] != $pconfig['tunnel_networkv6'])) && - openvpn_is_tunnel_network_in_use($pconfig['tunnel_networkv6'])) { - $input_errors[] = gettext("The submitted IPv6 Tunnel Network is already in use."); + if (!empty($pconfig['tunnel_networkv6']) && + (!isset($this_server_config) || + ($this_server_config['tunnel_networkv6'] != $pconfig['tunnel_networkv6'])) && + openvpn_is_tunnel_network_in_use($pconfig['tunnel_networkv6'])) { + $input_errors[] = gettext("The submitted IPv6 Tunnel Network is already in use."); + } + } else { + // pd6 type: validate WAN interface and prefix_id. + if (empty($pconfig['tunnel_track6_interface'])) { + $input_errors[] = gettext("IPv6 Tunnel Network: a WAN interface must be selected for DHCPv6 PD tracking."); + } else { + $subnet_bits = (int)config_get_path("interfaces/{$pconfig['tunnel_track6_interface']}/dhcp6-ia-pd-len", 0); + if ($subnet_bits < 1 || $subnet_bits > 16) { + $input_errors[] = gettext("The selected interface does not have a valid DHCPv6 prefix delegation configured."); + } else { + $max_id = (1 << $subnet_bits) - 1; + $pd_len = 64 - $subnet_bits; + $prefix_id = (int)($pconfig['tunnel_track6_prefix_id'] ?? 0); + if ($prefix_id < 0 || $prefix_id > $max_id) { + $input_errors[] = sprintf(gettext( + "IPv6 Prefix ID must be between 0 and %d (0x%x) for a /%d delegation." + ), $max_id, $max_id, $pd_len); + } + } + } } if ($result = openvpn_validate_cidr($pconfig['remote_network'], 'IPv4 Remote Network', true, "ipv4", true)) { @@ -763,6 +787,9 @@ foreach (array('', 'v6') as $ntype) { $server["tunnel_network{$ntype}"] = openvpn_tunnel_network_fix($pconfig["tunnel_network{$ntype}"]); } + $server['tunnel_networkv6_type'] = $pconfig['tunnel_networkv6_type'] ?? 'static'; + $server['tunnel_track6_interface'] = $pconfig['tunnel_track6_interface'] ?? ''; + $server['tunnel_track6_prefix_id'] = (int)($pconfig['tunnel_track6_prefix_id'] ?? 0); $server['remote_network'] = $pconfig['remote_network']; $server['remote_networkv6'] = $pconfig['remote_networkv6']; $server['gwredir'] = $pconfig['gwredir']; @@ -1294,15 +1321,43 @@ 'including DCO, Exit Notify, and Inactive.', '
'); + $section->addInput(new Form_Select( + 'tunnel_networkv6_type', + 'IPv6 Tunnel Network', + $pconfig['tunnel_networkv6_type'] ?? 'static', + [ + 'static' => gettext('Static (enter prefix below)'), + 'pd6' => gettext('Track Interface (DHCPv6 PD)'), + ] + ))->setHelp('Select "Static" to enter a fixed IPv6 prefix, or "Track Interface" to ' . + 'automatically derive a /64 from a DHCPv6 delegated prefix on a WAN interface. ' . + 'The tracked prefix is updated automatically when the delegated prefix changes.'); + $section->addInput(new Form_Input( 'tunnel_networkv6', - 'IPv6 Tunnel Network', + 'IPv6 Tunnel Network Prefix', 'text', $pconfig['tunnel_networkv6'] - ))->setHelp('This is the IPv6 virtual network or network type alias with a single entry used for private ' . - 'communications between this server and client hosts expressed using CIDR notation ' . - '(e.g. fe80::/64). The ::1 address in the network will be assigned to the server ' . - 'virtual interface. The remaining addresses will be assigned to connecting clients.'); + ))->setHelp('IPv6 virtual network in CIDR notation (e.g. fe80::/64). ' . + 'The ::1 address will be assigned to the server virtual interface. ' . + 'Only used when type is Static.'); + + $section->addInput(new Form_Select( + 'tunnel_track6_interface', + 'DHCPv6 PD WAN Interface', + $pconfig['tunnel_track6_interface'] ?? '', + openvpn_build_dhcp6pd_wan_list() + ))->setHelp('WAN interface with DHCPv6 prefix delegation. Only interfaces with a ' . + 'configured delegation prefix length are shown.'); + + $section->addInput(new Form_Input( + 'tunnel_track6_prefix_id', + 'DHCPv6 PD Prefix ID', + 'number', + $pconfig['tunnel_track6_prefix_id'] ?? 0 + ))->setHelp('Numeric subnet ID within the delegated prefix (0 = first /64, 1 = second, etc.). ' . + 'For a /60 delegation, valid values are 0-15. For /56, 0-255. For /48, 0-65535. ' . + 'Prefix ID 0 is typically already used by LAN.'); $section->addInput(new Form_Checkbox( 'serverbridge_dhcp', @@ -2381,6 +2436,13 @@ } } + function tunnel_networkv6_type_change() { + var pd6 = ($('#tunnel_networkv6_type').val() === 'pd6'); + hideInput('tunnel_networkv6', pd6); + hideInput('tunnel_track6_interface', !pd6); + hideInput('tunnel_track6_prefix_id', !pd6); + } + function ping_method_change() { pvalue = $('#ping_method').val(); @@ -2526,6 +2588,10 @@ allow_compression_change(); }); + $('#tunnel_networkv6_type').change(function () { + tunnel_networkv6_type_change(); + }); + function updateCipher(mem) { var found = false; var ciphers_all = ; @@ -2589,6 +2655,7 @@ ocspcheck_change(); allow_compression_change(); duplicate_cn_change(); + tunnel_networkv6_type_change(); }); //]]> --- a/etc/rc.newwanipv6 2026-05-02 20:17:54.710124000 +0000 +++ b/etc/rc.newwanipv6 2026-05-02 20:39:13.363539090 +0000 @@ -263,6 +263,9 @@ /* start OpenVPN server & clients */ if (substr($interface_real, 0, 4) != "ovpn") { openvpn_resync_all($interface, 'inet6'); + /* Also resync any OpenVPN instances using DHCPv6 PD tracking on this WAN + * that are NOT bound directly to it (e.g. bound to "any" or a LAN iface). */ + openvpn_resync_dhcp6pd_wan($interface); } /* reconfigure GRE/GIF tunnels */