summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorViacheslav Hletenko <v.gletenko@vyos.io>2026-06-23 13:36:44 +0300
committerGitHub <noreply@github.com>2026-06-23 13:36:44 +0300
commitd1e320047348d90202a23a598cd771e41cb0be24 (patch)
treeb64a18c06ed6f94ab04ad8dbe7acb3c4645deedd
parentd935b5a83b0fa81e3fc9d727cedbda7575f60d85 (diff)
parentd90693fd0da03f8de4ccc4a5a85e2f2c9823e682 (diff)
downloadvyos-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.in6
-rw-r--r--python/vyos/ifconfig/bridge.py51
-rw-r--r--python/vyos/ifconfig/macvlan.py70
-rw-r--r--python/vyos/utils/network.py35
-rwxr-xr-xsmoketest/scripts/cli/test_interfaces_pseudo-ethernet.py52
-rwxr-xr-xsrc/conf_mode/interfaces_pseudo-ethernet.py31
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):