Skip to content

Commit e3b5524

Browse files
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.
1 parent 6a5f196 commit e3b5524

6 files changed

Lines changed: 220 additions & 0 deletions

File tree

interface-definitions/interfaces_pseudo-ethernet.xml.in

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@
6262
#include <include/interface/redirect.xml.i>
6363
#include <include/interface/vif-s.xml.i>
6464
#include <include/interface/vif.xml.i>
65+
<leafNode name="anycast-gateway">
66+
<properties>
67+
<help>Use the interface as EVPN Anycast Gateway</help>
68+
<valueless/>
69+
</properties>
70+
</leafNode>
6571
</children>
6672
</tagNode>
6773
</children>

python/vyos/ifconfig/bridge.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ class BridgeIf(Interface):
104104
'del_port': {
105105
'shellcmd': 'ip link set dev {value} nomaster',
106106
},
107+
'add_local_fdb_entry': {
108+
'shellcmd': 'bridge fdb add {value} dev {ifname} self local',
109+
},
110+
'del_local_fdb_entry': {
111+
'shellcmd': 'bridge fdb del {value} dev {ifname} self local',
112+
},
107113
}}
108114

109115
def _create(self):
@@ -272,6 +278,26 @@ def set_vlan_protocol(self, protocol):
272278

273279
return self.set_interface('vlan_protocol', map[protocol])
274280

281+
def add_local_fdb_entry(self, mac_address: str):
282+
"""
283+
Add a local FDB entry for the given MAC address on the bridge interface.
284+
285+
Example:
286+
>>> from vyos.ifconfig import BridgeIf
287+
>>> BridgeIf('br0').add_local_fdb_entry('cc:38:2e:bf:7b:0d')
288+
"""
289+
self.set_interface('add_local_fdb_entry', mac_address.lower())
290+
291+
def del_local_fdb_entry(self, mac_address: str):
292+
"""
293+
Remove a local FDB entry for the given MAC address on the bridge interface.
294+
295+
Example:
296+
>>> from vyos.ifconfig import BridgeIf
297+
>>> BridgeIf('br0').del_local_fdb_entry('cc:38:2e:bf:7b:0d')
298+
"""
299+
self.set_interface('del_local_fdb_entry', mac_address.lower())
300+
275301
def update(self, config):
276302
""" General helper function which works on a dictionary retrieved by
277303
get_config_dict(). It's main intention is to consolidate the scattered

python/vyos/ifconfig/macvlan.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
1515

1616
from vyos.ifconfig.interface import Interface
17+
from vyos.utils.network import get_interface_config
1718

1819
@Interface.register
1920
class MACVLANIf(Interface):
@@ -43,3 +44,7 @@ def _create(self):
4344
def set_mode(self, mode):
4445
cmd = f'ip link set dev {self.ifname} type macvlan mode {mode}'
4546
return self._cmd(cmd)
47+
48+
def get_source_interface(self):
49+
interface_config = get_interface_config(self.ifname)
50+
return interface_config['link'] if interface_config is not None else None

python/vyos/utils/network.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ def get_vrf_tableid(interface: str):
158158
table = tmp['linkinfo']['info_slave_data']['table']
159159
return table
160160

161+
162+
def split_interface_vlans(interface: str) -> tuple:
163+
"""
164+
Parse a interface name (with optional VLAN suffixes) into its
165+
component parts.
166+
167+
Handles three input forms:
168+
- 'br0' -> root bridge interface only
169+
- 'br0.100' -> root bridge interface + one VLAN sub-interface level
170+
- 'br0.100.200' -> root bridge interface + two VLAN sub-interface levels (QinQ)
171+
172+
Returns a tuple with the following parts on success:
173+
(
174+
'br0', # root interface (always present)
175+
'100', # first VLAN suffix (None if not present)
176+
'200', # second VLAN suffix (None if not present)
177+
)
178+
"""
179+
180+
parts = interface.split('.')
181+
182+
# Guard: we support at most bridge + 2 VLAN levels (e.g. br0.100.200)
183+
if len(parts) > 3:
184+
raise ValueError(
185+
f'Interface "{interface}" has too many VLAN suffixes. '
186+
'Only up to two levels are supported (e.g. br0.100.200).'
187+
)
188+
189+
iface = parts[0]
190+
vlan_id = parts[1] if len(parts) >= 2 else None
191+
inner_vlan_id = parts[2] if len(parts) == 3 else None
192+
193+
return iface, vlan_id, inner_vlan_id
194+
195+
161196
def get_interface_config(interface):
162197
""" Returns the used encapsulation protocol for given interface.
163198
If interface does not exist, None is returned.

smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from base_interfaces_test import BasicInterfaceTest
2121
from base_vyostest_shim import VyOSUnitTestSHIM
22+
from vyos.configsession import ConfigSessionError
23+
from vyos.utils.process import cmd
2224

2325
from vyos.ifconfig import Section
2426

@@ -44,5 +46,55 @@ def setUpClass(cls):
4446
# call base-classes classmethod
4547
super(PEthInterfaceTest, cls).setUpClass()
4648

49+
def test_anycast_gateway(self):
50+
# Create the underlying bridge and sub-interface in the test
51+
52+
for i, peth in enumerate(self._interfaces):
53+
eth = peth[1:] # Convert peth0 -> eth0
54+
br = f'br{i}'
55+
vlan = str(i + 100)
56+
57+
# Format the MAC using index as a two-digit hexadecimal
58+
mac_address = f'00:aa:aa:aa:aa:{i:02x}'
59+
60+
with self.subTest(peth=peth, eth=eth, mac_address=mac_address, i=i):
61+
base_bridge_path = ['interfaces', 'bridge', br]
62+
base_br_member_path = base_bridge_path + ['member', 'interface']
63+
64+
self.cli_set(base_bridge_path + ['enable-vlan'])
65+
self.cli_set(base_br_member_path + [eth, 'native-vlan', vlan])
66+
self.cli_set(base_br_member_path + [f'vxlan{i}'])
67+
self.cli_set(base_bridge_path + ['vif', vlan])
68+
69+
self.cli_set(
70+
self._base_path + [peth, 'source-interface', f'{br}.{vlan}']
71+
)
72+
self.cli_set(self._base_path + [peth, 'anycast-gateway'])
73+
74+
# Anycast gateway requires MAC
75+
with self.assertRaises(ConfigSessionError):
76+
self.cli_commit()
77+
78+
self.cli_set(self._base_path + [peth, 'mac', mac_address])
79+
self.cli_commit()
80+
81+
# Verify FDB entry exists with flag
82+
fdb = cmd(f'bridge fdb show dev {br}')
83+
self.assertIn(f'{mac_address} master {br} permanent', fdb)
84+
self.assertIn(f'{mac_address} self permanent', fdb)
85+
86+
# Then remove just the anycast-gateway flag
87+
self.cli_delete(self._base_path + [peth, 'anycast-gateway'])
88+
self.cli_commit()
89+
90+
fdb = cmd(f'bridge fdb show dev {br}')
91+
self.assertNotIn(f'{mac_address} master {br} permanent', fdb)
92+
93+
# Clean up temp bridge and peth
94+
self.cli_delete(self._base_path + [peth])
95+
self.cli_delete(base_bridge_path)
96+
self.cli_commit()
97+
98+
4799
if __name__ == '__main__':
48100
unittest.main(verbosity=2, failfast=VyOSUnitTestSHIM.TestCase.debug_on())

src/conf_mode/interfaces_pseudo-ethernet.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
from vyos.configverify import verify_mtu_ipv6
3232
from vyos.configverify import verify_mirror_redirect
3333
from vyos.ifconfig import MACVLANIf
34+
from vyos.ifconfig import BridgeIf
3435
from vyos.utils.network import interface_exists
36+
from vyos.utils.network import split_interface_vlans
3537
from vyos import ConfigError
3638

3739
from vyos import airbag
@@ -68,6 +70,35 @@ def get_config(config=None):
6870

6971
return peth
7072

73+
def _verify_anycast_gateway(peth: dict):
74+
"""Validate anycast-gateway requirements."""
75+
76+
if 'anycast_gateway' not in peth:
77+
return
78+
79+
ifname = peth['ifname']
80+
81+
# Requirement 1: MAC address must be explicitly configured
82+
if 'mac' not in peth:
83+
raise ConfigError(
84+
f'Anycast-gateway requires an explicit MAC address to be set on interface {ifname}. '
85+
f'Use: set interfaces pseudo-ethernet {ifname} mac <mac>'
86+
)
87+
88+
# Requirement 2: source-interface must be a bridge or bridge sub-interface
89+
source_iface = peth.get('source_interface')
90+
if not source_iface:
91+
raise ConfigError(
92+
f'Anycast-gateway requires source-interface to be set on interface {ifname}'
93+
)
94+
95+
if not source_iface.startswith('br'):
96+
raise ConfigError(
97+
'Anycast-gateway requires source-interface to be a bridge '
98+
'or a bridge vlan interface (e.g. br0 or br0.100), but '
99+
f'"{source_iface}" is neither of these two.'
100+
)
101+
71102
def verify(peth):
72103
if 'deleted' in peth:
73104
verify_bridge_delete(peth)
@@ -82,12 +113,75 @@ def verify(peth):
82113
# use common function to verify VLAN configuration
83114
verify_vlan_config(peth)
84115

116+
_verify_anycast_gateway(peth)
117+
85118
return None
86119

87120
def generate(peth):
88121
return None
89122

123+
124+
def _apply_anycast_gateway(peth: dict, old_mac: str | None, old_source: str | None):
125+
"""Apply anycast gateway FDB entry for bridge if configured."""
126+
127+
def _get_bridge_by_source(source_interface: str | None):
128+
source_interface = source_interface or ''
129+
if source_interface.startswith('br'):
130+
bridge_ifname, *_ = split_interface_vlans(source_interface)
131+
if interface_exists(bridge_ifname):
132+
return BridgeIf(bridge_ifname)
133+
return None
134+
135+
# Is anycast-gateway set in the new config?
136+
is_anycast = 'anycast_gateway' in peth
137+
138+
# Was anycast-gateway previously active?
139+
# (old entry exists if both old_mac and old_source set)
140+
was_anycast = old_mac is not None and old_source is not None
141+
142+
# Detect a MAC address change while anycast-gateway stays enabled
143+
new_mac = peth.get('mac')
144+
mac_changed = is_anycast and old_mac and old_mac != new_mac
145+
146+
# Detect a source interface change while anycast-gateway stays enabled
147+
new_source = peth.get('source_interface')
148+
source_changed = is_anycast and old_source and old_source != new_source
149+
150+
# Clean up old FDB entry in the next situations:
151+
# 1. anycast-gateway was removed
152+
# 2. the MAC address changed (old entry is now stale)
153+
# 3. the source interface changed (old entry is now stale)
154+
# 4. the interface is being deleted
155+
if not is_anycast or mac_changed or source_changed or 'deleted' in peth:
156+
if old_source and old_mac:
157+
bridge = _get_bridge_by_source(old_source)
158+
if bridge:
159+
try:
160+
bridge.del_local_fdb_entry(old_mac)
161+
except OSError:
162+
pass # Bridge may already be gone, that is fine
163+
164+
if 'deleted' not in peth:
165+
# Add only on transition or key change
166+
should_add = (is_anycast and not was_anycast) or mac_changed or source_changed
167+
if should_add and new_source and new_mac:
168+
bridge = _get_bridge_by_source(new_source)
169+
if bridge:
170+
bridge.add_local_fdb_entry(new_mac)
171+
172+
90173
def apply(peth):
174+
# Obtain current MAC address and source interface if pseudo-ethernet exists.
175+
# It is neccessary to apply anycast gateway.
176+
current_mac = current_source = None
177+
if 'deleted' not in peth:
178+
peth_ifname = peth['ifname']
179+
if interface_exists(peth_ifname):
180+
piface = MACVLANIf(peth_ifname, create=False)
181+
if piface:
182+
current_mac = piface.get_mac()
183+
current_source = piface.get_source_interface()
184+
91185
# Check if the MACVLAN interface already exists
92186
if 'rebuild_required' in peth or 'deleted' in peth:
93187
if interface_exists(peth['ifname']):
@@ -100,6 +194,8 @@ def apply(peth):
100194
p = MACVLANIf(**peth)
101195
p.update(peth)
102196

197+
_apply_anycast_gateway(peth, current_mac, current_source)
198+
103199
if 'static_arp' in peth:
104200
call_dependents()
105201

0 commit comments

Comments
 (0)