diff options
| author | Viacheslav Hletenko <v.gletenko@vyos.io> | 2026-06-23 13:36:44 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-23 13:36:44 +0300 |
| commit | d1e320047348d90202a23a598cd771e41cb0be24 (patch) | |
| tree | b64a18c06ed6f94ab04ad8dbe7acb3c4645deedd | |
| parent | d935b5a83b0fa81e3fc9d727cedbda7575f60d85 (diff) | |
| parent | d90693fd0da03f8de4ccc4a5a85e2f2c9823e682 (diff) | |
| download | vyos-1x-d1e320047348d90202a23a598cd771e41cb0be24.tar.gz vyos-1x-d1e320047348d90202a23a598cd771e41cb0be24.zip | |
Merge pull request #5156 from alexandr-san4ez/T8540-current
pseudo-ethernet: T8540: Add anycast-gateway support for EVPN
| -rw-r--r-- | interface-definitions/interfaces_pseudo-ethernet.xml.in | 6 | ||||
| -rw-r--r-- | python/vyos/ifconfig/bridge.py | 51 | ||||
| -rw-r--r-- | python/vyos/ifconfig/macvlan.py | 70 | ||||
| -rw-r--r-- | python/vyos/utils/network.py | 35 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py | 52 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces_pseudo-ethernet.py | 31 |
6 files changed, 245 insertions, 0 deletions
diff --git a/interface-definitions/interfaces_pseudo-ethernet.xml.in b/interface-definitions/interfaces_pseudo-ethernet.xml.in index f6e3b6970..5687e3556 100644 --- a/interface-definitions/interfaces_pseudo-ethernet.xml.in +++ b/interface-definitions/interfaces_pseudo-ethernet.xml.in @@ -62,6 +62,12 @@ #include <include/interface/redirect.xml.i> #include <include/interface/vif-s.xml.i> #include <include/interface/vif.xml.i> + <leafNode name="anycast-gateway"> + <properties> + <help>Use the interface as EVPN Anycast Gateway</help> + <valueless/> + </properties> + </leafNode> </children> </tagNode> </children> diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index f1cc7c0e0..92596d07c 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -13,6 +13,8 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see <http://www.gnu.org/licenses/>. +import json + from vyos.ifconfig.interface import Interface from vyos.utils.assertion import assert_boolean from vyos.utils.assertion import assert_list @@ -97,6 +99,13 @@ class BridgeIf(Interface): }, }} + _command_get = {**Interface._command_get, **{ + 'fdb_entries': { + 'shellcmd': 'bridge -json -detail fdb show dev {ifname}', + 'format': json.loads, + }, + }} + _command_set = {**Interface._command_set, **{ 'add_port': { 'shellcmd': 'ip link set dev {value} master {ifname}', @@ -104,6 +113,12 @@ class BridgeIf(Interface): 'del_port': { 'shellcmd': 'ip link set dev {value} nomaster', }, + 'add_local_fdb_entry': { + 'shellcmd': 'bridge fdb add {value} dev {ifname} self local', + }, + 'del_local_fdb_entry': { + 'shellcmd': 'bridge fdb del {value} dev {ifname} self local', + }, }} def _create(self): @@ -272,6 +287,32 @@ class BridgeIf(Interface): return self.set_interface('vlan_protocol', map[protocol]) + def get_fdb_entries(self): + """ + Get the bridge forwarding database (FDB) entries + """ + return self.get_interface('fdb_entries') + + def add_local_fdb_entry(self, mac_address: str): + """ + Add a local FDB entry for the given MAC address on the bridge interface. + + Example: + >>> from vyos.ifconfig import BridgeIf + >>> BridgeIf('br0').add_local_fdb_entry('cc:38:2e:bf:7b:0d') + """ + self.set_interface('add_local_fdb_entry', mac_address.lower()) + + def del_local_fdb_entry(self, mac_address: str): + """ + Remove a local FDB entry for the given MAC address on the bridge interface. + + Example: + >>> from vyos.ifconfig import BridgeIf + >>> BridgeIf('br0').del_local_fdb_entry('cc:38:2e:bf:7b:0d') + """ + self.set_interface('del_local_fdb_entry', mac_address.lower()) + def update(self, config): """ General helper function which works on a dictionary retrieved by get_config_dict(). It's main intention is to consolidate the scattered @@ -332,6 +373,16 @@ class BridgeIf(Interface): cmd = f'bridge vlan del dev {self.ifname} vid {vlan} self' self._cmd(cmd) + # After deleting vif, the FDB entry for that VLAN can linger. + # We should remove it manually: + fdb_entries = self.get_fdb_entries() + for entry in fdb_entries: + mac = entry.get('mac') + entry_vlan = entry.get('vlan') + if entry_vlan and mac and str(entry_vlan) == vlan: + cmd = f'bridge fdb del {mac} dev {self.ifname} vlan {vlan}' + self._cmd(cmd) + for vlan in config.get('vif', {}): cmd = f'bridge vlan add dev {self.ifname} vid {vlan} self' self._cmd(cmd) diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index 7a26f9ef5..45fd1d351 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -14,6 +14,10 @@ # License along with this library. If not, see <http://www.gnu.org/licenses/>. from vyos.ifconfig.interface import Interface +from vyos.ifconfig import BridgeIf +from vyos.utils.network import get_interface_config +from vyos.utils.network import interface_exists +from vyos.utils.network import split_interface_vlans @Interface.register class MACVLANIf(Interface): @@ -40,6 +44,72 @@ class MACVLANIf(Interface): # interface is always A/D down. It needs to be enabled explicitly self.set_admin_state('down') + def _get_bridge_by_source(self, source_interface: str) -> BridgeIf | None: + """ + Resolve a source interface name to its root BridgeIf object. + + Handles plain bridge names (e.g. 'br0') and bridge sub-interfaces + with one or two VLAN suffixes (e.g. 'br0.100', 'br0.100.200'). + """ + + bridge = None + if source_interface.startswith('br'): + # We only need the root bridge name, so unpack with *_ to + # discard the VLAN parts. + bridge_ifname, *_ = split_interface_vlans(source_interface) + if interface_exists(bridge_ifname): + bridge = BridgeIf(bridge_ifname) + + return bridge + + def _create_anycast_gateway(self, source_interface, mac): + """Install a local FDB entry on the parent bridge for the anycast MAC""" + + if source_interface and mac: + bridge = self._get_bridge_by_source(source_interface) + if bridge: + bridge.add_local_fdb_entry(mac) + + def _delete_anycast_gateway(self, source_interface, mac): + """Remove the local FDB entry from the parent bridge for the anycast MAC""" + + if source_interface and mac: + bridge = self._get_bridge_by_source(source_interface) + if bridge: + try: + bridge.del_local_fdb_entry(mac) + except OSError: + pass # Bridge may already be gone, that is fine + + def update(self, config): + # Always attempt to remove any existing anycast FDB entry before + # applying the new config. It reads the currently live + # state of the interface (before update). + self._delete_anycast_gateway(self.get_source_interface(), self.get_mac()) + + # Apply all standard interface configuration + super().update(config) + + # After the interface is fully updated, install the new FDB entry if + # anycast-gateway is set in the incoming config. + if 'anycast_gateway' in self.config: + self._create_anycast_gateway( + self.config.get('source_interface'), self.get_mac() + ) + + def remove(self, skip_delete=False): + # Before tearing down the MACVLAN interface, clean up the anycast + # gateway FDB entry from the parent bridge. + self._delete_anycast_gateway( + self.get_source_interface(), self.get_mac() + ) + + return super().remove(skip_delete=skip_delete) + def set_mode(self, mode): cmd = f'ip link set dev {self.ifname} type macvlan mode {mode}' return self._cmd(cmd) + + def get_source_interface(self): + interface_config = get_interface_config(self.ifname) + return interface_config['link'] if interface_config is not None else None diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 8544b2163..cde374d03 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -158,6 +158,41 @@ def get_vrf_tableid(interface: str): table = tmp['linkinfo']['info_slave_data']['table'] return table + +def split_interface_vlans(interface: str) -> tuple: + """ + Parse a interface name (with optional VLAN suffixes) into its + component parts. + + Handles three input forms: + - 'br0' -> root bridge interface only + - 'br0.100' -> root bridge interface + one VLAN sub-interface level + - 'br0.100.200' -> root bridge interface + two VLAN sub-interface levels (QinQ) + + Returns a tuple with the following parts on success: + ( + 'br0', # root interface (always present) + '100', # first VLAN suffix (None if not present) + '200', # second VLAN suffix (None if not present) + ) + """ + + parts = interface.split('.') + + # Guard: we support at most bridge + 2 VLAN levels (e.g. br0.100.200) + if len(parts) > 3: + raise ValueError( + f'Interface "{interface}" has too many VLAN suffixes. ' + 'Only up to two levels are supported (e.g. br0.100.200).' + ) + + iface = parts[0] + vlan_id = parts[1] if len(parts) >= 2 else None + inner_vlan_id = parts[2] if len(parts) == 3 else None + + return iface, vlan_id, inner_vlan_id + + def get_interface_config(interface): """ Returns the used encapsulation protocol for given interface. If interface does not exist, None is returned. diff --git a/smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py b/smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py index 3be8e024f..c70d19474 100755 --- a/smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py +++ b/smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py @@ -19,6 +19,8 @@ import unittest from base_interfaces_test import BasicInterfaceTest from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.configsession import ConfigSessionError +from vyos.utils.process import cmd from vyos.ifconfig import Section @@ -44,5 +46,55 @@ class PEthInterfaceTest(BasicInterfaceTest.TestCase): # call base-classes classmethod super(PEthInterfaceTest, cls).setUpClass() + def test_anycast_gateway(self): + # Create the underlying bridge and sub-interface in the test + + for i, peth in enumerate(self._interfaces): + eth = peth[1:] # Convert peth0 -> eth0 + br = f'br{i}' + vlan = str(i + 100) + + # Format the MAC using index as a two-digit hexadecimal + mac_address = f'00:aa:aa:aa:aa:{i:02x}' + + with self.subTest(peth=peth, eth=eth, mac_address=mac_address, i=i): + base_bridge_path = ['interfaces', 'bridge', br] + base_br_member_path = base_bridge_path + ['member', 'interface'] + + self.cli_set(base_bridge_path + ['enable-vlan']) + self.cli_set(base_br_member_path + [eth, 'native-vlan', vlan]) + self.cli_set(base_br_member_path + [f'vxlan{i}']) + self.cli_set(base_bridge_path + ['vif', vlan]) + + self.cli_set( + self._base_path + [peth, 'source-interface', f'{br}.{vlan}'] + ) + self.cli_set(self._base_path + [peth, 'anycast-gateway']) + + # Anycast gateway requires MAC + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(self._base_path + [peth, 'mac', mac_address]) + self.cli_commit() + + # Verify FDB entry exists with flag + fdb = cmd(f'bridge fdb show dev {br}') + self.assertIn(f'{mac_address} master {br} permanent', fdb) + self.assertIn(f'{mac_address} self permanent', fdb) + + # Then remove just the anycast-gateway flag + self.cli_delete(self._base_path + [peth, 'anycast-gateway']) + self.cli_commit() + + fdb = cmd(f'bridge fdb show dev {br}') + self.assertNotIn(f'{mac_address} master {br} permanent', fdb) + + # Clean up temp bridge and peth + self.cli_delete(self._base_path + [peth]) + self.cli_delete(base_bridge_path) + self.cli_commit() + + if __name__ == '__main__': unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on()) diff --git a/src/conf_mode/interfaces_pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py index 6a4219343..12019d3e6 100755 --- a/src/conf_mode/interfaces_pseudo-ethernet.py +++ b/src/conf_mode/interfaces_pseudo-ethernet.py @@ -68,6 +68,35 @@ def get_config(config=None): return peth +def _verify_anycast_gateway(peth: dict): + """Validate anycast-gateway requirements.""" + + if 'anycast_gateway' not in peth: + return + + ifname = peth['ifname'] + + # Requirement 1: MAC address must be explicitly configured + if 'mac' not in peth: + raise ConfigError( + f'Anycast-gateway requires an explicit MAC address to be set on interface {ifname}. ' + f'Use: set interfaces pseudo-ethernet {ifname} mac <mac>' + ) + + # Requirement 2: source-interface must be a bridge or bridge sub-interface + source_iface = peth.get('source_interface') + if not source_iface: + raise ConfigError( + f'Anycast-gateway requires source-interface to be set on interface {ifname}' + ) + + if not source_iface.startswith('br'): + raise ConfigError( + 'Anycast-gateway requires source-interface to be a bridge ' + 'or a bridge vlan interface (e.g. br0 or br0.100), but ' + f'"{source_iface}" is neither of these two.' + ) + def verify(peth): if 'deleted' in peth: verify_bridge_delete(peth) @@ -82,6 +111,8 @@ def verify(peth): # use common function to verify VLAN configuration verify_vlan_config(peth) + _verify_anycast_gateway(peth) + return None def generate(peth): |
