summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChristian Breunig <christian@breunig.cc>2026-10-07 19:56:37 +0200
committerChristian Breunig <christian@breunig.cc>2026-10-31 09:58:29 +0000
commitc73faac7f95ec837da6f964d15cc05ff9ffd2de6 (patch)
treee78c29e0df1c817a8ab20c3a32d558ac98d2c869 /src
parent552ade702fc033f68cda998055bd5b74f195c7ac (diff)
downloadvyos-1x-c73faac7f95ec837da6f964d15cc05ff9ffd2de6.tar.gz
vyos-1x-c73faac7f95ec837da6f964d15cc05ff9ffd2de6.zip
interfaces: T3871: name interfaces from a slot-anchored mapping store
Names used to be derived from 'hw-id' in config.boot. A node carrying an address but no hw-id had to be matched to some NIC, and nothing available at boot could say which one - every heuristic could bind a configured address to the wrong physical wire. Naming authority moves to a mapping store binding a name to the slot the hardware occupies. The slot is the identity, so a replacement card in that slot is that interface again. The store renders systemd .link files, leaving udev to apply names as devices appear.
Diffstat (limited to 'src')
-rw-r--r--src/etc/udev/rules.d/62-temporary-interface-rename.rules1
-rw-r--r--src/etc/udev/rules.d/63-hyperv-vf-net.rules16
-rw-r--r--src/etc/udev/rules.d/64-vyos-vmware-net.rules14
-rw-r--r--src/etc/udev/rules.d/65-vyos-net.rules41
-rwxr-xr-xsrc/helpers/vyos-interface-rescan.py155
-rwxr-xr-xsrc/init/vyos-router37
-rwxr-xr-xsrc/system/vyos-net-name-resolve.py221
-rw-r--r--src/systemd/vyos-net-name-resolve.service15
-rw-r--r--src/tests/helper.py11
-rw-r--r--src/tests/test_ifname_store.py572
-rw-r--r--src/tests/test_net_name_resolve.py227
-rwxr-xr-xsrc/udev/vyos_net_name276
12 files changed, 1145 insertions, 441 deletions
diff --git a/src/etc/udev/rules.d/62-temporary-interface-rename.rules b/src/etc/udev/rules.d/62-temporary-interface-rename.rules
deleted file mode 100644
index 4a579dcab..000000000
--- a/src/etc/udev/rules.d/62-temporary-interface-rename.rules
+++ /dev/null
@@ -1 +0,0 @@
-SUBSYSTEM=="net", ACTION=="add", KERNEL=="eth*", DRIVERS=="?*", NAME="e$env{IFINDEX}"
diff --git a/src/etc/udev/rules.d/63-hyperv-vf-net.rules b/src/etc/udev/rules.d/63-hyperv-vf-net.rules
index e5ecb4ede..224af12ab 100644
--- a/src/etc/udev/rules.d/63-hyperv-vf-net.rules
+++ b/src/etc/udev/rules.d/63-hyperv-vf-net.rules
@@ -1,3 +1,12 @@
+# Azure/Hyper-V VF interfaces must be kept out of the ethN namespace that
+# VyOS manages. A VF is the acceleration datapath of an existing synthetic
+# NIC, not an interface in its own right, and it shares that NIC's MAC - so
+# left alone it would be taken for a second, independent interface.
+#
+# Renaming it to vf_ethN here, at device-add, is what keeps it out of the
+# way; vyos-net-name-resolve.py then skips anything named vf_* (as well as
+# anything with an SR-IOV physfn link, which is the general case).
+
ATTR{[dmi/id]sys_vendor}!="Microsoft Corporation", GOTO="end_hyperv_nic"
# Primary rename path for Azure VF.
@@ -6,4 +15,11 @@ ATTR{[dmi/id]sys_vendor}!="Microsoft Corporation", GOTO="end_hyperv_nic"
# the interfaces already renamed to vf_ethN
ACTION=="add", SUBSYSTEM=="net", DRIVERS=="hv_pci", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c"
+# Fallback path: a VF that still appears as a plain ethN, which happens
+# when it is seen in an intermediate state before the hv_pci match above
+# applies. Scoped to Azure on purpose - bare-metal Mellanox NICs are
+# ordinary interfaces and must not be caught here.
+ACTION=="add", SUBSYSTEM=="net", DRIVERS=="mlx*_core", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c"
+ACTION=="add", SUBSYSTEM=="net", DRIVERS=="mana", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c"
+
LABEL="end_hyperv_nic"
diff --git a/src/etc/udev/rules.d/64-vyos-vmware-net.rules b/src/etc/udev/rules.d/64-vyos-vmware-net.rules
deleted file mode 100644
index 66a4a069b..000000000
--- a/src/etc/udev/rules.d/64-vyos-vmware-net.rules
+++ /dev/null
@@ -1,14 +0,0 @@
-ATTR{[dmi/id]sys_vendor}!="VMware, Inc.", GOTO="end_vmware_nic"
-
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet0", ENV{VYOS_IFNAME}="eth0"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet1", ENV{VYOS_IFNAME}="eth1"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet2", ENV{VYOS_IFNAME}="eth2"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet3", ENV{VYOS_IFNAME}="eth3"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet4", ENV{VYOS_IFNAME}="eth4"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet5", ENV{VYOS_IFNAME}="eth5"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet6", ENV{VYOS_IFNAME}="eth6"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet7", ENV{VYOS_IFNAME}="eth7"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet8", ENV{VYOS_IFNAME}="eth8"
-ACTION=="add", SUBSYSTEM=="net", ATTRS{label}=="Ethernet9", ENV{VYOS_IFNAME}="eth9"
-
-LABEL="end_vmware_nic"
diff --git a/src/etc/udev/rules.d/65-vyos-net.rules b/src/etc/udev/rules.d/65-vyos-net.rules
deleted file mode 100644
index 0e7567cf8..000000000
--- a/src/etc/udev/rules.d/65-vyos-net.rules
+++ /dev/null
@@ -1,41 +0,0 @@
-# These rules use vyos_net_name to persistently name network interfaces
-# per "hwid" association in the VyOS configuration file.
-
-ACTION!="add", GOTO="vyos_net_end"
-SUBSYSTEM!="net", GOTO="vyos_net_end"
-
-# Fallback path for Azure VF interfaces: if a VF still appears as plain ethN,
-# rename it to vf_ethX before persistent synthetic interface naming runs to
-# prevent it from incorrectly being considered as a synthetic interface.
-ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mlx*_core", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c", GOTO="vyos_net_end"
-ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mana", KERNEL=="eth*", PROGRAM="vyos_vf_name %k", NAME="%c", GOTO="vyos_net_end"
-
-# VF interfaces are not handled by vyos_net_name, so skip them.
-# KERNEL=="vf_*" catches already-renamed Mellanox and MANA VFs.
-# DRIVERS=="mlx*_core" and DRIVERS=="mana" on Azure catch VF interfaces
-# in an unexpected intermediate state (still named ethN/eN) that must not
-# be remapped by vyos_net_name.
-# This guard is intentionally scoped to Microsoft Azure to
-# avoid excluding bare-metal Mellanox NICs on physical hardware, which do
-# need persistent naming via vyos_net_name.
-KERNEL=="vf_*", GOTO="vyos_net_end"
-ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mlx*_core", GOTO="vyos_net_end"
-ATTR{[dmi/id]sys_vendor}=="Microsoft Corporation", DRIVERS=="mana", GOTO="vyos_net_end"
-
-# Do name change for ethernet and wireless devices only
-KERNEL!="eth*|wlan*|e*", GOTO="vyos_net_end"
-
-# ignore "secondary" monitor interfaces of mac80211 drivers
-KERNEL=="wlan*", ATTRS{type}=="803", GOTO="vyos_net_end"
-
-# If using VyOS predefined names
-ENV{VYOS_IFNAME}!="eth*", GOTO="end_vyos_predef_names"
-
-DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address} $env{VYOS_IFNAME}", NAME="%c", GOTO="vyos_net_end"
-
-LABEL="end_vyos_predef_names"
-
-# ignore interfaces without a driver link like bridges and VLANs
-DRIVERS=="?*", PROGRAM="vyos_net_name %k $attr{address}", NAME="%c"
-
-LABEL="vyos_net_end"
diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py
index fea9bca1c..fd2bf907e 100755
--- a/src/helpers/vyos-interface-rescan.py
+++ b/src/helpers/vyos-interface-rescan.py
@@ -14,21 +14,25 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
+# Make discovered interfaces visible in the configuration, so a newly
+# installed NIC shows up without the operator adding it by hand.
#
+# T3871: the node is created bare. Writing a 'hw-id' back into config.boot is
+# what used to make a bad guess unrecoverable - naming belongs to the store.
-import os
-import stat
import argparse
import logging
-import netaddr
+import os
+import stat
+from pathlib import Path
from vyos.configtree import ConfigTree
from vyos.defaults import directories
+from vyos.ifconfig.ifname_store import load_store
from vyos.utils.permission import get_cfg_group_id
debug = False
-vyos_udev_dir = directories['vyos_udev_dir']
vyos_log_dir = directories['log']
log_file = os.path.splitext(os.path.basename(__file__))[0]
vyos_log_file = os.path.join(vyos_log_dir, log_file)
@@ -39,139 +43,73 @@ formatter = logging.Formatter('%(levelname)s: %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
-passlist = {
- '02:07:01' : 'Interlan',
- '02:60:60' : '3Com',
- '02:60:8c' : '3Com',
- '02:a0:c9' : 'Intel',
- '02:aa:3c' : 'Olivetti',
- '02:cf:1f' : 'CMC',
- '02:e0:3b' : 'Prominet',
- '02:e6:d3' : 'BTI',
- '52:54:00' : 'Realtek',
- '52:54:4c' : 'Novell 2000',
- '52:54:ab' : 'Realtec',
- 'e2:0c:0f' : 'Kingston Technologies'
-}
-
-def is_multicast(addr: netaddr.eui.EUI) -> bool:
- return bool(addr.words[0] & 0b1)
-
-def is_locally_administered(addr: netaddr.eui.EUI) -> bool:
- return bool(addr.words[0] & 0b10)
-
-def is_on_passlist(hwid: str) -> bool:
- top = hwid.rsplit(':', 3)[0]
- if top in list(passlist):
- return True
- return False
-
-def is_persistent(hwid: str) -> bool:
- addr = netaddr.EUI(hwid)
- if is_multicast(addr):
- return False
- if is_locally_administered(addr) and not is_on_passlist(hwid):
- return False
- return True
+
+def get_interface_type(intf: str) -> str:
+ if intf.startswith('eth'):
+ return 'ethernet'
+ if intf.startswith('wlan'):
+ return 'wireless'
+ logger.critical(f"Unrecognized interface type for '{intf}'")
+ return ''
+
def get_wireless_physical_device(intf: str) -> str:
- if 'wlan' not in intf:
- return ''
+ """The phy a wireless interface belongs to."""
try:
- tmp = os.readlink(f'/sys/class/net/{intf}/phy80211')
+ return os.path.basename(
+ os.readlink(f'/sys/class/net/{intf}/phy80211'))
except OSError:
- logger.critical(f"Failed to read '/sys/class/net/{intf}/phy80211'")
return ''
- phy = os.path.basename(tmp)
- logger.info(f"wireless phy is {phy}")
- return phy
-
-def get_interface_type(intf: str) -> str:
- if 'eth' in intf:
- intf_type = 'ethernet'
- elif 'wlan' in intf:
- intf_type = 'wireless'
- else:
- logger.critical('Unrecognized interface type!')
- intf_type = ''
- return intf_type
-
-def get_new_interfaces() -> dict:
- """ Read any new interface data left in /run/udev/vyos by vyos_net_name
- """
- interfaces = {}
-
- for intf in os.listdir(vyos_udev_dir):
- path = os.path.join(vyos_udev_dir, intf)
- try:
- with open(path) as f:
- hwid = f.read().rstrip()
- except OSError as e:
- logger.error(f"OSError {e}")
- continue
- interfaces[intf] = hwid
- # reverse sort to simplify insertion in config
- interfaces = {key: value for key, value in sorted(interfaces.items(),
- reverse=True)}
- return interfaces
-
-def filter_interfaces(intfs: dict) -> dict:
- """ Ignore no longer existing interfaces or non-persistent mac addresses
- """
- filtered = {}
-
- for intf, hwid in intfs.items():
- if not os.path.isdir(os.path.join('/sys/class/net', intf)):
- continue
- if not is_persistent(hwid):
- continue
- filtered[intf] = hwid
-
- return filtered
def interface_rescan(config_path: str):
- """ Read new data and update config file
- """
- interfaces = get_new_interfaces()
-
- logger.debug(f"interfaces from udev: {interfaces}")
+ """Add a node for every named interface which does not have one yet."""
+ store = load_store()
- interfaces = filter_interfaces(interfaces)
+ # a reserved name whose hardware is absent has nothing to create a node for
+ names = [name for name in sorted(store.get('interfaces', {}))
+ if Path(f'/sys/class/net/{name}').is_dir()]
- logger.debug(f"filtered interfaces: {interfaces}")
+ logger.debug(f'named interfaces present: {names}')
try:
- with open(config_path) as f:
- config_file = f.read()
+ config_file = Path(config_path).read_text()
except OSError as e:
- logger.critical(f"OSError {e}")
+ logger.critical(f'OSError {e}')
exit(1)
config = ConfigTree(config_file)
+ changed = False
- for intf, hwid in interfaces.items():
- logger.info(f"Writing '{intf}' '{hwid}' to config file")
+ for intf in names:
intf_type = get_interface_type(intf)
if not intf_type:
continue
+ if config.exists(['interfaces', intf_type, intf]):
+ continue
+
+ logger.info(f"Adding '{intf}' to the configuration")
if not config.exists(['interfaces', intf_type]):
config.set(['interfaces', intf_type])
config.set_tag(['interfaces', intf_type])
- config.set(['interfaces', intf_type, intf, 'hw-id'], value=hwid)
+ config.set(['interfaces', intf_type, intf])
+ changed = True
if intf_type == 'wireless':
phy = get_wireless_physical_device(intf)
- if not phy:
- continue
- config.set(['interfaces', intf_type, intf, 'physical-device'],
- value=phy)
+ if phy:
+ config.set(['interfaces', intf_type, intf, 'physical-device'],
+ value=phy)
+
+ if not changed:
+ return
try:
- with open(config_path, 'w') as f:
- f.write(config.to_string())
+ Path(config_path).write_text(config.to_string())
except OSError as e:
- logger.critical(f"OSError {e}")
+ logger.critical(f'OSError {e}')
+ exit(1)
+
def main():
global debug
@@ -202,5 +140,6 @@ def main():
interface_rescan(configfile)
+
if __name__ == '__main__':
main()
diff --git a/src/init/vyos-router b/src/init/vyos-router
index c0d12e2a7..ff1de0584 100755
--- a/src/init/vyos-router
+++ b/src/init/vyos-router
@@ -357,11 +357,39 @@ clear_or_override_config_files ()
update_interface_config ()
{
- if [ -d /run/udev/vyos ]; then
+ # add a configuration node for any interface the name store knows about
+ # that does not have one yet
+ if [ -f /opt/vyatta/etc/config/interface-mapping.json ]; then
$vyos_libexec_dir/vyos-interface-rescan.py $BOOTFILE
fi
}
+# warn (but do not fail boot) about configured hw-id interfaces whose
+# hardware was not found by vyos-net-name-resolve.service
+warn_missing_interface_hardware ()
+{
+ local status_file="/run/vyos-net-name-resolve.json"
+ [ -f "${status_file}" ] || return 0
+
+ jq -r '.missing // {} | to_entries[] | "\(.key) \(.value)"' "${status_file}" \
+ 2>/dev/null | while read -r intf hwid; do
+ [ -n "${intf}" ] || continue
+ log_failure_msg "interface '${intf}' (${hwid}) did not appear during boot - check cabling, hardware and driver/firmware. Its name stays reserved."
+ done
+
+ # A replacement NIC inheriting a slot's name also inherits that
+ # interface's addresses, so say so rather than doing it silently.
+ jq -r '.matched_by_path // {} | to_entries[] | "\(.key) \(.value)"' "${status_file}" \
+ 2>/dev/null | while read -r intf hwid; do
+ [ -n "${intf}" ] || continue
+ log_warning_msg "interface '${intf}' was matched to new hardware ${hwid} by slot - verify it is the port you expect before relying on its configuration"
+ done
+
+ if [ "$(jq -r '.persisted // true' "${status_file}" 2>/dev/null)" = "false" ]; then
+ log_failure_msg "interface names could not be persisted - the configuration volume is not mounted"
+ fi
+}
+
cleanup_post_commit_hooks () {
# Remove links from the post-commit hooks directory.
# note that this approach only supports hooks that are "configured",
@@ -606,6 +634,12 @@ start ()
disabled migrate || migrate_bootfile || overall_status=1
+ # config.boot is only guaranteed to exist/be current from this point on
+ # (tmpfs mount + encrypted config unlock + syntax migration have all
+ # already happened above) - hw-id based interface naming must be
+ # resolved against it before anything below applies interface config.
+ systemctl start vyos-net-name-resolve.service || overall_status=1
+
restore_if_missing_bootup_script pre
run_preconfig_script
@@ -613,6 +647,7 @@ start ()
run_postupgrade_script
update_interface_config || overall_status=1
+ warn_missing_interface_hardware
disabled system_activate || system_activate || overall_status=1
diff --git a/src/system/vyos-net-name-resolve.py b/src/system/vyos-net-name-resolve.py
new file mode 100755
index 000000000..b7fe052e6
--- /dev/null
+++ b/src/system/vyos-net-name-resolve.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+#
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# Boot-time maintenance of the interface mapping store (T3871).
+#
+# The store is rendered into systemd .link files, so udev does the actual
+# naming as each device is added. What is left here is what udev cannot do:
+# name hardware the store has never seen, record it, and re-render.
+#
+# Nothing here reads the configuration - a name is a property of the
+# hardware, not of what happens to be configured on it.
+
+import json
+import logging
+import logging.handlers
+import time
+from pathlib import Path
+
+from vyos.ifconfig.ifname_store import device_matches
+from vyos.ifconfig.ifname_store import discover_devices
+from vyos.ifconfig.ifname_store import load_store
+from vyos.ifconfig.ifname_store import resolve
+from vyos.ifconfig.ifname_store import save_store
+from vyos.ifconfig.ifname_store import sync_link_files
+from vyos.system.image import is_running_as_container
+from vyos.utils.process import run
+
+# bounded wait for known hardware to appear
+HARDWARE_WAIT_TIMEOUT = 10
+HARDWARE_WAIT_POLL = 0.25
+
+# unknown hardware has no specific device to wait for, so N identical
+# snapshots stand in for "probing has finished"
+HARDWARE_SETTLE_TIMEOUT = 10
+HARDWARE_SETTLE_STABLE_POLLS = 3
+
+status_file = Path('/run/vyos-net-name-resolve.json')
+
+logger = logging.getLogger()
+
+
+def _snapshot(devices: list) -> set:
+ return {(d['name'], d['mac']) for d in devices}
+
+
+def _unmatched(keys: set, devices: list) -> set:
+ """Recorded slots that no device present this boot occupies."""
+ return {key for key in keys
+ if not any(device_matches(d['properties'], key) for d in devices)}
+
+
+def wait_for_hardware(known_keys: set, timeout: float = HARDWARE_WAIT_TIMEOUT,
+ poll: float = HARDWARE_WAIT_POLL) -> tuple:
+ """Poll until every slot the store knows is filled, or until timeout."""
+ deadline = time.monotonic() + timeout
+ devices = discover_devices()
+ while True:
+ missing = _unmatched(known_keys, devices)
+ if not missing or time.monotonic() >= deadline:
+ return devices, missing
+ time.sleep(poll)
+ devices = discover_devices()
+
+
+def wait_for_settle(initial: list, timeout: float = HARDWARE_SETTLE_TIMEOUT,
+ poll: float = HARDWARE_WAIT_POLL,
+ stable_polls: int = HARDWARE_SETTLE_STABLE_POLLS) -> list:
+ """Poll until the set of interfaces stops changing, or until timeout.
+ Used before naming hardware the store has never seen, where there is no
+ specific MAC to wait for.
+ """
+ deadline = time.monotonic() + timeout
+ devices = initial
+ stable = 1 if devices else 0
+ while stable < stable_polls and time.monotonic() < deadline:
+ time.sleep(poll)
+ seen = discover_devices()
+ stable = stable + 1 if _snapshot(seen) == _snapshot(devices) else 1
+ devices = seen
+ return devices
+
+
+def get_ifindex(name: str) -> str:
+ try:
+ return Path(f'/sys/class/net/{name}/ifindex').read_text().strip()
+ except OSError:
+ return name
+
+
+def rename_interface(old: str, new: str) -> bool:
+ run(f'ip link set dev {old} down')
+ code = run(f'ip link set dev {old} name {new}')
+ if code != 0:
+ logger.error(f"failed to rename '{old}' -> '{new}' (exit {code})")
+ return False
+ logger.info(f"renamed '{old}' -> '{new}'")
+ return True
+
+
+def safe_bulk_rename(plan: dict) -> dict:
+ """Stage every interface under a scratch name derived from its ifindex
+ before assigning the final ones, so the batch cannot collide on a
+ permutation or a cycle. Only new hardware reaches this - udev has already
+ named everything the store knows.
+ """
+ if not plan:
+ return {}
+
+ applied = {}
+ scratch = {}
+ for old, target in plan.items():
+ tmp = f'vyeth{get_ifindex(old)}'
+ if rename_interface(old, tmp):
+ scratch[tmp] = (old, target)
+ applied[old] = target
+
+ for tmp, (old, target) in scratch.items():
+ if rename_interface(tmp, target):
+ run(f'ip link set dev {target} up')
+ else:
+ # still under the scratch name - do not report it as renamed
+ del applied[old]
+ run(f'ip link set dev {tmp} up')
+
+ return applied
+
+
+def write_status(store: dict, devices: list, applied: dict, report: dict,
+ persisted: bool) -> None:
+ """Publish what this pass decided, to warn about and to inspect later."""
+ absent = _unmatched(set(store['interfaces'].values()), devices)
+ status = {
+ 'interfaces': dict(sorted(store['interfaces'].items())),
+ 'missing': {
+ name: key for name, key in sorted(store['interfaces'].items())
+ if key in absent
+ },
+ 'renamed': applied,
+ 'matched': report.get('matched', {}),
+ 'matched_by_path': report.get('replaced', {}),
+ 'new_hardware': report.get('bootstrapped', {}),
+ 'persisted': persisted,
+ }
+ try:
+ status_file.write_text(json.dumps(status, indent=2))
+ except OSError as e:
+ logger.error(f'could not write status file: {e}')
+
+
+def main():
+ if is_running_as_container():
+ # a container owns no NIC - its veth pairs have no backing bus device
+ logger.info('running inside a container - skipping interface naming')
+ return
+
+ store = load_store()
+ known = set(store['interfaces'].values())
+
+ devices, _missing = wait_for_hardware(known)
+
+ if not store['interfaces'] or any(
+ not any(device_matches(d['properties'], k) for k in known)
+ for d in devices):
+ # unknown hardware present - let slower drivers finish registering
+ # so a merely late NIC is not treated as new
+ devices = wait_for_settle(devices)
+
+ absent = _unmatched(known, devices)
+ for name, key in sorted(store['interfaces'].items()):
+ if key in absent:
+ logger.warning(
+ f"'{name}' ({key}) did not appear within "
+ f'{HARDWARE_WAIT_TIMEOUT}s - hardware may be missing, still '
+ 'initializing, or its driver failed to load. Its name stays '
+ 'reserved.'
+ )
+
+ plan, store, report = resolve(devices, store)
+
+ for name, mac in sorted(report.get('bootstrapped', {}).items()):
+ logger.info(f"new hardware {mac} named '{name}'")
+ applied = safe_bulk_rename(plan)
+
+ persisted = save_store(store)
+ if persisted:
+ # render so udev applies the names on the next boot
+ try:
+ sync_link_files(store)
+ except OSError as e:
+ logger.error(f'could not write .link files: {e}')
+ else:
+ logger.warning(
+ 'the configuration volume is not mounted - interface names were '
+ 'resolved for this boot but could not be persisted'
+ )
+
+ write_status(store, devices, applied, report, persisted)
+
+
+if __name__ == '__main__':
+ syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
+ formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s')
+ syslog_handler.setFormatter(formatter)
+
+ logger.addHandler(syslog_handler)
+ logger.setLevel(logging.DEBUG)
+
+ main()
diff --git a/src/systemd/vyos-net-name-resolve.service b/src/systemd/vyos-net-name-resolve.service
new file mode 100644
index 000000000..c3563dd84
--- /dev/null
+++ b/src/systemd/vyos-net-name-resolve.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=VyOS persistent interface naming
+Documentation=man:vyos-net-name-resolve.py(8)
+
+# Started by vyos-router once the config directory is mounted - for an
+# encrypted volume that is only after it has been unlocked - so this can not
+# be ordered independently.
+DefaultDependencies=no
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/libexec/vyos/system/vyos-net-name-resolve.py
+TimeoutStartSec=90
+StandardOutput=journal+console
diff --git a/src/tests/helper.py b/src/tests/helper.py
index 2c4421d0a..5a74525b7 100644
--- a/src/tests/helper.py
+++ b/src/tests/helper.py
@@ -14,9 +14,20 @@
import sys
import importlib.util
+from importlib.machinery import SourceFileLoader
def prepare_module(file_path='', module_name=''):
+ """Load file_path as module_name, returning the loaded module.
+
+ file_path may be an extensionless script (e.g. udev helpers under
+ src/udev/) - spec_from_file_location() cannot infer a loader for those
+ from the suffix alone, so fall back to an explicit SourceFileLoader.
+ """
spec = importlib.util.spec_from_file_location(module_name, file_path)
+ if spec is None:
+ loader = SourceFileLoader(module_name, file_path)
+ spec = importlib.util.spec_from_loader(module_name, loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
sys.modules[module_name] = module
+ return module
diff --git a/src/tests/test_ifname_store.py b/src/tests/test_ifname_store.py
new file mode 100644
index 000000000..3f12594d4
--- /dev/null
+++ b/src/tests/test_ifname_store.py
@@ -0,0 +1,572 @@
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# Coverage for T3871: slot-anchored persistent interface naming.
+#
+# The regression classes at the bottom replay, verbatim, the field
+# reproductions from the review of vyos-1x PR #5417 - the cases where the
+# config-tree-driven resolver bound a configured address to the wrong
+# physical wire. Under the store-based design none of them have a code path
+# to travel, and these tests exist to keep it that way.
+
+import os
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+from vyos.ifconfig.ifname_store import STORE_VERSION
+from vyos.ifconfig.ifname_store import canonical_sort_key
+from vyos.ifconfig.ifname_store import device_matches
+from vyos.ifconfig.ifname_store import ambiguous_keys
+from vyos.ifconfig.ifname_store import hardware_key
+from vyos.ifconfig.ifname_store import discover_devices
+from vyos.ifconfig.ifname_store import empty_store
+from vyos.ifconfig.ifname_store import load_store
+from vyos.ifconfig.ifname_store import permanent_mac
+from vyos.ifconfig.ifname_store import render_link
+from vyos.ifconfig.ifname_store import resolve
+from vyos.ifconfig.ifname_store import sync_link_files
+from vyos.ifconfig.ifname_store import save_store
+
+
+def dev(name, path, mac, wireless=False, bus='pci'):
+ """A device sitting at 'path'. Its key is what udev would report for it -
+ here the raw ID_PATH, which is what a plain virtio or e1000e NIC with no
+ firmware slot index ends up keyed on."""
+ properties = {'ID_PATH': path, 'ID_BUS': bus} if path else {}
+ return {'name': name, 'mac': mac, 'wireless': wireless,
+ 'properties': properties,
+ 'key': hardware_key(properties)}
+
+
+def store_of(mapping, hardware=None):
+ """mapping: {name: path} - the slot each name is recorded against"""
+ return {
+ 'version': STORE_VERSION,
+ 'interfaces': {n: f'ID_PATH={p}' for n, p in mapping.items()},
+ 'hardware': dict(hardware or {}),
+ }
+
+
+class TestCanonicalOrder(unittest.TestCase):
+ def test_pci_sorted_numerically_not_lexically(self):
+ # 0000:09:00.0 must precede 0000:10:00.0 - string order gets this wrong
+ a = dev('x', 'pci-0000:00:09.0', 'aa:00:00:00:00:01')
+ b = dev('y', 'pci-0000:00:10.0', 'aa:00:00:00:00:02')
+ self.assertLess(canonical_sort_key(a), canonical_sort_key(b))
+
+ def test_pci_before_usb(self):
+ pci = dev('x', 'pci-0000:00:1f.6', 'aa:00:00:00:00:01')
+ usb = dev('y', 'usb-0:1:1.0', 'aa:00:00:00:00:00', bus='usb')
+ self.assertLess(canonical_sort_key(pci), canonical_sort_key(usb))
+
+ def test_order_is_independent_of_mac(self):
+ # the whole point: a numerically higher MAC in a lower slot still wins
+ low_slot = dev('x', 'pci-0000:00:02.0', 'ff:ff:ff:ff:ff:ff')
+ high_slot = dev('y', 'pci-0000:00:05.0', '00:00:00:00:00:01')
+ self.assertLess(canonical_sort_key(low_slot), canonical_sort_key(high_slot))
+
+
+class TestReplacedHardware(unittest.TestCase):
+ """A slot keeps its name when its card is swapped - which hands that
+ interface's addresses to different hardware. resolve() reports the change
+ so vyos-router can warn instead of letting it happen silently.
+ """
+
+ PATH = 'pci-0000:00:12.0'
+
+ def _device(self, mac):
+ return dev('eth0', self.PATH, mac)
+
+ def test_same_card_reports_nothing(self):
+ store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'})
+ _, _, report = resolve([self._device('aa:aa:aa:aa:aa:01')], store)
+ self.assertEqual(report['replaced'], {})
+
+ def test_swapped_card_is_reported_with_its_new_mac(self):
+ store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'})
+ _, new_store, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store)
+ self.assertEqual(report['replaced'], {'eth0': 'bb:bb:bb:bb:bb:02'})
+ # the name is still the slot's, and the new card is now what is on record
+ self.assertEqual(new_store['interfaces']['eth0'], f'ID_PATH={self.PATH}')
+ self.assertEqual(new_store['hardware']['eth0'], 'bb:bb:bb:bb:bb:02')
+
+ def test_reported_once_not_on_every_later_boot(self):
+ store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'})
+ _, store, _ = resolve([self._device('bb:bb:bb:bb:bb:02')], store)
+ _, _, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store)
+ self.assertEqual(report['replaced'], {})
+
+ def test_absent_card_keeps_its_record_so_a_later_swap_is_caught(self):
+ store = store_of({'eth0': self.PATH}, {'eth0': 'aa:aa:aa:aa:aa:01'})
+ _, store, _ = resolve([], store)
+ self.assertEqual(store['hardware']['eth0'], 'aa:aa:aa:aa:aa:01')
+ _, _, report = resolve([self._device('bb:bb:bb:bb:bb:02')], store)
+ self.assertEqual(report['replaced'], {'eth0': 'bb:bb:bb:bb:bb:02'})
+
+ def test_new_hardware_is_not_a_replacement(self):
+ _, _, report = resolve([self._device('aa:aa:aa:aa:aa:01')], empty_store())
+ self.assertEqual(report['replaced'], {})
+ self.assertIn('eth0', report['bootstrapped'])
+
+ def test_store_written_before_this_field_reports_nothing(self):
+ # nothing to compare against yet - and no interface may be renamed
+ legacy = {'version': STORE_VERSION,
+ 'interfaces': {'eth0': f'ID_PATH={self.PATH}'}}
+ plan, new_store, report = resolve([self._device('aa:aa:aa:aa:aa:01')],
+ legacy)
+ self.assertEqual(report['replaced'], {})
+ self.assertEqual(plan, {})
+ self.assertEqual(new_store['interfaces']['eth0'], f'ID_PATH={self.PATH}')
+
+
+class TestStoreIO(unittest.TestCase):
+ @staticmethod
+ def _config_dir(d):
+ """A directory that looks like a properly mounted config volume."""
+ (Path(d) / '.vyatta_config').touch()
+ return Path(d) / 'interface-mapping.json'
+
+ def test_roundtrip(self):
+ with tempfile.TemporaryDirectory() as d:
+ p = self._config_dir(d)
+ s = store_of({'eth0': 'pci-0000:00:12.0'})
+ self.assertTrue(save_store(s, p))
+ self.assertEqual(load_store(p), s)
+
+ def test_refuses_to_write_when_config_volume_is_not_mounted(self):
+ # TPM/LUKS unlock failed: vyos-router continues, but persisting here
+ # would write a shadow store that the real volume masks later
+ with tempfile.TemporaryDirectory() as d:
+ p = Path(d) / 'interface-mapping.json' # no .vyatta_config marker
+ self.assertFalse(save_store(store_of({'eth0': 'x'}), p))
+ self.assertFalse(p.exists())
+
+ def test_missing_file_is_empty_store(self):
+ with tempfile.TemporaryDirectory() as d:
+ self.assertEqual(load_store(Path(d) / 'nope.json'), empty_store())
+
+ def test_corrupt_file_is_empty_store(self):
+ with tempfile.TemporaryDirectory() as d:
+ p = self._config_dir(d)
+ p.write_text('{ this is not json')
+ self.assertEqual(load_store(p), empty_store())
+
+ def test_write_is_atomic_no_leftovers(self):
+ with tempfile.TemporaryDirectory() as d:
+ p = self._config_dir(d)
+ save_store(store_of({'eth0': 'pci-0000:00:12.0'}), p)
+ self.assertEqual(sorted(f.name for f in Path(d).iterdir()),
+ ['.vyatta_config', 'interface-mapping.json'])
+
+
+def final_names(report):
+ """Which name each MAC ended up with, read back off the resolve report."""
+ out = {}
+ for bucket in report.values():
+ for name, mac in bucket.items():
+ out[mac] = name
+ return out
+
+
+class TestDeviceMatches(unittest.TestCase):
+ """A stored key names the property it was taken from, so matching a
+ device is comparing that one property - never a guess about which
+ identifier was meant."""
+
+ def test_matches_the_named_property(self):
+ self.assertTrue(device_matches({'ID_NET_NAME_SLOT': 'ens18'},
+ 'ID_NET_NAME_SLOT=ens18'))
+
+ def test_same_value_under_a_different_property_does_not_match(self):
+ self.assertFalse(device_matches({'ID_NET_NAME_PATH': 'ens18'},
+ 'ID_NET_NAME_SLOT=ens18'))
+
+ def test_device_without_the_property_does_not_match(self):
+ self.assertFalse(device_matches({'ID_PATH': 'pci-0000:00:12.0'},
+ 'ID_NET_NAME_SLOT=ens18'))
+
+ def test_empty_or_malformed_key_never_matches(self):
+ self.assertFalse(device_matches({'ID_NET_NAME_SLOT': 'ens18'}, ''))
+ self.assertFalse(device_matches({'ID_NET_NAME_SLOT': 'ens18'}, 'ens18'))
+
+
+class TestResolveBasics(unittest.TestCase):
+ def test_first_boot_bootstraps_in_slot_order(self):
+ devices = [
+ dev('eth0', 'pci-0000:00:15.0', '00:00:5e:00:53:03'),
+ dev('eth1', 'pci-0000:00:12.0', '00:00:5e:00:53:00'),
+ dev('eth2', 'pci-0000:00:13.0', '00:00:5e:00:53:01'),
+ ]
+ _, new_store, _ = resolve(devices, empty_store())
+ self.assertEqual(new_store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:12.0')
+ self.assertEqual(new_store['interfaces']['eth1'], 'ID_PATH=pci-0000:00:13.0')
+ self.assertEqual(new_store['interfaces']['eth2'], 'ID_PATH=pci-0000:00:15.0')
+
+ def test_established_names_are_stable_regardless_of_probe_order(self):
+ store = store_of({'eth0': 'pci-0000:00:12.0', 'eth1': 'pci-0000:00:13.0'})
+ # drivers finished in the opposite order this boot
+ devices = [
+ dev('eth0', 'pci-0000:00:13.0', '00:00:5e:00:53:01'),
+ dev('eth1', 'pci-0000:00:12.0', '00:00:5e:00:53:00'),
+ ]
+ plan, _, _ = resolve(devices, store)
+ self.assertEqual(plan, {'eth0': 'eth1', 'eth1': 'eth0'})
+
+ def test_replacement_nic_in_same_slot_inherits_name(self):
+ # the slot is the identity, so this needs no special rule: whatever
+ # card is in eth1's slot is eth1, along with its configuration
+ store = store_of({'eth1': 'pci-0000:00:13.0'})
+ devices = [dev('eth0', 'pci-0000:00:13.0', 'bb:bb:bb:bb:bb:bb')]
+ plan, new_store, report = resolve(devices, store)
+ self.assertEqual(plan, {'eth0': 'eth1'})
+ self.assertEqual(report['matched'], {'eth1': 'bb:bb:bb:bb:bb:bb'})
+ self.assertEqual(new_store['interfaces']['eth1'], 'ID_PATH=pci-0000:00:13.0')
+
+ def test_a_card_moved_to_another_slot_takes_that_slot_s_name(self):
+ # the consequence of slot identity, stated plainly: move a card and it
+ # becomes whatever the new slot is called, not what it used to be
+ store = store_of({'eth1': 'pci-0000:00:13.0', 'eth2': 'pci-0000:00:14.0'})
+ devices = [dev('eth9', 'pci-0000:00:14.0', 'aa:aa:aa:aa:aa:aa')]
+ plan, _, report = resolve(devices, store)
+ self.assertEqual(plan, {'eth9': 'eth2'})
+ self.assertEqual(final_names(report)['aa:aa:aa:aa:aa:aa'], 'eth2')
+
+ def test_absent_hardware_keeps_its_name_reserved(self):
+ store = store_of({'eth0': 'pci-0000:00:12.0', 'eth1': 'pci-0000:00:13.0'})
+ # eth0's slot is empty; a brand new card appeared elsewhere
+ devices = [
+ dev('eth0', 'pci-0000:00:13.0', '00:00:5e:00:53:01'),
+ dev('eth1', 'pci-0000:00:1c.0', 'cc:cc:cc:cc:cc:cc'),
+ ]
+ _, new_store, report = resolve(devices, store)
+ # the new card must not inherit eth0's name, and with it eth0's config
+ self.assertNotEqual(final_names(report)['cc:cc:cc:cc:cc:cc'], 'eth0')
+ self.assertEqual(new_store['interfaces']['eth0'], 'ID_PATH=pci-0000:00:12.0')
+
+ def test_hardware_with_no_usable_path_falls_back_to_its_mac(self):
+ # some paravirtual buses give udev nothing to build a path from; the
+ # MAC-derived name is the last step of the key chain so the interface
+ # is still stable instead of being renamed every boot
+ properties = {'ID_NET_NAME_MAC': 'enxaabbccddeeff'}
+ device = {'name': 'eth0', 'mac': 'aa:bb:cc:dd:ee:ff', 'wireless': False,
+ 'properties': properties, 'key': hardware_key(properties)}
+ _, new_store, _ = resolve([device], empty_store())
+ self.assertEqual(new_store['interfaces']['eth0'],
+ 'ID_NET_NAME_MAC=enxaabbccddeeff')
+
+ def test_a_path_two_devices_share_is_not_used_to_identify_either(self):
+ # a modem can put several interfaces on one parent device, and they
+ # then report the same path. Keying both on it would give them one
+ # name between them, so fall through to something which tells them
+ # apart.
+ first = {'ID_PATH': 'pci-0000:00:12.0',
+ 'ID_NET_NAME_MAC': 'enxaabbccddee01'}
+ second = {'ID_PATH': 'pci-0000:00:12.0',
+ 'ID_NET_NAME_MAC': 'enxaabbccddee02'}
+ ambiguous = ambiguous_keys([first, second])
+ self.assertEqual(hardware_key(first, ambiguous),
+ 'ID_NET_NAME_MAC=enxaabbccddee01')
+ self.assertEqual(hardware_key(second, ambiguous),
+ 'ID_NET_NAME_MAC=enxaabbccddee02')
+
+ def test_a_path_only_one_device_has_is_still_used(self):
+ first = {'ID_PATH': 'pci-0000:00:12.0'}
+ second = {'ID_PATH': 'pci-0000:00:13.0'}
+ ambiguous = ambiguous_keys([first, second])
+ self.assertEqual(hardware_key(first, ambiguous),
+ 'ID_PATH=pci-0000:00:12.0')
+
+ def test_firmware_slot_is_preferred_over_a_bare_pci_path(self):
+ # a firmware slot index survives PCI renumbering, a bus address does not
+ properties = {'ID_NET_NAME_SLOT': 'ens18',
+ 'ID_NET_NAME_PATH': 'enp0s18',
+ 'ID_PATH': 'pci-0000:00:12.0'}
+ self.assertEqual(hardware_key(properties), 'ID_NET_NAME_SLOT=ens18')
+
+ def test_wireless_uses_its_own_namespace(self):
+ devices = [dev('eth0', 'pci-0000:00:12.0', 'aa:aa:aa:aa:aa:aa'),
+ dev('wlan5', 'pci-0000:00:14.0', 'cc:cc:cc:cc:cc:cc',
+ wireless=True)]
+ _, new_store, _ = resolve(devices, empty_store())
+ self.assertIn('eth0', new_store['interfaces'])
+ self.assertIn('wlan0', new_store['interfaces'])
+
+
+class TestPR5417Regressions(unittest.TestCase):
+ """Each test replays a reproduction from the review of PR #5417, where the
+ config-tree-driven resolver put a configured address on the wrong wire.
+ The assertions are about physical correctness - which MAC ends up behind
+ which name - not about log output.
+ """
+
+ def test_multi_nic_cloud_init_first_boot(self):
+ """2026-08-25 #1: three NICs, cloud-init synthesized a hw-id for eth0
+ only. eth1/eth2 were pushed to eth3/eth4 and both data addresses were
+ unreachable, persisting across reboot.
+ """
+ devices = [
+ dev('eth0', 'pci-0000:00:03.0', '52:54:00:00:00:10'),
+ dev('eth1', 'pci-0000:00:04.0', '52:54:00:ff:00:21'),
+ dev('eth2', 'pci-0000:00:05.0', '52:54:00:00:00:22'),
+ ]
+ _, new_store, report = resolve(devices, empty_store())
+ names = final_names(report)
+ self.assertEqual(names['52:54:00:00:00:10'], 'eth0')
+ self.assertEqual(names['52:54:00:ff:00:21'], 'eth1')
+ self.assertEqual(names['52:54:00:00:00:22'], 'eth2')
+ self.assertEqual(set(new_store['interfaces']), {'eth0', 'eth1', 'eth2'})
+
+ def test_names_follow_slots_not_mac_magnitude(self):
+ """2026-08-25 #2: the resolver swapped eth1/eth2 by MAC order, so each
+ address landed on the other's physical network, and the swap was
+ persisted with no warning. eth1 sits in the lower slot but has the
+ numerically HIGHER MAC.
+ """
+ devices = [
+ dev('eth1', 'pci-0000:00:04.0', '52:54:00:ff:00:21'),
+ dev('eth2', 'pci-0000:00:05.0', '52:54:00:00:00:22'),
+ ]
+ _, _, report = resolve(devices, empty_store())
+ names = final_names(report)
+ self.assertEqual(names['52:54:00:ff:00:21'], 'eth0')
+ self.assertEqual(names['52:54:00:00:00:22'], 'eth1')
+
+ def test_box_named_out_of_slot_order_is_not_reordered(self):
+ """2026-09-02 / 2026-09-09: a box first named by an earlier release in
+ MAC order - eth1 on the slot-5 NIC, eth4 on the slot-2 NIC. Deleting
+ hw-id entries made the resolver re-derive names in PCI slot order and
+ put eth1 and eth4 on each other's cable, silently and permanently.
+
+ The store records the slot each name was actually on, so nothing is
+ ever re-derived and the box keeps the names it had.
+ """
+ store = store_of({
+ 'eth0': 'pci-0000:00:01.0',
+ 'eth1': 'pci-0000:00:05.0',
+ 'eth2': 'pci-0000:00:03.0',
+ 'eth3': 'pci-0000:00:04.0',
+ 'eth4': 'pci-0000:00:02.0',
+ })
+ devices = [
+ dev('eth0', 'pci-0000:00:01.0', '00:00:5e:00:54:01'),
+ dev('eth1', 'pci-0000:00:05.0', '00:00:5e:00:54:12'),
+ dev('eth2', 'pci-0000:00:03.0', '00:00:5e:00:54:13'),
+ dev('eth3', 'pci-0000:00:04.0', '00:00:5e:00:54:14'),
+ dev('eth4', 'pci-0000:00:02.0', '00:00:5e:00:54:19'),
+ ]
+ plan, _, report = resolve(devices, store)
+ self.assertEqual(plan, {}, 'nothing may be renamed')
+ names = final_names(report)
+ self.assertEqual(names['00:00:5e:00:54:12'], 'eth1')
+ self.assertEqual(names['00:00:5e:00:54:19'], 'eth4')
+
+ def test_spare_nic_does_not_displace_configured_interfaces(self):
+ """2026-09-02 #2: a fourth, unconfigured NIC was enough to make the
+ three configured interfaces fail to get their hardware - eth1/eth2
+ stayed bare while eth3/eth4/eth5 appeared holding the NICs.
+ """
+ devices = [
+ dev('eth0', 'pci-0000:00:03.0', '00:00:5e:00:54:31'),
+ dev('eth1', 'pci-0000:00:04.0', '00:00:5e:00:54:39'),
+ dev('eth2', 'pci-0000:00:05.0', '00:00:5e:00:54:32'),
+ dev('eth3', 'pci-0000:00:06.0', '00:00:5e:00:54:3c'),
+ ]
+ _, new_store, report = resolve(devices, empty_store())
+ self.assertEqual(set(new_store['interfaces']),
+ {'eth0', 'eth1', 'eth2', 'eth3'})
+ names = final_names(report)
+ self.assertEqual(names['00:00:5e:00:54:31'], 'eth0')
+ self.assertEqual(names['00:00:5e:00:54:39'], 'eth1')
+ self.assertEqual(names['00:00:5e:00:54:32'], 'eth2')
+
+ def test_config_node_without_hardware_does_not_block_the_others(self):
+ """2026-09-09: eth0/eth1/eth2 configured with no hw-id on a two-NIC
+ VM. Every interface was left pending, both NICs parked on eth3/eth4,
+ and the box was unreachable from the network.
+ """
+ devices = [
+ dev('eth0', 'pci-0000:00:03.0', '00:00:5e:00:54:a1'),
+ dev('eth1', 'pci-0000:00:04.0', '00:00:5e:00:54:a9'),
+ ]
+ plan, new_store, report = resolve(devices, empty_store())
+ # the two NICs present take eth0 and eth1; a configured eth2 node
+ # simply has no hardware, which is a reporting matter not a naming one
+ self.assertEqual(plan, {})
+ names = final_names(report)
+ self.assertEqual(names['00:00:5e:00:54:a1'], 'eth0')
+ self.assertEqual(names['00:00:5e:00:54:a9'], 'eth1')
+ self.assertNotIn('eth3', new_store['interfaces'])
+
+
+class TestLinkFiles(unittest.TestCase):
+ """The .link layer is what removes the rename window: udev applies the
+ name as the device is added, so no probe-order name is ever observable.
+ """
+
+ def test_matches_the_same_property_the_store_keyed_on(self):
+ # udev must apply exactly the mapping recorded in the store - if the
+ # .link matched on anything else the two could disagree about what
+ # identifies an interface
+ body = render_link('eth1', 'ID_NET_NAME_SLOT=ens18')
+ self.assertIn('Property=ID_NET_NAME_SLOT=ens18', body)
+ self.assertIn('Name=eth1', body)
+ self.assertIn('Type=ether', body)
+
+ def test_link_for_a_radio_matches_the_wireless_type(self):
+ # a .link naming a type the device is not never applies, so a radio
+ # rendered as 'ether' would silently keep its probe-order name
+ body = render_link('wlan0', 'ID_PATH=pci-0000:00:14.0')
+ self.assertIn('Type=wlan', body)
+ self.assertNotIn('Type=ether', body)
+ self.assertIn('Name=wlan0', body)
+
+ def test_radio_named_by_resolve_gets_a_wireless_link(self):
+ # end to end: resolve() names by type, and the file written for that
+ # name has to agree with it
+ devices = [dev('wlan5', 'pci-0000:00:14.0', 'aa:aa:aa:aa:aa:01',
+ wireless=True),
+ dev('eth9', 'pci-0000:00:12.0', 'bb:bb:bb:bb:bb:02')]
+ _, store, _ = resolve(devices, empty_store())
+ self.assertIn('wlan0', store['interfaces'])
+ with tempfile.TemporaryDirectory() as d:
+ written = sync_link_files(store, Path(d))
+ bodies = {p.name: p.read_text() for p in written}
+ self.assertIn('Type=wlan', bodies['10-vyos-wlan0.link'])
+ self.assertIn('Type=ether', bodies['10-vyos-eth0.link'])
+
+ def test_one_file_per_interface(self):
+ with tempfile.TemporaryDirectory() as d:
+ written = sync_link_files(
+ store_of({'eth1': 'pci-0000:00:13.0'}), Path(d))
+ self.assertEqual([p.name for p in written], ['10-vyos-eth1.link'])
+
+ def test_stale_files_are_removed(self):
+ with tempfile.TemporaryDirectory() as d:
+ link_dir = Path(d)
+ sync_link_files(store_of({'eth1': 'p1',
+ 'eth2': 'p2'}), link_dir)
+ sync_link_files(store_of({'eth1': 'p1'}), link_dir)
+ remaining = sorted(p.name for p in link_dir.glob('*.link'))
+ self.assertEqual(remaining, ['10-vyos-eth1.link'])
+
+ def test_operator_files_are_left_alone(self):
+ with tempfile.TemporaryDirectory() as d:
+ link_dir = Path(d)
+ custom = link_dir / '99-my-tuning.link'
+ custom.write_text('[Match]\nType=ether\n\n[Link]\nMTUBytes=9000\n')
+ sync_link_files(store_of({'eth1': 'p1'}), link_dir)
+ self.assertTrue(custom.exists())
+
+
+class TestPermanentMac(unittest.TestCase):
+ """Reading the hardware's own address matters wherever a custom MAC may
+ be in effect: the current address is then the custom one. This used to
+ come from the hw-id node, which could also simply be stale.
+ """
+
+ def test_ethtool_permanent_address(self):
+ with mock.patch('vyos.ifconfig.ifname_store.rc_cmd',
+ return_value=(0, 'Permanent address: aa:bb:cc:dd:ee:ff\n')):
+ self.assertEqual(permanent_mac('eth0'), 'aa:bb:cc:dd:ee:ff')
+
+ def test_zero_address_falls_back_to_sysfs(self):
+ # some drivers report an all-zero "unsupported" permanent address
+ # instead of failing outright
+ with mock.patch('vyos.ifconfig.ifname_store.rc_cmd',
+ return_value=(0, 'Permanent address: 00:00:00:00:00:00\n')), \
+ mock.patch('pathlib.Path.read_text', return_value='11:22:33:44:55:66\n'):
+ self.assertEqual(permanent_mac('eth0'), '11:22:33:44:55:66')
+
+ def test_ethtool_unsupported_falls_back_to_sysfs(self):
+ with mock.patch('vyos.ifconfig.ifname_store.rc_cmd',
+ return_value=(1, 'Operation not supported')), \
+ mock.patch('pathlib.Path.read_text', return_value='11:22:33:44:55:66\n'):
+ self.assertEqual(permanent_mac('eth0'), '11:22:33:44:55:66')
+
+ def test_no_mac_available_returns_empty(self):
+ with mock.patch('vyos.ifconfig.ifname_store.rc_cmd', return_value=(1, '')), \
+ mock.patch('pathlib.Path.read_text', side_effect=OSError):
+ self.assertEqual(permanent_mac('eth0'), '')
+
+
+class TestDiscoverDevices(unittest.TestCase):
+ """Only real, independently-addressable hardware is a naming candidate.
+ Virtual interfaces have no bus device; enslaved interfaces, SR-IOV VFs
+ and hypervisor VF datapaths are acceleration children that share another
+ interface's MAC, so naming them would invent a second interface for one
+ physical port.
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+
+ for patch in (mock.patch('vyos.ifconfig.ifname_store.read_properties',
+ return_value={}),
+ mock.patch('vyos.ifconfig.ifname_store.rc_cmd',
+ return_value=(1, ''))):
+ patch.start()
+ self.addCleanup(patch.stop)
+
+ def make(name, mac=None, has_device=True, enslaved=False, is_vf=False):
+ path = os.path.join(self.tmp, name)
+ os.mkdir(path)
+ if has_device:
+ os.mkdir(os.path.join(path, 'device'))
+ if is_vf:
+ pf = os.path.join(self.tmp, f'{name}_pf')
+ os.mkdir(pf)
+ os.symlink(pf, os.path.join(path, 'device', 'physfn'))
+ if enslaved:
+ master = os.path.join(self.tmp, f'{name}_master')
+ os.mkdir(master)
+ os.symlink(master, os.path.join(path, 'master'))
+ if mac:
+ with open(os.path.join(path, 'address'), 'w') as f:
+ f.write(mac + '\n')
+
+ make('eth0', mac='aa:aa:aa:aa:aa:00')
+ make('eth1', mac='aa:aa:aa:aa:aa:01')
+ make('br0', mac='aa:aa:aa:aa:aa:99', has_device=False)
+ make('lo', has_device=False)
+ make('eth8', mac='aa:aa:aa:aa:aa:08', enslaved=True)
+ make('eth9', mac='aa:aa:aa:aa:aa:09', is_vf=True)
+ make('vf_eth0', mac='aa:aa:aa:aa:aa:00')
+
+ def _names(self):
+ return {d['name'] for d in discover_devices(self.tmp)}
+
+ def test_only_physical_independent_interfaces(self):
+ self.assertEqual(self._names(), {'eth0', 'eth1'})
+
+ def test_virtual_interfaces_excluded(self):
+ self.assertNotIn('br0', self._names())
+ self.assertNotIn('lo', self._names())
+
+ def test_enslaved_interface_excluded(self):
+ self.assertNotIn('eth8', self._names())
+
+ def test_sriov_virtual_function_excluded(self):
+ # the general marker, independent of hypervisor - the previous
+ # exclusion relied on udev rules scoped to a Microsoft DMI vendor
+ # string and would miss a bare-metal Mellanox VF
+ self.assertNotIn('eth9', self._names())
+
+ def test_hypervisor_vf_datapath_excluded(self):
+ # parked as vf_* by 63-hyperv-vf-net.rules; shares its synthetic
+ # parent's MAC and must not be taken for a second interface
+ self.assertNotIn('vf_eth0', self._names())
diff --git a/src/tests/test_net_name_resolve.py b/src/tests/test_net_name_resolve.py
new file mode 100644
index 000000000..e4473f49b
--- /dev/null
+++ b/src/tests/test_net_name_resolve.py
@@ -0,0 +1,227 @@
+# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# Coverage for T3871: the boot-time half of persistent interface naming -
+# settling, renaming and boot ordering.
+#
+# The naming decision and hardware discovery both live in
+# vyos.ifconfig.ifname_store and are covered by test_ifname_store.py.
+
+import os
+import shutil
+import tempfile
+import unittest
+from unittest import mock
+
+from helper import prepare_module
+
+_here = os.path.dirname(__file__)
+
+resolver = prepare_module(
+ os.path.join(_here, '../system/vyos-net-name-resolve.py'),
+ 'vyos_net_name_resolve')
+
+_container_patcher = None
+
+
+def setUpModule():
+ # main() deliberately short-circuits inside a container, and these tests
+ # are routinely run from one (the VyOS build container). Pin the detection
+ # off for the whole module so the naming pass under test actually runs -
+ # TestMainInContainer patches it back on locally where that is the point.
+ global _container_patcher
+ _container_patcher = mock.patch.object(
+ resolver, 'is_running_as_container', return_value=False)
+ _container_patcher.start()
+
+
+def tearDownModule():
+ _container_patcher.stop()
+
+
+def device(name, mac, path='', wireless=False):
+ return {'name': name, 'mac': mac, 'path': path, 'wireless': wireless,
+ 'properties': {}}
+
+
+class TestWaitForSettle(unittest.TestCase):
+ """There is no specific MAC to wait for when naming hardware the store
+ has never seen, so settling is detected as N consecutive identical
+ discover_physical_interfaces() snapshots instead.
+ """
+
+ def test_returns_after_n_consecutive_stable_polls(self):
+ one = [device('eth0', 'm0')]
+ two = [device('eth0', 'm0'), device('eth1', 'm1')]
+ snapshots = [one, two, two, two]
+ with mock.patch.object(resolver, 'discover_devices',
+ side_effect=snapshots):
+ result = resolver.wait_for_settle([], timeout=5, poll=0,
+ stable_polls=3)
+ self.assertEqual(resolver._snapshot(result),
+ {('eth0', 'm0'), ('eth1', 'm1')})
+
+ def test_bounded_by_timeout_when_never_stable(self):
+ counter = iter(range(1000))
+
+ def ever_changing(*_a, **_kw):
+ return [device('eth0', f'm{next(counter)}')]
+
+ clock = iter([0, 0.1, 20, 20])
+ with mock.patch.object(resolver, 'discover_devices',
+ side_effect=ever_changing), \
+ mock.patch('time.monotonic', side_effect=lambda: next(clock)):
+ result = resolver.wait_for_settle([], timeout=10, poll=0,
+ stable_polls=3)
+ # must return SOMETHING (the last snapshot seen), not hang or crash
+ self.assertEqual(len(result), 1)
+
+ def test_stable_polls_of_one_returns_on_initial_snapshot(self):
+ initial = [device('eth0', 'm0')]
+ with mock.patch.object(resolver, 'discover_devices') as m:
+ result = resolver.wait_for_settle(initial, timeout=5, poll=0,
+ stable_polls=1)
+ m.assert_not_called()
+ self.assertEqual(result, initial)
+
+
+class TestSafeBulkRename(unittest.TestCase):
+ """Only hardware the store has never seen should reach this on a settled
+ system, but when it does the two-phase rename must report accurately: a
+ phase-2 (scratch -> final target) failure must not be reported as a
+ successful rename, and must not leave the interface down indefinitely
+ under a name nothing else knows about.
+ """
+
+ def setUp(self):
+ patcher = mock.patch.object(resolver, 'get_ifindex',
+ side_effect=lambda name: name)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_all_renames_succeed(self):
+ with mock.patch.object(resolver, 'run', return_value=0):
+ applied = resolver.safe_bulk_rename({'eth5': 'eth0'})
+ self.assertEqual(applied, {'eth5': 'eth0'})
+
+ def test_phase_two_failure_is_not_reported_as_applied(self):
+ def fake_run(command, *_a, **_kw):
+ return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
+
+ with mock.patch.object(resolver, 'run', side_effect=fake_run):
+ applied = resolver.safe_bulk_rename({'eth5': 'eth0'})
+
+ self.assertNotIn('eth5', applied)
+
+ def test_phase_two_failure_brings_scratch_name_back_up(self):
+ calls = []
+
+ def fake_run(command, *_a, **_kw):
+ calls.append(command)
+ return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
+
+ with mock.patch.object(resolver, 'run', side_effect=fake_run):
+ resolver.safe_bulk_rename({'eth5': 'eth0'})
+
+ self.assertIn('ip link set dev vyetheth5 up', calls)
+ self.assertNotIn('ip link set dev eth0 up', calls)
+
+ def test_one_failure_does_not_affect_other_renames_in_the_batch(self):
+ def fake_run(command, *_a, **_kw):
+ return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
+
+ with mock.patch.object(resolver, 'run', side_effect=fake_run):
+ applied = resolver.safe_bulk_rename({'eth5': 'eth0',
+ 'eth9': 'eth1'})
+
+ self.assertNotIn('eth5', applied)
+ self.assertEqual(applied.get('eth9'), 'eth1')
+
+
+class TestMainInContainer(unittest.TestCase):
+ """A container owns no NIC: its interfaces are runtime-created veth pairs
+ with no backing bus device in sysfs and a host-assigned MAC that changes
+ on every start. There is nothing to name and nothing worth persisting.
+ """
+
+ def setUp(self):
+ status_dir = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, status_dir, ignore_errors=True)
+ self._orig_status_file = resolver.status_file
+ resolver.status_file = resolver.Path(status_dir) / 'status.json'
+ self.addCleanup(setattr, resolver, 'status_file',
+ self._orig_status_file)
+
+ def test_naming_pass_is_skipped_entirely(self):
+ with mock.patch.object(resolver, 'is_running_as_container',
+ return_value=True), \
+ mock.patch.object(resolver, 'load_store') as load, \
+ mock.patch.object(resolver, 'discover_devices') as discover, \
+ mock.patch.object(resolver, 'save_store') as save, \
+ mock.patch.object(resolver, 'run') as run:
+ resolver.main()
+
+ load.assert_not_called()
+ discover.assert_not_called()
+ save.assert_not_called()
+ run.assert_not_called()
+ self.assertFalse(resolver.status_file.exists())
+
+
+class TestBootOrdering(unittest.TestCase):
+ """The store lives in the config directory, which does not exist until
+ vyos-router mounts (and, for an encrypted config volume, decrypts) it. So
+ the pass must be started explicitly from inside vyos-router after that
+ point, and must not be reintroduced as an independently-scheduled unit
+ ordered before it.
+ """
+
+ def setUp(self):
+ path = os.path.join(_here, '../init/vyos-router')
+ with open(path) as f:
+ self.lines = f.readlines()
+
+ def _first_index(self, needle):
+ for i, line in enumerate(self.lines):
+ if needle in line:
+ return i
+ self.fail(f"'{needle}' not found in src/init/vyos-router")
+
+ def test_resolver_runs_after_migrate_and_before_config_apply(self):
+ # search for call SITES, not the function definitions further up
+ migrate = self._first_index('migrate_bootfile || overall_status=1')
+ resolve = self._first_index(
+ 'systemctl start vyos-net-name-resolve.service')
+ update_iface = self._first_index(
+ 'update_interface_config || overall_status=1')
+ load_boot = self._first_index('disabled configure || load_bootfile')
+
+ self.assertLess(migrate, resolve,
+ 'resolver must run after config.boot is current')
+ self.assertLess(resolve, update_iface,
+ 'resolver must write the store before the rescan reads it')
+ self.assertLess(resolve, load_boot,
+ 'resolver must run before the CLI config is applied')
+
+ def test_service_unit_not_independently_ordered_before_vyos_router(self):
+ path = os.path.join(_here, '../systemd/vyos-net-name-resolve.service')
+ with open(path) as f:
+ unit = f.read()
+ self.assertNotIn('Before=vyos-router.service', unit)
+ self.assertNotIn('Before=network-pre.target', unit)
+ self.assertNotIn('WantedBy=', unit)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/src/udev/vyos_net_name b/src/udev/vyos_net_name
deleted file mode 100755
index f5c3b9f1b..000000000
--- a/src/udev/vyos_net_name
+++ /dev/null
@@ -1,276 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright VyOS maintainers and contributors <maintainers@vyos.io>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-import os
-import re
-import time
-import logging
-import logging.handlers
-import tempfile
-from pathlib import Path
-from sys import argv
-
-from vyos.configtree import ConfigTree
-from vyos.defaults import directories
-from vyos.utils.process import cmdl
-from vyos.utils.boot import boot_configuration_complete
-from vyos.utils.locking import Lock
-from vyos.migrate import ConfigMigrate
-
-# Define variables
-vyos_udev_dir = directories['vyos_udev_dir']
-config_path = '/opt/vyatta/etc/config/config.boot'
-
-
-def is_available(intfs: dict, intf_name: str) -> bool:
- """Check if interface name is already assigned"""
- if intf_name in list(intfs.values()):
- return False
- return True
-
-
-def find_available(intfs: dict, prefix: str) -> str:
- """Find lowest indexed interface name that is not assigned"""
- index_list = [
- int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x
- ]
- index_list.sort()
- # find 'holes' in list, if any
- missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list))
- if missing:
- return f'{prefix}{missing[0]}'
-
- return f'{prefix}{len(index_list)}'
-
-
-def mod_ifname(ifname: str) -> str:
- """Check interface with names eX and return ifname on the next format eth{ifindex} - 2"""
- if re.match('^e[0-9]+$', ifname):
- intf = ifname.split('e')
- if intf[1]:
- if int(intf[1]) >= 2:
- return 'eth' + str(int(intf[1]) - 2)
- else:
- return 'eth' + str(intf[1])
-
- return ifname
-
-
-def get_biosdevname(ifname: str) -> str:
- """Use legacy vyatta-biosdevname to query for name
-
- This is carried over for compatibility only, and will likely be dropped
- going forward.
- XXX: This throws an error, and likely has for a long time, unnoticed
- since vyatta_net_name redirected stderr to /dev/null.
- """
- intf = mod_ifname(ifname)
-
- if 'eth' not in intf:
- return intf
- if os.path.isdir('/proc/xen'):
- return intf
-
- time.sleep(1)
-
- try:
- biosname = cmdl(['/sbin/biosdevname', '--policy', 'all_ethN', '-i', ifname])
- except Exception as e:
- logger.error(f'biosdevname error: {e}')
- biosname = ''
-
- return intf if biosname == '' else biosname
-
-
-def leave_rescan_hint(intf_name: str, hwid: str):
- """Write interface information reported by udev
-
- This script is called while the root mount is still read-only. Leave
- information in /run/udev: file name, the interface; contents, the
- hardware id.
- """
- try:
- os.mkdir(vyos_udev_dir)
- except FileExistsError:
- pass
- except Exception as e:
- logger.critical(f'Error creating rescan hint directory: {e}')
- exit(1)
-
- try:
- with open(os.path.join(vyos_udev_dir, intf_name), 'w') as f:
- f.write(hwid)
- except OSError as e:
- logger.critical(f'OSError {e}')
-
-
-def get_configfile_interfaces() -> dict:
- """Read existing interfaces from config file"""
- interfaces: dict = {}
-
- if not os.path.isfile(config_path):
- # If the case, then we are running off of livecd; return empty
- return interfaces
-
- try:
- with open(config_path) as f:
- config_file = f.read()
- except OSError as e:
- logger.critical(f'OSError {e}')
- exit(1)
-
- try:
- config = ConfigTree(config_file)
- except Exception:
- try:
- logger.debug('updating component version string syntax')
- # this will update the component version string syntax,
- # required for updates 1.2 --> 1.3/1.4
- with tempfile.NamedTemporaryFile() as fp:
- with open(fp.name, 'w') as fd:
- fd.write(config_file)
- config_migrate = ConfigMigrate(fp.name)
- if config_migrate.syntax_update_needed():
- config_migrate.update_syntax()
- config_migrate.write_config()
- with open(fp.name) as fd:
- config_file = fd.read()
-
- config = ConfigTree(config_file)
-
- except Exception as e:
- logger.critical(f'ConfigTree error: {e}')
- exit(1)
-
- base = ['interfaces', 'ethernet']
- if config.exists(base):
- eth_intfs = config.list_nodes(base)
- for intf in eth_intfs:
- path = base + [intf, 'hw-id']
- if not config.exists(path):
- logger.warning(f"no 'hw-id' entry for {intf}")
- continue
- hwid = config.return_value(path)
- if hwid in list(interfaces):
- logger.warning(
- f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
- )
- continue
- interfaces[hwid] = intf
-
- base = ['interfaces', 'wireless']
- if config.exists(base):
- wlan_intfs = config.list_nodes(base)
- for intf in wlan_intfs:
- path = base + [intf, 'hw-id']
- if not config.exists(path):
- logger.warning(f"no 'hw-id' entry for {intf}")
- continue
- hwid = config.return_value(path)
- if hwid in list(interfaces):
- logger.warning(
- f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}'
- )
- continue
- interfaces[hwid] = intf
-
- logger.debug(f'config file entries: {interfaces}')
-
- return interfaces
-
-
-def add_assigned_interfaces(intfs: dict):
- """Add interfaces found by previous invocation of udev rule"""
- if not os.path.isdir(vyos_udev_dir):
- return
-
- for intf in os.listdir(vyos_udev_dir):
- path = os.path.join(vyos_udev_dir, intf)
- try:
- with open(path) as f:
- hwid = f.read().rstrip()
- except OSError as e:
- logger.error(f'OSError {e}')
- continue
- intfs[hwid] = intf
-
-
-def on_boot_event(intf_name: str, hwid: str, predefined: str = '') -> str:
- """Called on boot by vyos-router: 'coldplug' in vyatta_net_name"""
- logger.info(f'lookup {intf_name}, {hwid}')
- interfaces = get_configfile_interfaces()
- logger.debug(f'config file interfaces are {interfaces}')
-
- if hwid in list(interfaces):
- logger.info(f"use mapping from config file: '{hwid}' -> '{interfaces[hwid]}'")
- return interfaces[hwid]
-
- add_assigned_interfaces(interfaces)
- logger.debug(f'adding assigned interfaces: {interfaces}')
-
- if predefined:
- newname = predefined
- logger.info(f"predefined interface name for '{intf_name}' is '{newname}'")
- else:
- newname = get_biosdevname(intf_name)
- logger.info(f"biosdevname returned '{newname}' for '{intf_name}'")
-
- if not is_available(interfaces, newname):
- prefix = re.sub(r'\d+$', '', newname)
- newname = find_available(interfaces, prefix)
-
- logger.info(f"new name for '{intf_name}' is '{newname}'")
-
- leave_rescan_hint(newname, hwid)
-
- return newname
-
-
-def hotplug_event():
- # Not yet implemented, since interface-rescan will only be run on boot.
- pass
-
-
-if __name__ == '__main__':
- # Set up logging to syslog
- syslog_handler = logging.handlers.SysLogHandler(address='/dev/log')
- formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s')
- syslog_handler.setFormatter(formatter)
-
- logger = logging.getLogger()
- logger.addHandler(syslog_handler)
- logger.setLevel(logging.DEBUG)
-
- logger.debug(f'Started with arguments: {argv}')
-
- if len(argv) > 3:
- predef_name = argv[3]
- else:
- predef_name = ''
-
- lock = Lock('vyos_net_name')
- # Wait 60 seconds for other running scripts to finish
- lock.acquire(60)
-
- if not boot_configuration_complete():
- res = on_boot_event(argv[1], argv[2], predefined=predef_name)
- logger.debug(f'on boot, returned name is {res}')
- print(res)
- else:
- logger.debug('boot configuration complete')
-
- lock.release()
- logger.debug('Finished')