From 912fe33b3c6b5fba957f924c186b766ad87e86ae Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Sun, 30 Aug 2026 15:48:56 -0600 Subject: [PATCH] falcon-lab: add focused BGP MD5 regression Signed-off-by: Trey Aspelund --- falcon-lab/src/scenario.rs | 109 +++++++++--- falcon-lab/src/scenario/bgp_md5.rs | 261 +++++++++++++++++++++++++++++ falcon-lab/src/topo.rs | 16 +- 3 files changed, 357 insertions(+), 29 deletions(-) create mode 100644 falcon-lab/src/scenario/bgp_md5.rs diff --git a/falcon-lab/src/scenario.rs b/falcon-lab/src/scenario.rs index 310b2fb3..a999348a 100644 --- a/falcon-lab/src/scenario.rs +++ b/falcon-lab/src/scenario.rs @@ -43,6 +43,8 @@ use std::{ }; use tokio::time::timeout; +mod bgp_md5; + // Falcon derives dladm link names from the deployment name by appending the // node, endpoint kind, and link type, and illumos reserves one byte of // MAXLINKNAMELEN (32) for the NUL terminator. Check every deployment name at @@ -206,12 +208,14 @@ impl Scenario for InteropScenario { #[derive(Copy, Clone, Debug, ValueEnum, strum::VariantArray)] pub(crate) enum Interop3LinkScenario { Bare, + BgpMd5, } impl Interop3LinkScenario { pub const fn name(self) -> &'static str { match self { Self::Bare => "interop3_bare", + Self::BgpMd5 => "interop3_md5", } } } @@ -226,6 +230,15 @@ impl Scenario for Interop3LinkScenario { .context("clear stale Junos config")?; self.run_bare(options.persistent).await } + Self::BgpMd5 => { + bgp_md5::run( + self, + options.persistent, + options.diag_on_fail, + options.commits, + ) + .await + } } } @@ -304,7 +317,7 @@ struct BootedInterop { dpd: DpdClient, #[allow(dead_code)] mgmt_addr: IpAddr, - scenario: InteropScenario, + layout: InteropLayout, protocols: ProtocolDiagnostics, } @@ -315,6 +328,35 @@ struct InteropRouters { cr3: JuniperNode, } +#[derive(Copy, Clone)] +enum InteropLayout { + SingleLink(InteropScenario), + ThreeLinks(Interop3LinkScenario), +} + +impl InteropLayout { + const fn name(self) -> &'static str { + match self { + Self::SingleLink(scenario) => scenario.name(), + Self::ThreeLinks(scenario) => scenario.name(), + } + } + + const fn front_ports(self) -> usize { + match self { + Self::SingleLink(_) => 4, + Self::ThreeLinks(_) => 12, + } + } + + const fn peer_mgmt_addr(self) -> &'static str { + match self { + Self::SingleLink(_) => HELIOS_MGMT_ADDR, + Self::ThreeLinks(_) => "vioif3/dhcp", + } + } +} + struct InteropPeerStates { cr1: T, cr2: T, @@ -366,7 +408,7 @@ where }; let mgd = bt.mgd.clone(); let peer_mgd = bt.peer_mgd.clone(); - let topo_name = bt.scenario.name(); + let topo_name = bt.layout.name(); let protocols = bt.protocols; let result = body(bt).await; if let Err(e) = &result { @@ -503,7 +545,7 @@ async fn boot_mgd_duo( /// supplies a closure that populates a `JoinSet` with vendor-peer setup futures /// so that they run concurrently with the npuvm install. async fn boot_interop( - scenario: InteropScenario, + layout: InteropLayout, persistent: bool, diag_on_fail: bool, commits: NpuvmCommits, @@ -518,15 +560,31 @@ where Arc, ) -> tokio::task::JoinSet>, { - let Interop { - mut d, - ox, - peer, - cr1, - cr2, - cr3, - } = Interop::build(scenario)?; - let topo_name = scenario.name(); + let (mut d, ox, peer, cr1, cr2, cr3) = match layout { + InteropLayout::SingleLink(scenario) => { + let Interop { + d, + ox, + peer, + cr1, + cr2, + cr3, + } = Interop::build(scenario)?; + (d, ox, peer, cr1, cr2, cr3) + } + InteropLayout::ThreeLinks(scenario) => { + let Interop3Link { + d, + ox, + peer, + cr1, + cr2, + cr3, + } = Interop3Link::build(scenario)?; + (d, ox, peer, cr1, cr2, cr3) + } + }; + let topo_name = layout.name(); d.persistent = persistent; clear_staged_routing_configs().context("clear stale Junos config")?; info!(d.log, "{topo_name}: launching interop topology"); @@ -546,6 +604,7 @@ where peer, InteropRouters { cr1, cr2, cr3 }, commits, + layout, spawn_peer_setups, ) .await; @@ -562,7 +621,7 @@ where peer_mgd, dpd, mgmt_addr, - scenario, + layout, protocols, }), Err(e) => { @@ -588,6 +647,7 @@ async fn boot_interop_inner( peer: MgdNode, routers: InteropRouters, commits: NpuvmCommits, + layout: InteropLayout, spawn_peer_setups: F, ) -> Result<(MgdClient, MgdClient, DpdClient, IpAddr)> where @@ -603,14 +663,14 @@ where let peer_illumos = peer.illumos(); let (mgmt_addr, peer_mgmt_addr) = tokio::try_join!( ox_illumos.dhcp(ad, HELIOS_MGMT_ADDR), - peer_illumos.dhcp(ad, HELIOS_MGMT_ADDR), + peer_illumos.dhcp(ad, layout.peer_mgmt_addr()), )?; let mut js = spawn_peer_setups(cr1, cr2, cr3, ad.clone()); let npuvm_ad = ad.clone(); js.spawn(async move { ox.dendrite() - .npuvm(npuvm_ad, 4, 0, commits) + .npuvm(npuvm_ad, layout.front_ports(), 0, commits) .await .context("setup ox npuvm") }); @@ -632,18 +692,15 @@ where .await .context("wait_for_dpd")?; - for link in ["qsfp0", "qsfp1", "qsfp2", "qsfp3"] { - softnpu_link_create(&dpd, link) + for port in 0..layout.front_ports() { + let link = format!("qsfp{port}"); + softnpu_link_create(&dpd, &link) .await .context(format!("create {link}"))?; } - for link in [ - "tfportqsfp0_0", - "tfportqsfp1_0", - "tfportqsfp2_0", - "tfportqsfp3_0", - ] { - ox.illumos().wait_for_link(ad, link, OP_TIMEOUT).await?; + for port in 0..layout.front_ports() { + let link = format!("tfportqsfp{port}_0"); + ox.illumos().wait_for_link(ad, &link, OP_TIMEOUT).await?; } Ok((mgd, peer_mgd, dpd, mgmt_addr)) @@ -793,7 +850,7 @@ async fn run_interop_unnumbered( commits: NpuvmCommits, ) -> Result<()> { let bt = boot_interop( - scenario, + InteropLayout::SingleLink(scenario), persistent, diag_on_fail, commits, @@ -1384,7 +1441,7 @@ async fn run_interop_bfd_static( commits: NpuvmCommits, ) -> Result<()> { let bt = boot_interop( - scenario, + InteropLayout::SingleLink(scenario), persistent, diag_on_fail, commits, diff --git a/falcon-lab/src/scenario/bgp_md5.rs b/falcon-lab/src/scenario/bgp_md5.rs new file mode 100644 index 00000000..3b39e5c0 --- /dev/null +++ b/falcon-lab/src/scenario/bgp_md5.rs @@ -0,0 +1,261 @@ +//! BGP TCP MD5 interop scenario. + +use super::{ + BootedInterop, Interop3LinkScenario, InteropLayout, OP_TIMEOUT, + boot_interop, run_with_optional_diagnostics, +}; +use crate::{ + dendrite::NpuvmCommits, diagnostics::ProtocolDiagnostics, mgd::wait_for_mgd, +}; +use anyhow::{Context, Result}; +use dpd_client::types::{Ipv4Entry, LinkId, PortId}; +use mg_admin_client::{Client as MgdClient, types::Neighbor}; +use mg_api_types::bgp::{ + config::{Ipv4UnicastConfig, PeerInfo, Router}, + policy::ImportExportPolicy4, + session::FsmStateKind, +}; +use std::{ + net::{Ipv4Addr, SocketAddr}, + time::Duration, +}; +use tokio::time::{Instant, sleep, timeout}; + +const DUT_ASN: u32 = 33; +const PEER_ASN: u32 = 47; +const DUT_ADDR: Ipv4Addr = Ipv4Addr::new(10, 0, 3, 1); +const PEER_ADDR: Ipv4Addr = Ipv4Addr::new(10, 0, 3, 2); +const DUT_LINK: &str = "tfportqsfp9_0"; +const PEER_LINK: &str = "vioif0"; +const TEST_KEY: &str = "falcon-bgp-md5-test-key"; +const STABILITY_INTERVAL: Duration = Duration::from_secs(13); + +pub(super) async fn run( + scenario: Interop3LinkScenario, + persistent: bool, + diag_on_fail: bool, + commits: NpuvmCommits, +) -> Result<()> { + let bt = boot_interop( + InteropLayout::ThreeLinks(scenario), + persistent, + diag_on_fail, + commits, + ProtocolDiagnostics::Bgp, + |_cr1, _cr2, _cr3, _ad| tokio::task::JoinSet::new(), + ) + .await?; + run_with_optional_diagnostics(bt, diag_on_fail, body).await +} + +// This fixture proves session establishment only. Do not reuse it for +// route-installation testing: peers such as FRR may select numbered or +// IPv4-mapped-IPv6 next hops. +async fn body(bt: BootedInterop) -> Result<()> { + let BootedInterop { + ad, + ox, + peer, + mgd, + peer_mgd, + dpd, + .. + } = bt; + let port = PortId::Qsfp("qsfp9".parse().expect("parse qsfp9 port")); + dpd.link_ipv4_create( + &port, + &LinkId(0), + &Ipv4Entry { + addr: DUT_ADDR, + tag: "falcon-lab".into(), + }, + ) + .await + .context("dpd: program 10.0.3.1 on qsfp9/0")?; + ox.illumos() + .staticaddr(&ad, &format!("{DUT_LINK}/v4"), "10.0.3.1/24") + .await + .context("assign DUT IPv4 address")?; + peer.illumos() + .staticaddr(&ad, &format!("{PEER_LINK}/v4"), "10.0.3.2/24") + .await + .context("assign peer IPv4 address")?; + + ox.run_mgd(&ad).await?; + wait_for_mgd(&mgd, OP_TIMEOUT, &ad.log).await?; + for (client, asn, id, label) in [ + (&mgd, DUT_ASN, 33, "DUT"), + (&peer_mgd, PEER_ASN, 47, "peer"), + ] { + client + .create_router(&Router { + asn, + graceful_shutdown: false, + id, + listen: "[::]:179".to_owned(), + }) + .await + .with_context(|| format!("{label}: create router"))?; + } + + mgd.create_neighbor(&md5_neighbor( + DUT_ASN, PEER_ASN, "peer", DUT_ADDR, PEER_ADDR, true, + )) + .await + .context("DUT: create passive MD5 neighbor")?; + peer_mgd + .create_neighbor(&md5_neighbor( + PEER_ASN, DUT_ASN, "dut", PEER_ADDR, DUT_ADDR, false, + )) + .await + .context("peer: create active MD5 neighbor")?; + + let (dut, peer) = wait_for_established(&mgd, &peer_mgd).await?; + anyhow::ensure!( + dut.local_tcp_port == 179 && dut.remote_tcp_port != 179, + "DUT did not accept the inbound BGP connection: local port {}, remote port {}", + dut.local_tcp_port, + dut.remote_tcp_port + ); + anyhow::ensure!( + peer.local_tcp_port != 179 && peer.remote_tcp_port == 179, + "peer did not initiate the inbound BGP connection: local port {}, remote port {}", + peer.local_tcp_port, + peer.remote_tcp_port + ); + + let dut_transitions = transition_counters(&dut); + let peer_transitions = transition_counters(&peer); + let deadline = Instant::now() + STABILITY_INTERVAL; + while Instant::now() < deadline { + let dut = peer_info(&mgd, DUT_ASN, "peer") + .await + .context("DUT PeerInfo disappeared during hold-time observation")?; + let peer = peer_info(&peer_mgd, PEER_ASN, "dut").await.context( + "peer PeerInfo disappeared during hold-time observation", + )?; + assert_stable(&dut, dut_transitions, "DUT")?; + assert_stable(&peer, peer_transitions, "peer")?; + sleep(Duration::from_millis(250)).await; + } + + let dut = peer_info(&mgd, DUT_ASN, "peer") + .await + .context("DUT PeerInfo disappeared after hold-time observation")?; + let peer = peer_info(&peer_mgd, PEER_ASN, "dut") + .await + .context("peer PeerInfo disappeared after hold-time observation")?; + assert_stable(&dut, dut_transitions, "DUT")?; + assert_stable(&peer, peer_transitions, "peer")?; + anyhow::ensure!( + dut.fsm_state_duration > Duration::from_secs(12), + "DUT was not continuously Established for two hold-time periods" + ); + anyhow::ensure!( + peer.fsm_state_duration > Duration::from_secs(12), + "peer was not continuously Established for two hold-time periods" + ); + Ok(()) +} + +fn md5_neighbor( + asn: u32, + remote_asn: u32, + name: &str, + source: Ipv4Addr, + remote: Ipv4Addr, + passive: bool, +) -> Neighbor { + Neighbor { + asn, + name: name.to_owned(), + group: "bgp-md5".to_owned(), + host: SocketAddr::new(remote.into(), 179), + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 2, + resolution: 100, + passive, + remote_asn: Some(remote_asn), + min_ttl: None, + md5_auth_key: Some(TEST_KEY.to_owned()), + multi_exit_discriminator: None, + communities: Vec::new(), + local_pref: None, + enforce_first_as: false, + vlan_id: None, + ipv4_unicast: Some(Ipv4UnicastConfig { + import_policy: ImportExportPolicy4::NoFiltering, + export_policy: ImportExportPolicy4::NoFiltering, + nexthop: None, + }), + ipv6_unicast: None, + deterministic_collision_resolution: false, + idle_hold_jitter: None, + connect_retry_jitter: None, + src_addr: Some(source.into()), + src_port: None, + } +} + +async fn wait_for_established( + dut: &MgdClient, + peer: &MgdClient, +) -> Result<(PeerInfo, PeerInfo)> { + timeout(Duration::from_secs(30), async { + loop { + if let (Some(dut), Some(peer)) = ( + peer_info(dut, DUT_ASN, "peer").await, + peer_info(peer, PEER_ASN, "dut").await, + ) && dut.fsm_state == FsmStateKind::Established + && peer.fsm_state == FsmStateKind::Established + { + return (dut, peer); + } + sleep(Duration::from_millis(250)).await; + } + }) + .await + .context("timed out waiting for both MD5 peers to become Established") +} + +async fn peer_info(mgd: &MgdClient, asn: u32, name: &str) -> Option { + mgd.get_neighbors(asn) + .await + .ok()? + .into_inner() + .into_values() + .find(|peer| peer.name == name) +} + +fn assert_stable( + peer: &PeerInfo, + transitions: [u64; 8], + label: &str, +) -> Result<()> { + anyhow::ensure!( + peer.fsm_state == FsmStateKind::Established, + "{label} left Established" + ); + anyhow::ensure!( + transition_counters(peer) == transitions, + "{label} FSM transition counters changed" + ); + Ok(()) +} + +fn transition_counters(peer: &PeerInfo) -> [u64; 8] { + let c = &peer.counters; + [ + c.transitions_to_idle, + c.transitions_to_connect, + c.transitions_to_active, + c.transitions_to_open_sent, + c.transitions_to_open_confirm, + c.transitions_to_connection_collision, + c.transitions_to_session_setup, + c.transitions_to_established, + ] +} diff --git a/falcon-lab/src/topo.rs b/falcon-lab/src/topo.rs index 8b45417c..e5b8b5f7 100644 --- a/falcon-lab/src/topo.rs +++ b/falcon-lab/src/topo.rs @@ -62,10 +62,13 @@ pub struct Interop { pub cr3: JuniperNode, } -// The bare scenario only drives the runner; node handles arrive with the -// first scenario that interacts with individual VMs. pub struct Interop3Link { pub d: Runner, + pub ox: MgdNode, + pub peer: MgdNode, + pub cr1: FrrNode, + pub cr2: EosNode, + pub cr3: JuniperNode, } struct InteropNodes { @@ -149,7 +152,14 @@ impl Topology for Interop3Link { fn build(scenario: Interop3LinkScenario) -> Result { let nodes = build_interop(scenario.name(), 3)?; - Ok(Self { d: nodes.d }) + Ok(Self { + d: nodes.d, + ox: MgdNode(nodes.ox), + peer: MgdNode(nodes.peer), + cr1: FrrNode(nodes.cr1), + cr2: EosNode(nodes.cr2), + cr3: JuniperNode(nodes.cr3), + }) } fn runner_mut(&mut self) -> &mut Runner {