From d90693fd0da03f8de4ccc4a5a85e2f2c9823e682 Mon Sep 17 00:00:00 2001 From: Oleksandr Kuchmystyi Date: Thu, 30 Apr 2026 15:57:27 +0300 Subject: pseudo-ethernet: T8540: Add anycast-gateway support for EVPN Introduce 'anycast-gateway' leafNode for pseudo-ethernet interfaces. When set, a local FDB entry is installed on the parent bridge to prevent the shared anycast MAC from leaking over the VXLAN overlay. --- python/vyos/ifconfig/bridge.py | 51 ++++++++++++++++++++++++++++++ python/vyos/ifconfig/macvlan.py | 70 +++++++++++++++++++++++++++++++++++++++++ python/vyos/utils/network.py | 35 +++++++++++++++++++++ 3 files changed, 156 insertions(+) (limited to 'python') 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 . +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 . 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. -- cgit v1.2.3