From 134e2e71477490533a7c745929649bae88b8bdd4 Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Wed, 14 Jan 2026 13:46:16 +0100 Subject: [PATCH 1/3] Add IPv6 configuration support Implement IPv6 support in the Network Configuration dialog: - Add SLAAC (Stateless Address Autoconfiguration) support - Add static IPv6 address configuration - Add IPv6 gateway configuration (with link-local address support) - Add IPv6 DNS server configuration - Fix existing GUI bugs (labels, connect handlers) New functions in net_api.py: - start_static_ipv6_network() - enable_slaac() / disable_slaac() - get_ipv6_addresses() - has_slaac_enabled() - get_ipv6_gateway() New function in query.py: - get_interface_settings_ipv6() Tested on GhostBSD 14.3-RELEASE with FritzBox router. Closes #120 --- NetworkMgr/configuration.py | 213 ++++++++++++++++++++++++++++-------- NetworkMgr/net_api.py | 59 ++++++++++ NetworkMgr/query.py | 105 ++++++++++++++++++ 3 files changed, 332 insertions(+), 45 deletions(-) diff --git a/NetworkMgr/configuration.py b/NetworkMgr/configuration.py index 36d7bb1..d0689a4 100755 --- a/NetworkMgr/configuration.py +++ b/NetworkMgr/configuration.py @@ -11,9 +11,12 @@ restart_card_network, restart_routing_and_dhcp, start_static_network, + start_static_ipv6_network, + enable_slaac, + disable_slaac, wait_inet ) -from NetworkMgr.query import get_interface_settings +from NetworkMgr.query import get_interface_settings, get_interface_settings_ipv6 from subprocess import run rcconf = open('/etc/rc.conf', 'r').read() @@ -46,20 +49,28 @@ def edit_ipv4_setting(self, widget): self.saveButton.set_sensitive(True) def edit_ipv6_setting(self, widget, value): - if value == "SLAAC": - self.ipInputAddressEntry6.set_sensitive(False) - self.ipInputMaskEntry6.set_sensitive(False) - self.ipInputGatewayEntry6.set_sensitive(False) - self.prymary_dnsEntry6.set_sensitive(False) - self.searchEntry6.set_sensitive(False) - self.saveButton.set_sensitive(False) - else: - self.ipInputAddressEntry6.set_sensitive(True) - self.ipInputMaskEntry6.set_sensitive(True) - self.ipInputGatewayEntry6.set_sensitive(True) - self.prymary_dnsEntry6.set_sensitive(True) - self.searchEntry6.set_sensitive(True) - self.saveButton.set_sensitive(True) + if widget.get_active(): + self.method6 = value + # Check if GUI elements exist (may be called during init) + if not hasattr(self, 'ipInputAddressEntry6'): + return + if value == "SLAAC": + self.ipInputAddressEntry6.set_sensitive(False) + self.ipInputMaskEntry6.set_sensitive(False) + self.ipInputGatewayEntry6.set_sensitive(False) + self.prymary_dnsEntry6.set_sensitive(False) + self.searchEntry6.set_sensitive(False) + else: + self.ipInputAddressEntry6.set_sensitive(True) + self.ipInputMaskEntry6.set_sensitive(True) + self.ipInputGatewayEntry6.set_sensitive(True) + self.prymary_dnsEntry6.set_sensitive(True) + self.searchEntry6.set_sensitive(True) + # Enable save button if method changed + if self.method6 == self.currentSettings6["Assignment Method"]: + self.saveButton.set_sensitive(False) + else: + self.saveButton.set_sensitive(True) def entry_trigger_save_button(self, widget, event): self.saveButton.set_sensitive(True) @@ -92,6 +103,9 @@ def __init__(self, selected_nic=None): interfaceComboBox.set_active(active_index) self.currentSettings = get_interface_settings(DEFAULT_NIC) self.method = self.currentSettings["Assignment Method"] + # IPv6 settings + self.currentSettings6 = get_interface_settings_ipv6(DEFAULT_NIC) + self.method6 = self.currentSettings6["Assignment Method"] interfaceComboBox.connect("changed", self.cbox_config_refresh, self.NICS) # Build Label to sit in front of the ComboBox @@ -267,31 +281,37 @@ def __init__(self, selected_nic=None): interfaceBox6.pack_start(labelOne6, False, False, 0) interfaceBox6.pack_end(interfaceComboBox6, True, True, 0) - # Add radio button to toggle DHCP or not - rb_slaac6 = Gtk.RadioButton.new_with_label(None, "SLAAC") - rb_slaac6.set_margin_top(15) - rb_slaac6.connect("toggled", self.edit_ipv6_setting, "SLAAC") - rb_manual6 = Gtk.RadioButton.new_with_label_from_widget( - rb_slaac6, "Manual") - rb_manual6.set_margin_top(15) - rb_manual6.join_group(rb_slaac6) - rb_manual6.connect("toggled", self.edit_ipv6_setting, "Manual") - - radioButtonLabel6 = Gtk.Label(label="IPv4 Method:") + # Add radio button to toggle SLAAC or Manual + self.rb_slaac6 = Gtk.RadioButton.new_with_label(None, "SLAAC") + self.rb_slaac6.set_margin_top(15) + self.rb_slaac6.connect("toggled", self.edit_ipv6_setting, "SLAAC") + self.rb_manual6 = Gtk.RadioButton.new_with_label_from_widget( + self.rb_slaac6, "Manual") + self.rb_manual6.set_margin_top(15) + self.rb_manual6.join_group(self.rb_slaac6) + self.rb_manual6.connect("toggled", self.edit_ipv6_setting, "Manual") + + # Set initial state based on current settings + if self.method6 == "Manual": + self.rb_manual6.set_active(True) + else: + self.rb_slaac6.set_active(True) + + radioButtonLabel6 = Gtk.Label(label="IPv6 Method:") radioButtonLabel6.set_margin_top(15) radioButtonLabel6.set_margin_start(30) radioBox6 = Gtk.Box(orientation=0, spacing=50) radioBox6.set_homogeneous(False) radioBox6.pack_start(radioButtonLabel6, False, False, 0) - radioBox6.pack_start(rb_slaac6, True, False, 0) - radioBox6.pack_end(rb_manual6, True, True, 0) + radioBox6.pack_start(self.rb_slaac6, True, False, 0) + radioBox6.pack_end(self.rb_manual6, True, True, 0) # Add Manual Address Field ipInputAddressLabel6 = Gtk.Label(label="Address") ipInputAddressLabel6.set_margin_top(15) - ipInputMaskLabel6 = Gtk.Label(label="Subnet Mask") + ipInputMaskLabel6 = Gtk.Label(label="Prefix Length") ipInputMaskLabel6.set_margin_top(15) ipInputGatewayLabel6 = Gtk.Label(label="Gateway") @@ -301,7 +321,7 @@ def __init__(self, selected_nic=None): self.ipInputAddressEntry6.set_margin_start(15) self.ipInputAddressEntry6.connect("key-release-event", self.entry_trigger_save_button) self.ipInputMaskEntry6 = Gtk.Entry() - self.ipInputAddressEntry6.connect("key-release-event", self.entry_trigger_save_button) + self.ipInputMaskEntry6.connect("key-release-event", self.entry_trigger_save_button) self.ipInputGatewayEntry6 = Gtk.Entry() self.ipInputGatewayEntry6.set_margin_end(15) self.ipInputGatewayEntry6.connect("key-release-event", self.entry_trigger_save_button) @@ -353,11 +373,13 @@ def __init__(self, selected_nic=None): searchBox6.pack_start(searchLabel6, False, False, 0) searchBox6.pack_end(self.searchEntry6, True, True, 0) - self.ipInputAddressEntry6.set_sensitive(False) - self.ipInputMaskEntry6.set_sensitive(False) - self.ipInputGatewayEntry6.set_sensitive(False) - self.prymary_dnsEntry6.set_sensitive(False) - self.searchEntry6.set_sensitive(False) + # Set initial sensitivity based on current method (SLAAC = disabled, Manual = enabled) + manual_enabled = (self.method6 == "Manual") + self.ipInputAddressEntry6.set_sensitive(manual_enabled) + self.ipInputMaskEntry6.set_sensitive(manual_enabled) + self.ipInputGatewayEntry6.set_sensitive(manual_enabled) + self.prymary_dnsEntry6.set_sensitive(manual_enabled) + self.searchEntry6.set_sensitive(manual_enabled) gridOne6 = Gtk.Grid() gridOne6.set_column_homogeneous(True) @@ -370,7 +392,6 @@ def __init__(self, selected_nic=None): gridOne6.attach(ipEntryBox6, 0, 3, 4, 1) gridOne6.attach(dnsEntryBox6, 0, 4, 4, 1) gridOne6.attach(searchBox6, 0, 5, 4, 1) - gridOne6.set_sensitive(False) # Build Notebook @@ -401,7 +422,7 @@ def __init__(self, selected_nic=None): # Apply Tab 2 content and formatting to the notebook nb.append_page(gridOne6) - nb.set_tab_label_text(gridOne6, "IPv6 Settings WIP") + nb.set_tab_label_text(gridOne6, "IPv6 Settings") # Put all the widgets together into one window mainBox = Gtk.Box(orientation=1, spacing=0) mainBox.pack_start(nb, True, True, 0) @@ -412,8 +433,11 @@ def __init__(self, selected_nic=None): # for the newly selected active interface. def cbox_config_refresh(self, widget, nics): # actions here need to refresh the values on the first two tabs. - self.currentSettings = get_interface_settings(nics[widget.get_active()]) + selected_nic = nics[widget.get_active()] + self.currentSettings = get_interface_settings(selected_nic) + self.currentSettings6 = get_interface_settings_ipv6(selected_nic) self.update_interface_settings() + self.update_interface_settings_ipv6() def update_interface_settings(self): self.ipInputAddressEntry.set_text(self.currentSettings["Interface IP"]) @@ -427,6 +451,28 @@ def update_interface_settings(self): else: self.rb_manual4.set_active(True) + def update_interface_settings_ipv6(self): + self.ipInputAddressEntry6.set_text(self.currentSettings6.get("Interface IPv6", "")) + self.ipInputMaskEntry6.set_text(str(self.currentSettings6.get("Prefix Length", "64"))) + self.ipInputGatewayEntry6.set_text(self.currentSettings6.get("Default Gateway", "")) + self.prymary_dnsEntry6.set_text(self.currentSettings6.get("DNS Server 1", "")) + self.searchEntry6.set_text(self.currentSettings6.get("Search Domain", "")) + self.method6 = self.currentSettings6.get("Assignment Method", "SLAAC") + if self.method6 == "Manual": + self.rb_manual6.set_active(True) + self.ipInputAddressEntry6.set_sensitive(True) + self.ipInputMaskEntry6.set_sensitive(True) + self.ipInputGatewayEntry6.set_sensitive(True) + self.prymary_dnsEntry6.set_sensitive(True) + self.searchEntry6.set_sensitive(True) + else: + self.rb_slaac6.set_active(True) + self.ipInputAddressEntry6.set_sensitive(False) + self.ipInputMaskEntry6.set_sensitive(False) + self.ipInputGatewayEntry6.set_sensitive(False) + self.prymary_dnsEntry6.set_sensitive(False) + self.searchEntry6.set_sensitive(False) + def commit_pending_changes(self, widget): self.hide_window() GLib.idle_add(self.update_system) @@ -479,8 +525,81 @@ def update_system(self): wait_inet(nic) restart_routing_and_dhcp(nic) + # Apply IPv6 configuration + self.update_system_ipv6(nic) + self.destroy() + def update_system_ipv6(self, nic): + """Apply IPv6 configuration changes.""" + inet6 = self.ipInputAddressEntry6.get_text() + prefixlen = self.ipInputMaskEntry6.get_text() or "64" + gateway6 = self.ipInputGatewayEntry6.get_text() + dns6 = self.prymary_dnsEntry6.get_text() + + if self.method6 == 'Manual': + # Static IPv6 configuration + ifconfig_ipv6 = f'ifconfig_{nic}_ipv6="inet6 {inet6} prefixlen {prefixlen}"' + self.update_rc_conf(ifconfig_ipv6) + + # Disable rtsold for static configuration + self.update_rc_conf('rtsold_enable="NO"') + + # Apply the static IPv6 address + if inet6: + disable_slaac(nic) + start_static_ipv6_network(nic, inet6, prefixlen) + + # Set IPv6 default gateway if provided + if gateway6: + # Link-local addresses (fe80::) need interface suffix + if gateway6.lower().startswith('fe80:') and '%' not in gateway6: + gateway6_full = f'{gateway6}%{nic}' + else: + gateway6_full = gateway6 + # Save with interface suffix in rc.conf for persistence + self.update_rc_conf(f'ipv6_defaultrouter="{gateway6_full}"') + # Apply gateway immediately + run('route delete -inet6 default 2>/dev/null', shell=True) + run(f'route add -inet6 default {gateway6_full}', shell=True) + + # Add IPv6 DNS to resolv.conf if provided + if dns6: + self.add_ipv6_dns(dns6) + else: + # SLAAC configuration + ifconfig_ipv6 = f'ifconfig_{nic}_ipv6="inet6 accept_rtadv"' + self.update_rc_conf(ifconfig_ipv6) + + # Enable rtsold for SLAAC + self.update_rc_conf('rtsold_enable="YES"') + + # Remove static IPv6 gateway if switching to SLAAC + try: + self.remove_rc_conf_var('ipv6_defaultrouter') + except Exception: + pass # Variable may not exist + + # Enable SLAAC + enable_slaac(nic) + + def add_ipv6_dns(self, dns6): + """Add IPv6 DNS server to resolv.conf without removing existing entries.""" + resolv_path = '/etc/resolv.conf' + try: + with open(resolv_path, 'r') as f: + content = f.read() + # Check if this IPv6 DNS is already present + if f'nameserver {dns6}' not in content: + with open(resolv_path, 'a') as f: + f.write(f'nameserver {dns6}\n') + except Exception: + pass # resolv.conf may be managed by dhclient + + def remove_rc_conf_var(self, varname): + """Remove a variable from rc.conf using sysrc.""" + run(f'sysrc -x {varname}', shell=True) + def hide_window(self): self.hide() return False @@ -492,13 +611,17 @@ def update_rc_conf(self, line): run(f'sysrc {line}', shell=True) def remove_rc_conf_line(self, line): - with open('/etc/rc.conf', "r+") as rc_conf: - lines = rc_conf.readlines() - rc_conf.seek(0) - idx = lines.index(line) - lines.pop(idx) - rc_conf.truncate() - rc_conf.writelines(lines) + try: + with open('/etc/rc.conf', "r+") as rc_conf: + lines = rc_conf.readlines() + if line in lines: + rc_conf.seek(0) + idx = lines.index(line) + lines.pop(idx) + rc_conf.truncate() + rc_conf.writelines(lines) + except (ValueError, FileNotFoundError): + pass # Line doesn't exist, nothing to remove def network_card_configuration(default_int): diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index 24b2348..1b8cbae 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -317,6 +317,65 @@ def start_static_network(netcard, inet, netmask): run('service routing restart', shell=True) +# IPv6 configuration functions + +def start_static_ipv6_network(netcard, inet6, prefixlen): + """Configure a static IPv6 address on the given interface.""" + run(f'ifconfig {netcard} inet6 {inet6} prefixlen {prefixlen}', shell=True) + sleep(1) + run('service routing restart', shell=True) + + +def enable_slaac(netcard): + """Enable SLAAC (Stateless Address Autoconfiguration) on the interface.""" + # Remove any existing IPv6 addresses first + run(f'ifconfig {netcard} inet6 -accept_rtadv', shell=True) + sleep(0.5) + # Enable accept_rtadv for SLAAC + run(f'ifconfig {netcard} inet6 accept_rtadv', shell=True) + # Start rtsold to solicit router advertisements + run(f'rtsol {netcard}', shell=True) + + +def disable_slaac(netcard): + """Disable SLAAC on the interface.""" + run(f'ifconfig {netcard} inet6 -accept_rtadv', shell=True) + + +def get_ipv6_addresses(netcard): + """Get all IPv6 addresses configured on the interface.""" + try: + output = check_output(f'ifconfig {netcard}', shell=True, universal_newlines=True) + # Match inet6 addresses, excluding link-local (fe80::) and localhost (::1) + addresses = re.findall(r'inet6 ([0-9a-fA-F:]+)%?\S* prefixlen (\d+)', output) + return [(addr, int(prefixlen)) for addr, prefixlen in addresses] + except Exception: + return [] + + +def has_slaac_enabled(netcard): + """Check if SLAAC (accept_rtadv) is enabled on the interface.""" + try: + output = check_output(f'ifconfig {netcard}', shell=True, universal_newlines=True) + return 'ACCEPT_RTADV' in output + except Exception: + return False + + +def get_ipv6_gateway(): + """Get the default IPv6 gateway from routing table.""" + try: + output = check_output('netstat -rn -f inet6', shell=True, universal_newlines=True) + for line in output.splitlines(): + if line.startswith('default'): + parts = line.split() + if len(parts) >= 2: + return parts[1] + except Exception: + pass + return "" + + def startnetworkcard(netcard): run(f'service netif start {netcard}', shell=True) sleep(1) diff --git a/NetworkMgr/query.py b/NetworkMgr/query.py index 07b9bc7..fd9803f 100644 --- a/NetworkMgr/query.py +++ b/NetworkMgr/query.py @@ -5,6 +5,111 @@ import os +def get_interface_settings_ipv6(active_nic): + """Get IPv6 settings for the given network interface.""" + ipv6_settings = {} + rc_conf = open("/etc/rc.conf", "r").read() + + # Check if SLAAC is enabled (accept_rtadv in rc.conf) + slaac_search = re.search( + fr'^ifconfig_{active_nic}_ipv6=".*accept_rtadv', + rc_conf, + re.MULTILINE | re.IGNORECASE + ) + if slaac_search: + ipv6_settings["Assignment Method"] = "SLAAC" + else: + # Check for static IPv6 configuration + static_search = re.search( + fr'^ifconfig_{active_nic}_ipv6="inet6\s+([0-9a-fA-F:]+).*prefixlen\s+(\d+)', + rc_conf, + re.MULTILINE + ) + if static_search: + ipv6_settings["Assignment Method"] = "Manual" + else: + ipv6_settings["Assignment Method"] = "SLAAC" # Default + + # Get current IPv6 address from ifconfig + try: + ifcmd = f"ifconfig {active_nic}" + ifoutput = check_output(ifcmd.split(" "), universal_newlines=True) + + # Find global IPv6 addresses (exclude link-local fe80::) + ipv6_matches = re.findall( + r'inet6 ([0-9a-fA-F:]+)%?\S* prefixlen (\d+)', + ifoutput + ) + # Filter out link-local addresses + global_addrs = [(addr, plen) for addr, plen in ipv6_matches + if not addr.lower().startswith('fe80:')] + + if global_addrs: + ipv6_settings["Interface IPv6"] = global_addrs[0][0] + ipv6_settings["Prefix Length"] = global_addrs[0][1] + else: + ipv6_settings["Interface IPv6"] = "" + ipv6_settings["Prefix Length"] = "64" + except Exception: + ipv6_settings["Interface IPv6"] = "" + ipv6_settings["Prefix Length"] = "64" + + # Get IPv6 default gateway from rc.conf or routing table + gateway_search = re.search( + r'^ipv6_defaultrouter="([0-9a-fA-F:]+)"', + rc_conf, + re.MULTILINE + ) + if gateway_search: + ipv6_settings["Default Gateway"] = gateway_search.group(1) + else: + # Try to get from routing table + try: + netstat_output = check_output( + 'netstat -rn -f inet6'.split(), + universal_newlines=True + ) + for line in netstat_output.splitlines(): + if line.startswith('default'): + parts = line.split() + if len(parts) >= 2: + # Remove interface suffix if present (e.g., fe80::1%em0) + gw = parts[1].split('%')[0] + ipv6_settings["Default Gateway"] = gw + break + else: + ipv6_settings["Default Gateway"] = "" + except Exception: + ipv6_settings["Default Gateway"] = "" + + # Get IPv6 DNS servers from resolv.conf + ipv6_settings["DNS Server 1"] = "" + if os.path.exists('/etc/resolv.conf'): + resolv_conf = open('/etc/resolv.conf').read() + # Match IPv6 nameservers (must contain at least one colon) + ipv6_nameservers = re.findall( + r'^nameserver\s+([0-9a-fA-F]*:[0-9a-fA-F:]+)', + resolv_conf, + re.MULTILINE + ) + if ipv6_nameservers: + ipv6_settings["DNS Server 1"] = ipv6_nameservers[0] + + # Get search domain (shared with IPv4) + ipv6_settings["Search Domain"] = "" + if os.path.exists('/etc/resolv.conf'): + resolv_conf = open('/etc/resolv.conf').read() + search_match = re.search(r'^search\s+(.+)$', resolv_conf, re.MULTILINE) + if search_match: + ipv6_settings["Search Domain"] = search_match.group(1).strip() + else: + domain_match = re.search(r'^domain\s+(.+)$', resolv_conf, re.MULTILINE) + if domain_match: + ipv6_settings["Search Domain"] = domain_match.group(1).strip() + + return ipv6_settings + + def get_interface_settings(active_nic): interface_settings = {} rc_conf = open("/etc/rc.conf", "r").read() From 3dbcdf26a4be58352856821584b1e3328f77f25e Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Wed, 14 Jan 2026 13:50:18 +0100 Subject: [PATCH 2/3] Address code review feedback - Fix ipv6_defaultrouter regex to match link-local with interface suffix - Fix enable_slaac docstring to match actual behavior - Fix get_ipv6_addresses docstring (returns all addresses, not filtered) --- NetworkMgr/net_api.py | 10 ++++++---- NetworkMgr/query.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/NetworkMgr/net_api.py b/NetworkMgr/net_api.py index 1b8cbae..b4416c6 100755 --- a/NetworkMgr/net_api.py +++ b/NetworkMgr/net_api.py @@ -327,8 +327,9 @@ def start_static_ipv6_network(netcard, inet6, prefixlen): def enable_slaac(netcard): - """Enable SLAAC (Stateless Address Autoconfiguration) on the interface.""" - # Remove any existing IPv6 addresses first + """Enable SLAAC (Stateless Address Autoconfiguration) on the interface + by toggling accept_rtadv and soliciting router advertisements.""" + # First disable, then re-enable to ensure clean state run(f'ifconfig {netcard} inet6 -accept_rtadv', shell=True) sleep(0.5) # Enable accept_rtadv for SLAAC @@ -343,10 +344,11 @@ def disable_slaac(netcard): def get_ipv6_addresses(netcard): - """Get all IPv6 addresses configured on the interface.""" + """Get all IPv6 addresses configured on the interface. + Returns list of tuples: (address, prefixlen).""" try: output = check_output(f'ifconfig {netcard}', shell=True, universal_newlines=True) - # Match inet6 addresses, excluding link-local (fe80::) and localhost (::1) + # Match all inet6 addresses with their prefix lengths addresses = re.findall(r'inet6 ([0-9a-fA-F:]+)%?\S* prefixlen (\d+)', output) return [(addr, int(prefixlen)) for addr, prefixlen in addresses] except Exception: diff --git a/NetworkMgr/query.py b/NetworkMgr/query.py index fd9803f..31e3d38 100644 --- a/NetworkMgr/query.py +++ b/NetworkMgr/query.py @@ -55,8 +55,9 @@ def get_interface_settings_ipv6(active_nic): ipv6_settings["Prefix Length"] = "64" # Get IPv6 default gateway from rc.conf or routing table + # Pattern allows optional interface suffix for link-local (e.g., fe80::1%em0) gateway_search = re.search( - r'^ipv6_defaultrouter="([0-9a-fA-F:]+)"', + r'^ipv6_defaultrouter="([0-9a-fA-F:%]+)"', rc_conf, re.MULTILINE ) From 9ccfa4f025b0261a0656bd8152b24d57c3914d1c Mon Sep 17 00:00:00 2001 From: Sebastian van de Meer Date: Wed, 14 Jan 2026 13:51:55 +0100 Subject: [PATCH 3/3] Fix ipv6_defaultrouter regex to properly match interface suffix --- NetworkMgr/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NetworkMgr/query.py b/NetworkMgr/query.py index 31e3d38..2f0d2dc 100644 --- a/NetworkMgr/query.py +++ b/NetworkMgr/query.py @@ -57,7 +57,7 @@ def get_interface_settings_ipv6(active_nic): # Get IPv6 default gateway from rc.conf or routing table # Pattern allows optional interface suffix for link-local (e.g., fe80::1%em0) gateway_search = re.search( - r'^ipv6_defaultrouter="([0-9a-fA-F:%]+)"', + r'^ipv6_defaultrouter="([0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?)"', rc_conf, re.MULTILINE )