diff options
| author | Alex Kudentsov <43482574+alexk37@users.noreply.github.com> | 2026-06-20 20:38:51 +0700 |
|---|---|---|
| committer | Alex Kudentsov <43482574+alexk37@users.noreply.github.com> | 2026-06-20 20:51:25 +0700 |
| commit | 23649ea9dec442795997aac3da148e3312018d75 (patch) | |
| tree | bf3299599652b8c4f5e9e1ec97fb165a3bd17654 | |
| parent | 6c0fd99099a6251a12bc2287d5ba2ab115cd8015 (diff) | |
| download | vyos-1x-23649ea9dec442795997aac3da148e3312018d75.tar.gz vyos-1x-23649ea9dec442795997aac3da148e3312018d75.zip | |
T8990: bgp: fix VPNv4/VPNv6 leaked routes flapping on every commit
VRF route leaking via VPNv4/VPNv6 caused all imported (leaked) routes to
be withdrawn and reinstalled on every commit - even commits unrelated to
BGP, VRF or routing - briefly blackholing traffic that depends on them.
Root cause: the BGP address-family template rendered the route-target
using the "route-target vpn ..." alias. FRR accepts that alias on input
but its config writer only ever emits the canonical "rt vpn ..." form.
frr-reload.py diffs the generated config against FRR's running config
line by line and has no normalisation for this, so the route-target lines
never matched and were deleted and re-added on every reload. Re-applying
the route-target runs FRR's vpn_leak_prechange()/postchange(), a
non-atomic withdraw-all then reinstall-all of every leaked route.
Emit the canonical "rt vpn" keyword so the rendered config round-trips
through frr-reload unchanged. FRR also folds an identical import+export
route-target into "both" (an order-sensitive, byte-identical match), so
do the same to stay idempotent; reordered or differing lists stay split
on both sides.
Add test_bgp_33_vpn_route_target_idempotency covering both / identical
export+import / asymmetric route-target, reading the generated FRR config
(getFRRconfig/vtysh returns FRR's already-canonical form and cannot catch
the alias).
| -rw-r--r-- | data/templates/frr/bgpd.frr.j2 | 15 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_protocols_bgp.py | 49 |
2 files changed, 61 insertions, 3 deletions
diff --git a/data/templates/frr/bgpd.frr.j2 b/data/templates/frr/bgpd.frr.j2 index 27d13c40c..c10aa2826 100644 --- a/data/templates/frr/bgpd.frr.j2 +++ b/data/templates/frr/bgpd.frr.j2 @@ -428,14 +428,23 @@ router bgp {{ system_as }} {{ 'vrf ' ~ vrf if vrf is vyos_defined }} {{ 'as-nota {% if afi_config.rd.vpn.export is vyos_defined %} rd vpn export {{ afi_config.rd.vpn.export }} {% endif %} +{# T8990: emit FRR's canonical "rt vpn" keyword (not the "route-target vpn" #} +{# alias), and fold an identical import+export into "both" exactly as FRR #} +{# does. FRR only folds on an identical, order-sensitive match, which is #} +{# what the string compare below tests - so reordered or differing RT lists #} +{# stay split on both sides too. This keeps the rendered config #} +{# byte-identical to FRR's running config, so frr-reload produces no diff #} +{# that would otherwise flap leaked routes on every commit. #} {% if afi_config.route_target.vpn.both is vyos_defined %} - route-target vpn both {{ afi_config.route_target.vpn.both }} + rt vpn both {{ afi_config.route_target.vpn.both }} +{% elif afi_config.route_target.vpn.export is vyos_defined and afi_config.route_target.vpn.export == afi_config.route_target.vpn.import %} + rt vpn both {{ afi_config.route_target.vpn.export }} {% else %} {% if afi_config.route_target.vpn.export is vyos_defined %} - route-target vpn export {{ afi_config.route_target.vpn.export }} + rt vpn export {{ afi_config.route_target.vpn.export }} {% endif %} {% if afi_config.route_target.vpn.import is vyos_defined %} - route-target vpn import {{ afi_config.route_target.vpn.import }} + rt vpn import {{ afi_config.route_target.vpn.import }} {% endif %} {% endif %} {% if afi_config.route_target.both is vyos_defined %} diff --git a/smoketest/scripts/cli/test_protocols_bgp.py b/smoketest/scripts/cli/test_protocols_bgp.py index e9f615921..680829bce 100755 --- a/smoketest/scripts/cli/test_protocols_bgp.py +++ b/smoketest/scripts/cli/test_protocols_bgp.py @@ -25,6 +25,7 @@ from vyos.configsession import ConfigSessionError from vyos.template import is_ipv6 from vyos.utils.process import process_named_running from vyos.utils.process import cmd +from vyos.utils.file import read_file from vyos.frrender import bgp_daemon ASN = '64512' @@ -1792,6 +1793,54 @@ class TestProtocolsBGP(VyOSUnitTestSHIM.TestCase): self.assertIn(f'router bgp {ASN}', frrconfig) self.assertIn(f' neighbor {neighbor} bfd strict hold-time {bfd_hold_time}', frrconfig) + def test_bgp_33_vpn_route_target_idempotency(self): + # T8990: VPNv4/VPNv6 inter-VRF route-leak config must be rendered using + # FRR's canonical "rt vpn" keyword (not the "route-target vpn" alias), + # and an equal import+export route-target must be collapsed into "both" + # - exactly as FRR stores it. Otherwise the generated config never + # matches FRR's running config, frr-reload re-applies the route-target + # on every commit, and the leaked routes are withdrawn/reinstalled + # (flap) on every - even unrelated - commit. + # + # This intentionally reads the generated FRR config file instead of + # getFRRconfig()/vtysh: vtysh returns FRR's already-canonicalized + # config ("rt vpn"), so it cannot detect us emitting the + # "route-target vpn" alias - which is the actual T8990 defect. + frr_conf = '/run/frr/config/vyos.frr.conf' + afi_path = base_path + ['address-family', 'ipv4-unicast'] + + self.cli_set(afi_path + ['export', 'vpn']) + self.cli_set(afi_path + ['import', 'vpn']) + self.cli_set(afi_path + ['rd', 'vpn', 'export', f'{ASN}:1']) + + # symmetric route-target via "both" + self.cli_set(afi_path + ['route-target', 'vpn', 'both', f'{ASN}:100']) + self.cli_commit() + frrconfig = read_file(frr_conf) + self.assertIn(f' rt vpn both {ASN}:100', frrconfig) + self.assertNotIn('route-target vpn', frrconfig) + self.cli_delete(afi_path + ['route-target', 'vpn', 'both']) + + # symmetric route-target via equal import + export must collapse to + # "both" to match FRR's canonical rendering + self.cli_set(afi_path + ['route-target', 'vpn', 'export', f'{ASN}:100']) + self.cli_set(afi_path + ['route-target', 'vpn', 'import', f'{ASN}:100']) + self.cli_commit() + frrconfig = read_file(frr_conf) + self.assertIn(f' rt vpn both {ASN}:100', frrconfig) + self.assertNotIn('route-target vpn', frrconfig) + self.cli_delete(afi_path + ['route-target', 'vpn', 'export']) + self.cli_delete(afi_path + ['route-target', 'vpn', 'import']) + + # asymmetric route-target -> separate import/export lines + self.cli_set(afi_path + ['route-target', 'vpn', 'export', f'{ASN}:200']) + self.cli_set(afi_path + ['route-target', 'vpn', 'import', f'{ASN}:300']) + self.cli_commit() + frrconfig = read_file(frr_conf) + self.assertIn(f' rt vpn export {ASN}:200', frrconfig) + self.assertIn(f' rt vpn import {ASN}:300', frrconfig) + self.assertNotIn('route-target vpn', frrconfig) + def test_bgp_99_bmp(self): target_name = 'instance-bmp' target_address = '127.0.0.1' |
