From 719a096dcc59e2cf34497e1ce59bd71e1498ab69 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Tue, 18 Nov 2025 11:36:39 -0700 Subject: [PATCH 01/20] bgp: initial MP-BGP implementation Implement MP_REACH_NLRI and MP_UNREACH_NLRI (RFC 4760) for IPv6 route exchange. Wire format (RFC 7606 compliance): - Always encode MP-BGP attributes first in UPDATE - Never encode both traditional and MP-BGP encodings in the same UPDATE - De-duplicate received non-MP attributes - Reject duplicate MP-BGP attributes with NOTIFICATION - Handle received UPDATE carrying traditional and MP-BGP encodings - Handle received UPDATE carrying both MP_REACH_NLRI and MP_UNREACH_NLRI Next-hop handling: - Add BgpNexthop enum: Ipv4, Ipv6Single, Ipv6Double (link-local + GUA) - Add UpdateMessage::nexthop() to centralize next-hop extraction logic - Preserve raw bytes in MpReach for capability-aware parsing Fanout refactor: - Split into Fanout4/Fanout6 using zero-sized marker types - Add RouteUpdate enum carrying typed prefix Vecs - Replace FSM event passing to use AFI/SAFI-specific announcements Session layer updates: - Process MP_REACH_NLRI/MP_UNREACH_NLRI in received UPDATEs - Extract IPv4/IPv6 prefixes separately for RIB updates - Encode IPv4 Unicast in traditional nlri/withdrawn fields - Encode IPv6 Unicast in MP-BGP attributes Message parsing: - Add MpReach/MpUnreach structs - Add prefixes4_from_wire()/prefixes6_from_wire() helpers - Restrict UPDATE nlri/withdrawn fields to Prefix4 - Restrict NEXT_HOP attribute to Ipv4Addr - Add Display impl for Aggregator/As4Aggregator Error handling: - Add UnsupportedAddressFamily error variant - Add MalformedAttributeList error variant Testing: - Add property-based tests for codec round-trips - Cover BgpNexthop, MpReach, MpUnreach, UpdateMessage variants - Test RFC 7606 encoding order and deduplication behavior Closes: #397 --- .gitignore | 1 + bgp/src/error.rs | 6 + bgp/src/fanout.rs | 112 ++- bgp/src/messages.rs | 1496 +++++++++++++++++++++++++++++++-- bgp/src/policy.rs | 22 +- bgp/src/proptest.rs | 693 +++++++++++++++- bgp/src/rhai_integration.rs | 25 +- bgp/src/router.rs | 310 ++++--- bgp/src/session.rs | 1547 +++++++++++++++++++++++++++-------- mgd/src/bgp_admin.rs | 2 + rdb-types/src/lib.rs | 26 +- rdb/src/types.rs | 26 + 12 files changed, 3670 insertions(+), 596 deletions(-) diff --git a/.gitignore b/.gitignore index 4792577f..4f285d36 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ out download tags !.github +claude diff --git a/bgp/src/error.rs b/bgp/src/error.rs index 8b92b5cc..aa029c08 100644 --- a/bgp/src/error.rs +++ b/bgp/src/error.rs @@ -139,6 +139,9 @@ pub enum Error { #[error("Unsupported optional parameter code {0:?}")] UnsupportedOptionalParameterCode(crate::messages::OptionalParameterCode), + #[error("Unsupported address family: AFI={0} SAFI={1}")] + UnsupportedAddressFamily(u16, u8), + #[error("Self loop detected")] SelfLoopDetected, @@ -207,6 +210,9 @@ pub enum Error { #[error("Connection registry is full: {0}")] RegistryFull(String), + + #[error("Malformed attribute list: {0}")] + MalformedAttributeList(String), } #[derive(Debug)] diff --git a/bgp/src/fanout.rs b/bgp/src/fanout.rs index d05bc366..b5c21eae 100644 --- a/bgp/src/fanout.rs +++ b/bgp/src/fanout.rs @@ -3,26 +3,39 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::connection::BgpConnection; -use crate::messages::UpdateMessage; -use crate::session::{AdminEvent, FsmEvent}; +use crate::session::{AdminEvent, FsmEvent, RouteUpdate}; use crate::{COMPONENT_BGP, MOD_NEIGHBOR}; +use rdb::types::{Ipv4Marker, Ipv6Marker, Prefix4, Prefix6}; use slog::Logger; use std::collections::BTreeMap; +use std::marker::PhantomData; use std::net::IpAddr; use std::sync::mpsc::Sender; const UNIT_FANOUT: &str = "fanout"; -pub struct Fanout { +/// Type aliases for address-family-specific fanouts +pub type Fanout4 = Fanout; +pub type Fanout6 = Fanout; + +/// Fanout for distributing routes to peers for a specific address family. +/// +/// The type parameter `Af` ensures compile-time safety: +/// - Fanout4 (Fanout<_, Ipv4Marker>) only accepts Prefix4 +/// - Fanout6 (Fanout<_, Ipv6Marker>) only accepts Prefix6 +pub struct Fanout { /// Indexed neighbor address egress: BTreeMap>, + /// Zero-sized marker for address family type enforcement + _af: PhantomData, } //NOTE necessary as #derive is broken for generic types -impl Default for Fanout { +impl Default for Fanout { fn default() -> Self { Self { egress: BTreeMap::new(), + _af: PhantomData, } } } @@ -32,22 +45,66 @@ pub struct Egress { pub log: Logger, } -impl Fanout { - pub fn send(&self, origin: IpAddr, update: &UpdateMessage) { - for (id, e) in &self.egress { - if *id == origin { +// IPv4-specific implementation +impl Fanout { + /// Announce IPv4 routes to all peers. + pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { + let route_update = RouteUpdate::V4 { nlri, withdrawn }; + + for egress in self.egress.values() { + egress.announce_routes(route_update.clone()); + } + } + + /// Announce IPv4 routes to all peers except the origin. + pub fn announce_except( + &self, + origin: IpAddr, + nlri: Vec, + withdrawn: Vec, + ) { + let route_update = RouteUpdate::V4 { nlri, withdrawn }; + + for (peer_addr, egress) in &self.egress { + if *peer_addr == origin { continue; } - e.send(update); + egress.announce_routes(route_update.clone()); } } +} - pub fn send_all(&self, update: &UpdateMessage) { - for e in self.egress.values() { - e.send(update); +// IPv6-specific implementation +impl Fanout { + /// Announce IPv6 routes to all peers. + pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { + let route_update = RouteUpdate::V6 { nlri, withdrawn }; + + for egress in self.egress.values() { + egress.announce_routes(route_update.clone()); } } + /// Announce IPv6 routes to all peers except the origin. + pub fn announce_except( + &self, + origin: IpAddr, + nlri: Vec, + withdrawn: Vec, + ) { + let route_update = RouteUpdate::V6 { nlri, withdrawn }; + + for (peer_addr, egress) in &self.egress { + if *peer_addr == origin { + continue; + } + egress.announce_routes(route_update.clone()); + } + } +} + +// Common methods available for all address families +impl Fanout { pub fn add_egress(&mut self, peer: IpAddr, egress: Egress) { self.egress.insert(peer, egress); } @@ -62,19 +119,32 @@ impl Fanout { } impl Egress { - fn send(&self, update: &UpdateMessage) { - if let Some(tx) = self.event_tx.as_ref() - && let Err(e) = - tx.send(FsmEvent::Admin(AdminEvent::Announce(update.clone()))) + fn announce_routes(&self, route_update: RouteUpdate) { + let Some(tx) = self.event_tx.as_ref() else { + return; + }; + + // Extract summary info before send() consumes route_update. + // This avoids expensive formatting when send succeeds (common case). + let (af, nlri_count, withdrawn_count) = ( + route_update.afi(), + route_update.nlri_count(), + route_update.withdrawn_count(), + ); + + if let Err(e) = + tx.send(FsmEvent::Admin(AdminEvent::Announce(route_update))) { - slog::error!(self.log, - "failed to send update to egress fanout: {e}"; + slog::error!( + self.log, + "failed to send routes to egress: {e}"; "component" => COMPONENT_BGP, "module" => MOD_NEIGHBOR, "unit" => UNIT_FANOUT, - "message" => "update", - "message_contents" => format!("{update}"), - "error" => format!("{e}") + "address_family" => af, + "nlri_count" => nlri_count, + "withdrawn_count" => withdrawn_count, + "error" => format!("{e}"), ); } } diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index 177a50e0..e96e6b41 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -14,7 +14,7 @@ use rdb::types::{AddressFamily, Prefix4, Prefix6}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ - collections::BTreeSet, + collections::{BTreeSet, HashSet}, fmt::{Display, Formatter}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, }; @@ -709,9 +709,9 @@ pub struct Tlv { Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, JsonSchema, )] pub struct UpdateMessage { - pub withdrawn: Vec, + pub withdrawn: Vec, pub path_attributes: Vec, - pub nlri: Vec, + pub nlri: Vec, } impl UpdateMessage { @@ -755,22 +755,65 @@ impl UpdateMessage { fn withdrawn_to_wire(&self) -> Result, Error> { let mut buf = Vec::new(); for w in &self.withdrawn { - let wire_bytes = match w { - Prefix::V4(p) => p.to_wire(), - Prefix::V6(p) => p.to_wire(), - }; - buf.extend_from_slice(&wire_bytes); + buf.extend_from_slice(&w.to_wire()); } Ok(buf) } + /// RFC 7606 Section 5.1: + /// ```text + /// 5. Parsing of Network Layer Reachability Information (NLRI) Fields + /// + /// 5.1. Encoding NLRI + /// + /// To facilitate the determination of the NLRI field in an UPDATE + /// message with a malformed attribute: + /// + /// o The MP_REACH_NLRI or MP_UNREACH_NLRI attribute (if present) SHALL + /// be encoded as the very first path attribute in an UPDATE message. + /// + /// o An UPDATE message MUST NOT contain more than one of the following: + /// non-empty Withdrawn Routes field, non-empty Network Layer + /// Reachability Information field, MP_REACH_NLRI attribute, and + /// MP_UNREACH_NLRI attribute. + /// + /// Since older BGP speakers may not implement these restrictions, an + /// implementation MUST still be prepared to receive these fields in any + /// position or combination. + /// ``` + /// + /// Note: While we MUST encode MP-BGP attributes first per the spec, during + /// decoding we accept them in any position for interoperability with older + /// BGP speakers (see the last paragraph above). fn path_attrs_to_wire(&self) -> Result, Error> { let mut buf = Vec::new(); + + // Encode MP-BGP attributes first (RFC 7606 Section 5.1 requirement) for p in &self.path_attributes { - buf.extend_from_slice(&p.to_wire( - p.typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0, - )?); + if matches!( + p.value, + PathAttributeValue::MpReachNlri(_) + | PathAttributeValue::MpUnreachNlri(_) + ) { + buf.extend_from_slice(&p.to_wire( + p.typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0, + )?); + } } + + // Then encode all other attributes + for p in &self.path_attributes { + if !matches!( + p.value, + PathAttributeValue::MpReachNlri(_) + | PathAttributeValue::MpUnreachNlri(_) + ) { + buf.extend_from_slice(&p.to_wire( + p.typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0, + )?); + } + } + Ok(buf) } @@ -779,11 +822,7 @@ impl UpdateMessage { for n in &self.nlri { // TODO hacked in ADD_PATH //buf.extend_from_slice(&0u32.to_be_bytes()); - let wire_bytes = match n { - Prefix::V4(p) => p.to_wire(), - Prefix::V6(p) => p.to_wire(), - }; - buf.extend_from_slice(&wire_bytes); + buf.extend_from_slice(&n.to_wire()); } Ok(buf) } @@ -791,14 +830,13 @@ impl UpdateMessage { pub fn from_wire(input: &[u8]) -> Result { let (input, len) = be_u16(input)?; let (input, withdrawn_input) = take(len)(input)?; - let withdrawn = - Self::prefixes_from_wire(withdrawn_input, AddressFamily::Ipv4)?; + let withdrawn = Self::prefixes4_from_wire(withdrawn_input)?; let (input, len) = be_u16(input)?; let (input, attrs_input) = take(len)(input)?; let path_attributes = Self::path_attrs_from_wire(attrs_input)?; - let nlri = Self::prefixes_from_wire(input, AddressFamily::Ipv4)?; + let nlri = Self::prefixes4_from_wire(input)?; Ok(UpdateMessage { withdrawn, @@ -807,42 +845,181 @@ impl UpdateMessage { }) } - fn prefixes_from_wire( - mut buf: &[u8], + /// Check for duplicate MP_REACH_NLRI or MP_UNREACH_NLRI attributes. + /// + /// RFC 7606 Section 3g: + /// ```text + /// g. If the MP_REACH_NLRI attribute or the MP_UNREACH_NLRI [RFC4760] + /// attribute appears more than once in the UPDATE message, then a + /// NOTIFICATION message MUST be sent with the Error Subcode + /// "Malformed Attribute List". If any other attribute (whether + /// recognized or unrecognized) appears more than once in an UPDATE + /// message, then all the occurrences of the attribute other than the + /// first one SHALL be discarded and the UPDATE message will continue + /// to be processed. + /// ``` + pub fn check_duplicate_mp_attributes(&self) -> Result<(), Error> { + let mut has_mp_reach = false; + let mut has_mp_unreach = false; + + for attr in &self.path_attributes { + match &attr.value { + PathAttributeValue::MpReachNlri(_) => { + if has_mp_reach { + return Err(Error::MalformedAttributeList( + "Multiple MP_REACH_NLRI attributes".into(), + )); + } + has_mp_reach = true; + } + PathAttributeValue::MpUnreachNlri(_) => { + if has_mp_unreach { + return Err(Error::MalformedAttributeList( + "Multiple MP_UNREACH_NLRI attributes".into(), + )); + } + has_mp_unreach = true; + } + _ => {} + } + } + + Ok(()) + } + + /// Parse prefixes from wire format. + /// Dispatches to the appropriate version-specific parser. + pub fn prefixes_from_wire( + buf: &[u8], afi: AddressFamily, ) -> Result, Error> { + match afi { + AddressFamily::Ipv4 => Self::prefixes4_from_wire(buf) + .map(|v| v.into_iter().map(Prefix::V4).collect()), + AddressFamily::Ipv6 => Self::prefixes6_from_wire(buf) + .map(|v| v.into_iter().map(Prefix::V6).collect()), + } + } + + /// Parse IPv4 prefixes from wire format. + pub fn prefixes4_from_wire(mut buf: &[u8]) -> Result, Error> { + let mut result = Vec::new(); + while !buf.is_empty() { + let (out, prefix4) = Prefix4::from_wire(buf) + .map_err(|_| Error::InvalidNlriPrefix(buf.to_vec()))?; + result.push(prefix4); + buf = out; + } + Ok(result) + } + + /// Parse IPv6 prefixes from wire format. + pub fn prefixes6_from_wire(mut buf: &[u8]) -> Result, Error> { let mut result = Vec::new(); while !buf.is_empty() { - // XXX: handle error for individual prefix? - let (out, pfx) = prefix_from_wire(buf, afi)?; - result.push(pfx); + let (out, prefix6) = Prefix6::from_wire(buf) + .map_err(|_| Error::InvalidNlriPrefix(buf.to_vec()))?; + result.push(prefix6); buf = out; } Ok(result) } + /// Parse Path Attributes carried in an Update from wire format. + /// + /// For any given path attribute, only the first instance is respected. + /// Subsequent instances are discarded. The exceptions to this are MP-BGP + /// attributes, which are not allowed to show up multiple times in an + /// Update. + /// + /// All of this is mandated by RFC 7606 Section 3(g): + /// ```text + /// g. If the MP_REACH_NLRI attribute or the MP_UNREACH_NLRI [RFC4760] + /// attribute appears more than once in the UPDATE message, then a + /// NOTIFICATION message MUST be sent with the Error Subcode + /// "Malformed Attribute List". If any other attribute (whether + /// recognized or unrecognized) appears more than once in an UPDATE + /// message, then all the occurrences of the attribute other than the + /// first one SHALL be discarded and the UPDATE message will continue + /// to be processed. + /// ``` + /// + /// Note: Currently, this is implemented by retaining MpReachNlri and + /// MpUnreachNlri in the parsed Update so the session layer (FSM) can detect + /// the duplication and react accordingly (by sending a Malformed Attribute + /// List Notification). fn path_attrs_from_wire( mut buf: &[u8], ) -> Result, Error> { let mut result = Vec::new(); + let mut seen_types: HashSet = HashSet::new(); + loop { if buf.is_empty() { break; } let (out, pa) = PathAttribute::from_wire(buf)?; - result.push(pa); + + let type_code = pa.typ.type_code as u8; + let is_mp_bgp = matches!( + pa.typ.type_code, + PathAttributeTypeCode::MpReachNlri + | PathAttributeTypeCode::MpUnreachNlri + ); + + if is_mp_bgp || !seen_types.contains(&type_code) { + // Keep MP-BGP attributes (duplicates trigger NOTIFICATION via + // check_duplicate_mp_attributes at session layer) and first + // occurrence of non-MP-BGP attributes. + seen_types.insert(type_code); + result.push(pa); + } + // else: discard duplicate non-MP-BGP attribute per RFC 7606 3(g) + buf = out; } Ok(result) } + /// This method parses an UpdateMessage and returns a BgpNexthop which + /// represents the most correct next-hop for the situation. Since there + /// are so many different ways to encode a BGP nexthop (often only being + /// valid with certain combinations of MP-BGP address-families and next-hop + /// capabilities), this method centralizes the logic for parsing and + /// selection of received nexthops. + pub fn nexthop(&self) -> Result { + let mp = + match self.path_attributes.iter().find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) { + // This Update is not MP-BGP, so we can just hand out NEXT_HOP + None => { + return self + .nexthop4() + .map(|n4| n4.into()) + .ok_or(Error::MissingNexthop); + } + Some(mp) => mp, + }; + + // This Update is MP-BGP, so we need to use the AFI/SAFI (represented + // here via Afi) and the nh_len to derive the nexthop type. + // XXX: extended nexthop validation + BgpNexthop::from_bytes( + &mp.nh_bytes, + mp.nh_len, + Afi::try_from(mp.afi).map_err(|_| { + Error::UnsupportedAddressFamily(mp.afi, mp.safi) + })?, + ) + } + pub fn nexthop4(&self) -> Option { - for a in &self.path_attributes { - if let PathAttributeValue::NextHop(IpAddr::V4(addr)) = a.value { - return Some(addr); - } - } - None + self.path_attributes.iter().find_map(|a| match a.value { + PathAttributeValue::NextHop(addr) => Some(addr), + _ => None, + }) } pub fn graceful_shutdown(&self) -> bool { @@ -1091,6 +1268,10 @@ pub enum PathAttributeTypeCode { Aggregator = 7, Communities = 8, + /// RFC 4760 + MpReachNlri = 14, + MpUnreachNlri = 15, + /// RFC 6793 As4Path = 17, As4Aggregator = 18, @@ -1114,6 +1295,12 @@ impl From for PathAttributeTypeCode { PathAttributeValue::Communities(_) => { PathAttributeTypeCode::Communities } + PathAttributeValue::MpReachNlri(_) => { + PathAttributeTypeCode::MpReachNlri + } + PathAttributeValue::MpUnreachNlri(_) => { + PathAttributeTypeCode::MpUnreachNlri + } /* TODO according to RFC 4893 we do not have this as an explicit * attribute type when 4-byte ASNs have been negotiated - but are * there some circumstances when we'll need transitional mode? @@ -1139,8 +1326,8 @@ pub enum PathAttributeValue { */ /// The AS set associated with a path AsPath(Vec), - /// The nexthop associated with a path - NextHop(IpAddr), + /// The nexthop associated with a path (IPv4 only for traditional BGP) + NextHop(Ipv4Addr), /// A metric used for external (inter-AS) links to discriminate among /// multiple entry or exit points. MultiExitDisc(u32), @@ -1155,7 +1342,10 @@ pub enum PathAttributeValue { As4Path(Vec), /// This attribute is included in routes that are formed by aggregation. As4Aggregator([u8; 8]), - //MpReachNlri(MpReachNlri), //TODO for IPv6 + /// Carries reachable MP-BGP NLRI and Next-hop (advertisement). + MpReachNlri(MpReach), + /// Carries unreachable MP-BGP NLRI (withdrawal). + MpUnreachNlri(MpUnreach), } impl PathAttributeValue { @@ -1173,10 +1363,7 @@ impl PathAttributeValue { } Ok(buf) } - Self::NextHop(addr) => match addr { - IpAddr::V4(a) => Ok(a.octets().into()), - IpAddr::V6(a) => Ok(a.octets().into()), - }, + Self::NextHop(addr) => Ok(addr.octets().into()), Self::As4Path(segments) => { let mut buf = Vec::new(); for s in segments { @@ -1193,6 +1380,8 @@ impl PathAttributeValue { } Self::LocalPref(value) => Ok(value.to_be_bytes().into()), Self::MultiExitDisc(value) => Ok(value.to_be_bytes().into()), + Self::MpReachNlri(mp) => mp.to_wire(), + Self::MpUnreachNlri(mp) => mp.to_wire(), x => Err(Error::UnsupportedPathAttributeValue(x.clone())), } } @@ -1227,9 +1416,9 @@ impl PathAttributeValue { }); } let (_input, b) = take(4usize)(input)?; - Ok(PathAttributeValue::NextHop( - Ipv4Addr::new(b[0], b[1], b[2], b[3]).into(), - )) + Ok(PathAttributeValue::NextHop(Ipv4Addr::new( + b[0], b[1], b[2], b[3], + ))) } PathAttributeTypeCode::MultiExitDisc => { let (_input, v) = be_u32(input)?; @@ -1263,6 +1452,14 @@ impl PathAttributeValue { let (_input, v) = be_u32(input)?; Ok(PathAttributeValue::LocalPref(v)) } + PathAttributeTypeCode::MpReachNlri => { + let (_remaining, mp_reach) = MpReach::from_wire(input)?; + Ok(PathAttributeValue::MpReachNlri(mp_reach)) + } + PathAttributeTypeCode::MpUnreachNlri => { + let (_remaining, mp_unreach) = MpUnreach::from_wire(input)?; + Ok(PathAttributeValue::MpUnreachNlri(mp_unreach)) + } x => Err(Error::UnsupportedPathAttributeTypeCode(x)), } } @@ -1285,12 +1482,24 @@ impl Display for PathAttributeValue { PathAttributeValue::LocalPref(pref) => { write!(f, "local-pref: {pref}") } - // XXX: Do real formatting - PathAttributeValue::Aggregator(agg) => write!( - f, - "aggregator: [{} {} {} {} {} {}]", - agg[0], agg[1], agg[2], agg[3], agg[4], agg[5] - ), + /* + * RFC 4271 + * + * g) AGGREGATOR (Type Code 7) + * + * AGGREGATOR is an optional transitive attribute of length 6. + * The attribute contains the last AS number that formed the + * aggregate route (encoded as 2 octets), followed by the IP + * address of the BGP speaker that formed the aggregate route + * (encoded as 4 octets). This SHOULD be the same address as + * the one used for the BGP Identifier of the speaker. + */ + PathAttributeValue::Aggregator(agg) => { + let [a0, a1, a2, a3, a4, a5] = *agg; + let asn = u16::from_be_bytes([a0, a1]); + let ip = Ipv4Addr::from([a2, a3, a4, a5]); + write!(f, "aggregator: [ asn: {asn}, ip: {ip} ]",) + } PathAttributeValue::Communities(comms) => { let comms = comms .iter() @@ -1299,6 +1508,27 @@ impl Display for PathAttributeValue { .join(" "); write!(f, "communities: [{comms}]") } + PathAttributeValue::MpReachNlri(reach) => { + write!( + f, + "mp-reach-nlri: [ afi: {}, safi: {}, next-hop-len: {}, next-hop-bytes: {} bytes, reserved: {}, nlri-bytes: {} bytes ]", + reach.afi, + reach.safi, + reach.nh_len, + reach.nh_bytes.len(), + reach.reserved, + reach.nlri_bytes.len() + ) + } + PathAttributeValue::MpUnreachNlri(unreach) => { + write!( + f, + "mp-unreach-nlri: [ afi: {}, safi: {}, withdrawn-bytes: {} bytes ]", + unreach.afi_raw, + unreach.safi_raw, + unreach.withdrawn_bytes.len() + ) + } PathAttributeValue::As4Path(path_segs) => { let path = path_segs .iter() @@ -1307,12 +1537,20 @@ impl Display for PathAttributeValue { .join(" "); write!(f, "as4-path: [{path}]") } - // XXX: Do real formatting - PathAttributeValue::As4Aggregator(agg) => write!( - f, - "as4-aggregator: [{} {} {} {} {} {} {} {}]", - agg[0], agg[1], agg[2], agg[3], agg[4], agg[5], agg[6], agg[7] - ), + /* + * RFC 6793 + * + * Similarly, this document defines a new BGP path attribute called + * AS4_AGGREGATOR, which is optional transitive. The AS4_AGGREGATOR + * attribute has the same semantics and the same encoding as the + * AGGREGATOR attribute, except that it carries a four-octet AS number. + */ + PathAttributeValue::As4Aggregator(agg) => { + let [a0, a1, a2, a3, a4, a5, a6, a7] = *agg; + let asn = u32::from_be_bytes([a0, a1, a2, a3]); + let ip = Ipv4Addr::from([a4, a5, a6, a7]); + write!(f, "as4-aggregator: [ asn: {asn}, ip: {ip} ]") + } } } } @@ -1499,6 +1737,570 @@ pub enum AsPathType { AsSequence = 2, } +/// BGP next-hops can come in multiple forms, defined in several different RFCs. +/// This enum represents the forms supported by this implementation. +/// +/// In the case of IPv6, RFC 2545 defined the use of either: +/// 1) A single non-link-local next-hop (length=16) +/// 2) A non-link-local plus a link-local next-hop (length=32) +/// +/// This does not account for only a link-local address as the sole next-hop. +/// As such, many different implementations decided they would encode this in a +/// variety of ways (since there was no canonical source of truth): +/// a) Single-address encoding just the link-local (length=16) +/// b) Double-address encoding the link-local in both positions (length=32) +/// c) Double-address encoding the link-local in its normal position, but 0's in +/// the non-link-local position (length=32) +/// etc. +/// This led to `draft-ietf-idr-linklocal-capability` which specifies more +/// detailed encoding and error handling standards, signaled via a new +/// Link-Local Next Hop Capability. +/// +/// In addition to this, RFC 8950 (formerly RFC 5549) specified the +/// advertisement of IPv4 NLRI via an IPv6 next-hop, enabled via the Extended +/// Next Hop capability. This excerpt contains the encoding logic from RFC 8950: +/// ```text +/// Specifically, this document allows advertising the MP_REACH_NLRI +/// attribute [RFC4760] with this content: +/// +/// * AFI = 1 +/// +/// * SAFI = 1, 2, or 4 +/// +/// * Length of Next Hop Address = 16 or 32 +/// +/// * Next Hop Address = IPv6 address of a next hop (potentially +/// followed by the link-local IPv6 address of the next hop). This +/// field is to be constructed as per Section 3 of [RFC2545]. +/// +/// * NLRI = NLRI as per the AFI/SAFI definition +/// +/// [..] +/// +/// This is in addition to the existing mode of operation allowing +/// advertisement of NLRI for of <1/1>, <1/2>, and <1/4> with +/// a next-hop address of an IPv4 type and advertisement of NLRI for an +/// of <1/128> and <1/129> with a next-hop address of a VPN- +/// IPv4 type. +/// +/// The BGP speaker receiving the advertisement MUST use the Length of +/// Next Hop Address field to determine which network-layer protocol the +/// next-hop address belongs to. +/// +/// * When the AFI/SAFI is <1/1>, <1/2>, or <1/4> and when the Length of +/// Next Hop Address field is equal to 16 or 32, the next-hop address +/// is of type IPv6. +/// +/// * When the AFI/SAFI is <1/128> or <1/129> and when the Length of +/// Next Hop Address field is equal to 24 or 48, the next-hop address +/// is of type VPN-IPv6. +/// ``` +/// +/// RFC 8950 also goes on to state that Extended Next Hop is not specified for +/// any AFI/SAFI other than IPv4 {Unicast, Multicast, Labeled Unicast, +/// Unicast VPN, Multicast VPN}, because IPv4 next-hops can already be signaled +/// within IPv6 or VPN-IPv6 encoding (via IPv4-mapped IPv6 addresses). +/// +/// So for our purposes, IPv4 Unicast NLRI may have Next-hops in the form of: +/// a) IPv4 nexthop +/// b) IPv6 single GUA (w/ Extended Next-Hop) +/// c) IPv6 single LL (w/ Extended Next-Hop + Link-Local Next Hop) +/// d) IPv6 double (w/ Extended Next-Hop) +/// +/// and IPv6 Unicast NLRI may have Next-hops in the form of: +/// a) IPv6 single (IPv4-mapped) +/// b) IPv6 single GUA +/// c) IPv6 single LL (w/ Link-Local Next Hop) +/// d) IPv6 double +#[derive( + Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +pub enum BgpNexthop { + Ipv4(Ipv4Addr), + Ipv6Single(Ipv6Addr), + Ipv6Double((Ipv6Addr, Ipv6Addr)), +} + +impl BgpNexthop { + /// Parse next-hop from raw bytes based on AFI and length. + /// + /// Per RFC 4760 and RFC 2545: + /// - IPv4: 4 bytes (single IPv4 address) + /// - IPv6: 16 bytes (single global unicast) or 32 bytes (global + link-local) + /// + /// # Arguments + /// * `nh_bytes` - Raw next-hop bytes + /// * `nh_len` - Next-hop length field + /// * `afi` - Validated AFI (determines expected format) + /// + /// # Returns + /// Parsed BgpNexthop or error if length is invalid for the AFI + pub fn from_bytes( + nh_bytes: &[u8], + nh_len: u8, + afi: Afi, + ) -> Result { + if nh_bytes.len() != nh_len as usize { + return Err(Error::InvalidAddress(format!( + "next-hop bytes length {} doesn't match nh_len {}", + nh_bytes.len(), + nh_len + ))); + } + + // SAFETY: The length check above guarantees nh_bytes.len() == nh_len. + // Each match arm below only matches when nh_len equals the exact size + // needed for copy_from_slice, so all slice operations are bounds-safe. + // XXX: extended nexthop support + match (afi, nh_len) { + (Afi::Ipv4, 4) => { + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(nh_bytes); + Ok(BgpNexthop::Ipv4(Ipv4Addr::from(bytes))) + } + (Afi::Ipv6, 16) => { + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(nh_bytes); + Ok(BgpNexthop::Ipv6Single(Ipv6Addr::from(bytes))) + } + (Afi::Ipv6, 32) => { + let mut bytes1 = [0u8; 16]; + let mut bytes2 = [0u8; 16]; + bytes1.copy_from_slice(&nh_bytes[..16]); + bytes2.copy_from_slice(&nh_bytes[16..32]); + Ok(BgpNexthop::Ipv6Double(( + Ipv6Addr::from(bytes1), + Ipv6Addr::from(bytes2), + ))) + } + _ => Err(Error::InvalidAddress(format!( + "invalid next-hop length {} for AFI {:?}", + nh_len, afi + ))), + } + } + + /// Get byte length of this next-hop + pub fn byte_len(&self) -> u8 { + match self { + // 4 bytes + BgpNexthop::Ipv4(_) => (Ipv4Addr::BITS / 8) as u8, + // 16 bytes + BgpNexthop::Ipv6Single(_) => (Ipv6Addr::BITS / 8) as u8, + // 32 bytes + BgpNexthop::Ipv6Double(_) => ((Ipv6Addr::BITS * 2) / 8) as u8, + } + } +} + +impl Display for BgpNexthop { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + BgpNexthop::Ipv4(a4) => write!(f, "{a4}"), + BgpNexthop::Ipv6Single(a6) => write!(f, "{a6}"), + BgpNexthop::Ipv6Double((a, b)) => write!(f, "({a}, {b})"), + } + } +} + +impl From for BgpNexthop { + fn from(value: Ipv4Addr) -> Self { + BgpNexthop::Ipv4(value) + } +} + +impl From for BgpNexthop { + fn from(value: Ipv6Addr) -> Self { + BgpNexthop::Ipv6Single(value) + } +} + +impl From<(Ipv6Addr, Ipv6Addr)> for BgpNexthop { + fn from(value: (Ipv6Addr, Ipv6Addr)) -> Self { + BgpNexthop::Ipv6Double(value) + } +} + +impl From for BgpNexthop { + fn from(value: IpAddr) -> Self { + match value { + IpAddr::V4(ip4) => BgpNexthop::Ipv4(ip4), + IpAddr::V6(ip6) => BgpNexthop::Ipv6Single(ip6), + } + } +} + +/// ```text +/// 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14): +/// +/// This is an optional non-transitive attribute that can be used for the +/// following purposes: +/// +/// (a) to advertise a feasible route to a peer +/// +/// (b) to permit a router to advertise the Network Layer address of the +/// router that should be used as the next hop to the destinations +/// listed in the Network Layer Reachability Information field of the +/// MP_NLRI attribute. +/// +/// The attribute is encoded as shown below: +/// +/// +---------------------------------------------------------+ +/// | Address Family Identifier (2 octets) | +/// +---------------------------------------------------------+ +/// | Subsequent Address Family Identifier (1 octet) | +/// +---------------------------------------------------------+ +/// | Length of Next Hop Network Address (1 octet) | +/// +---------------------------------------------------------+ +/// | Network Address of Next Hop (variable) | +/// +---------------------------------------------------------+ +/// | Reserved (1 octet) | +/// +---------------------------------------------------------+ +/// | Network Layer Reachability Information (variable) | +/// +---------------------------------------------------------+ +/// ```` +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MpReach { + /// Raw AFI value (not validated during parsing - validation deferred to session layer) + pub afi: u16, + + /// Raw SAFI value (not validated during parsing - validation deferred to session layer) + pub safi: u8, + + /// Length of Next Hop Network Address field + pub nh_len: u8, + + /// Next-hop bytes (raw, not parsed - parsing deferred to session layer) + pub nh_bytes: Vec, + + /// Reserved field (must be 0 per RFC 4760) + pub reserved: u8, + + /// NLRI bytes (raw, not parsed - parsing deferred to session layer) + pub nlri_bytes: Vec, +} + +impl MpReach { + pub fn new(afi: Afi, nh: BgpNexthop, nlri: Vec) -> Self { + // Convert next-hop to bytes + let nh_bytes = match &nh { + BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Double((addr1, addr2)) => { + let mut buf = Vec::new(); + buf.extend_from_slice(&addr1.octets()); + buf.extend_from_slice(&addr2.octets()); + buf + } + }; + + // Convert NLRI to bytes + let mut nlri_bytes = Vec::new(); + for prefix in &nlri { + match prefix { + Prefix::V4(p) => nlri_bytes.extend_from_slice(&p.to_wire()), + Prefix::V6(p) => nlri_bytes.extend_from_slice(&p.to_wire()), + } + } + + Self { + afi: afi as u16, + safi: Safi::Unicast as u8, + nh_len: nh.byte_len(), + nh_bytes, + reserved: 0u8, + nlri_bytes, + } + } + + /// Create an MpReach for IPv6 unicast routes. + /// This is the type-safe version that takes Vec directly, + /// avoiding the need for runtime type validation. + pub fn new_v6(nh: BgpNexthop, nlri: Vec) -> Self { + // Convert next-hop to bytes + let nh_bytes = match &nh { + BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Double((addr1, addr2)) => { + let mut buf = Vec::new(); + buf.extend_from_slice(&addr1.octets()); + buf.extend_from_slice(&addr2.octets()); + buf + } + }; + + // Convert NLRI to bytes - no match needed, we know it's V6 + let mut nlri_bytes = Vec::new(); + for prefix in &nlri { + nlri_bytes.extend_from_slice(&prefix.to_wire()); + } + + Self { + afi: Afi::Ipv6 as u16, + safi: Safi::Unicast as u8, + nh_len: nh.byte_len(), + nh_bytes, + reserved: 0u8, + nlri_bytes, + } + } + + /// Parse MP_REACH_NLRI from wire format. + /// + /// ## RFC 4760 Section 3: MP_REACH_NLRI Encoding + /// + /// This attribute is optional and non-transitive. It carries: + /// - AFI (2 octets): Address Family Identifier + /// - SAFI (1 octet): Subsequent Address Family Identifier + /// - Next Hop Length (1 octet): Length of next hop address + /// - Next Hop (variable): Network address of next hop + /// - Reserved (1 octet): Must be 0 + /// - NLRI (variable): Network Layer Reachability Information + /// + /// ## Parsing Philosophy + /// + /// Unsupported AFI/SAFI is a **parsing error**, not a validation error, because: + /// 1. We cannot parse NLRI without knowing the address family format + /// 2. We cannot skip over NLRI bytes without knowing prefix encoding rules + /// 3. Per RFC 7606, malformed attributes that prevent NLRI location determination + /// should trigger session reset or AFI/SAFI disable + /// + /// Therefore, this method validates that the AFI/SAFI tuple is supported during + /// parsing. The resulting `MpReach` is guaranteed to contain a valid, supported + /// address family. + /// + /// ## Parse Failures + /// + /// Parsing fails (returns `Err`) when: + /// - Buffer is too small for fixed-size fields + /// - Next-hop length is invalid for the given AFI + /// - AFI/SAFI combination is unsupported (structural requirement for parsing) + /// + /// ## Session-Level Validation + /// + /// After successful parsing, session-level code must still validate that this + /// address family was negotiated with the peer via capability exchange. + /// + /// # Arguments + /// * `input` - Wire format bytes to parse + /// + /// # Returns + /// `Ok((remaining_bytes, MpReach))` on successful parse + /// `Err(Error)` if wire format is malformed or address family is unsupported + pub fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Error> { + // Parse AFI (2 bytes) - DON'T VALIDATE (store raw value) + let (input, afi_raw) = be_u16(input)?; + + // Parse SAFI (1 byte) - DON'T VALIDATE (store raw value) + let (input, safi_raw) = be_u8(input)?; + + // Parse Next-hop Length (1 byte) + let (input, nh_len) = be_u8(input)?; + + // Extract next-hop bytes (structural bounds check only - don't parse/validate) + if input.len() < nh_len as usize { + return Err(Error::TooSmall(format!( + "next-hop field too short: need {} bytes, have {}", + nh_len, + input.len() + ))); + } + let nh_bytes = input[..nh_len as usize].to_vec(); + let input = &input[nh_len as usize..]; + + // Parse Reserved byte (1 byte) + let (input, reserved) = be_u8(input)?; + + // Store remaining bytes as raw NLRI (don't parse) + let nlri_bytes = input.to_vec(); + + Ok(( + &[], // All remaining bytes consumed + MpReach { + afi: afi_raw, + safi: safi_raw, + nh_len, + nh_bytes, + reserved, + nlri_bytes, + }, + )) + } + + /// Serialize MP_REACH_NLRI to wire format. + pub fn to_wire(&self) -> Result, Error> { + let mut buf = Vec::new(); + + // AFI (2 bytes) + buf.extend_from_slice(&self.afi.to_be_bytes()); + + // SAFI (1 byte) + buf.push(self.safi); + + // Next-hop Length (1 byte) + buf.push(self.nh_len); + + // Next-hop (raw bytes) + buf.extend_from_slice(&self.nh_bytes); + + // Reserved (1 byte) + buf.push(self.reserved); + + // NLRI (raw bytes) + buf.extend_from_slice(&self.nlri_bytes); + + Ok(buf) + } +} + +impl Display for MpReach { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MpReach[AFI={}, SAFI={}, nh_len={}, nlri_bytes={}]", + self.afi, + self.safi, + self.nh_len, + self.nlri_bytes.len() + ) + } +} + +/// ```text +/// 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15): +/// +/// This is an optional non-transitive attribute that can be used for the +/// purpose of withdrawing multiple unfeasible routes from service. +/// +/// The attribute is encoded as shown below: +/// +/// +---------------------------------------------------------+ +/// | Address Family Identifier (2 octets) | +/// +---------------------------------------------------------+ +/// | Subsequent Address Family Identifier (1 octet) | +/// +---------------------------------------------------------+ +/// | Withdrawn Routes (variable) | +/// +---------------------------------------------------------+ +/// ``` +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MpUnreach { + /// Raw AFI value (not validated during parsing - validation deferred to session layer) + pub afi_raw: u16, + + /// Raw SAFI value (not validated during parsing - validation deferred to session layer) + pub safi_raw: u8, + + /// Withdrawn routes bytes (raw, not parsed - parsing deferred to session layer) + pub withdrawn_bytes: Vec, +} + +impl MpUnreach { + pub fn new(afi: Afi, _nh: BgpNexthop, withdrawn: Vec) -> Self { + // Convert withdrawn routes to bytes + let mut withdrawn_bytes = Vec::new(); + for prefix in &withdrawn { + match prefix { + Prefix::V4(p) => { + withdrawn_bytes.extend_from_slice(&p.to_wire()) + } + Prefix::V6(p) => { + withdrawn_bytes.extend_from_slice(&p.to_wire()) + } + } + } + + Self { + afi_raw: afi as u16, + safi_raw: Safi::Unicast as u8, + withdrawn_bytes, + } + } + + /// Create an MpUnreach for IPv6 unicast withdrawn routes. + /// This is the type-safe version that takes Vec directly, + /// avoiding the need for runtime type validation. + pub fn new_v6(withdrawn: Vec) -> Self { + // Convert withdrawn routes to bytes - no match needed, we know it's V6 + let mut withdrawn_bytes = Vec::new(); + for prefix in &withdrawn { + withdrawn_bytes.extend_from_slice(&prefix.to_wire()); + } + + Self { + afi_raw: Afi::Ipv6 as u16, + safi_raw: Safi::Unicast as u8, + withdrawn_bytes, + } + } + + /// Parse MP_UNREACH_NLRI from wire format. + /// + /// ## RFC 4760 Section 4: MP_UNREACH_NLRI Encoding + /// + /// This attribute is optional and non-transitive. It carries: + /// - AFI (2 octets): Address Family Identifier + /// - SAFI (1 octet): Subsequent Address Family Identifier + /// - Withdrawn Routes (variable): Routes to withdraw + /// + /// Per RFC 7606: Attribute length must be at least 3 octets (AFI + SAFI minimum). + /// + /// ## Parsing Philosophy + /// + /// Like MP_REACH_NLRI, unsupported AFI/SAFI is a parsing error because we cannot + /// parse withdrawn routes without knowing the address family format. The resulting + /// `MpUnreach` is guaranteed to contain a valid, supported address family. + /// + /// Session-level validation must still check that this address family was + /// negotiated with the peer. + pub fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Error> { + // Parse AFI (2 bytes) - DON'T VALIDATE (store raw value) + let (input, afi_raw) = be_u16(input)?; + + // Parse SAFI (1 byte) - DON'T VALIDATE (store raw value) + let (input, safi_raw) = be_u8(input)?; + + // Store remaining bytes as raw withdrawn routes (don't parse) + let withdrawn_bytes = input.to_vec(); + + Ok(( + &[], // All remaining bytes consumed + MpUnreach { + afi_raw, + safi_raw, + withdrawn_bytes, + }, + )) + } + + /// Serialize MP_UNREACH_NLRI to wire format. + pub fn to_wire(&self) -> Result, Error> { + let mut buf = Vec::new(); + + // AFI (2 bytes) + buf.extend_from_slice(&self.afi_raw.to_be_bytes()); + + // SAFI (1 byte) + buf.push(self.safi_raw); + + // Withdrawn routes (raw bytes) + buf.extend_from_slice(&self.withdrawn_bytes); + + Ok(buf) + } +} + +impl Display for MpUnreach { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MpUnreach[AFI={}, SAFI={}, withdrawn_bytes={}]", + self.afi_raw, + self.safi_raw, + self.withdrawn_bytes.len() + ) + } +} + /// Notification messages are exchanged between BGP peers when an exceptional /// event has occurred. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] @@ -2744,6 +3546,22 @@ impl Capability { } } } + + /// Helper function to generate an IPv4 Unicast MP-BGP capability. + pub fn ipv4_unicast() -> Self { + Self::MultiprotocolExtensions { + afi: Afi::Ipv4 as u16, + safi: Safi::Unicast as u8, + } + } + + /// Helper function to generate an IPv6 Unicast MP-BGP capability. + pub fn ipv6_unicast() -> Self { + Self::MultiprotocolExtensions { + afi: Afi::Ipv6 as u16, + safi: Safi::Unicast as u8, + } + } } /// The set of capability codes supported by this BGP implementation @@ -2997,6 +3815,17 @@ impl From for CapabilityCode { } /// Address families supported by Maghemite BGP. +#[derive( + Debug, + Copy, + Clone, + Deserialize, + Eq, + PartialEq, + Serialize, + TryFromPrimitive, + JsonSchema, +)] #[repr(u16)] pub enum Afi { /// Internet protocol version 4 @@ -3005,39 +3834,81 @@ pub enum Afi { Ipv6 = 2, } +impl From for AddressFamily { + fn from(value: Afi) -> Self { + match value { + Afi::Ipv4 => AddressFamily::Ipv4, + Afi::Ipv6 => AddressFamily::Ipv6, + } + } +} + +impl From for Afi { + fn from(value: AddressFamily) -> Self { + match value { + AddressFamily::Ipv4 => Afi::Ipv4, + AddressFamily::Ipv6 => Afi::Ipv6, + } + } +} + +impl Display for Afi { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Afi::Ipv4 => write!(f, "IPv4"), + Afi::Ipv6 => write!(f, "IPv6"), + } + } +} + +impl slog::Value for Afi { + fn serialize( + &self, + _record: &slog::Record, + key: slog::Key, + serializer: &mut dyn slog::Serializer, + ) -> slog::Result { + serializer.emit_str(key, &self.to_string()) + } +} + /// Subsequent address families supported by Maghemite BGP. +#[derive( + Debug, + Copy, + Clone, + Deserialize, + Eq, + PartialEq, + Serialize, + TryFromPrimitive, + JsonSchema, +)] #[repr(u8)] pub enum Safi { /// Network Layer Reachability Information used for unicast forwarding - NlriUnicast = 1, + Unicast = 1, } -/// Decode prefix from BGP wire format with explicit address family. -/// -/// The address family parameter must match the AFI/SAFI context where this -/// prefix was encoded. This is required because prefixes <= 32 bits long (e.g. -/// default routes) encode identically for both IPv4 and IPv6, so external -/// context is needed to disambiguate. -/// -/// Returns (remaining_bytes, prefix) -fn prefix_from_wire( - input: &[u8], - afi: AddressFamily, -) -> Result<(&[u8], Prefix), Error> { - match afi { - AddressFamily::Ipv4 => { - let (remaining, prefix4) = Prefix4::from_wire(input) - .map_err(|_| Error::InvalidNlriPrefix(input.to_vec()))?; - Ok((remaining, prefix4.into())) - } - AddressFamily::Ipv6 => { - let (remaining, prefix6) = Prefix6::from_wire(input) - .map_err(|_| Error::InvalidNlriPrefix(input.to_vec()))?; - Ok((remaining, prefix6.into())) +impl Display for Safi { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Safi::Unicast => write!(f, "Unicast"), } } } +impl slog::Value for Safi { + fn serialize( + &self, + _record: &slog::Record, + key: slog::Key, + serializer: &mut dyn slog::Serializer, + ) -> slog::Result { + serializer.emit_str(key, &self.to_string()) + } +} + // ============================================================================ // API Compatibility Types (VERSION_INITIAL / v1.0.0) // ============================================================================ @@ -3089,9 +3960,17 @@ pub struct UpdateMessageV1 { impl From for UpdateMessageV1 { fn from(msg: UpdateMessage) -> Self { Self { - withdrawn: msg.withdrawn.into_iter().map(PrefixV1::from).collect(), + withdrawn: msg + .withdrawn + .into_iter() + .map(|p| PrefixV1::from(Prefix::V4(p))) + .collect(), path_attributes: msg.path_attributes, - nlri: msg.nlri.into_iter().map(PrefixV1::from).collect(), + nlri: msg + .nlri + .into_iter() + .map(|p| PrefixV1::from(Prefix::V4(p))) + .collect(), } } } @@ -3210,10 +4089,10 @@ mod tests { #[test] fn update_round_trip() { let um0 = UpdateMessage { - withdrawn: vec![rdb::Prefix::V4(rdb::Prefix4::new( + withdrawn: vec![rdb::Prefix4::new( std::net::Ipv4Addr::new(0, 23, 1, 12), 32, - ))], + )], path_attributes: vec![PathAttribute { typ: PathAttributeType { flags: path_attribute_flags::OPTIONAL @@ -3226,14 +4105,8 @@ mod tests { }]), }], nlri: vec![ - rdb::Prefix::V4(rdb::Prefix4::new( - std::net::Ipv4Addr::new(0, 23, 1, 13), - 32, - )), - rdb::Prefix::V4(rdb::Prefix4::new( - std::net::Ipv4Addr::new(0, 23, 1, 14), - 32, - )), + rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 13), 32), + rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 14), 32), ], }; @@ -3568,4 +4441,451 @@ mod tests { other => panic!("Expected BadLength error, got: {:?}", other), } } + + // ========================================================================= + // BgpNexthop tests + // ========================================================================= + + #[test] + fn bgp_nexthop_ipv4_from_bytes() { + let bytes = [192, 0, 2, 1]; + let nh = BgpNexthop::from_bytes(&bytes, 4, Afi::Ipv4) + .expect("valid IPv4 nexthop"); + assert_eq!(nh, BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1))); + } + + #[test] + fn bgp_nexthop_ipv6_single_from_bytes() { + let addr = Ipv6Addr::from_str("2001:db8::1").unwrap(); + let bytes = addr.octets(); + let nh = BgpNexthop::from_bytes(&bytes, 16, Afi::Ipv6) + .expect("valid IPv6 single nexthop"); + assert_eq!(nh, BgpNexthop::Ipv6Single(addr)); + } + + #[test] + fn bgp_nexthop_ipv6_double_from_bytes() { + let global = Ipv6Addr::from_str("2001:db8::1").unwrap(); + let link_local = Ipv6Addr::from_str("fe80::1").unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&global.octets()); + bytes.extend_from_slice(&link_local.octets()); + + let nh = BgpNexthop::from_bytes(&bytes, 32, Afi::Ipv6) + .expect("valid IPv6 double nexthop"); + assert_eq!(nh, BgpNexthop::Ipv6Double((global, link_local))); + } + + #[test] + fn bgp_nexthop_invalid_length() { + // IPv4 AFI with wrong length + let bytes = [192, 0, 2, 1, 0, 0]; // 6 bytes instead of 4 + let result = BgpNexthop::from_bytes(&bytes, 6, Afi::Ipv4); + assert!(result.is_err()); + + // IPv6 AFI with wrong length (neither 16 nor 32) + let bytes = [0u8; 20]; + let result = BgpNexthop::from_bytes(&bytes, 20, Afi::Ipv6); + assert!(result.is_err()); + } + + #[test] + fn bgp_nexthop_length_mismatch() { + // nh_bytes.len() != nh_len + let bytes = [192, 0, 2, 1]; + let result = BgpNexthop::from_bytes(&bytes, 8, Afi::Ipv4); + assert!(result.is_err()); + } + + #[test] + fn bgp_nexthop_byte_len() { + let ipv4 = BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)); + assert_eq!(ipv4.byte_len(), 4); + + let ipv6_single = + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); + assert_eq!(ipv6_single.byte_len(), 16); + + let ipv6_double = BgpNexthop::Ipv6Double(( + Ipv6Addr::from_str("2001:db8::1").unwrap(), + Ipv6Addr::from_str("fe80::1").unwrap(), + )); + assert_eq!(ipv6_double.byte_len(), 32); + } + + // ========================================================================= + // MpReach tests + // ========================================================================= + + #[test] + fn mp_reach_new_v4() { + let nh = BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)); + let nlri = vec![ + Prefix::V4(rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)), + Prefix::V4(rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12)), + ]; + + let mp_reach = MpReach::new(Afi::Ipv4, nh, nlri); + + assert_eq!(mp_reach.afi, Afi::Ipv4 as u16); + assert_eq!(mp_reach.safi, Safi::Unicast as u8); + assert_eq!(mp_reach.nh_len, 4); + assert_eq!(mp_reach.reserved, 0); + } + + #[test] + fn mp_reach_new_v6() { + let nh = + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); + let nlri = vec![ + rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:1::").unwrap(), 48), + rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:2::").unwrap(), 48), + ]; + + let mp_reach = MpReach::new_v6(nh, nlri); + + assert_eq!(mp_reach.afi, Afi::Ipv6 as u16); + assert_eq!(mp_reach.safi, Safi::Unicast as u8); + assert_eq!(mp_reach.nh_len, 16); + assert_eq!(mp_reach.reserved, 0); + } + + #[test] + fn mp_reach_round_trip() { + let nh = + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); + let nlri = vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:1::").unwrap(), + 48, + )]; + + let original = MpReach::new_v6(nh, nlri); + let wire = original.to_wire().expect("to_wire should succeed"); + let (remaining, parsed) = + MpReach::from_wire(&wire).expect("from_wire should succeed"); + + assert!(remaining.is_empty(), "all bytes should be consumed"); + assert_eq!(original.afi, parsed.afi); + assert_eq!(original.safi, parsed.safi); + assert_eq!(original.nh_len, parsed.nh_len); + assert_eq!(original.nh_bytes, parsed.nh_bytes); + assert_eq!(original.nlri_bytes, parsed.nlri_bytes); + } + + // ========================================================================= + // MpUnreach tests + // ========================================================================= + + #[test] + fn mp_unreach_new_v6() { + let withdrawn = vec![ + rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:1::").unwrap(), 48), + rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:2::").unwrap(), 48), + ]; + + let mp_unreach = MpUnreach::new_v6(withdrawn); + + assert_eq!(mp_unreach.afi_raw, Afi::Ipv6 as u16); + assert_eq!(mp_unreach.safi_raw, Safi::Unicast as u8); + } + + #[test] + fn mp_unreach_round_trip() { + let withdrawn = vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:dead::").unwrap(), + 48, + )]; + + let original = MpUnreach::new_v6(withdrawn); + let wire = original.to_wire().expect("to_wire should succeed"); + let (remaining, parsed) = + MpUnreach::from_wire(&wire).expect("from_wire should succeed"); + + assert!(remaining.is_empty(), "all bytes should be consumed"); + assert_eq!(original.afi_raw, parsed.afi_raw); + assert_eq!(original.safi_raw, parsed.safi_raw); + assert_eq!(original.withdrawn_bytes, parsed.withdrawn_bytes); + } + + // ========================================================================= + // RFC 7606 validation tests + // ========================================================================= + + #[test] + fn check_duplicate_mp_attributes_none() { + let update = UpdateMessage::default(); + assert!(update.check_duplicate_mp_attributes().is_ok()); + } + + #[test] + fn check_duplicate_mp_attributes_single_reach() { + let mp_reach = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![], + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }], + nlri: vec![], + }; + + assert!(update.check_duplicate_mp_attributes().is_ok()); + } + + #[test] + fn check_duplicate_mp_attributes_duplicate_reach() { + let mp_reach1 = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![], + ); + let mp_reach2 = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::2").unwrap()), + vec![], + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach1), + }, + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach2), + }, + ], + nlri: vec![], + }; + + assert!(update.check_duplicate_mp_attributes().is_err()); + } + + /// Test that MP-BGP attributes are always encoded first in the wire format, + /// regardless of their position in the path_attributes vector. + /// + /// RFC 7606 Section 5.1 requires: + /// "The MP_REACH_NLRI or MP_UNREACH_NLRI attribute (if present) SHALL + /// be encoded as the very first path attribute in an UPDATE message." + #[test] + fn mp_bgp_attributes_encoded_first() { + let mp_reach = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![], + ); + + // Create an UpdateMessage with MP-BGP attribute NOT first in the vector + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::TRANSITIVE, + type_code: PathAttributeTypeCode::Origin, + }, + value: PathAttributeValue::Origin(PathOrigin::Igp), + }, + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }, + ], + nlri: vec![], + }; + + // Encode to wire format + let wire = update.to_wire().expect("encoding should succeed"); + + // Skip withdrawn routes length (2 bytes) and empty withdrawn routes (0 bytes) + // Skip path attributes length (2 bytes) + // First path attribute should be MP_REACH_NLRI + let path_attrs_start = 4; // 2 (withdrawn len) + 0 (withdrawn) + 2 (attrs len) + + // Read the first attribute's type code (flags byte + type code byte) + let first_attr_type_code = wire[path_attrs_start + 1]; + assert_eq!( + first_attr_type_code, + PathAttributeTypeCode::MpReachNlri as u8, + "MP_REACH_NLRI should be encoded as the first path attribute" + ); + } + + /// Test that decoding accepts both traditional NLRI and MP-BGP encoding + /// in the same UPDATE message (RFC 7606 Section 5.1 interoperability). + #[test] + fn decoding_accepts_mixed_nlri_encoding() { + // Create an UPDATE with both traditional NLRI and MP_REACH_NLRI + let mp_reach = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8::").unwrap(), + 32, + )], + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }], + nlri: vec![rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)], + }; + + // Encode to wire and decode back - should succeed + let wire = update.to_wire().expect("encoding should succeed"); + let decoded = UpdateMessage::from_wire(&wire); + assert!( + decoded.is_ok(), + "decoding mixed traditional+MP-BGP should succeed" + ); + + let decoded = decoded.unwrap(); + // Verify both encodings are present + assert_eq!(decoded.nlri.len(), 1, "traditional NLRI should be present"); + assert!( + decoded + .path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::MpReachNlri(_))), + "MP_REACH_NLRI should be present" + ); + } + + /// Test that decoding accepts both MP_REACH_NLRI and MP_UNREACH_NLRI + /// in the same UPDATE message (RFC 7606 Section 5.1 interoperability). + #[test] + fn decoding_accepts_reach_and_unreach_together() { + let mp_reach = MpReach::new_v6( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:1::").unwrap(), + 48, + )], + ); + + let mp_unreach = MpUnreach::new_v6(vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:2::").unwrap(), + 48, + )]); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }, + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }, + ], + nlri: vec![], + }; + + // Encode to wire and decode back - should succeed + let wire = update.to_wire().expect("encoding should succeed"); + let decoded = UpdateMessage::from_wire(&wire); + assert!( + decoded.is_ok(), + "decoding MP_REACH + MP_UNREACH together should succeed" + ); + + let decoded = decoded.unwrap(); + // Verify both are present + let has_reach = decoded + .path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::MpReachNlri(_))); + let has_unreach = decoded + .path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::MpUnreachNlri(_))); + assert!(has_reach, "MP_REACH_NLRI should be present"); + assert!(has_unreach, "MP_UNREACH_NLRI should be present"); + } + + /// Test that duplicate non-MP-BGP path attributes are deduplicated during + /// decoding, keeping only the first occurrence (RFC 7606 Section 3g). + #[test] + fn decoding_deduplicates_non_mp_attributes() { + // Manually construct wire bytes with duplicate ORIGIN attributes + // to test deduplication during parsing + let mut wire = Vec::new(); + + // Withdrawn routes length (0) + wire.extend_from_slice(&0u16.to_be_bytes()); + + // Path attributes: two ORIGIN attributes (second should be discarded) + let attrs = vec![ + // First ORIGIN attribute (IGP = 0) + path_attribute_flags::TRANSITIVE, // flags + PathAttributeTypeCode::Origin as u8, // type + 1, // length + PathOrigin::Igp as u8, // value + // Second ORIGIN attribute (EGP = 1) - should be discarded + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, + PathOrigin::Egp as u8, + ]; + + // Path attributes length + wire.extend_from_slice(&(attrs.len() as u16).to_be_bytes()); + wire.extend_from_slice(&attrs); + + // NLRI (empty) + + let decoded = UpdateMessage::from_wire(&wire); + assert!(decoded.is_ok(), "decoding should succeed"); + + let decoded = decoded.unwrap(); + + // Should only have one ORIGIN attribute (the first one, IGP) + let origins: Vec<_> = decoded + .path_attributes + .iter() + .filter_map(|a| match &a.value { + PathAttributeValue::Origin(o) => Some(*o), + _ => None, + }) + .collect(); + + assert_eq!( + origins.len(), + 1, + "should have exactly one ORIGIN after deduplication" + ); + assert_eq!( + origins[0], + PathOrigin::Igp, + "should keep the first ORIGIN (IGP), not the second (EGP)" + ); + } } diff --git a/bgp/src/policy.rs b/bgp/src/policy.rs index d1229727..e2f7a492 100644 --- a/bgp/src/policy.rs +++ b/bgp/src/policy.rs @@ -30,13 +30,13 @@ use crate::messages::{ CapabilityCode, Message, OpenMessage, Prefix, UpdateMessage, }; use crate::rhai_integration::*; +use rdb::{Prefix4, Prefix6}; use rhai::{ AST, Dynamic, Engine, EvalAltResult, FnPtr, NativeCallContext, ParseError, Scope, }; use slog::{Logger, debug, info}; -use std::collections::HashSet; -use std::net::IpAddr; +use std::{collections::HashSet, net::IpAddr}; const UNIT_CHECKER: &str = "checker"; @@ -123,11 +123,9 @@ impl ShaperResult { ) -> UpdateMessage { // anything that was previously being announced that is no longer // being announced, must be withdrawn - let previous: HashSet = - a.nlri.iter().cloned().collect(); + let previous: HashSet = a.nlri.iter().cloned().collect(); - let current: HashSet = - b.nlri.iter().cloned().collect(); + let current: HashSet = b.nlri.iter().cloned().collect(); let mut new = b.clone(); new.withdrawn = previous.difference(¤t).cloned().collect(); @@ -229,7 +227,11 @@ pub fn new_rhai_engine() -> Engine { engine .register_type_with_name::("Prefix") - .register_fn("within", prefix_within_rhai); + .register_fn("within", prefix_within_rhai) + .register_type_with_name::("Prefix4") + .register_fn("within", prefix4_within_rhai) + .register_type_with_name::("Prefix6") + .register_fn("within", prefix6_within_rhai); #[cfg(debug_assertions)] { @@ -519,13 +521,13 @@ mod test { let addr = "198.51.100.1".parse().unwrap(); let originated = UpdateMessage { nlri: vec![ - "10.10.0.0/16".parse().unwrap(), - "10.128.0.0/16".parse().unwrap(), + "10.10.0.0/16".parse::().unwrap(), + "10.128.0.0/16".parse::().unwrap(), ], ..Default::default() }; let filtered = UpdateMessage { - nlri: vec!["10.128.0.0/16".parse().unwrap()], + nlri: vec!["10.128.0.0/16".parse::().unwrap()], ..Default::default() }; let source = diff --git a/bgp/src/proptest.rs b/bgp/src/proptest.rs index ca29128b..e9924dca 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -4,29 +4,291 @@ //! Property-based tests for BGP wire format using proptest //! -//! These tests verify key invariants of prefix wire encoding/decoding to ensure -//! correctness and consistency of wire format operations. +//! These tests verify key invariants of wire encoding/decoding to ensure +//! correctness and consistency of wire format operations for: +//! - Prefix4/Prefix6 encoding +//! - BgpNexthop encoding +//! - MpReach/MpUnreach (MP-BGP path attributes) +//! - UpdateMessage (complete BGP UPDATE messages) +//! - RFC 7606 compliance (attribute deduplication, MP-BGP ordering) -use crate::messages::BgpWireFormat; +use crate::messages::{ + Afi, As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, MpReach, + MpUnreach, PathAttribute, PathAttributeType, PathAttributeTypeCode, + PathAttributeValue, PathOrigin, UpdateMessage, path_attribute_flags, +}; use proptest::prelude::*; -use rdb::types::{Prefix4, Prefix6}; +use rdb::types::{Prefix, Prefix4, Prefix6}; use std::net::{Ipv4Addr, Ipv6Addr}; -// Strategy for generating valid IPv4 prefixes +// ============================================================================= +// Prefix Strategies +// ============================================================================= + +/// Strategy for generating valid IPv4 prefixes fn ipv4_prefix_strategy() -> impl Strategy { (any::(), 0u8..=32u8).prop_map(|(addr_bits, length)| { Prefix4::new(Ipv4Addr::from(addr_bits), length) }) } -// Strategy for generating valid IPv6 prefixes +/// Strategy for generating valid IPv6 prefixes fn ipv6_prefix_strategy() -> impl Strategy { (any::(), 0u8..=128u8).prop_map(|(addr_bits, length)| { Prefix6::new(Ipv6Addr::from(addr_bits), length) }) } +/// Strategy for generating a vector of IPv4 prefixes (limited size for perf) +fn ipv4_prefixes_strategy() -> impl Strategy> { + prop::collection::vec(ipv4_prefix_strategy(), 0..5) +} + +/// Strategy for generating a vector of IPv6 prefixes (limited size for perf) +fn ipv6_prefixes_strategy() -> impl Strategy> { + prop::collection::vec(ipv6_prefix_strategy(), 0..5) +} + +// ============================================================================= +// BgpNexthop Strategies +// ============================================================================= + +/// Strategy for generating IPv4 next-hops +fn nexthop_ipv4_strategy() -> impl Strategy { + any::().prop_map(|bits| BgpNexthop::Ipv4(Ipv4Addr::from(bits))) +} + +/// Strategy for generating IPv6 single next-hops +fn nexthop_ipv6_single_strategy() -> impl Strategy { + any::().prop_map(|bits| BgpNexthop::Ipv6Single(Ipv6Addr::from(bits))) +} + +/// Strategy for generating IPv6 double next-hops (global + link-local) +fn nexthop_ipv6_double_strategy() -> impl Strategy { + (any::(), any::()).prop_map(|(global, link_local)| { + BgpNexthop::Ipv6Double(( + Ipv6Addr::from(global), + Ipv6Addr::from(link_local), + )) + }) +} + +// ============================================================================= +// Path Attribute Strategies +// ============================================================================= + +/// Strategy for generating PathOrigin values +fn path_origin_strategy() -> impl Strategy { + prop_oneof![ + Just(PathOrigin::Igp), + Just(PathOrigin::Egp), + Just(PathOrigin::Incomplete), + ] +} + +/// Strategy for generating AS path segments +fn as_path_segment_strategy() -> impl Strategy { + ( + prop_oneof![Just(AsPathType::AsSet), Just(AsPathType::AsSequence)], + prop::collection::vec(any::(), 1..5), + ) + .prop_map(|(typ, value)| As4PathSegment { typ, value }) +} + +/// Strategy for generating AS paths (vector of segments) +fn as_path_strategy() -> impl Strategy> { + prop::collection::vec(as_path_segment_strategy(), 0..3) +} + +/// Strategy for generating a set of distinct traditional path attributes +/// (no duplicates by type code) +fn distinct_traditional_attrs_strategy() +-> impl Strategy> { + ( + prop::option::of(path_origin_strategy()), + prop::option::of(as_path_strategy()), + prop::option::of(any::()), // nexthop + prop::option::of(any::()), // med + prop::option::of(any::()), // local_pref + ) + .prop_map(|(origin, as_path, nexthop, med, local_pref)| { + let mut attrs = Vec::new(); + if let Some(o) = origin { + attrs.push(PathAttribute::from(PathAttributeValue::Origin(o))); + } + if let Some(p) = as_path { + attrs.push(PathAttribute::from(PathAttributeValue::AsPath(p))); + } + if let Some(nh) = nexthop { + attrs.push(PathAttribute::from(PathAttributeValue::NextHop( + Ipv4Addr::from(nh), + ))); + } + if let Some(m) = med { + attrs.push(PathAttribute::from( + PathAttributeValue::MultiExitDisc(m), + )); + } + if let Some(lp) = local_pref { + attrs.push(PathAttribute::from(PathAttributeValue::LocalPref( + lp, + ))); + } + attrs + }) +} + +// ============================================================================= +// MpReach/MpUnreach Strategies +// ============================================================================= + +/// Strategy for generating IPv4 MpReach +fn mp_reach_v4_strategy() -> impl Strategy { + (nexthop_ipv4_strategy(), ipv4_prefixes_strategy()).prop_map( + |(nexthop, nlri)| { + MpReach::new( + Afi::Ipv4, + nexthop, + nlri.into_iter().map(Prefix::V4).collect(), + ) + }, + ) +} + +/// Strategy for generating IPv6 MpReach with single next-hop +fn mp_reach_v6_single_strategy() -> impl Strategy { + (nexthop_ipv6_single_strategy(), ipv6_prefixes_strategy()).prop_map( + |(nexthop, nlri)| { + MpReach::new( + Afi::Ipv6, + nexthop, + nlri.into_iter().map(Prefix::V6).collect(), + ) + }, + ) +} + +/// Strategy for generating IPv6 MpReach with double next-hop +fn mp_reach_v6_double_strategy() -> impl Strategy { + (nexthop_ipv6_double_strategy(), ipv6_prefixes_strategy()).prop_map( + |(nexthop, nlri)| { + MpReach::new( + Afi::Ipv6, + nexthop, + nlri.into_iter().map(Prefix::V6).collect(), + ) + }, + ) +} + +/// Strategy for generating any valid MpReach +fn mp_reach_strategy() -> impl Strategy { + prop_oneof![ + mp_reach_v4_strategy(), + mp_reach_v6_single_strategy(), + mp_reach_v6_double_strategy(), + ] +} + +/// Strategy for generating IPv4 MpUnreach +fn mp_unreach_v4_strategy() -> impl Strategy { + (nexthop_ipv4_strategy(), ipv4_prefixes_strategy()).prop_map( + |(nexthop, withdrawn)| { + MpUnreach::new( + Afi::Ipv4, + nexthop, + withdrawn.into_iter().map(Prefix::V4).collect(), + ) + }, + ) +} + +/// Strategy for generating IPv6 MpUnreach +fn mp_unreach_v6_strategy() -> impl Strategy { + ipv6_prefixes_strategy().prop_map(MpUnreach::new_v6) +} + +/// Strategy for generating any valid MpUnreach +fn mp_unreach_strategy() -> impl Strategy { + prop_oneof![mp_unreach_v4_strategy(), mp_unreach_v6_strategy(),] +} + +// ============================================================================= +// UpdateMessage Strategies +// ============================================================================= + +/// Strategy for generating traditional IPv4-only UpdateMessage +fn update_traditional_strategy() -> impl Strategy { + ( + ipv4_prefixes_strategy(), + distinct_traditional_attrs_strategy(), + ipv4_prefixes_strategy(), + ) + .prop_map(|(withdrawn, path_attributes, nlri)| UpdateMessage { + withdrawn, + path_attributes, + nlri, + }) +} + +/// Strategy for generating UpdateMessage with MP_REACH_NLRI +fn update_mp_reach_strategy() -> impl Strategy { + (mp_reach_strategy(), distinct_traditional_attrs_strategy()).prop_map( + |(mp_reach, mut attrs)| { + attrs.push(PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }); + UpdateMessage { + withdrawn: vec![], + path_attributes: attrs, + nlri: vec![], + } + }, + ) +} + +/// Strategy for generating UpdateMessage with MP_UNREACH_NLRI +fn update_mp_unreach_strategy() -> impl Strategy { + mp_unreach_strategy().prop_map(|mp_unreach| UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }], + nlri: vec![], + }) +} + +/// Strategy for generating any valid UpdateMessage +fn update_strategy() -> impl Strategy { + prop_oneof![ + update_traditional_strategy(), + update_mp_reach_strategy(), + update_mp_unreach_strategy(), + ] +} + +// ============================================================================= +// Property Tests +// ============================================================================= + proptest! { + #![proptest_config(ProptestConfig { + cases: 256, + ..ProptestConfig::default() + })] + + // ------------------------------------------------------------------------- + // Prefix Round-Trip Tests + // ------------------------------------------------------------------------- + /// Property: IPv4 wire format round-trip is identity #[test] fn prop_ipv4_wire_format_roundtrip(prefix in ipv4_prefix_strategy()) { @@ -48,4 +310,423 @@ proptest! { prop_assert_eq!(decoded, prefix, "Decoded prefix should match original"); prop_assert_eq!(remaining.len(), 0, "Should consume all bytes"); } + + /// Property: Multiple IPv4 prefixes round-trip through traditional UPDATE + #[test] + fn prop_ipv4_prefixes_roundtrip(prefixes in ipv4_prefixes_strategy()) { + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![], + nlri: prefixes.clone(), + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + prop_assert_eq!(decoded.nlri, prefixes, "NLRI prefixes should round-trip"); + } + + /// Property: Multiple IPv4 withdrawn prefixes round-trip through traditional UPDATE + #[test] + fn prop_ipv4_withdrawn_roundtrip(prefixes in ipv4_prefixes_strategy()) { + let update = UpdateMessage { + withdrawn: prefixes.clone(), + path_attributes: vec![], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + prop_assert_eq!(decoded.withdrawn, prefixes, "Withdrawn prefixes should round-trip"); + } + + /// Property: Multiple IPv6 prefixes round-trip through MP_REACH_NLRI + #[test] + fn prop_ipv6_prefixes_via_mp_reach( + prefixes in ipv6_prefixes_strategy(), + nexthop in nexthop_ipv6_single_strategy() + ) { + let mp_reach = MpReach::new( + Afi::Ipv6, + nexthop, + prefixes.iter().cloned().map(Prefix::V6).collect(), + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Extract MP_REACH_NLRI and verify prefixes + let mp_reach_attr = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) + .expect("should have MP_REACH_NLRI"); + + let decoded_prefixes = UpdateMessage::prefixes6_from_wire(&mp_reach_attr.nlri_bytes) + .expect("should parse NLRI"); + + prop_assert_eq!(decoded_prefixes, prefixes, "IPv6 NLRI prefixes should round-trip"); + } + + /// Property: Multiple IPv6 withdrawn prefixes round-trip through MP_UNREACH_NLRI + #[test] + fn prop_ipv6_withdrawn_via_mp_unreach(prefixes in ipv6_prefixes_strategy()) { + let mp_unreach = MpUnreach::new_v6(prefixes.clone()); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Extract MP_UNREACH_NLRI and verify prefixes + let mp_unreach_attr = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + _ => None, + }) + .expect("should have MP_UNREACH_NLRI"); + + let decoded_prefixes = UpdateMessage::prefixes6_from_wire(&mp_unreach_attr.withdrawn_bytes) + .expect("should parse withdrawn"); + + prop_assert_eq!(decoded_prefixes, prefixes, "IPv6 withdrawn prefixes should round-trip"); + } + + /// Property: Multiple IPv4 prefixes round-trip through MP_REACH_NLRI (MP-BGP encoding) + #[test] + fn prop_ipv4_prefixes_via_mp_reach( + prefixes in ipv4_prefixes_strategy(), + nexthop in nexthop_ipv4_strategy() + ) { + let mp_reach = MpReach::new( + Afi::Ipv4, + nexthop, + prefixes.iter().cloned().map(Prefix::V4).collect(), + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Extract MP_REACH_NLRI and verify prefixes + let mp_reach_attr = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) + .expect("should have MP_REACH_NLRI"); + + let decoded_prefixes = UpdateMessage::prefixes4_from_wire(&mp_reach_attr.nlri_bytes) + .expect("should parse NLRI"); + + prop_assert_eq!(decoded_prefixes, prefixes, "IPv4 MP-BGP NLRI prefixes should round-trip"); + } + + /// Property: Multiple IPv4 withdrawn prefixes round-trip through MP_UNREACH_NLRI (MP-BGP encoding) + #[test] + fn prop_ipv4_withdrawn_via_mp_unreach( + prefixes in ipv4_prefixes_strategy(), + nexthop in nexthop_ipv4_strategy() + ) { + let mp_unreach = MpUnreach::new( + Afi::Ipv4, + nexthop, + prefixes.iter().cloned().map(Prefix::V4).collect(), + ); + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Extract MP_UNREACH_NLRI and verify prefixes + let mp_unreach_attr = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + _ => None, + }) + .expect("should have MP_UNREACH_NLRI"); + + let decoded_prefixes = UpdateMessage::prefixes4_from_wire(&mp_unreach_attr.withdrawn_bytes) + .expect("should parse withdrawn"); + + prop_assert_eq!(decoded_prefixes, prefixes, "IPv4 MP-BGP withdrawn prefixes should round-trip"); + } + + // ------------------------------------------------------------------------- + // BgpNexthop Round-Trip Tests (via MpReach) + // ------------------------------------------------------------------------- + + /// Property: BgpNexthop IPv4 round-trip through MpReach preserves next-hop + #[test] + fn prop_nexthop_ipv4_via_mp_reach(nexthop in nexthop_ipv4_strategy()) { + let mp_reach = MpReach::new(Afi::Ipv4, nexthop, vec![]); + let attr = PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }; + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![attr], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Extract and verify the next-hop + let decoded_nexthop = decoded.nexthop().expect("should have nexthop"); + prop_assert_eq!(decoded_nexthop, nexthop, "IPv4 nexthop should round-trip"); + } + + /// Property: BgpNexthop IPv6 single round-trip through MpReach preserves next-hop + #[test] + fn prop_nexthop_ipv6_single_via_mp_reach(nexthop in nexthop_ipv6_single_strategy()) { + let mp_reach = MpReach::new(Afi::Ipv6, nexthop, vec![]); + let attr = PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }; + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![attr], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + let decoded_nexthop = decoded.nexthop().expect("should have nexthop"); + prop_assert_eq!(decoded_nexthop, nexthop, "IPv6 single nexthop should round-trip"); + } + + /// Property: BgpNexthop IPv6 double round-trip through MpReach preserves next-hop + #[test] + fn prop_nexthop_ipv6_double_via_mp_reach(nexthop in nexthop_ipv6_double_strategy()) { + let mp_reach = MpReach::new(Afi::Ipv6, nexthop, vec![]); + let attr = PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }; + + let update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![attr], + nlri: vec![], + }; + + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + let decoded_nexthop = decoded.nexthop().expect("should have nexthop"); + prop_assert_eq!(decoded_nexthop, nexthop, "IPv6 double nexthop should round-trip"); + } + + // ------------------------------------------------------------------------- + // UpdateMessage Round-Trip Tests + // ------------------------------------------------------------------------- + + /// Property: Traditional UpdateMessage round-trip preserves structure + #[test] + fn prop_update_traditional_roundtrip(update in update_traditional_strategy()) { + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + prop_assert_eq!(decoded.withdrawn, update.withdrawn); + prop_assert_eq!(decoded.nlri, update.nlri); + // Path attributes may be reordered but should have same count + prop_assert_eq!( + decoded.path_attributes.len(), + update.path_attributes.len(), + "Path attribute count should match" + ); + } + + /// Property: MP-BGP UpdateMessage with MP_REACH_NLRI round-trip works + #[test] + fn prop_update_mp_reach_roundtrip(update in update_mp_reach_strategy()) { + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Should have MP_REACH_NLRI attribute + let has_mp_reach = decoded.path_attributes.iter().any(|a| { + matches!(a.value, PathAttributeValue::MpReachNlri(_)) + }); + prop_assert!(has_mp_reach, "Decoded should have MP_REACH_NLRI"); + } + + /// Property: MP-BGP UpdateMessage with MP_UNREACH_NLRI round-trip works + #[test] + fn prop_update_mp_unreach_roundtrip(update in update_mp_unreach_strategy()) { + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Should have MP_UNREACH_NLRI attribute + let has_mp_unreach = decoded.path_attributes.iter().any(|a| { + matches!(a.value, PathAttributeValue::MpUnreachNlri(_)) + }); + prop_assert!(has_mp_unreach, "Decoded should have MP_UNREACH_NLRI"); + } + + // ------------------------------------------------------------------------- + // RFC 7606 Compliance Tests + // ------------------------------------------------------------------------- + + /// Property: MP-BGP attributes are always encoded first (RFC 7606 Section 5.1) + #[test] + fn prop_mp_bgp_attrs_encoded_first(update in update_mp_reach_strategy()) { + let wire = update.to_wire().expect("should encode"); + + // Skip to path attributes section + // Wire format: 2 bytes withdrawn len + withdrawn + 2 bytes attrs len + attrs + nlri + let withdrawn_len = u16::from_be_bytes([wire[0], wire[1]]) as usize; + let attrs_start = 2 + withdrawn_len + 2; + + if wire.len() > attrs_start + 1 { + // First attribute's type code is at offset 1 (after flags byte) + let first_type_code = wire[attrs_start + 1]; + + prop_assert!( + first_type_code == PathAttributeTypeCode::MpReachNlri as u8 + || first_type_code == PathAttributeTypeCode::MpUnreachNlri as u8, + "First path attribute should be MP-BGP when present, got type code {}", + first_type_code + ); + } + } + + /// Property: Duplicate non-MP-BGP attributes are deduplicated to first occurrence + #[test] + fn prop_duplicate_attrs_deduplicated( + origin1 in path_origin_strategy(), + origin2 in path_origin_strategy() + ) { + // Manually construct wire bytes with duplicate ORIGIN attributes + let mut wire = Vec::new(); + + // Withdrawn routes length (0) + wire.extend_from_slice(&0u16.to_be_bytes()); + + // Path attributes: two ORIGIN attributes (second should be discarded) + let attrs = vec![ + // First ORIGIN attribute + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, // length + origin1 as u8, + // Second ORIGIN attribute (should be discarded) + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, // length + origin2 as u8, + ]; + + // Path attributes length + wire.extend_from_slice(&(attrs.len() as u16).to_be_bytes()); + wire.extend_from_slice(&attrs); + + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Should only have one ORIGIN attribute + let origins: Vec<_> = decoded.path_attributes.iter() + .filter_map(|a| match &a.value { + PathAttributeValue::Origin(o) => Some(*o), + _ => None, + }) + .collect(); + + prop_assert_eq!(origins.len(), 1, "Should have exactly one ORIGIN after dedup"); + prop_assert_eq!(origins[0], origin1, "Should keep first ORIGIN value"); + } + + /// Property: Encoding then decoding produces semantically equivalent message + #[test] + fn prop_encode_decode_semantic_equivalence(update in update_strategy()) { + let wire = update.to_wire().expect("should encode"); + let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); + + // Withdrawn and NLRI should be identical + prop_assert_eq!(decoded.withdrawn, update.withdrawn); + prop_assert_eq!(decoded.nlri, update.nlri); + + // Count of each attribute type should match + // (may be reordered due to MP-BGP first encoding rule) + for type_code in [ + PathAttributeTypeCode::Origin, + PathAttributeTypeCode::AsPath, + PathAttributeTypeCode::NextHop, + PathAttributeTypeCode::MultiExitDisc, + PathAttributeTypeCode::LocalPref, + PathAttributeTypeCode::MpReachNlri, + PathAttributeTypeCode::MpUnreachNlri, + ] { + let orig_count = update.path_attributes.iter() + .filter(|a| a.typ.type_code == type_code) + .count(); + let decoded_count = decoded.path_attributes.iter() + .filter(|a| a.typ.type_code == type_code) + .count(); + + prop_assert_eq!( + orig_count, decoded_count, + "Attribute {:?} count should match", type_code + ); + } + } } diff --git a/bgp/src/rhai_integration.rs b/bgp/src/rhai_integration.rs index 276ce994..4382f424 100644 --- a/bgp/src/rhai_integration.rs +++ b/bgp/src/rhai_integration.rs @@ -11,6 +11,7 @@ use crate::{ }, policy::{CheckerResult, ShaperResult}, }; +use rdb::{Prefix4, Prefix6}; use rhai::{Module, export_module, plugin::*}; macro_rules! create_enum_module { @@ -104,17 +105,17 @@ impl UpdateMessage { pub fn prefix_filter(&mut self, f: F) where - F: Clone + Fn(&Prefix) -> bool, + F: Clone + Fn(&Prefix4) -> bool, { self.withdrawn.retain(f.clone()); self.nlri.retain(f); } - pub fn get_nlri(&mut self) -> Vec { + pub fn get_nlri(&mut self) -> Vec { self.nlri.clone() } - pub fn set_nlri(&mut self, value: Vec) { + pub fn set_nlri(&mut self, value: Vec) { self.nlri = value; } } @@ -189,3 +190,21 @@ pub fn prefix_within_rhai(prefix: &mut Prefix, x: &str) -> bool { let s = *prefix; s.within(&x) } + +pub fn prefix4_within_rhai(prefix: &mut Prefix4, x: &str) -> bool { + let x: Prefix = match x.parse() { + Ok(p) => p, + Err(_) => return false, + }; + let s = Prefix::V4(*prefix); + s.within(&x) +} + +pub fn prefix6_within_rhai(prefix: &mut Prefix6, x: &str) -> bool { + let x: Prefix = match x.parse() { + Ok(p) => p, + Err(_) => return false, + }; + let s = Prefix::V6(*prefix); + s.within(&x) +} diff --git a/bgp/src/router.rs b/bgp/src/router.rs index 6dc84c33..e0f3514f 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -2,36 +2,36 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::config::PeerConfig; -use crate::config::RouterConfig; -use crate::connection::BgpConnection; -use crate::error::Error; -use crate::fanout::{Egress, Fanout}; -use crate::messages::PathOrigin; -use crate::messages::{ - As4PathSegment, AsPathType, Community, PathAttribute, PathAttributeValue, - Prefix, UpdateMessage, -}; -use crate::policy::load_checker; -use crate::policy::load_shaper; -use crate::session::{ - AdminEvent, FsmEvent, NeighborInfo, SessionEndpoint, SessionInfo, - SessionRunner, +use crate::{ + COMPONENT_BGP, + config::{PeerConfig, RouterConfig}, + connection::BgpConnection, + error::Error, + fanout::{Egress, Fanout4, Fanout6}, + messages::{ + As4PathSegment, AsPathType, Community, PathAttribute, + PathAttributeValue, PathOrigin, Prefix, + }, + policy::{load_checker, load_shaper}, + session::{ + AdminEvent, FsmEvent, NeighborInfo, SessionEndpoint, SessionInfo, + SessionRunner, + }, }; use mg_common::{lock, read_lock, write_lock}; -use rdb::{Asn, Db}; -use rdb::{Prefix4, Prefix6}; +use rdb::{Asn, Db, Prefix4, Prefix6}; use rhai::AST; use slog::Logger; -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::net::IpAddr; -use std::net::SocketAddr; -use std::sync::MutexGuard; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, Sender}; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::Duration; +use std::{ + collections::{BTreeMap, BTreeSet}, + net::{IpAddr, SocketAddr}, + sync::{ + Arc, Mutex, MutexGuard, RwLock, + atomic::{AtomicBool, Ordering}, + mpsc::{Receiver, Sender}, + }, + time::Duration, +}; const UNIT_SESSION_RUNNER: &str = "session_runner"; @@ -51,7 +51,7 @@ pub struct Router { pub policy: Policy, /// The logger used by this router. - log: Logger, + pub log: Logger, /// A flag indicating whether this router should shut itself down. shutdown: AtomicBool, @@ -69,7 +69,10 @@ pub struct Router { /// will also act as a redistribution mechanism from one peer session /// to all others. If/when we do that, there will need to be export /// policy that governs what updates fan out to what peers. - fanout: Arc>>, + /// Note: Since peers can have any combination of address families enabled, + /// fanout must be maintained per address family. + pub fanout4: Arc>>, + pub fanout6: Arc>>, } unsafe impl Send for Router {} @@ -90,7 +93,8 @@ impl Router { graceful_shutdown: AtomicBool::new(false), db, sessions: Mutex::new(BTreeMap::new()), - fanout: Arc::new(RwLock::new(Fanout::default())), + fanout4: Arc::new(RwLock::new(Fanout4::default())), + fanout6: Arc::new(RwLock::new(Fanout6::default())), policy: Policy::default(), } } @@ -170,26 +174,61 @@ impl Router { for (addr, session) in sessions.iter() { // Ensure fanout is set up for this session (needed for restart scenario) if let Some(endpoint) = lock!(self.addr_to_session).get(addr) { - self.add_fanout(*addr, endpoint.event_tx.clone()); + if lock!(endpoint.config).ipv4_enabled { + self.add_fanout4(*addr, endpoint.event_tx.clone()); + } + if lock!(endpoint.config).ipv6_enabled { + self.add_fanout6(*addr, endpoint.event_tx.clone()); + } } self.spawn_session_thread(session.clone()); } } - pub fn add_fanout(&self, peer: IpAddr, event_tx: Sender>) { - let mut fanout = write_lock!(self.fanout); + pub fn add_fanout4(&self, peer: IpAddr, event_tx: Sender>) { + let mut fanout = write_lock!(self.fanout4); + fanout.add_egress( + peer, + Egress { + event_tx: Some(event_tx), + log: self.log.clone(), + }, + ) + } + + pub fn add_fanout6(&self, peer: IpAddr, event_tx: Sender>) { + let mut fanout = write_lock!(self.fanout6); fanout.add_egress( peer, Egress { - event_tx: Some(event_tx.clone()), + event_tx: Some(event_tx), log: self.log.clone(), }, ) } + /// Remove a peer from any fanouts they're a member of. pub fn remove_fanout(&self, peer: IpAddr) { - let mut fanout = write_lock!(self.fanout); - fanout.remove_egress(peer); + // Note: We intentionally use separate locks for fanout4 and fanout6 to allow + // independent operation of IPv4 and IPv6 route distribution. There is a brief + // window between releasing the fanout4 lock and acquiring the fanout6 lock + // where the peer state is inconsistent (removed from one but not the other). + // + // This race is benign because: + // 1. Fanout removal only occurs during administrative operations (session + // deletion, router shutdown), not the hot path + // 2. Any route announcement sent during this window to the removed peer will + // fail harmlessly (channel disconnected) + // 3. The final state is always consistent (peer removed from both fanouts) + // 4. FsmState::Established transitions properly handle route announcements + { + let mut fanout = write_lock!(self.fanout4); + fanout.remove_egress(peer); + } + { + let mut fanout = write_lock!(self.fanout6); + fanout.remove_egress(peer); + } } pub fn ensure_session( @@ -250,6 +289,9 @@ impl Router { session_info.resolution = Duration::from_millis(peer.resolution); session_info.bind_addr = bind_addr; + let ipv4 = session_info.ipv4_enabled; + let ipv6 = session_info.ipv6_enabled; + let session = Arc::new(Mutex::new(session_info)); a2s.insert( @@ -271,18 +313,16 @@ impl Router { event_rx, event_tx.clone(), neighbor.clone(), - self.config.asn, - self.config.id, - self.db.clone(), - self.fanout.clone(), - //TODO remove all the other self properties in favor just passing - // the router through. self.clone(), - self.log.clone(), )); self.spawn_session_thread(runner.clone()); - self.add_fanout(neighbor.host.ip(), event_tx); + if ipv4 { + self.add_fanout4(neighbor.host.ip(), event_tx.clone()); + } + if ipv6 { + self.add_fanout6(neighbor.host.ip(), event_tx.clone()); + } lock!(self.sessions).insert(neighbor.host.ip(), runner.clone()); Ok(runner) @@ -336,7 +376,7 @@ impl Router { // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.announce_origin4(&prefixes); + self.announce_origin4(prefix4); } Ok(()) } @@ -356,63 +396,75 @@ impl Router { let new: BTreeSet<&Prefix4> = prefix4.iter().collect(); - let to_withdraw: Vec<_> = - current.difference(&new).map(|x| (**x).into()).collect(); + let to_withdraw: Vec = + current.difference(&new).map(|x| **x).collect(); - let to_announce: Vec<_> = - new.difference(¤t).map(|x| (**x).into()).collect(); + let to_announce: Vec = + new.difference(¤t).map(|x| **x).collect(); self.db.set_origin4(&prefix4)?; // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.withdraw_origin4(&to_withdraw); - self.announce_origin4(&to_announce); + self.withdraw_origin4(to_withdraw); + self.announce_origin4(to_announce); } Ok(()) } pub fn clear_origin4(&self) -> Result<(), Error> { let current = self.db.get_origin4()?; - let prefix: Vec = - current.iter().cloned().map(Into::into).collect(); // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.withdraw_origin4(&prefix); + self.withdraw_origin4(current); } self.db.clear_origin4()?; Ok(()) } - fn announce_origin4(&self, prefixes: &Vec) { - let mut update = UpdateMessage { - path_attributes: self.base_attributes(), - ..Default::default() - }; - - for p in prefixes { - update.nlri.push(*p); + fn announce_origin4(&self, prefixes: Vec) { + if prefixes.is_empty() { + return; } - if !update.nlri.is_empty() { - read_lock!(self.fanout).send_all(&update); - } - } + let pfx_str = prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); - pub fn withdraw_origin4(&self, prefixes: &Vec) { - let mut update = UpdateMessage { - path_attributes: self.base_attributes(), - ..Default::default() - }; + slog::debug!( + self.log, + "announcing originated IPv4 prefixes"; + "component" => COMPONENT_BGP, + "prefixes" => format!("[{pfx_str}]"), + "count" => prefixes.len(), + ); - for p in prefixes { - update.withdrawn.push(*p); - } + read_lock!(self.fanout4).announce_all(prefixes, vec![]); + } - if !update.withdrawn.is_empty() { - read_lock!(self.fanout).send_all(&update); + pub fn withdraw_origin4(&self, prefixes: Vec) { + if prefixes.is_empty() { + return; } + + let pfx_str = prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + + slog::debug!( + self.log, + "withdrawing originated IPv4 prefixes"; + "component" => COMPONENT_BGP, + "prefixes" => format!("[{pfx_str}]"), + "count" => prefixes.len(), + ); + + read_lock!(self.fanout4).announce_all(vec![], prefixes); } pub fn create_origin6(&self, prefixes: Vec) -> Result<(), Error> { @@ -428,7 +480,7 @@ impl Router { // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.announce_origin6(&prefixes); + self.announce_origin6(prefix6); } Ok(()) } @@ -448,63 +500,75 @@ impl Router { let new: BTreeSet<&Prefix6> = prefix6.iter().collect(); - let to_withdraw: Vec<_> = - current.difference(&new).map(|x| (**x).into()).collect(); + let to_withdraw: Vec = + current.difference(&new).map(|x| **x).collect(); - let to_announce: Vec<_> = - new.difference(¤t).map(|x| (**x).into()).collect(); + let to_announce: Vec = + new.difference(¤t).map(|x| **x).collect(); self.db.set_origin6(&prefix6)?; // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.withdraw_origin6(&to_withdraw); - self.announce_origin6(&to_announce); + self.withdraw_origin6(to_withdraw); + self.announce_origin6(to_announce); } Ok(()) } pub fn clear_origin6(&self) -> Result<(), Error> { let current = self.db.get_origin6()?; - let prefix: Vec = - current.iter().cloned().map(Into::into).collect(); // Skip network propagation if router is shutdown if !self.shutdown.load(Ordering::Acquire) { - self.withdraw_origin6(&prefix); + self.withdraw_origin6(current); } self.db.clear_origin6()?; Ok(()) } - fn announce_origin6(&self, prefixes: &Vec) { - let mut update = UpdateMessage { - path_attributes: self.base_attributes(), - ..Default::default() - }; - - for p in prefixes { - update.nlri.push(*p); + fn announce_origin6(&self, prefixes: Vec) { + if prefixes.is_empty() { + return; } - if !update.nlri.is_empty() { - read_lock!(self.fanout).send_all(&update); - } - } + let pfx_str = prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); - pub fn withdraw_origin6(&self, prefixes: &Vec) { - let mut update = UpdateMessage { - path_attributes: self.base_attributes(), - ..Default::default() - }; + slog::debug!( + self.log, + "announcing originated IPv6 prefixes"; + "component" => COMPONENT_BGP, + "prefixes" => format!("[{pfx_str}]"), + "count" => prefixes.len(), + ); - for p in prefixes { - update.withdrawn.push(*p); - } + read_lock!(self.fanout6).announce_all(prefixes, vec![]); + } - if !update.withdrawn.is_empty() { - read_lock!(self.fanout).send_all(&update); + pub fn withdraw_origin6(&self, prefixes: Vec) { + if prefixes.is_empty() { + return; } + + let pfx_str = prefixes + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + + slog::debug!( + self.log, + "withdrawing originated IPv6 prefixes"; + "component" => COMPONENT_BGP, + "prefixes" => format!("[{pfx_str}]"), + "count" => prefixes.len(), + ); + + read_lock!(self.fanout6).announce_all(vec![], prefixes); } pub fn base_attributes(&self) -> Vec { @@ -572,16 +636,32 @@ impl Router { } fn announce_all(&self) -> Result<(), Error> { - let originated = self.db.get_origin4()?; + let originated4 = self.db.get_origin4()?; + + if !originated4.is_empty() { + slog::debug!( + self.log, + "announcing all originated IPv4 prefixes"; + "component" => COMPONENT_BGP, + "count" => originated4.len(), + ); - let mut update = UpdateMessage { - path_attributes: self.base_attributes(), - ..Default::default() - }; - for p in &originated { - update.nlri.push((*p).into()); + read_lock!(self.fanout4).announce_all(originated4, vec![]); + } + + // Also announce IPv6 originated routes + let originated6 = self.db.get_origin6()?; + + if !originated6.is_empty() { + slog::debug!( + self.log, + "announcing all originated IPv6 prefixes"; + "component" => COMPONENT_BGP, + "count" => originated6.len(), + ); + + read_lock!(self.fanout6).announce_all(originated6, vec![]); } - read_lock!(self.fanout).send_all(&update); Ok(()) } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 9bdb772b..243d4794 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -9,32 +9,34 @@ use crate::{ BgpConnection, BgpConnector, ConnectionDirection, ConnectionId, }, error::{Error, ExpectationMismatch}, - fanout::Fanout, + fanout::{Fanout4, Fanout6}, log::{collision_log, session_log, session_log_lite}, messages::{ - AddPathElement, Afi, Capability, CeaseErrorSubcode, Community, - ErrorCode, ErrorSubcode, Message, MessageKind, NotificationMessage, - OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, - UpdateMessage, + AddPathElement, Afi, BgpNexthop, Capability, CeaseErrorSubcode, + Community, ErrorCode, ErrorSubcode, Message, MessageKind, MpReach, + MpUnreach, NotificationMessage, OpenMessage, PathAttributeValue, + RouteRefreshMessage, Safi, UpdateErrorSubcode, UpdateMessage, }, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, router::Router, }; use mg_common::{lock, read_lock, write_lock}; -use rdb::{Asn, BgpPathProperties, Db, ImportExportPolicy, Prefix, Prefix4}; +use rdb::{ + Asn, BgpPathProperties, Db, ImportExportPolicy, Prefix, Prefix4, Prefix6, +}; pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use slog::Logger; -use std::sync::mpsc::{Receiver, Sender}; use std::{ collections::{BTreeSet, VecDeque}, fmt::{self, Display, Formatter}, - net::SocketAddr, + net::{IpAddr, SocketAddr}, sync::{ Arc, Mutex, RwLock, atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{Receiver, Sender}, }, time::{Duration, Instant}, }; @@ -58,6 +60,10 @@ pub struct PeerConnection { pub asn: u32, /// The actual capabilities received from the peer (runtime state) pub caps: BTreeSet, + /// The IPv4 Unicast AFI/SAFI was negotiated (advertised by both sides) + pub ipv4: bool, + /// The IPv6 Unicast AFI/SAFI was negotiated (advertised by both sides) + pub ipv6: bool, } impl Clone for PeerConnection { @@ -67,6 +73,8 @@ impl Clone for PeerConnection { id: self.id, asn: self.asn, caps: self.caps.clone(), + ipv4: self.ipv4, + ipv6: self.ipv6, } } } @@ -94,6 +102,67 @@ pub enum CollisionConnectionKind { Missing, } +/// Pure function to determine which connection wins in a collision. +/// +/// ```text +/// RFC 4271 Section 6.8 +/// +/// 1) The BGP Identifier of the local system is compared to the BGP +/// Identifier of the remote system (as specified in the OPEN +/// message). Comparing BGP Identifiers is done by converting them +/// to host byte order and treating them as 4-octet unsigned +/// integers. +/// +/// 2) If the value of the local BGP Identifier is less than the +/// remote one, the local system closes the BGP connection that +/// already exists (the one that is already in the OpenConfirm +/// state), and accepts the BGP connection initiated by the remote +/// system. +/// +/// 3) Otherwise, the local system closes the newly created BGP +/// connection (the one associated with the newly received OPEN +/// message), and continues to use the existing one (the one that +/// is already in the OpenConfirm state). +/// +/// Unless allowed via configuration, a connection collision with an +/// existing BGP connection that is in the Established state causes +/// closing of the newly created connection. +/// +/// Note that a connection collision cannot be detected with connections +/// that are in Idle, Connect, or Active states. +/// +/// Closing the BGP connection (that results from the collision +/// resolution procedure) is accomplished by sending the NOTIFICATION +/// message with the Error Code Cease. +/// ``` +/// +/// # Arguments +/// * `exist_direction` - direction of the existing connection (Inbound or Outbound) +/// * `local_bgp_id` - Our BGP Identifier +/// * `remote_bgp_id` - Peer's BGP Identifier +/// +/// # Returns +/// `CollisionResolution` indicating whether exist or new connection wins +pub fn collision_resolution( + exist_direction: ConnectionDirection, + local_bgp_id: u32, + remote_bgp_id: u32, +) -> CollisionResolution { + if local_bgp_id < remote_bgp_id { + // The peer has a higher RID, keep the connection they initiated + match exist_direction { + ConnectionDirection::Inbound => CollisionResolution::ExistWins, + ConnectionDirection::Outbound => CollisionResolution::NewWins, + } + } else { + // The local system has a higher RID, keep the connection we initiated + match exist_direction { + ConnectionDirection::Inbound => CollisionResolution::NewWins, + ConnectionDirection::Outbound => CollisionResolution::ExistWins, + } + } +} + /// The states a BGP finite state machine may be at any given time. Many /// of these states carry a connection by value. This is the same connection /// that moves from state to state as transitions are made. Transitions from @@ -219,10 +288,81 @@ impl From<&FsmState> for FsmStateKind { } } +/// Type-safe route update that enforces IP version consistency at compile time. +/// This eliminates the need for runtime Afi validation and prevents +/// mixing IPv4 routes with IPv6 encoding or vice versa. +#[derive(Clone, Debug)] +pub enum RouteUpdate { + V4 { + withdrawn: Vec, + nlri: Vec, + }, + V6 { + withdrawn: Vec, + nlri: Vec, + }, +} + +impl RouteUpdate { + pub fn is_empty(&self) -> bool { + match self { + RouteUpdate::V4 { withdrawn, nlri } => { + withdrawn.is_empty() && nlri.is_empty() + } + RouteUpdate::V6 { withdrawn, nlri } => { + withdrawn.is_empty() && nlri.is_empty() + } + } + } + + pub fn afi(&self) -> Afi { + match self { + RouteUpdate::V4 { .. } => Afi::Ipv4, + RouteUpdate::V6 { .. } => Afi::Ipv6, + } + } + + pub fn nlri_count(&self) -> usize { + match self { + RouteUpdate::V4 { withdrawn: _, nlri } => nlri.len(), + RouteUpdate::V6 { withdrawn: _, nlri } => nlri.len(), + } + } + + pub fn withdrawn_count(&self) -> usize { + match self { + RouteUpdate::V4 { withdrawn, .. } => withdrawn.len(), + RouteUpdate::V6 { withdrawn, .. } => withdrawn.len(), + } + } +} + +impl Display for RouteUpdate { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + RouteUpdate::V4 { withdrawn, nlri } => { + write!( + f, + "RouteUpdate::V4 {{ nlri: {:?}, withdrawn: {:?} }}", + nlri, withdrawn + ) + } + RouteUpdate::V6 { withdrawn, nlri } => { + write!( + f, + "RouteUpdate::V6 {{ nlri: {:?}, withdrawn: {:?} }}", + nlri, withdrawn + ) + } + } + } +} + #[derive(Clone, Debug)] pub enum AdminEvent { - // Instructs peer to announce the update - Announce(UpdateMessage), + /// Announce/withdraw routes for a specific address family. + /// The session layer will build the appropriate UPDATE message. + Announce(RouteUpdate), // The shaper for the router has changed. Event contains previous checker. // Current shaper is available in the router policy object. @@ -259,7 +399,7 @@ pub enum AdminEvent { impl AdminEvent { fn title(&self) -> &'static str { match self { - AdminEvent::Announce(_) => "announce", + AdminEvent::Announce(_) => "announce routes", AdminEvent::ShaperChanged(_) => "shaper changed", AdminEvent::CheckerChanged(_) => "checker changed", AdminEvent::ExportPolicyChanged(_) => "export policy changed", @@ -560,6 +700,10 @@ pub struct SessionInfo { /// resolution even when one connection is already in Established state. /// When false, Established connection always wins (timing-based resolution). pub deterministic_collision_resolution: bool, + /// This session is configured to support the IPv4 Unicast MP-BGP AFI/SAFI + pub ipv4_enabled: bool, + /// This session is configured to support the IPv6 Unicast MP-BGP AFI/SAFI + pub ipv6_enabled: bool, } impl SessionInfo { @@ -590,6 +734,10 @@ impl SessionInfo { connect_retry_jitter: None, // XXX: plumb this through to the neighbor API endpoint deterministic_collision_resolution: false, + // XXX: plumb this through to the neighbor API endpoint + ipv4_enabled: true, + // XXX: plumb this through to the neighbor API endpoint + ipv6_enabled: false, } } } @@ -884,6 +1032,18 @@ macro_rules! connect_timeout { }; } +macro_rules! active_afi { + ($self:expr, $their_caps:ident) => {{ + let cap4 = Capability::ipv4_unicast(); + let cap6 = Capability::ipv6_unicast(); + let our_caps = lock!($self.caps_tx); + ( + our_caps.contains(&cap4) && $their_caps.contains(&cap4), + our_caps.contains(&cap6) && $their_caps.contains(&cap6), + ) + }}; +} + /// Registry for tracking active connections #[derive(Debug)] pub enum ConnectionRegistry { @@ -1165,7 +1325,8 @@ pub struct SessionRunner { shutdown: AtomicBool, running: AtomicBool, db: Db, - fanout: Arc>>, + fanout4: Arc>>, + fanout6: Arc>>, router: Arc>, /// Registry of active connections with typestate enforcement @@ -1192,67 +1353,6 @@ pub enum CollisionResolution { NewWins, } -/// Pure function to determine which connection wins in a collision. -/// -/// ```text -/// RFC 4271 Section 6.8 -/// -/// 1) The BGP Identifier of the local system is compared to the BGP -/// Identifier of the remote system (as specified in the OPEN -/// message). Comparing BGP Identifiers is done by converting them -/// to host byte order and treating them as 4-octet unsigned -/// integers. -/// -/// 2) If the value of the local BGP Identifier is less than the -/// remote one, the local system closes the BGP connection that -/// already exists (the one that is already in the OpenConfirm -/// state), and accepts the BGP connection initiated by the remote -/// system. -/// -/// 3) Otherwise, the local system closes the newly created BGP -/// connection (the one associated with the newly received OPEN -/// message), and continues to use the existing one (the one that -/// is already in the OpenConfirm state). -/// -/// Unless allowed via configuration, a connection collision with an -/// existing BGP connection that is in the Established state causes -/// closing of the newly created connection. -/// -/// Note that a connection collision cannot be detected with connections -/// that are in Idle, Connect, or Active states. -/// -/// Closing the BGP connection (that results from the collision -/// resolution procedure) is accomplished by sending the NOTIFICATION -/// message with the Error Code Cease. -/// ``` -/// -/// # Arguments -/// * `exist_direction` - direction of the existing connection (Inbound or Outbound) -/// * `local_bgp_id` - Our BGP Identifier -/// * `remote_bgp_id` - Peer's BGP Identifier -/// -/// # Returns -/// `CollisionResolution` indicating whether exist or new connection wins -pub fn collision_resolution( - exist_direction: ConnectionDirection, - local_bgp_id: u32, - remote_bgp_id: u32, -) -> CollisionResolution { - if local_bgp_id < remote_bgp_id { - // The peer has a higher RID, keep the connection they initiated - match exist_direction { - ConnectionDirection::Inbound => CollisionResolution::ExistWins, - ConnectionDirection::Outbound => CollisionResolution::NewWins, - } - } else { - // The local system has a higher RID, keep the connection we initiated - match exist_direction { - ConnectionDirection::Inbound => CollisionResolution::NewWins, - ConnectionDirection::Outbound => CollisionResolution::ExistWins, - } - } -} - impl Drop for SessionRunner { fn drop(&mut self) { let peer_ip = self.neighbor.host.ip(); @@ -1268,18 +1368,12 @@ impl Drop for SessionRunner { impl SessionRunner { /// Create a new BGP session runner. Only creates the session runner /// object. Must call `start` to begin the peering state machine. - #[allow(clippy::too_many_arguments)] pub fn new( session: Arc>, event_rx: Receiver>, event_tx: Sender>, neighbor: NeighborInfo, - asn: Asn, - id: u32, - db: Db, - fanout: Arc>>, router: Arc>, - log: Logger, ) -> SessionRunner { let session_info = lock!(session); let runner = SessionRunner { @@ -1287,8 +1381,8 @@ impl SessionRunner { connect_retry_counter: AtomicU64::new(0), event_rx, event_tx: event_tx.clone(), - asn, - id, + asn: router.config.asn, + id: router.config.id, neighbor, state: Arc::new(Mutex::new(FsmStateKind::Idle)), last_state_change: Mutex::new(Instant::now()), @@ -1299,17 +1393,18 @@ impl SessionRunner { session_info.connect_retry_jitter, session_info.idle_hold_jitter, event_tx.clone(), - log.clone(), + router.log.clone(), )), - log, + log: router.log.clone(), shutdown: AtomicBool::new(false), running: AtomicBool::new(false), - fanout, - router, + fanout4: router.fanout4.clone(), + fanout6: router.fanout6.clone(), + router: router.clone(), message_history: Arc::new(Mutex::new(MessageHistory::default())), fsm_event_history: Arc::new(Mutex::new(FsmEventHistory::new())), counters: Arc::new(SessionCounters::default()), - db, + db: router.db.clone(), caps_tx: Arc::new(Mutex::new(BTreeSet::new())), connection_registry: Arc::new( Mutex::new(ConnectionRegistry::new()), @@ -1778,12 +1873,8 @@ impl SessionRunner { } fn initialize_capabilities(&self) { - *lock!(self.caps_tx) = BTreeSet::from([ + let mut caps = BTreeSet::from([ //Capability::EnhancedRouteRefresh{}, - Capability::MultiprotocolExtensions { - afi: 1, //IP - safi: 1, //NLRI for unicast - }, //Capability::GracefulRestart{}, Capability::AddPath { elements: BTreeSet::from([AddPathElement { @@ -1794,6 +1885,13 @@ impl SessionRunner { }, Capability::RouteRefresh {}, ]); + if lock!(self.session).ipv4_enabled { + caps.insert(Capability::ipv4_unicast()); + } + if lock!(self.session).ipv6_enabled { + caps.insert(Capability::ipv6_unicast()); + } + *lock!(self.caps_tx) = caps; } /// Initial state. Refuse all incoming BGP connections. No resources @@ -3234,11 +3332,16 @@ impl SessionRunner { // hold_timer set in handle_open(), enable it here conn_timer!(conn, hold).enable(); + let caps = om.get_capabilities(); + let (ipv4, ipv6) = active_afi!(self, caps); + let pc = PeerConnection { conn, id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4, + ipv6, }; // Upgrade this connection from Partial to Full in the registry @@ -4372,11 +4475,16 @@ impl SessionRunner { .connection_retries .fetch_add(1, Ordering::Relaxed); + let caps = om.get_capabilities(); + let (ipv4, ipv6) = active_afi!(self, caps); + let new_pc = PeerConnection { conn: new, id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4, + ipv6, }; conn_timer!(new_pc.conn, hold).restart(); @@ -4682,11 +4790,16 @@ impl SessionRunner { conn_timer!(exist, hold).restart(); conn_timer!(exist, keepalive).restart(); + let caps = om.get_capabilities(); + let (ipv4, ipv6) = active_afi!(self, caps); + let exist_pc = PeerConnection { conn: exist.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4, + ipv6, }; // Upgrade existing connection from Partial to Full in the registry @@ -4813,12 +4926,18 @@ impl SessionRunner { self.stop(Some(&exist), None, StopReason::CollisionResolution); + let caps = om.get_capabilities(); + let (ipv4, ipv6) = active_afi!(self, caps); + let new_pc = PeerConnection { conn: new.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4, + ipv6 }; + conn_timer!(new, hold).restart(); conn_timer!(new, keepalive).restart(); @@ -5050,55 +5169,108 @@ impl SessionRunner { } // Collect the prefixes this router is originating. - let originated = match self.db.get_origin4() { - Ok(value) => value, - Err(e) => { - //TODO possible death loop. Should we just panic here? - session_log!( - self, - error, - pc.conn, - "failed to get originated routes from db"; - "error" => format!("{e}") - ); - return FsmState::SessionSetup(pc); + let originated4 = if pc.ipv4 { + match self.db.get_origin4() { + Ok(value) => value, + Err(e) => { + //TODO possible death loop. Should we just panic here? + session_log!( + self, + error, + pc.conn, + "failed to get originated routes from db"; + "error" => format!("{e}") + ); + return FsmState::SessionSetup(pc); + } + } + } else { + Vec::new() + }; + + let originated6 = if pc.ipv6 { + match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + //TODO possible death loop. Should we just panic here? + session_log!( + self, + error, + pc.conn, + "failed to get originated routes from db"; + "error" => format!("{e}") + ); + return FsmState::SessionSetup(pc); + } } + } else { + Vec::new() }; // Ensure the router has a fanout entry for this peer. - write_lock!(self.fanout).add_egress( - self.neighbor.host.ip(), - crate::fanout::Egress { - event_tx: Some(self.event_tx.clone()), - log: self.log.clone(), - }, - ); + if pc.ipv4 { + write_lock!(self.fanout4).add_egress( + self.neighbor.host.ip(), + crate::fanout::Egress { + event_tx: Some(self.event_tx.clone()), + log: self.log.clone(), + }, + ); + } + if pc.ipv6 { + write_lock!(self.fanout6).add_egress( + self.neighbor.host.ip(), + crate::fanout::Egress { + event_tx: Some(self.event_tx.clone()), + log: self.log.clone(), + }, + ); + } self.send_keepalive(&pc.conn); conn_timer!(pc.conn, keepalive).restart(); - // Send an update to our peer with the prefixes this router is - // originating. - if !originated.is_empty() { - let mut update = UpdateMessage { - path_attributes: self.router.base_attributes(), - ..Default::default() - }; - for p in originated { - update.nlri.push(p.into()); - } - if let Err(e) = - self.send_update(update, &pc, ShaperApplication::Current) - { - session_log!( - self, - error, - pc.conn, - "failed to send update, fsm transition to idle"; - "error" => format!("{e}") - ); - return self.exit_established(pc); - } + // Send an update to our peer with the IPv4 Unicast prefixes this router + // is originating. + if !originated4.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V4 { + nlri: originated4, + withdrawn: vec![], + }, + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send originated IPv4 routes: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); + } + + // Send IPv6 Unicast prefixes using MP-BGP encoding + if !originated6.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V6 { + nlri: originated6, + withdrawn: vec![], + }, + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send originated IPv6 routes: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); } // Transition to the established state. @@ -5108,25 +5280,46 @@ impl SessionRunner { fn originate_update( &self, pc: &PeerConnection, - sa: ShaperApplication, + _sa: ShaperApplication, ) -> anyhow::Result<()> { - let originated = match self.db.get_origin4() { - Ok(value) => value, + // Get originated IPv4 routes + let originated4 = match self.db.get_origin4() { + Ok(originated) => originated, Err(e) => { - //TODO possible death loop. Should we just panic here? - anyhow::bail!("failed to get originated from db: {e}"); + anyhow::bail!("failed to get originated IPv4 from db: {e}"); } }; - let mut update = UpdateMessage { - path_attributes: self.router.base_attributes(), - ..Default::default() - }; - for p in originated { - update.nlri.push(p.into()); + + if !originated4.is_empty() { + self.send_update( + RouteUpdate::V4 { + nlri: originated4, + withdrawn: vec![], + }, + pc, + ShaperApplication::Current, + )?; } - if let Err(e) = self.send_update(update, pc, sa) { - anyhow::bail!("shaper changed: sending update to peer failed {e}"); + + // Get originated IPv6 routes + let originated6 = match self.db.get_origin6() { + Ok(originated) => originated, + Err(e) => { + anyhow::bail!("failed to get originated IPv6 from db: {e}"); + } + }; + + if !originated6.is_empty() { + self.send_update( + RouteUpdate::V6 { + nlri: originated6, + withdrawn: vec![], + }, + pc, + ShaperApplication::Current, + )?; } + Ok(()) } @@ -5155,10 +5348,8 @@ impl SessionRunner { self.exit_established(pc) } - // An announce request has come from the administrative API or - // another peer session (redistribution). Send the update to our - // peer. - AdminEvent::Announce(update) => { + // Handle route announcements from fanout layer + AdminEvent::Announce(route_update) => { // XXX: Send a route-refresh to our peer in the event we // remove an originated route. This is needed if our // peer is advertising a route that we're originating, @@ -5170,20 +5361,41 @@ impl SessionRunner { // adj-rib-in pre-policy, so a route-refresh is the // only mechanism we really have to trigger a re-learn // of the route without changing the current design. - if let Err(e) = self.send_update( - update, - &pc, - ShaperApplication::Current, - ) { + let (afi, nlri_count, withdrawn_count) = match &route_update + { + RouteUpdate::V4 { nlri, withdrawn } => { + (Afi::Ipv4, nlri.len(), withdrawn.len()) + } + RouteUpdate::V6 { nlri, withdrawn } => { + (Afi::Ipv6, nlri.len(), withdrawn.len()) + } + }; + + session_log!( + self, + debug, + pc.conn, + "received announce-routes event: afi={}, nlri={}, withdrawn={}", + afi, + nlri_count, + withdrawn_count; + ); + + if let Err(e) = self.send_update( + route_update, + &pc, + ShaperApplication::Current, + ) { session_log!( self, error, pc.conn, - "failed to send update, fsm transition to idle"; + "failed to send update from announce-routes: {e}"; "error" => format!("{e}") ); return self.exit_established(pc); } + FsmState::Established(pc) } @@ -5210,80 +5422,75 @@ impl SessionRunner { let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { - //TODO possible death loop. Should we just panic here? session_log!( self, error, pc.conn, - "failed to get originated routes from db"; + "failed to get originated IPv4 routes from db"; "error" => format!("{e}") ); return FsmState::SessionSetup(pc); } }; - let originated_before: BTreeSet = match previous { - ImportExportPolicy::NoFiltering => { - originated.iter().cloned().collect() - } - ImportExportPolicy::Allow(list) => originated - .clone() - .into_iter() - .filter(|x| list.contains(&Prefix::from(*x))) - .collect(), - }; + + // Determine which routes to announce/withdraw based on policy change let session = lock!(self.session); - let current = &session.allow_export; - let originated_after: BTreeSet = match current { + let originated_before: BTreeSet = match previous { ImportExportPolicy::NoFiltering => { originated.iter().cloned().collect() } - ImportExportPolicy::Allow(list) => originated + ImportExportPolicy::Allow(ref list) => originated .clone() .into_iter() .filter(|x| list.contains(&Prefix::from(*x))) .collect(), }; + + let originated_after: BTreeSet = + match &session.allow_export { + ImportExportPolicy::NoFiltering => { + originated.iter().cloned().collect() + } + ImportExportPolicy::Allow(list) => originated + .clone() + .into_iter() + .filter(|x| list.contains(&Prefix::from(*x))) + .collect(), + }; drop(session); - let to_withdraw: BTreeSet<&Prefix4> = originated_before + let to_withdraw: Vec = originated_before .difference(&originated_after) + .cloned() .collect(); - let to_announce: BTreeSet<&Prefix4> = originated_after + let to_announce: Vec = originated_after .difference(&originated_before) + .cloned() .collect(); - if to_withdraw.is_empty() && to_announce.is_empty() { - return FsmState::Established(pc); - } - - let update = UpdateMessage { - path_attributes: self.router.base_attributes(), - withdrawn: to_withdraw - .into_iter() - .map(|x| crate::messages::Prefix::from(*x)) - .collect(), - nlri: to_announce - .into_iter() - .map(|x| crate::messages::Prefix::from(*x)) - .collect(), - }; - - if let Err(e) = self.send_update( - update, - &pc, - ShaperApplication::Current, - ) { + if (!to_withdraw.is_empty() || !to_announce.is_empty()) + && let Err(e) = self.send_update( + RouteUpdate::V4 { + nlri: to_announce, + withdrawn: to_withdraw, + }, + &pc, + ShaperApplication::Current, + ) + { session_log!( self, error, pc.conn, - "failed to send update, fsm transition to idle"; + "failed to send export policy update: {e}"; "error" => format!("{e}") ); return self.exit_established(pc); } + // TODO: Also handle IPv6 originated routes when needed + FsmState::Established(pc) } @@ -5300,7 +5507,7 @@ impl SessionRunner { } AdminEvent::ReAdvertiseRoutes => { - if let Err(e) = self.refresh_react(&pc) { + if let Err(e) = self.refresh_react4(&pc) { session_log!( self, error, @@ -5639,11 +5846,17 @@ impl SessionRunner { msg_kind, false, ); + let caps = om.get_capabilities(); + let (ipv4, ipv6) = + active_afi!(self, caps); + let new_pc = PeerConnection { conn: incoming_conn.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4, + ipv6, }; // Clean up the old established connection @@ -6047,7 +6260,7 @@ impl SessionRunner { ); if let Err(e) = conn.send(Message::RouteRefresh(RouteRefreshMessage { afi: Afi::Ipv4 as u16, - safi: Safi::NlriUnicast as u8, + safi: Safi::Unicast as u8, })) { session_log!( self, @@ -6305,81 +6518,303 @@ impl SessionRunner { Ok(former.difference(¤t)) } - /// Send an update message to the session peer. - fn send_update( + /// Derive peer-specific next-hop based on AFI and negotiated capabilities. + fn derive_nexthop( &self, - mut update: UpdateMessage, + afi: Afi, pc: &PeerConnection, - shaper_application: ShaperApplication, - ) -> Result<(), Error> { - let nexthop = pc.conn.local().ip().to_canonical(); + ) -> Result { + let local_ip = pc.conn.local().ip().to_canonical(); + + // Future: Check for Extended Next-Hop capability here + // if afi == Afi::Ipv4 && pc.caps.contains(&Capability::ExtendedNextHop) { + // if let IpAddr::V6(ipv6) = local_ip { + // return Ok(BgpNexthop::Ipv6Single(ipv6)); + // } + // } + + // Standard behavior: use local IP as next-hop + match (afi, local_ip) { + (Afi::Ipv4, std::net::IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), + (Afi::Ipv6, std::net::IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), + (Afi::Ipv4, std::net::IpAddr::V6(_)) => { + Err(Error::InvalidAddress( + "IPv4 routes require IPv4 next-hop (Extended Next-Hop not negotiated)".into() + )) + } + (Afi::Ipv6, std::net::IpAddr::V4(_)) => { + Err(Error::InvalidAddress( + "IPv6 routes require IPv6 next-hop".into() + )) + } + } + } - update - .path_attributes - .push(PathAttributeValue::NextHop(nexthop).into()); + /// Add peer-specific path attributes to an UPDATE message. + /// This adds MED, LOCAL_PREF, and Communities based on session configuration. + fn enrich_update( + &self, + update: &mut UpdateMessage, + _pc: &PeerConnection, + ) -> Result<(), Error> { + let session = lock!(self.session); - if let Some(med) = lock!(self.session).multi_exit_discriminator { + // Add MED if configured + if let Some(med) = session.multi_exit_discriminator { update .path_attributes .push(PathAttributeValue::MultiExitDisc(med).into()); } + // Add LOCAL_PREF for iBGP if self.is_ibgp().unwrap_or(false) { - update.path_attributes.push( - PathAttributeValue::LocalPref( - lock!(self.session).local_pref.unwrap_or(0), - ) - .into(), - ); + let local_pref = session.local_pref.unwrap_or(0); + update + .path_attributes + .push(PathAttributeValue::LocalPref(local_pref).into()); } - let cs: Vec = lock!(self.session) + // Add communities + let communities: Vec = session .communities .clone() .into_iter() .map(Community::from) .collect(); - if !cs.is_empty() { + if !communities.is_empty() { update .path_attributes - .push(PathAttributeValue::Communities(cs).into()) + .push(PathAttributeValue::Communities(communities).into()); } + Ok(()) + } + + /// Apply export policy filtering to UPDATE message. + /// Filters NLRI based on allow_export configuration. + fn apply_export_policy( + &self, + update: &mut UpdateMessage, + ) -> Result<(), Error> { if let ImportExportPolicy::Allow(ref policy) = lock!(self.session).allow_export { - let message_policy = policy - .iter() - .filter_map(|x| match x { - rdb::Prefix::V4(x) => Some(x), - _ => None, - }) - .map(|x| crate::messages::Prefix::from(*x)) - .collect::>(); + let allowed: BTreeSet = policy.iter().copied().collect(); - update.nlri.retain(|x| message_policy.contains(x)); - }; + // Filter traditional NLRI field + update.nlri.retain(|p| allowed.contains(&Prefix::V4(*p))); + + // TODO: Filter MP-BGP NLRI (if present) + // Since MP-BGP attributes now store raw bytes, filtering requires + // parsing NLRI first. This should be handled during UPDATE construction + // in send_update_v2() instead of post-hoc filtering. + // See docs/update-construction-layered-design-plan.md for details. + } + + Ok(()) + } + + /// Build and send a peer-specific UPDATE message. + /// + /// This constructs UPDATE messages from type-safe routes and handles: + /// - Peer-specific next-hop derivation + /// - AFI-specific encoding (Traditional for IPv4, MP-BGP for IPv6) + /// - Addition of session-specific attributes (MED, LOCAL_PREF, Communities) + /// - Export policy filtering + /// - Shaper policy application + /// + /// # Arguments + /// * `route_update` - Type-safe route update (V4 or V6) + /// * `pc` - Peer connection (provides next-hop and enrichment context) + /// * `shaper_application` - How to apply export shaper policy + fn send_update( + &self, + route_update: RouteUpdate, + pc: &PeerConnection, + shaper_application: ShaperApplication, + ) -> Result<(), Error> { + // Early exit if nothing to send + if route_update.is_empty() { + return Ok(()); + } + + // Extract AFI and build the UpdateMessage based on variant + let (afi, mut update) = match route_update { + RouteUpdate::V4 { nlri, withdrawn } => { + session_log!( + self, + debug, + pc.conn, + "building IPv4 update: nlri={}, withdrawn={}", + nlri.len(), + withdrawn.len(); + ); + + // Derive IPv4 next-hop + let nexthop = self.derive_nexthop(Afi::Ipv4, pc)?; + let nh4 = match nexthop { + BgpNexthop::Ipv4(addr) => addr, + _ => { + return Err(Error::InvalidAddress( + "IPv4 routes require IPv4 next-hop".into(), + )); + } + }; + + session_log!( + self, + debug, + pc.conn, + "derived next-hop: {}", + nh4; + ); + + // Build UpdateMessage directly with type-safe Vec + let mut path_attrs = self.router.base_attributes(); + path_attrs.push(PathAttributeValue::NextHop(nh4).into()); - let out = match self.shape_update(update, shaper_application)? { - ShaperResult::Emit(msg) => msg, - ShaperResult::Drop => return Ok(()), + let update = UpdateMessage { + withdrawn, + nlri, + path_attributes: path_attrs, + }; + + (Afi::Ipv4, update) + } + RouteUpdate::V6 { nlri, withdrawn } => { + session_log!( + self, + debug, + pc.conn, + "building IPv6 update: nlri={}, withdrawn={}", + nlri.len(), + withdrawn.len(); + ); + + // Derive IPv6 next-hop + let nexthop = self.derive_nexthop(Afi::Ipv6, pc)?; + + session_log!( + self, + debug, + pc.conn, + "derived next-hop: {:?}", + nexthop; + ); + + // Build UpdateMessage with MP-BGP attributes + let mut path_attrs = self.router.base_attributes(); + + // Add MP_REACH_NLRI for announcements + if !nlri.is_empty() { + let reach = MpReach::new_v6(nexthop, nlri); + path_attrs + .push(PathAttributeValue::MpReachNlri(reach).into()); + } + + // Add MP_UNREACH_NLRI for withdrawals + if !withdrawn.is_empty() { + let unreach = MpUnreach::new_v6(withdrawn); + path_attrs.push( + PathAttributeValue::MpUnreachNlri(unreach).into(), + ); + } + + let update = UpdateMessage { + withdrawn: vec![], // Traditional fields empty for IPv6 + nlri: vec![], + path_attributes: path_attrs, + }; + + (Afi::Ipv6, update) + } }; - lock!(self.message_history).send(out.clone(), *pc.conn.id()); + session_log!( + self, + debug, + pc.conn, + "built update skeleton: afi={}, traditional_nlri={}, mp_bgp={}", + afi, + !update.nlri.is_empty(), + update.path_attributes.iter().any(|a| matches!( + a.value, + PathAttributeValue::MpReachNlri(_) | PathAttributeValue::MpUnreachNlri(_) + )); + ); + + // 3. Add peer-specific enrichments + self.enrich_update(&mut update, pc)?; + + // 4. Apply export policy filtering + self.apply_export_policy(&mut update)?; + + // Check if update was completely filtered out + let has_content = !update.nlri.is_empty() + || !update.withdrawn.is_empty() + || update.path_attributes.iter().any(|a| { + matches!( + a.value, + PathAttributeValue::MpReachNlri(_) + | PathAttributeValue::MpUnreachNlri(_) + ) + }); + + if !has_content { + session_log!( + self, + debug, + pc.conn, + "update completely filtered by export policy"; + ); + return Ok(()); + } + + // 5. Apply shaper policy + let shaped_update = + match self.shape_update(update, shaper_application)? { + ShaperResult::Emit(msg) => msg, + ShaperResult::Drop => { + session_log!( + self, + debug, + pc.conn, + "update dropped by shaper policy"; + ); + return Ok(()); + } + }; + + // 6. Send the message + self.send_update_message(shaped_update, pc) + } + + /// Send a pre-constructed UPDATE message to peer. + /// This is the LOW-LEVEL send operation extracted from old send_update(). + fn send_update_message( + &self, + update: Message, + pc: &PeerConnection, + ) -> Result<(), Error> { + // Record in message history + lock!(self.message_history).send(update.clone(), *pc.conn.id()); + // Update counters self.counters.updates_sent.fetch_add(1, Ordering::Relaxed); + // Log session_log!( self, info, pc.conn, "sending update"; "message" => "update", - "message_contents" => format!("{out}") + "message_contents" => format!("{update}") ); - if let Err(e) = pc.conn.send(out) { + // Send + if let Err(e) = pc.conn.send(update) { session_log!( self, error, @@ -6390,10 +6825,10 @@ impl SessionRunner { self.counters .update_send_failure .fetch_add(1, Ordering::Relaxed); - Err(e) - } else { - Ok(()) + return Err(e); } + + Ok(()) } fn transition_from_idle(&self) -> FsmState { @@ -6420,7 +6855,12 @@ impl SessionRunner { session_timer!(self, connect_retry).stop(); self.connect_retry_counter.fetch_add(1, Ordering::Relaxed); - write_lock!(self.fanout).remove_egress(self.neighbor.host.ip()); + if pc.ipv4 { + write_lock!(self.fanout4).remove_egress(self.neighbor.host.ip()); + } + if pc.ipv6 { + write_lock!(self.fanout6).remove_egress(self.neighbor.host.ip()); + } // remove peer prefixes from db self.db.remove_bgp_prefixes_from_peer(&pc.conn.peer().ip()); @@ -6620,6 +7060,23 @@ impl SessionRunner { ); return; } + + // Validate MP-BGP attributes before processing + // This now does parsing and validation of MP-BGP attributes from raw bytes + if let Err(e) = self.validate_mp_attributes(&mut update, pc) { + session_log!( + self, + error, + pc.conn, + "MP-BGP attribute validation failed: {e}"; + "error" => format!("{e}"), + "message" => "update" + ); + // Notification already sent by validate_mp_attributes() + // Don't process this UPDATE + return; + } + self.apply_static_update_policy(&mut update); if let Some(checker) = read_lock!(self.router.policy.checker).as_ref() { @@ -6660,7 +7117,9 @@ impl SessionRunner { .map(|x| crate::messages::Prefix::from(*x)) .collect::>(); - update.nlri.retain(|x| message_policy.contains(x)); + update + .nlri + .retain(|x| message_policy.contains(&Prefix::V4(*x))); }; self.update_rib(&update, pc); @@ -6672,8 +7131,318 @@ impl SessionRunner { // self.fanout_update(&update); } - pub fn refresh_react(&self, pc: &PeerConnection) -> Result<(), Error> { - // XXX: Update for IPv6 + /// Validate MP-BGP attributes (MP_REACH_NLRI, MP_UNREACH_NLRI). + /// + /// This implements RFC 4760 Section 7 and RFC 7606 error handling for MP-BGP. + /// + /// ## RFC 4760 Section 7: Error Handling + /// + /// When an UPDATE message with incorrect MP_REACH_NLRI or MP_UNREACH_NLRI + /// is received: + /// 1. Delete all BGP routes from that peer with the same AFI/SAFI + /// 2. Ignore all subsequent routes with that AFI/SAFI for the session duration + /// 3. Optionally terminate the session with NOTIFICATION: + /// - Error Code: UPDATE Message Error + /// - Subcode: Optional Attribute Error + /// + /// ## RFC 7606: Revised Error Handling + /// + /// The approach used depends on the error severity: + /// - **Session reset**: Most severe, terminates BGP session + /// - **AFI/SAFI disable**: Ignore subsequent routes for specific address family + /// - **Treat-as-withdraw**: Process as route withdrawal + /// - **Attribute discard**: Remove attribute, continue processing + /// + /// For MP_REACH_NLRI/MP_UNREACH_NLRI: + /// - Malformed attributes that prevent NLRI location → Session reset or AFI/SAFI disable + /// - Unsupported AFI/SAFI → AFI/SAFI disable (our implementation: discard + log) + /// - Unnegotiated AFI/SAFI → AFI/SAFI disable (our implementation: discard + log) + /// + /// ## Implementation Details + /// + /// This performs semantic validation: + /// 1. Check that AFI/SAFI is supported by this implementation + /// 2. Check that AFI/SAFI was negotiated with this peer + /// + /// Invalid attributes are logged and removed from the update message, effectively + /// implementing an "AFI/SAFI disable" approach by consistently filtering out + /// unsupported/unnegotiated address families. + /// + /// ## Error Handling at Parse Time + /// + /// Note that structural errors (duplicate attributes, malformed wire format) + /// are caught earlier during parsing in `UpdateMessage::from_wire()` and + /// `connection_tcp.rs`, which sends appropriate NOTIFICATION messages per + /// RFC 7606 Section 5.2. + fn validate_mp_attributes( + &self, + update: &mut UpdateMessage, + pc: &PeerConnection, + ) -> Result<(), Error> { + use crate::messages::{ + Afi, BgpNexthop, PathAttributeValue, Safi, UpdateMessage as UM, + }; + use rdb::types::AddressFamily; + + // We'll rebuild the attributes list with validated/parsed MP-BGP attributes + let mut validated_attributes = Vec::new(); + let mut had_error = false; + + for attr in &update.path_attributes { + match &attr.value { + PathAttributeValue::MpReachNlri(mp_reach) => { + // Step 1: Validate AFI/SAFI support + let afi = match Afi::try_from(mp_reach.afi) { + Ok(afi) => afi, + Err(_) => { + session_log!( + self, + error, + pc.conn, + "Unsupported AFI {} in MP_REACH_NLRI", + mp_reach.afi; + ); + + // Send notification for unsupported AFI/SAFI + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::OptionalAttribute, + ), + ); + + had_error = true; + continue; // Skip this attribute + } + }; + + let safi = match Safi::try_from(mp_reach.safi) { + Ok(safi) => safi, + Err(_) => { + session_log!( + self, + error, + pc.conn, + "Unsupported SAFI {} in MP_REACH_NLRI", + mp_reach.safi; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::OptionalAttribute, + ), + ); + + had_error = true; + continue; + } + }; + + // Step 2: Check if AFI/SAFI was negotiated + let negotiated = match (afi, safi) { + (Afi::Ipv4, Safi::Unicast) => pc.ipv4, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6, + }; + + if !negotiated { + session_log!( + self, + warn, + pc.conn, + "MP_REACH_NLRI for unnegotiated AFI/SAFI: {}/{}", + afi, safi; + ); + + // Don't send notification - just filter silently per existing pattern + continue; + } + + // Step 3: Parse next-hop with validated AFI + let _nexthop = match BgpNexthop::from_bytes( + &mp_reach.nh_bytes, + mp_reach.nh_len, + afi, + ) { + Ok(nh) => nh, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "Invalid next-hop in MP_REACH_NLRI: {e}"; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::OptionalAttribute, + ), + ); + + had_error = true; + continue; + } + }; + + // Step 4: Parse NLRI with validated AFI + let address_family: AddressFamily = afi.into(); + let _nlri = match UM::prefixes_from_wire( + &mp_reach.nlri_bytes, + address_family, + ) { + Ok(prefixes) => prefixes, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "Invalid NLRI in MP_REACH_NLRI: {e}"; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + ); + + had_error = true; + continue; + } + }; + + // Note: For now, we keep the attribute with raw bytes + // Future enhancement: store parsed values in a new struct variant + validated_attributes.push(attr.clone()); + } + + PathAttributeValue::MpUnreachNlri(mp_unreach) => { + // Similar validation for MP_UNREACH_NLRI + let afi = match Afi::try_from(mp_unreach.afi_raw) { + Ok(afi) => afi, + Err(_) => { + session_log!( + self, + error, + pc.conn, + "Unsupported AFI {} in MP_UNREACH_NLRI", + mp_unreach.afi_raw; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::OptionalAttribute, + ), + ); + + had_error = true; + continue; + } + }; + + let safi = match Safi::try_from(mp_unreach.safi_raw) { + Ok(safi) => safi, + Err(_) => { + session_log!( + self, + error, + pc.conn, + "Unsupported SAFI {} in MP_UNREACH_NLRI", + mp_unreach.safi_raw; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::OptionalAttribute, + ), + ); + + had_error = true; + continue; + } + }; + + // Check if AFI/SAFI was negotiated + let negotiated = match (afi, safi) { + (Afi::Ipv4, Safi::Unicast) => pc.ipv4, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6, + }; + + if !negotiated { + session_log!( + self, + warn, + pc.conn, + "MP_UNREACH_NLRI for unnegotiated AFI/SAFI: {}/{}", + afi, safi; + ); + + // Don't send notification - just filter silently + continue; + } + + // Parse withdrawn routes with validated AFI + let address_family: AddressFamily = afi.into(); + let _withdrawn = match UM::prefixes_from_wire( + &mp_unreach.withdrawn_bytes, + address_family, + ) { + Ok(prefixes) => prefixes, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "Invalid withdrawn routes in MP_UNREACH_NLRI: {e}"; + ); + + self.send_notification( + &pc.conn, + ErrorCode::Update, + ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + ); + + had_error = true; + continue; + } + }; + + // Keep the attribute with raw bytes + validated_attributes.push(attr.clone()); + } + + _ => { + // Keep all other attributes as-is + validated_attributes.push(attr.clone()); + } + } + } + + if had_error { + return Err(Error::MalformedAttributeList( + "MP-BGP attribute validation failed".into(), + )); + } + + update.path_attributes = validated_attributes; + Ok(()) + } + + pub fn refresh_react4( + &self, + pc: &PeerConnection, + ) -> Result<(), Error> { let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -6681,22 +7450,55 @@ impl SessionRunner { self, error, pc.conn, - "failed to get originated routes from db"; + "failed to get originated IPv4 routes from db"; "error" => format!("{e}") ); // This is not a protocol level issue return Ok(()); } }; + if !originated.is_empty() { - let mut update = UpdateMessage { - path_attributes: self.router.base_attributes(), - ..Default::default() - }; - for p in originated { - update.nlri.push(p.into()); + self.send_update( + RouteUpdate::V4 { + nlri: originated, + withdrawn: vec![], + }, + pc, + ShaperApplication::Current, + )?; + } + Ok(()) + } + + pub fn refresh_react6( + &self, + pc: &PeerConnection, + ) -> Result<(), Error> { + let originated = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated IPv6 routes from db"; + "error" => format!("{e}") + ); + // This is not a protocol level issue + return Ok(()); } - self.send_update(update, pc, ShaperApplication::Current)?; + }; + + if !originated.is_empty() { + self.send_update( + RouteUpdate::V6 { + nlri: originated, + withdrawn: vec![], + }, + pc, + ShaperApplication::Current, + )?; } Ok(()) } @@ -6706,37 +7508,82 @@ impl SessionRunner { msg: RouteRefreshMessage, pc: &PeerConnection, ) -> Result<(), Error> { - // XXX: Update for IPv6 - if msg.afi != Afi::Ipv4 as u16 { - return Ok(()); + if msg.safi != Safi::Unicast as u8 { + return Err(Error::UnsupportedAddressFamily(msg.afi, msg.safi)); + } + + let af = match Afi::try_from(msg.afi) { + Ok(afi) => afi, + Err(_) => { + return Err(Error::UnsupportedAddressFamily(msg.afi, msg.safi)); + } + }; + + match af { + Afi::Ipv4 => self.refresh_react4(pc), + Afi::Ipv6 => self.refresh_react6(pc), } - self.refresh_react(pc) } /// Update this router's RIB based on an update message from a peer. fn update_rib(&self, update: &UpdateMessage, pc: &PeerConnection) { - let originated = match self.db.get_origin4() { + let originated4 = match self.db.get_origin4() { Ok(value) => value, Err(e) => { session_log!( self, error, pc.conn, - "failed to get originated routes from db"; + "failed to get originated ipv4 routes from db"; "error" => format!("{e}") ); Vec::new() } }; + let _originated6 = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv6 routes from db"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + + let nexthop = match update.nexthop() { + Ok(nh) => match nh { + BgpNexthop::Ipv4(ip4) => IpAddr::V4(ip4), + BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(ip6), + BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(gua), + }, + Err(e) => { + session_log!( + self, + warn, + pc.conn, + "error parsing nexthop from update: {e}"; + "error" => format!("{e}"), + "message" => "update", + "message_contents" => format!("{update}").as_str() + ); + self.counters + .update_nexhop_missing + .fetch_add(1, Ordering::Relaxed); + return; + } + }; + let withdrawn: Vec = update .withdrawn .iter() - .filter(|p| match p { - Prefix::V4(p4) => !originated.contains(p4), - Prefix::V6(_p6) => false, // XXX: update for v6 - } && p.valid_for_rib()) + .filter(|p| !originated4.contains(p) && p.valid_for_rib()) .copied() + .map(Prefix::V4) .collect(); self.db @@ -6745,63 +7592,16 @@ impl SessionRunner { let nlri: Vec = update .nlri .iter() - .filter(|p| match p { - Prefix::V4(p4) => !originated.contains(p4), - Prefix::V6(_p6) => false, // XXX: update for v6 - } && p.valid_for_rib()) + .filter(|p| { + !originated4.contains(p) + && p.valid_for_rib() + && !self.prefix_via_self(Prefix::V4(**p), nexthop) + }) .copied() + .map(Prefix::V4) .collect(); if !nlri.is_empty() { - // TODO: parse and prefer nexthop in MP_REACH_NLRI - // - // Per RFC 4760: - // """ - // The next hop information carried in the MP_REACH_NLRI path attribute - // defines the Network Layer address of the router that SHOULD be used - // as the next hop to the destinations listed in the MP_NLRI attribute - // in the UPDATE message. - // - // [..] - // - // An UPDATE message that carries no NLRI, other than the one encoded in - // the MP_REACH_NLRI attribute, SHOULD NOT carry the NEXT_HOP attribute. - // If such a message contains the NEXT_HOP attribute, the BGP speaker - // that receives the message SHOULD ignore this attribute. - // """ - // - // i.e. - // 1) NEXT_HOP SHOULD NOT be sent unless there are no MP_REACH_NLRI - // 2) NEXT_HOP SHOULD be ignored unless there are no MP_REACH_NLRI - // - // The standards do not state whether an implementation can/should send - // IPv4 Unicast prefixes embedded in an MP_REACH_NLRI attribute or in the - // classic NLRI field of an Update message. If we participate in MP-BGP - // and negotiate IPv4 Unicast, it's entirely likely that we'll peer with - // other BGP speakers falling into any of the combinations: - // a) MP not negotiated, IPv4 Unicast in NLRI, NEXT_HOP included - // b) MP negotiated, IPv4 Unicast in NLRI, NEXT_HOP included - // c) MP negotiated, IPv4 Unicast in NLRI, NEXT_HOP not included - // d) MP negotiated, IPv4 Unicast in MP_REACH_NLRI, NEXT_HOP included - // e) MP negotiated, IPv4 Unicast in MP_REACH_NLRI, NEXT_HOP not included - let nexthop = match update.nexthop4() { - Some(nh) => nh, - None => { - session_log!( - self, - warn, - pc.conn, - "recieved update with nlri, but no nexthop"; - "message" => "update", - "message_contents" => format!("{update}").as_str() - ); - self.counters - .update_nexhop_missing - .fetch_add(1, Ordering::Relaxed); - return; - } - }; - let mut as_path = Vec::new(); if let Some(segments_list) = update.as_path() { for segments in &segments_list { @@ -6809,7 +7609,7 @@ impl SessionRunner { } } let path = rdb::Path { - nexthop: nexthop.into(), + nexthop, shutdown: update.graceful_shutdown(), rib_priority: DEFAULT_RIB_PRIORITY_BGP, bgp: Some(BgpPathProperties { @@ -6836,8 +7636,13 @@ impl SessionRunner { update: &UpdateMessage, peer_as: u32, ) -> Result<(), Error> { + // RFC 7606 Section 3(g): Reject duplicate MP-BGP attributes + update.check_duplicate_mp_attributes()?; + + // Path vector routing and prefix validation self.check_for_self_in_path(update)?; - self.check_nexthop_self(update)?; + + // Optional enforce-first-AS validation let info = lock!(self.session); if info.enforce_first_as { self.enforce_first_as(update, peer_as)?; @@ -6879,25 +7684,19 @@ impl SessionRunner { Ok(()) } - fn check_nexthop_self(&self, update: &UpdateMessage) -> Result<(), Error> { - // nothing to check when no prefixes presnt, and nexthop not required - // for pure withdraw - if update.nlri.is_empty() { - return Ok(()); - } - let nexthop = match update.nexthop4() { - Some(nh) => nh, - None => return Err(Error::MissingNexthop), - }; - for prefix in &update.nlri { - if let rdb::Prefix::V4(p4) = prefix - && p4.length == 32 - && p4.value == nexthop - { - return Err(Error::NexthopSelf(p4.value.into())); + /// Do not accept routes advertised with themselves as the next-hop. + /// e.g. + /// Do not allow 2001::1/32 with a nexthop of 2001::1 + fn prefix_via_self(&self, prefix: Prefix, nexthop: IpAddr) -> bool { + match (prefix, nexthop) { + (Prefix::V4(p4), IpAddr::V4(ip4)) => { + p4.length == Prefix4::HOST_MASK && p4.value == ip4 } + (Prefix::V6(p6), IpAddr::V6(ip6)) => { + p6.length == Prefix6::HOST_MASK && p6.value == ip6 + } + _ => false, } - Ok(()) } fn enforce_first_as( @@ -6921,15 +7720,6 @@ impl SessionRunner { Ok(()) } - // NOTE: for now we are only acting as an edge router. This means we - // do not redistribute announcements. So for now this function - // is unused. However, this may change in the future. - #[allow(dead_code)] - fn fanout_update(&self, update: &UpdateMessage) { - let fanout = read_lock!(self.fanout); - fanout.send(self.neighbor.host.ip(), update); - } - /// Return the current BGP peer state of this session runner. pub fn state(&self) -> FsmStateKind { *lock!(self.state) @@ -7347,4 +8137,77 @@ mod tests { assert_eq!(record.previous_state, Some(FsmStateKind::Idle)); assert!(record.details.is_some()); } + + // ========================================================================= + // RouteUpdate tests + // ========================================================================= + + #[test] + fn route_update_is_empty() { + // Empty V4 + let empty_v4 = RouteUpdate::V4 { + nlri: vec![], + withdrawn: vec![], + }; + assert!(empty_v4.is_empty()); + + // Empty V6 + let empty_v6 = RouteUpdate::V6 { + nlri: vec![], + withdrawn: vec![], + }; + assert!(empty_v6.is_empty()); + + // Non-empty V4 with NLRI + let non_empty_v4 = RouteUpdate::V4 { + nlri: vec![Prefix4::new(std::net::Ipv4Addr::new(10, 0, 0, 0), 8)], + withdrawn: vec![], + }; + assert!(!non_empty_v4.is_empty()); + + // Non-empty V6 with withdrawn + let non_empty_v6 = RouteUpdate::V6 { + nlri: vec![], + withdrawn: vec![Prefix6::new( + std::net::Ipv6Addr::from([ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + 32, + )], + }; + assert!(!non_empty_v6.is_empty()); + } + + #[test] + fn route_update_display_v4() { + let update = RouteUpdate::V4 { + nlri: vec![Prefix4::new(std::net::Ipv4Addr::new(10, 0, 0, 0), 8)], + withdrawn: vec![Prefix4::new( + std::net::Ipv4Addr::new(192, 168, 0, 0), + 16, + )], + }; + let display = format!("{}", update); + // Uses Debug format {:?} for prefix contents, which shows struct fields + assert!(display.contains("RouteUpdate::V4")); + assert!(display.contains("10.0.0.0")); + assert!(display.contains("192.168.0.0")); + } + + #[test] + fn route_update_display_v6() { + let update = RouteUpdate::V6 { + nlri: vec![Prefix6::new( + std::net::Ipv6Addr::from([ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + 32, + )], + withdrawn: vec![], + }; + let display = format!("{}", update); + // Uses Debug format {:?} for prefix contents + assert!(display.contains("RouteUpdate::V6")); + assert!(display.contains("2001:db8")); + } } diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index 653bb1ac..df36eaf9 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -1064,6 +1064,8 @@ pub(crate) mod helpers { idle_hold_jitter: Some((0.75, 1.0)), connect_retry_jitter: None, deterministic_collision_resolution: false, + ipv4_enabled: true, + ipv6_enabled: false, }; let start_session = if ensure { diff --git a/rdb-types/src/lib.rs b/rdb-types/src/lib.rs index b3ddeec1..174afa8b 100644 --- a/rdb-types/src/lib.rs +++ b/rdb-types/src/lib.rs @@ -41,7 +41,7 @@ impl Ord for Prefix4 { } impl Prefix4 { - const HOST_MASK: u8 = 32; + pub const HOST_MASK: u8 = 32; /// Create a new `Prefix4` from an IP address and net mask. /// The newly created `Prefix4` will have its host bits zeroed upon creation @@ -62,7 +62,7 @@ impl Prefix4 { pub fn host_bits_are_unset(&self) -> bool { let mask = match self.length { 0 => 0, - _ => (!0u32) << (32 - self.length), + _ => u32::MAX << (Self::HOST_MASK - self.length), }; self.value.to_bits() & mask == self.value.to_bits() @@ -71,7 +71,7 @@ impl Prefix4 { pub fn unset_host_bits(&mut self) { let mask = match self.length { 0 => 0, - _ => (!0u32) << (32 - self.length), + _ => u32::MAX << (Self::HOST_MASK - self.length), }; self.value = Ipv4Addr::from_bits(self.value.to_bits() & mask) @@ -91,8 +91,11 @@ impl Prefix4 { } // Create masks for comparison - let shift_amount = 32 - other.length; - let mask = !0u32 << shift_amount; + let shift_amount = Self::HOST_MASK - other.length; + if shift_amount >= Self::HOST_MASK { + return false; // Invalid case + } + let mask = u32::MAX << shift_amount; let self_masked = self.value.to_bits() & mask; let other_masked = other.value.to_bits() & mask; @@ -166,7 +169,7 @@ impl fmt::Display for Prefix6 { } impl Prefix6 { - const HOST_MASK: u8 = 128; + pub const HOST_MASK: u8 = 128; /// Create a new `Prefix6` from an IP address and net mask. /// The newly created `Prefix6` will have its host bits zeroed upon creation @@ -187,7 +190,7 @@ impl Prefix6 { pub fn host_bits_are_unset(&self) -> bool { let mask = match self.length { 0 => 0, - _ => (!0u128) << (128 - self.length), + _ => u128::MAX << (Self::HOST_MASK - self.length), }; self.value.to_bits() & mask == self.value.to_bits() @@ -196,7 +199,7 @@ impl Prefix6 { pub fn unset_host_bits(&mut self) { let mask = match self.length { 0 => 0, - _ => (!0u128) << (128 - self.length), + _ => u128::MAX << (Self::HOST_MASK - self.length), }; self.value = Ipv6Addr::from_bits(self.value.to_bits() & mask) @@ -216,11 +219,11 @@ impl Prefix6 { } // Create masks for comparison - let shift_amount = 128 - other.length; - if shift_amount >= 128 { + let shift_amount = Self::HOST_MASK - other.length; + if shift_amount >= Self::HOST_MASK { return false; // Invalid case } - let mask = !0u128 << shift_amount; + let mask = u128::MAX << shift_amount; let self_masked = self.value.to_bits() & mask; let other_masked = other.value.to_bits() & mask; @@ -438,3 +441,4 @@ pub enum ProtocolFilter { /// Static routes only Static, } + diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 8fc19b9d..9feed3e5 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -18,6 +18,32 @@ use std::str::FromStr; // Re-export core types from rdb-types pub use rdb_types::{AddressFamily, Prefix, Prefix4, Prefix6, ProtocolFilter}; +// Marker types for compile-time address family discrimination. +// +// These zero-sized types enable type-level enforcement of IPv4/IPv6 +// separation in generic data structures. Used in conjunction with +// PhantomData for compile-time type safety with no runtime overhead. +// +// Example: +// ``` +// struct TypedContainer { +// data: Vec, +// _af: PhantomData, +// } +// +// // These are different types at compile time +// type Ipv4Container = TypedContainer; +// type Ipv6Container = TypedContainer; +// ``` + +/// IPv4 address family marker (zero-sized type) +#[derive(Clone, Copy, Debug)] +pub struct Ipv4Marker; + +/// IPv6 address family marker (zero-sized type) +#[derive(Clone, Copy, Debug)] +pub struct Ipv6Marker; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] pub struct Path { pub nexthop: IpAddr, From 7d1715d66e472c4d82eb5b52707eb390d88b7992 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Fri, 5 Dec 2025 09:15:37 -0700 Subject: [PATCH 02/20] bgp: more MP-BGP fill-in and parse error framework Continue MP-BGP (RFC 4760) implementation with MpReachNlri and MpUnreachNlri path attribute types. Add comprehensive framework for parse error handling for all BGP message types, with revised UPDATE error handling per RFC 7606. Message layer changes: - Rewrite MpReachNlri/MpUnreachNlri path attribute types to use parsed types in the struct, moving structural parsing into connection layer and treating unsupported AFI/SAFIs as parsing errors (if we don't support it, then we didn't advertise it, and you sending it to us is an error). - Introduce MessageParseError enum covering all message types (Open, Update, Notification, RouteRefresh) with structured error reasons - Add UpdateParseError with RFC 7606 error actions and reason types preserving section context (withdrawn/attributes/nlri) - Add OpenParseError, NotificationParseError, RouteRefreshParseError for non-UPDATE message failures - Implement mandatory attribute validation (ORIGIN, AS_PATH, NEXT_HOP) with treat-as-withdraw semantics per RFC 7606 - Track parse errors in UpdateMessage for session-layer processing Session layer changes: - Add AfiSafiState enum to track capability negotiation outcome per address family (Unconfigured/Advertised/Negotiated) - Add StopReason::ParseError variant carrying error codes for proper NOTIFICATION message generation - Handle ConnectionEvent::ParseError in all FSM states - Add TcpConnectionFails event for recv loop IO errors Connection layer changes: - Distinguish parse errors from IO errors via RecvError enum - Send ParseError events to FSM for all message types instead of converting to generic IO errors, preserving error codes for NOTIFICATION generation Testing: - Add unit tests for attribute error action selection per RFC 7606 - Add unit tests for attribute flag validation - Add unit tests for error collection during UPDATE parsing - Add unit tests for mandatory attribute validation - Update proptest strategies for new MpReachNlri/MpUnreachNlri types --- bgp/src/connection_channel.rs | 25 + bgp/src/connection_tcp.rs | 197 +- bgp/src/messages.rs | 3701 +++++++++++++++++++++++++++------ bgp/src/params.rs | 2 +- bgp/src/proptest.rs | 188 +- bgp/src/router.rs | 24 +- bgp/src/session.rs | 1162 +++++++---- rdb-types/src/lib.rs | 1 - 8 files changed, 4097 insertions(+), 1203 deletions(-) diff --git a/bgp/src/connection_channel.rs b/bgp/src/connection_channel.rs index 41bc0c24..e0370ffc 100644 --- a/bgp/src/connection_channel.rs +++ b/bgp/src/connection_channel.rs @@ -374,6 +374,15 @@ impl BgpConnectionChannel { break; } + // Note: Unlike BgpConnectionTcp, this has no ParseErrors. + // BgpConnectionChannel is a wrapper around Message, + // which is the type representation of a fully parsed + // and valid message. This means it's not possible to + // exchange invalid messages as-is. To support this, + // the channel would need to wrap a different type + // (feasible, but of limited utility) or update the + // Message type to include possibly-invalid states + // (also feasible, but undesirable). match rx.recv_timeout(timeout) { Ok(msg) => { connection_log_lite!(log, @@ -414,6 +423,22 @@ impl BgpConnectionChannel { "connection_id" => conn_id.short(), "channel_id" => channel_id ); + // Notify session runner that the connection failed, + // unless this is a graceful shutdown + if !dropped.load(Ordering::Relaxed) + && let Err(e) = event_tx.send(FsmEvent::Connection( + ConnectionEvent::TcpConnectionFails(conn_id), + )) + { + connection_log_lite!(log, warn, + "error sending TcpConnectionFails event to {peer}: {e}"; + "direction" => direction.as_str(), + "peer" => format!("{peer}"), + "connection_id" => conn_id.short(), + "channel_id" => channel_id, + "error" => format!("{e}") + ); + } break; } } diff --git a/bgp/src/connection_tcp.rs b/bgp/src/connection_tcp.rs index 7ab5fdc3..18b86c71 100644 --- a/bgp/src/connection_tcp.rs +++ b/bgp/src/connection_tcp.rs @@ -12,8 +12,11 @@ use crate::{ error::Error, log::{connection_log, connection_log_lite}, messages::{ - ErrorCode, ErrorSubcode, Header, Message, MessageType, - NotificationMessage, OpenMessage, RouteRefreshMessage, UpdateMessage, + ErrorCode, ErrorSubcode, Header, Message, MessageParseError, + MessageType, NotificationMessage, NotificationParseError, + NotificationParseErrorReason, OpenErrorSubcode, OpenMessage, + OpenParseError, OpenParseErrorReason, RouteRefreshMessage, + RouteRefreshParseError, RouteRefreshParseErrorReason, UpdateMessage, }, session::{ ConnectionEvent, FsmEvent, SessionEndpoint, SessionEvent, SessionInfo, @@ -59,6 +62,15 @@ const PFKEY_DURATION: Duration = Duration::from_secs(60 * 2); #[cfg(target_os = "illumos")] const PFKEY_KEEPALIVE: Duration = Duration::from_secs(60); +/// Error type for recv_msg operations. +/// Distinguishes between IO errors (connection issues) and parse errors (bad messages). +enum RecvError { + /// IO error (connection closed, timeout, etc.) - recv loop should break + Io(std::io::Error), + /// Parse error (malformed message) - should send ParseError event to FSM + Parse(MessageParseError), +} + pub struct BgpListenerTcp { listener: TcpListener, } @@ -609,18 +621,63 @@ impl BgpConnectionTcp { break; } } - Err(e) => { - connection_log_lite!(l, info, - "recv_msg error (peer: {peer}, conn_id: {}): {e}", - conn_id.short(); - "direction" => direction, - "connection" => format!("{conn:?}"), - "connection_peer" => format!("{peer}"), - "connection_id" => conn_id.short(), - "error" => format!("{e}") - ); - // Break the loop on connection errors to prevent zombie threads - // that continue trying to read from closed connections + Err(recv_err) => { + match recv_err { + RecvError::Io(e) => { + connection_log_lite!(l, info, + "recv_msg IO error (peer: {peer}, conn_id: {}): {e}", + conn_id.short(); + "direction" => direction, + "connection" => format!("{conn:?}"), + "connection_peer" => format!("{peer}"), + "connection_id" => conn_id.short(), + "error" => format!("{e}") + ); + // Notify session runner that the TCP + // connection failed. Skip if a shutdown + // signal arrived while trying to get a + // new message. + if !dropped.load(Ordering::Relaxed) + && let Err(e) = event_tx.send(FsmEvent::Connection( + ConnectionEvent::TcpConnectionFails(conn_id), + )) + { + connection_log_lite!(l, warn, + "error sending TcpConnectionFails event to {peer}: {e}"; + "direction" => direction, + "connection" => format!("{conn:?}"), + "connection_peer" => format!("{peer}"), + "connection_id" => conn_id.short(), + "error" => format!("{e}") + ); + } + } + RecvError::Parse(parse_err) => { + connection_log_lite!(l, error, + "recv_msg parse error (peer: {peer}, conn_id: {}): {parse_err}", + conn_id.short(); + "direction" => direction, + "connection" => format!("{conn:?}"), + "connection_peer" => format!("{peer}"), + "connection_id" => conn_id.short(), + "error" => format!("{parse_err}") + ); + // Notify FSM about fatal + // (notification-worthy) parse errors. + if let Err(e) = event_tx.send(FsmEvent::Connection( + ConnectionEvent::ParseError { conn_id, error: parse_err }, + )) { + connection_log_lite!(l, warn, + "error sending parse error event to {peer}: {e}"; + "direction" => direction, + "connection" => format!("{conn:?}"), + "connection_peer" => format!("{peer}"), + "connection_id" => conn_id.short(), + "error" => format!("{e}") + ); + } + } + } break; } } @@ -685,12 +742,12 @@ impl BgpConnectionTcp { dropped: Arc, log: &Logger, direction: ConnectionDirection, - ) -> std::io::Result { - use crate::messages::OpenErrorSubcode; - let hdr = Self::recv_header(stream, dropped.clone())?; + ) -> Result { + let hdr = Self::recv_header(stream, dropped.clone()) + .map_err(RecvError::Io)?; let mut msgbuf = vec![0u8; usize::from(hdr.length) - Header::WIRE_SIZE]; - stream.read_exact(&mut msgbuf)?; + stream.read_exact(&mut msgbuf).map_err(RecvError::Io)?; let msg = match hdr.typ { MessageType::Open => match OpenMessage::from_wire(&msgbuf) { @@ -698,20 +755,38 @@ impl BgpConnectionTcp { Err(e) => { connection_log_lite!(log, error, - "open message error: {e}"; + "OPEN parse error: {e}"; "direction" => direction, "connection" => format!("{stream:?}"), "error" => format!("{e}") ); - let subcode = match e { - Error::UnsupportedCapability(_) => { - OpenErrorSubcode::UnsupportedCapability - } - _ => OpenErrorSubcode::Unspecific, + let (subcode, reason) = match &e { + Error::UnsupportedCapability(cap) => ( + OpenErrorSubcode::UnsupportedCapability, + OpenParseErrorReason::Other { + detail: format!( + "unsupported capability: {:?}", + cap + ), + }, + ), + Error::UnsupportedCapabilityCode(code) => ( + OpenErrorSubcode::UnsupportedCapability, + OpenParseErrorReason::UnsupportedCapability { + code: *code as u8, + }, + ), + _ => ( + OpenErrorSubcode::Unspecific, + OpenParseErrorReason::Other { + detail: e.to_string(), + }, + ), }; - if let Err(e) = Self::send_notification( + // Still send NOTIFICATION for OPEN errors (required by RFC) + if let Err(notify_err) = Self::send_notification( stream, log, direction, @@ -721,28 +796,59 @@ impl BgpConnectionTcp { ) { connection_log_lite!(log, error, - "error sending notification: {e}"; + "error sending notification: {notify_err}"; "direction" => direction, "connection" => format!("{stream:?}"), - "error" => format!("{e}") + "error" => format!("{notify_err}") ); } - return Err(std::io::Error::other("open message error")); + + return Err(RecvError::Parse(MessageParseError::Open( + OpenParseError { + error_code: ErrorCode::Open, + error_subcode: ErrorSubcode::Open(subcode), + reason, + }, + ))); } }, MessageType::Update => match UpdateMessage::from_wire(&msgbuf) { Ok(m) => m.into(), - Err(_) => { - return Err(std::io::Error::other("update message error")); + Err(update_err) => { + connection_log_lite!(log, + error, + "UPDATE parse error: {}", update_err; + "direction" => direction, + "connection" => format!("{stream:?}"), + "error" => format!("{update_err}") + ); + return Err(RecvError::Parse(MessageParseError::Update( + update_err, + ))); } }, MessageType::Notification => { match NotificationMessage::from_wire(&msgbuf) { Ok(m) => m.into(), - Err(_) => { - return Err(std::io::Error::other( - "notification message error", - )); + Err(e) => { + connection_log_lite!(log, + error, + "NOTIFICATION parse error: {e}"; + "direction" => direction, + "connection" => format!("{stream:?}"), + "error" => format!("{e}") + ); + return Err(RecvError::Parse(MessageParseError::Notification( + NotificationParseError { + error_code: ErrorCode::Header, + error_subcode: ErrorSubcode::Header( + crate::messages::HeaderErrorSubcode::BadMessageType, + ), + reason: NotificationParseErrorReason::Other { + detail: e.to_string(), + }, + }, + ))); } } } @@ -750,10 +856,25 @@ impl BgpConnectionTcp { MessageType::RouteRefresh => { match RouteRefreshMessage::from_wire(&msgbuf) { Ok(m) => m.into(), - Err(_) => { - return Err(std::io::Error::other( - "route refresh message error", - )); + Err(e) => { + connection_log_lite!(log, + error, + "ROUTE_REFRESH parse error: {e}"; + "direction" => direction, + "connection" => format!("{stream:?}"), + "error" => format!("{e}") + ); + return Err(RecvError::Parse(MessageParseError::RouteRefresh( + RouteRefreshParseError { + error_code: ErrorCode::Header, + error_subcode: ErrorSubcode::Header( + crate::messages::HeaderErrorSubcode::BadMessageType, + ), + reason: RouteRefreshParseErrorReason::Other { + detail: e.to_string(), + }, + }, + ))); } } } diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index e96e6b41..ced1c993 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -40,8 +40,47 @@ pub trait BgpWireFormat: Sized { fn from_wire(input: &[u8]) -> Result<(&[u8], T), Self::Error>; } +/// Errors from parsing NLRI prefixes. +/// +/// These preserve the specific failure reason so callers can convert +/// to `UpdateParseErrorReason` with the appropriate section context. +#[derive(Debug, Clone)] +pub enum PrefixParseError { + /// No data available for prefix length byte + MissingLength, + /// Prefix length exceeds maximum for address family + InvalidMask { length: u8, max: u8 }, + /// Not enough bytes for declared prefix length + Truncated { needed: usize, available: usize }, +} + +impl PrefixParseError { + /// Convert to UpdateParseErrorReason with section context. + pub fn into_reason(self, section: &'static str) -> UpdateParseErrorReason { + match self { + Self::MissingLength => { + UpdateParseErrorReason::NlriMissingLength { section } + } + Self::InvalidMask { length, max } => { + UpdateParseErrorReason::InvalidNlriMask { + section, + length, + max, + } + } + Self::Truncated { needed, available } => { + UpdateParseErrorReason::TruncatedNlri { + section, + needed, + available, + } + } + } + } +} + impl BgpWireFormat for Prefix4 { - type Error = Error; + type Error = PrefixParseError; fn to_wire(&self) -> Vec { let mut buf = vec![self.length]; @@ -52,26 +91,25 @@ impl BgpWireFormat for Prefix4 { fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Self::Error> { if input.is_empty() { - return Err(Error::TooSmall("prefix length byte missing".into())); + return Err(PrefixParseError::MissingLength); } let len = input[0]; // Validate length bound for IPv4 (structural validation) - if len > 32 { - return Err(Error::TooLarge(format!( - "invalid IPv4 prefix length {} > 32", - len - ))); + if len > Prefix4::HOST_MASK { + return Err(PrefixParseError::InvalidMask { + length: len, + max: Prefix4::HOST_MASK, + }); } let byte_count = (len as usize).div_ceil(8); if input.len() < 1 + byte_count { - return Err(Error::TooSmall(format!( - "prefix data too short: need {} bytes, have {}", - 1 + byte_count, - input.len() - ))); + return Err(PrefixParseError::Truncated { + needed: 1 + byte_count, + available: input.len(), + }); } let mut bytes = [0u8; 4]; @@ -106,7 +144,7 @@ impl BgpWireFormat for Prefix4 { } impl BgpWireFormat for Prefix6 { - type Error = Error; + type Error = PrefixParseError; fn to_wire(&self) -> Vec { let mut buf = vec![self.length]; @@ -115,28 +153,27 @@ impl BgpWireFormat for Prefix6 { buf } - fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Error> { + fn from_wire(input: &[u8]) -> Result<(&[u8], Self), PrefixParseError> { if input.is_empty() { - return Err(Error::TooSmall("prefix length byte missing".into())); + return Err(PrefixParseError::MissingLength); } let len = input[0]; // Validate length bound for IPv6 (structural validation) - if len > 128 { - return Err(Error::TooLarge(format!( - "invalid IPv6 prefix length {} > 128", - len - ))); + if len > Prefix6::HOST_MASK { + return Err(PrefixParseError::InvalidMask { + length: len, + max: Prefix6::HOST_MASK, + }); } let byte_count = (len as usize).div_ceil(8); if input.len() < 1 + byte_count { - return Err(Error::TooSmall(format!( - "prefix data too short: need {} bytes, have {}", - 1 + byte_count, - input.len() - ))); + return Err(PrefixParseError::Truncated { + needed: 1 + byte_count, + available: input.len(), + }); } let mut bytes = [0u8; 16]; @@ -240,13 +277,13 @@ impl Message { } } - pub fn title(&self) -> &str { + pub fn title(&self) -> &'static str { match self { - Message::Open(_) => "open", - Message::Update(_) => "update", - Message::Notification(_) => "notification", - Message::KeepAlive => "keepalive", - Message::RouteRefresh(_) => "route refresh", + Message::Open(_) => "open message", + Message::Update(_) => "update message", + Message::Notification(_) => "notification message", + Message::KeepAlive => "keepalive message", + Message::RouteRefresh(_) => "route refresh message", } } @@ -710,8 +747,21 @@ pub struct Tlv { )] pub struct UpdateMessage { pub withdrawn: Vec, - pub path_attributes: Vec, + pub path_attributes: Vec, // XXX: use map for O(1) lookups? pub nlri: Vec, + + /// True if a TreatAsWithdraw error occurred during from_wire(). + /// When true, session should process all NLRI (v4 + v6) as withdrawals. + /// Not serialized - only used for internal signaling. + #[serde(skip)] + pub treat_as_withdraw: bool, + + /// All attribute parse errors encountered during from_wire(). + /// Includes both TreatAsWithdraw and Discard errors. + /// SessionReset errors cause early return and are not collected here. + /// Not serialized - only used for internal signaling. + #[serde(skip)] + pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, } impl UpdateMessage { @@ -827,64 +877,175 @@ impl UpdateMessage { Ok(buf) } - pub fn from_wire(input: &[u8]) -> Result { - let (input, len) = be_u16(input)?; - let (input, withdrawn_input) = take(len)(input)?; - let withdrawn = Self::prefixes4_from_wire(withdrawn_input)?; + /// Parse UPDATE message with RFC 7606 error tracking. + /// + /// Parses sequentially: withdrawn → attributes → nlri. + /// If an attribute error occurs, continues to parse NLRI so it can be + /// withdrawn per RFC 7606 "treat-as-withdraw" semantics. + /// + /// Returns `Ok(UpdateMessage)` on success (possibly with `treat_as_withdraw` + /// field set), or `Err(UpdateParseError)` for fatal errors requiring session + /// reset. + pub fn from_wire(input: &[u8]) -> Result { + // 1. Parse withdrawn routes length and extract bytes + let (input, len) = be_u16::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::Other { + detail: format!("failed to parse withdrawn length: {e}"), + }, + })?; + let (input, withdrawn_input) = take(len)(input).map_err( + |_e: nom::Err>| UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::InvalidWithdrawnLength { + declared: len, + available: input.len(), + }, + }, + )?; + + // 2. Parse withdrawn prefixes (SessionReset on failure) + let withdrawn = match Self::prefixes4_from_wire(withdrawn_input) { + Ok(w) => w, + Err(e) => { + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::InvalidNetworkField, + ), + reason: e.into_reason("withdrawn"), + }); + } + }; - let (input, len) = be_u16(input)?; - let (input, attrs_input) = take(len)(input)?; - let path_attributes = Self::path_attrs_from_wire(attrs_input)?; + // 3. Parse path attributes length and extract bytes + let (input, len) = be_u16::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::Other { + detail: format!( + "failed to parse path attributes length: {e}" + ), + }, + })?; + let (input, attrs_input) = take(len)(input).map_err( + |_e: nom::Err>| UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::InvalidAttributeLength { + declared: len, + available: input.len(), + }, + }, + )?; + + // 4. Parse path attributes, collecting all errors + let ParsedPathAttrs { + attrs: path_attributes, + errors: attr_errors, + treat_as_withdraw, + } = Self::path_attrs_from_wire(attrs_input)?; + + // 6. Parse NLRI (remaining bytes) + // Even if attrs had errors, we need NLRI for TreatAsWithdraw + let nlri = match Self::prefixes4_from_wire(input) { + Ok(n) => n, + Err(e) => { + // NLRI parse failure = SessionReset (strongest action) + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::InvalidNetworkField, + ), + reason: e.into_reason("nlri"), + }); + } + }; - let nlri = Self::prefixes4_from_wire(input)?; + // 7. Validate mandatory attributes (RFC 4271 Section 5.1.2) + // Only required when UPDATE carries reachability information (NLRI). + // Missing mandatory attrs = TreatAsWithdraw per RFC 7606. + let mut errors = attr_errors; + let mut treat_as_withdraw = treat_as_withdraw; - Ok(UpdateMessage { - withdrawn, - path_attributes, - nlri, - }) - } + // Check if we have any NLRI (traditional or MP-BGP) + let has_traditional_nlri = !nlri.is_empty(); + let has_mp_reach = path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::MpReachNlri(_))); - /// Check for duplicate MP_REACH_NLRI or MP_UNREACH_NLRI attributes. - /// - /// RFC 7606 Section 3g: - /// ```text - /// g. If the MP_REACH_NLRI attribute or the MP_UNREACH_NLRI [RFC4760] - /// attribute appears more than once in the UPDATE message, then a - /// NOTIFICATION message MUST be sent with the Error Subcode - /// "Malformed Attribute List". If any other attribute (whether - /// recognized or unrecognized) appears more than once in an UPDATE - /// message, then all the occurrences of the attribute other than the - /// first one SHALL be discarded and the UPDATE message will continue - /// to be processed. - /// ``` - pub fn check_duplicate_mp_attributes(&self) -> Result<(), Error> { - let mut has_mp_reach = false; - let mut has_mp_unreach = false; + if has_traditional_nlri || has_mp_reach { + // ORIGIN is always required when there's NLRI + let has_origin = path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::Origin(_))); + if !has_origin { + treat_as_withdraw = true; + errors.push(( + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin, + }, + AttributeAction::TreatAsWithdraw, + )); + } - for attr in &self.path_attributes { - match &attr.value { - PathAttributeValue::MpReachNlri(_) => { - if has_mp_reach { - return Err(Error::MalformedAttributeList( - "Multiple MP_REACH_NLRI attributes".into(), - )); - } - has_mp_reach = true; - } - PathAttributeValue::MpUnreachNlri(_) => { - if has_mp_unreach { - return Err(Error::MalformedAttributeList( - "Multiple MP_UNREACH_NLRI attributes".into(), - )); - } - has_mp_unreach = true; + // AS_PATH is always required when there's NLRI + // Note: AS_PATH parses to As4Path variant internally (always uses 4-byte ASNs) + let has_as_path = path_attributes.iter().any(|a| { + matches!( + a.value, + PathAttributeValue::AsPath(_) + | PathAttributeValue::As4Path(_) + ) + }); + if !has_as_path { + treat_as_withdraw = true; + errors.push(( + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::AsPath, + }, + AttributeAction::TreatAsWithdraw, + )); + } + + // NEXT_HOP is required for traditional NLRI (IPv4 in NLRI field). + // When MP_REACH_NLRI is present without traditional NLRI, the nexthop + // is carried inside the MP attribute, so NEXT_HOP is not required. + if has_traditional_nlri { + let has_next_hop = path_attributes + .iter() + .any(|a| matches!(a.value, PathAttributeValue::NextHop(_))); + if !has_next_hop { + treat_as_withdraw = true; + errors.push(( + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop, + }, + AttributeAction::TreatAsWithdraw, + )); } - _ => {} } } - Ok(()) + Ok(UpdateMessage { + withdrawn, + path_attributes, + nlri, + treat_as_withdraw, + errors, + }) } /// Parse prefixes from wire format. @@ -892,7 +1053,7 @@ impl UpdateMessage { pub fn prefixes_from_wire( buf: &[u8], afi: AddressFamily, - ) -> Result, Error> { + ) -> Result, PrefixParseError> { match afi { AddressFamily::Ipv4 => Self::prefixes4_from_wire(buf) .map(|v| v.into_iter().map(Prefix::V4).collect()), @@ -902,11 +1063,12 @@ impl UpdateMessage { } /// Parse IPv4 prefixes from wire format. - pub fn prefixes4_from_wire(mut buf: &[u8]) -> Result, Error> { + pub fn prefixes4_from_wire( + mut buf: &[u8], + ) -> Result, PrefixParseError> { let mut result = Vec::new(); while !buf.is_empty() { - let (out, prefix4) = Prefix4::from_wire(buf) - .map_err(|_| Error::InvalidNlriPrefix(buf.to_vec()))?; + let (out, prefix4) = Prefix4::from_wire(buf)?; result.push(prefix4); buf = out; } @@ -914,18 +1076,23 @@ impl UpdateMessage { } /// Parse IPv6 prefixes from wire format. - pub fn prefixes6_from_wire(mut buf: &[u8]) -> Result, Error> { + pub fn prefixes6_from_wire( + mut buf: &[u8], + ) -> Result, PrefixParseError> { let mut result = Vec::new(); while !buf.is_empty() { - let (out, prefix6) = Prefix6::from_wire(buf) - .map_err(|_| Error::InvalidNlriPrefix(buf.to_vec()))?; + let (out, prefix6) = Prefix6::from_wire(buf)?; result.push(prefix6); buf = out; } Ok(result) } - /// Parse Path Attributes carried in an Update from wire format. + /// Parse path attributes from wire format with RFC 7606 error handling. + /// + /// This function handles the framing (type header + length parsing) for each + /// attribute internally, which allows it to continue parsing after non-fatal + /// errors because it always knows where the next attribute starts. /// /// For any given path attribute, only the first instance is respected. /// Subsequent instances are discarded. The exceptions to this are MP-BGP @@ -944,41 +1111,255 @@ impl UpdateMessage { /// to be processed. /// ``` /// - /// Note: Currently, this is implemented by retaining MpReachNlri and - /// MpUnreachNlri in the parsed Update so the session layer (FSM) can detect - /// the duplication and react accordingly (by sending a Malformed Attribute - /// List Notification). + /// # Returns + /// - `Ok(ParsedPathAttrs)`: Successfully parsed (may contain non-fatal errors) + /// - `Err(UpdateParseError)`: Fatal error (SessionReset) - parsing cannot continue fn path_attrs_from_wire( mut buf: &[u8], - ) -> Result, Error> { + ) -> Result { let mut result = Vec::new(); + let mut errors = Vec::new(); + let mut treat_as_withdraw = false; let mut seen_types: HashSet = HashSet::new(); + let mut has_mp_reach = false; + let mut has_mp_unreach = false; loop { if buf.is_empty() { break; } - let (out, pa) = PathAttribute::from_wire(buf)?; - let type_code = pa.typ.type_code as u8; - let is_mp_bgp = matches!( - pa.typ.type_code, - PathAttributeTypeCode::MpReachNlri - | PathAttributeTypeCode::MpUnreachNlri - ); + // ===== FRAMING: Parse attribute header (type + length) ===== + + // 1. Parse 2-byte type header (flags + type_code) + let (remaining, type_bytes) = + match take::<_, _, nom::error::Error<&[u8]>>(2usize)(buf) { + Ok((r, t)) => (r, t), + Err(e) => { + // Can't even read type header - fatal framing error + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: + UpdateParseErrorReason::AttributeParseError { + type_code: None, + detail: format!( + "failed to read attribute type: {e}" + ), + }, + }); + } + }; + + // 2. Parse PathAttributeType from the 2 bytes + let typ = match PathAttributeType::from_wire(type_bytes) { + Ok(t) => t, + Err(e) => { + // Unknown/invalid type code - fatal framing error + // (we don't know the length encoding without valid flags) + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::AttributeParseError { + type_code: None, + detail: format!("invalid attribute type: {e}"), + }, + }); + } + }; - if is_mp_bgp || !seen_types.contains(&type_code) { - // Keep MP-BGP attributes (duplicates trigger NOTIFICATION via - // check_duplicate_mp_attributes at session layer) and first - // occurrence of non-MP-BGP attributes. - seen_types.insert(type_code); - result.push(pa); + let type_code_u8 = typ.type_code as u8; + + // 3. Validate attribute flags (RFC 7606 Section 3c) + // Even if flag validation fails, we need to parse the length to skip + // the attribute. Track the error but continue to length parsing. + let flag_error = validate_attribute_flags(&typ).err(); + + // 4. Parse length (1 or 2 bytes depending on EXTENDED_LENGTH flag) + let (remaining, len) = + if typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0 { + match be_u16::<_, nom::error::Error<&[u8]>>(remaining) { + Ok((r, l)) => (r, l as usize), + Err(e) => { + // Can't read extended length - fatal framing error + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "failed to read extended length: {e}" + ), + }, + }); + } + } + } else { + match parse_u8::<_, nom::error::Error<&[u8]>>(remaining) { + Ok((r, l)) => (r, l as usize), + Err(e) => { + // Can't read length - fatal framing error + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "failed to read length: {e}" + ), + }, + }); + } + } + }; + + // 5. Extract `len` bytes for the attribute value + let (remaining, value_bytes) = match take::< + _, + _, + nom::error::Error<&[u8]>, + >(len)(remaining) + { + Ok((r, v)) => (r, v), + Err(e) => { + // Declared length exceeds available bytes - fatal framing error + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "attribute truncated: declared {} bytes, {e}", + len + ), + }, + }); + } + }; + + // ===== We now know where the next attribute starts! ===== + // Update buf to point past this attribute for the next iteration + buf = remaining; + + // ===== Handle flag validation error ===== + // Now that we've advanced buf, we can handle the flag error + if let Some((reason, action)) = flag_error { + match action { + AttributeAction::SessionReset => { + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason, + }); + } + AttributeAction::TreatAsWithdraw => { + treat_as_withdraw = true; + errors.push((reason, action)); + continue; // Skip value parsing, move to next attribute + } + AttributeAction::Discard => { + errors.push((reason, action)); + continue; // Skip value parsing, move to next attribute + } + } } - // else: discard duplicate non-MP-BGP attribute per RFC 7606 3(g) - buf = out; + // ===== VALUE PARSING: Parse the attribute value ===== + match PathAttribute::from_bytes(typ.clone(), value_bytes) { + Ok(pa) => { + // ===== DUPLICATE DETECTION ===== + let is_mp_reach = + pa.typ.type_code == PathAttributeTypeCode::MpReachNlri; + let is_mp_unreach = pa.typ.type_code + == PathAttributeTypeCode::MpUnreachNlri; + + // Track MP-BGP duplicates for Session Reset (RFC 7606 §3g) + if is_mp_reach { + if has_mp_reach { + // Duplicate MP_REACH_NLRI - Session Reset + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: + UpdateParseErrorReason::DuplicateMpReachNlri, + }); + } + has_mp_reach = true; + } + if is_mp_unreach { + if has_mp_unreach { + // Duplicate MP_UNREACH_NLRI - Session Reset + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::DuplicateMpUnreachNlri, + }); + } + has_mp_unreach = true; + } + + let is_mp_bgp = is_mp_reach || is_mp_unreach; + + if is_mp_bgp || !seen_types.contains(&type_code_u8) { + // Keep MP-BGP attributes and first occurrence of + // non-MP-BGP attributes. + seen_types.insert(type_code_u8); + result.push(pa); + } + // else: discard duplicate non-MP-BGP attribute per RFC 7606 3(g) + } + Err(reason) => { + // Value parsing failed - determine action based on attribute type + let action = typ.error_action(); + + match action { + AttributeAction::SessionReset => { + // Fatal error - return immediately + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason, + }); + } + AttributeAction::TreatAsWithdraw => { + // Record error, skip this attribute, continue parsing + treat_as_withdraw = true; + errors.push((reason, action)); + } + AttributeAction::Discard => { + // Record error, skip this attribute, continue parsing + errors.push((reason, action)); + } + } + } + } } - Ok(result) + + Ok(ParsedPathAttrs { + attrs: result, + errors, + treat_as_withdraw, + }) } /// This method parses an UpdateMessage and returns a BgpNexthop which @@ -988,31 +1369,19 @@ impl UpdateMessage { /// capabilities), this method centralizes the logic for parsing and /// selection of received nexthops. pub fn nexthop(&self) -> Result { - let mp = - match self.path_attributes.iter().find_map(|a| match &a.value { - PathAttributeValue::MpReachNlri(mp) => Some(mp), - _ => None, - }) { - // This Update is not MP-BGP, so we can just hand out NEXT_HOP - None => { - return self - .nexthop4() - .map(|n4| n4.into()) - .ok_or(Error::MissingNexthop); - } - Some(mp) => mp, - }; - - // This Update is MP-BGP, so we need to use the AFI/SAFI (represented - // here via Afi) and the nh_len to derive the nexthop type. - // XXX: extended nexthop validation - BgpNexthop::from_bytes( - &mp.nh_bytes, - mp.nh_len, - Afi::try_from(mp.afi).map_err(|_| { - Error::UnsupportedAddressFamily(mp.afi, mp.safi) - })?, - ) + // Find MP_REACH_NLRI if present + match self.path_attributes.iter().find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) { + // This Update is MP-BGP, nexthop is already parsed + Some(mp) => Ok(*mp.nexthop()), + // This Update is not MP-BGP, use the NEXT_HOP attribute + None => self + .nexthop4() + .map(|n4| n4.into()) + .ok_or(Error::MissingNexthop), + } } pub fn nexthop4(&self) -> Option { @@ -1127,7 +1496,8 @@ impl Display for UpdateMessage { write!( f, - "Update[ path_attributes: {p_str}) withdrawn({}) nlri({}) ]", + "Update[ treat-as-withdraw: ({}) path_attributes: ({p_str}) withdrawn({}) nlri({}) ]", + self.treat_as_withdraw, if !w_str.is_empty() { &w_str } else { "empty" }, if !n_str.is_empty() { &n_str } else { "empty" } ) @@ -1190,22 +1560,71 @@ impl PathAttribute { Ok(buf) } - fn from_wire(input: &[u8]) -> Result<(&[u8], PathAttribute), Error> { - let (input, type_input) = take(2usize)(input)?; - let typ = PathAttributeType::from_wire(type_input)?; + /// Parse a path attribute from bounded value bytes. + /// + /// The caller has already: + /// 1. Parsed the 2-byte type header (flags + type code) + /// 2. Validated the attribute flags via `validate_attribute_flags()` + /// 3. Parsed the length and extracted exactly `len` bytes + /// + /// This function parses only the attribute value from the bounded slice. + fn from_bytes( + typ: PathAttributeType, + value_bytes: &[u8], + ) -> Result { + let value = PathAttributeValue::from_wire(value_bytes, typ.type_code)?; + Ok(PathAttribute { typ, value }) + } +} - let (input, len) = - if typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0 { - let (input, len) = be_u16(input)?; - (input, len as usize) - } else { - let (input, len) = parse_u8(input)?; - (input, len as usize) - }; - let (input, pa_input) = take(len)(input)?; - let value = PathAttributeValue::from_wire(pa_input, typ.type_code)?; - Ok((input, PathAttribute { typ, value })) +/// RFC 7606 Section 3(c): Validate attribute flags match expected values. +/// +/// Each attribute type has specific requirements for the Optional and Transitive +/// flags. If the received flags don't match, the attribute is malformed. +/// +/// Returns `Ok(())` if flags are valid, or `Err` with the appropriate error and action. +fn validate_attribute_flags( + typ: &PathAttributeType, +) -> Result<(), (UpdateParseErrorReason, AttributeAction)> { + use path_attribute_flags::*; + + let optional = typ.flags & OPTIONAL != 0; + let transitive = typ.flags & TRANSITIVE != 0; + + // Define expected flags for each known attribute type + // Format: (expected_optional, expected_transitive) + let expected = match typ.type_code { + // Well-known mandatory/discretionary: Optional=0, Transitive=1 + PathAttributeTypeCode::Origin + | PathAttributeTypeCode::AsPath + | PathAttributeTypeCode::NextHop + | PathAttributeTypeCode::LocalPref + | PathAttributeTypeCode::AtomicAggregate => (false, true), + + // Optional non-transitive: Optional=1, Transitive=0 + PathAttributeTypeCode::MultiExitDisc + | PathAttributeTypeCode::MpReachNlri + | PathAttributeTypeCode::MpUnreachNlri => (true, false), + + // Optional transitive: Optional=1, Transitive=1 + PathAttributeTypeCode::Aggregator + | PathAttributeTypeCode::Communities + | PathAttributeTypeCode::As4Path + | PathAttributeTypeCode::As4Aggregator => (true, true), + }; + + let (expected_optional, expected_transitive) = expected; + + if optional != expected_optional || transitive != expected_transitive { + let reason = UpdateParseErrorReason::InvalidAttributeFlags { + type_code: typ.type_code as u8, + flags: typ.flags, + }; + let action = typ.error_action(); + return Err((reason, action)); } + + Ok(()) } /// Type encoding for a path attribute. @@ -1229,6 +1648,59 @@ impl PathAttributeType { let type_code = PathAttributeTypeCode::try_from(type_code)?; Ok(PathAttributeType { flags, type_code }) } + + /// Determine RFC 7606 action for errors on this attribute type. + /// + /// RFC 7606 specifies different error handling actions for different attribute types: + /// - Session Reset: Critical errors that prevent reliable parsing + /// - Treat-as-withdraw: Errors in route-affecting attributes + /// - Attribute Discard: Errors in informational-only attributes + pub fn error_action(&self) -> AttributeAction { + match self.type_code { + // Well-known mandatory attributes (RFC 7606 Section 7.1-7.3) + PathAttributeTypeCode::Origin + | PathAttributeTypeCode::AsPath + | PathAttributeTypeCode::NextHop => { + AttributeAction::TreatAsWithdraw + } + + // MP-BGP attributes: SessionReset on any error because we never + // negotiate AFI/SAFIs we don't support, so receiving one we can't + // parse is a protocol violation + PathAttributeTypeCode::MpReachNlri + | PathAttributeTypeCode::MpUnreachNlri => { + AttributeAction::SessionReset + } + + // MULTI_EXIT_DISC (RFC 7606 Section 7.4): affects route selection + PathAttributeTypeCode::MultiExitDisc => { + AttributeAction::TreatAsWithdraw + } + + // LOCAL_PREF (RFC 7606 Section 7.5): affects route selection + // Note: From eBGP peers this should be discarded, but that requires + // session context. For now, treat as withdraw for safety. + PathAttributeTypeCode::LocalPref => { + AttributeAction::TreatAsWithdraw + } + + // Communities (RFC 7606 Section 7.8): affects policy/route selection + PathAttributeTypeCode::Communities => { + AttributeAction::TreatAsWithdraw + } + + // AS4_PATH: Same as AS_PATH, affects loop detection and route selection + PathAttributeTypeCode::As4Path => AttributeAction::TreatAsWithdraw, + + // ATOMIC_AGGREGATE (RFC 7606 Section 7.6): informational only + // AGGREGATOR (RFC 7606 Section 7.7): informational only + // AS4_AGGREGATOR: Same as AGGREGATOR + // These don't affect route selection, so discard is safe + PathAttributeTypeCode::AtomicAggregate + | PathAttributeTypeCode::Aggregator + | PathAttributeTypeCode::As4Aggregator => AttributeAction::Discard, + } + } } pub mod path_attribute_flags { @@ -1343,9 +1815,9 @@ pub enum PathAttributeValue { /// This attribute is included in routes that are formed by aggregation. As4Aggregator([u8; 8]), /// Carries reachable MP-BGP NLRI and Next-hop (advertisement). - MpReachNlri(MpReach), + MpReachNlri(MpReachNlri), /// Carries unreachable MP-BGP NLRI (withdrawal). - MpUnreachNlri(MpUnreach), + MpUnreachNlri(MpUnreachNlri), } impl PathAttributeValue { @@ -1389,11 +1861,24 @@ impl PathAttributeValue { pub fn from_wire( mut input: &[u8], type_code: PathAttributeTypeCode, - ) -> Result { + ) -> Result { + // Helper for nom type annotation + type NomErr<'a> = nom::error::Error<&'a [u8]>; + match type_code { PathAttributeTypeCode::Origin => { - let (_input, origin) = be_u8(input)?; - Ok(PathAttributeValue::Origin(PathOrigin::try_from(origin)?)) + let (_input, origin) = + be_u8::<_, NomErr<'_>>(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: format!("{e}"), + } + })?; + PathOrigin::try_from(origin) + .map(PathAttributeValue::Origin) + .map_err(|_| UpdateParseErrorReason::InvalidOriginValue { + value: origin, + }) } PathAttributeTypeCode::AsPath => { let mut segments = Vec::new(); @@ -1401,7 +1886,12 @@ impl PathAttributeValue { if input.is_empty() { break; } - let (out, seg) = As4PathSegment::from_wire(input)?; + let (out, seg) = + As4PathSegment::from_wire(input).map_err(|e| { + UpdateParseErrorReason::MalformedAsPath { + detail: format!("{e}"), + } + })?; segments.push(seg); input = out; } @@ -1410,18 +1900,30 @@ impl PathAttributeValue { PathAttributeTypeCode::NextHop => { // For IPv4 unicast, the length of this attribute MUST be 4 octets. if input.len() != 4 { - return Err(Error::BadLength { + return Err(UpdateParseErrorReason::MalformedNextHop { expected: 4, - found: input.len() as u8, + got: input.len(), }); } - let (_input, b) = take(4usize)(input)?; + let (_input, b) = take::<_, _, NomErr<'_>>(4usize)(input) + .map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::NextHop(Ipv4Addr::new( b[0], b[1], b[2], b[3], ))) } PathAttributeTypeCode::MultiExitDisc => { - let (_input, v) = be_u32(input)?; + let (_input, v) = + be_u32::<_, NomErr<'_>>(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::MultiExitDisc(v)) } PathAttributeTypeCode::As4Path => { @@ -1430,7 +1932,12 @@ impl PathAttributeValue { if input.is_empty() { break; } - let (out, seg) = As4PathSegment::from_wire(input)?; + let (out, seg) = + As4PathSegment::from_wire(input).map_err(|e| { + UpdateParseErrorReason::MalformedAsPath { + detail: format!("{e}"), + } + })?; segments.push(seg); input = out; } @@ -1442,25 +1949,39 @@ impl PathAttributeValue { if input.is_empty() { break; } - let (out, v) = be_u32(input)?; + let (out, v) = + be_u32::<_, NomErr<'_>>(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: format!("{e}"), + } + })?; communities.push(Community::from(v)); input = out; } Ok(PathAttributeValue::Communities(communities)) } PathAttributeTypeCode::LocalPref => { - let (_input, v) = be_u32(input)?; + let (_input, v) = + be_u32::<_, NomErr<'_>>(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::LocalPref(v)) } PathAttributeTypeCode::MpReachNlri => { - let (_remaining, mp_reach) = MpReach::from_wire(input)?; + let (_remaining, mp_reach) = MpReachNlri::from_wire(input)?; Ok(PathAttributeValue::MpReachNlri(mp_reach)) } PathAttributeTypeCode::MpUnreachNlri => { - let (_remaining, mp_unreach) = MpUnreach::from_wire(input)?; + let (_remaining, mp_unreach) = MpUnreachNlri::from_wire(input)?; Ok(PathAttributeValue::MpUnreachNlri(mp_unreach)) } - x => Err(Error::UnsupportedPathAttributeTypeCode(x)), + x => Err(UpdateParseErrorReason::UnrecognizedMandatoryAttribute { + type_code: x as u8, + }), } } } @@ -1509,25 +2030,10 @@ impl Display for PathAttributeValue { write!(f, "communities: [{comms}]") } PathAttributeValue::MpReachNlri(reach) => { - write!( - f, - "mp-reach-nlri: [ afi: {}, safi: {}, next-hop-len: {}, next-hop-bytes: {} bytes, reserved: {}, nlri-bytes: {} bytes ]", - reach.afi, - reach.safi, - reach.nh_len, - reach.nh_bytes.len(), - reach.reserved, - reach.nlri_bytes.len() - ) + write!(f, "mp-reach-nlri: {}", reach) } PathAttributeValue::MpUnreachNlri(unreach) => { - write!( - f, - "mp-unreach-nlri: [ afi: {}, safi: {}, withdrawn-bytes: {} bytes ]", - unreach.afi_raw, - unreach.safi_raw, - unreach.withdrawn_bytes.len() - ) + write!(f, "mp-unreach-nlri: {}", unreach) } PathAttributeValue::As4Path(path_segs) => { let path = path_segs @@ -1891,6 +2397,20 @@ impl BgpNexthop { BgpNexthop::Ipv6Double(_) => ((Ipv6Addr::BITS * 2) / 8) as u8, } } + + /// Serialize next-hop to wire format bytes + pub fn to_bytes(&self) -> Vec { + match self { + BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), + BgpNexthop::Ipv6Double((addr1, addr2)) => { + let mut buf = Vec::new(); + buf.extend_from_slice(&addr1.octets()); + buf.extend_from_slice(&addr2.octets()); + buf + } + } + } } impl Display for BgpNexthop { @@ -1930,6 +2450,37 @@ impl From for BgpNexthop { } } +/// Parse IPv4 prefixes from wire format +fn prefixes4_from_wire( + mut buf: &[u8], +) -> Result, PrefixParseError> { + let mut result = Vec::new(); + while !buf.is_empty() { + let (out, prefix4) = Prefix4::from_wire(buf)?; + result.push(prefix4); + buf = out; + } + Ok(result) +} + +/// Parse IPv6 prefixes from wire format +fn prefixes6_from_wire( + mut buf: &[u8], +) -> Result, PrefixParseError> { + let mut result = Vec::new(); + while !buf.is_empty() { + let (out, prefix6) = Prefix6::from_wire(buf)?; + result.push(prefix6); + buf = out; + } + Ok(result) +} + +/// MP_REACH_NLRI path attribute +/// +/// Each variant represents a specific AFI+SAFI combination, providing +/// compile-time guarantees about the address family of routes being announced. +/// /// ```text /// 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14): /// @@ -1948,224 +2499,256 @@ impl From for BgpNexthop { /// +---------------------------------------------------------+ /// | Address Family Identifier (2 octets) | /// +---------------------------------------------------------+ -/// | Subsequent Address Family Identifier (1 octet) | -/// +---------------------------------------------------------+ -/// | Length of Next Hop Network Address (1 octet) | -/// +---------------------------------------------------------+ -/// | Network Address of Next Hop (variable) | -/// +---------------------------------------------------------+ -/// | Reserved (1 octet) | -/// +---------------------------------------------------------+ -/// | Network Layer Reachability Information (variable) | -/// +---------------------------------------------------------+ -/// ```` -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MpReach { - /// Raw AFI value (not validated during parsing - validation deferred to session layer) - pub afi: u16, - - /// Raw SAFI value (not validated during parsing - validation deferred to session layer) - pub safi: u8, - - /// Length of Next Hop Network Address field - pub nh_len: u8, - - /// Next-hop bytes (raw, not parsed - parsing deferred to session layer) - pub nh_bytes: Vec, - - /// Reserved field (must be 0 per RFC 4760) - pub reserved: u8, - - /// NLRI bytes (raw, not parsed - parsing deferred to session layer) - pub nlri_bytes: Vec, +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "afi_safi", rename_all = "snake_case")] +pub enum MpReachNlri { + /// IPv4 Unicast routes (AFI=1, SAFI=1) + Ipv4Unicast(MpReachIpv4Unicast), + /// IPv6 Unicast routes (AFI=2, SAFI=1) + Ipv6Unicast(MpReachIpv6Unicast), } -impl MpReach { - pub fn new(afi: Afi, nh: BgpNexthop, nlri: Vec) -> Self { - // Convert next-hop to bytes - let nh_bytes = match &nh { - BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Double((addr1, addr2)) => { - let mut buf = Vec::new(); - buf.extend_from_slice(&addr1.octets()); - buf.extend_from_slice(&addr2.octets()); - buf - } - }; +/// IPv4 Unicast MP_REACH_NLRI contents. +/// +/// Contains the next-hop and NLRI for IPv4 unicast route announcements +/// carried via MP-BGP (RFC 4760). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpReachIpv4Unicast { + /// Next-hop for IPv4 routes. + /// + /// Currently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops + /// when extended next-hop capability (RFC 8950) is implemented. + pub nexthop: BgpNexthop, + /// IPv4 prefixes being announced + pub nlri: Vec, +} - // Convert NLRI to bytes - let mut nlri_bytes = Vec::new(); - for prefix in &nlri { - match prefix { - Prefix::V4(p) => nlri_bytes.extend_from_slice(&p.to_wire()), - Prefix::V6(p) => nlri_bytes.extend_from_slice(&p.to_wire()), - } - } +/// IPv6 Unicast MP_REACH_NLRI contents. +/// +/// Contains the next-hop and NLRI for IPv6 unicast route announcements +/// carried via MP-BGP (RFC 4760). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpReachIpv6Unicast { + /// Next-hop for IPv6 routes. + /// + /// Can be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` + /// (32 bytes with link-local address). + pub nexthop: BgpNexthop, + /// IPv6 prefixes being announced + pub nlri: Vec, +} - Self { - afi: afi as u16, - safi: Safi::Unicast as u8, - nh_len: nh.byte_len(), - nh_bytes, - reserved: 0u8, - nlri_bytes, +impl MpReachNlri { + /// Returns the AFI for this MP_REACH_NLRI. + pub fn afi(&self) -> Afi { + match self { + Self::Ipv4Unicast(_) => Afi::Ipv4, + Self::Ipv6Unicast(_) => Afi::Ipv6, } } - /// Create an MpReach for IPv6 unicast routes. - /// This is the type-safe version that takes Vec directly, - /// avoiding the need for runtime type validation. - pub fn new_v6(nh: BgpNexthop, nlri: Vec) -> Self { - // Convert next-hop to bytes - let nh_bytes = match &nh { - BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Double((addr1, addr2)) => { - let mut buf = Vec::new(); - buf.extend_from_slice(&addr1.octets()); - buf.extend_from_slice(&addr2.octets()); - buf - } - }; + /// Returns the SAFI for this MP_REACH_NLRI (always Unicast). + pub fn safi(&self) -> Safi { + Safi::Unicast + } - // Convert NLRI to bytes - no match needed, we know it's V6 - let mut nlri_bytes = Vec::new(); - for prefix in &nlri { - nlri_bytes.extend_from_slice(&prefix.to_wire()); + /// Returns the next-hop for this MP_REACH_NLRI. + pub fn nexthop(&self) -> &BgpNexthop { + match self { + Self::Ipv4Unicast(inner) => &inner.nexthop, + Self::Ipv6Unicast(inner) => &inner.nexthop, } + } - Self { - afi: Afi::Ipv6 as u16, - safi: Safi::Unicast as u8, - nh_len: nh.byte_len(), - nh_bytes, - reserved: 0u8, - nlri_bytes, + /// Returns true if there are no prefixes in this MP_REACH_NLRI. + pub fn is_empty(&self) -> bool { + match self { + Self::Ipv4Unicast(inner) => inner.nlri.is_empty(), + Self::Ipv6Unicast(inner) => inner.nlri.is_empty(), } } - /// Parse MP_REACH_NLRI from wire format. - /// - /// ## RFC 4760 Section 3: MP_REACH_NLRI Encoding - /// - /// This attribute is optional and non-transitive. It carries: - /// - AFI (2 octets): Address Family Identifier - /// - SAFI (1 octet): Subsequent Address Family Identifier - /// - Next Hop Length (1 octet): Length of next hop address - /// - Next Hop (variable): Network address of next hop - /// - Reserved (1 octet): Must be 0 - /// - NLRI (variable): Network Layer Reachability Information - /// - /// ## Parsing Philosophy - /// - /// Unsupported AFI/SAFI is a **parsing error**, not a validation error, because: - /// 1. We cannot parse NLRI without knowing the address family format - /// 2. We cannot skip over NLRI bytes without knowing prefix encoding rules - /// 3. Per RFC 7606, malformed attributes that prevent NLRI location determination - /// should trigger session reset or AFI/SAFI disable - /// - /// Therefore, this method validates that the AFI/SAFI tuple is supported during - /// parsing. The resulting `MpReach` is guaranteed to contain a valid, supported - /// address family. - /// - /// ## Parse Failures - /// - /// Parsing fails (returns `Err`) when: - /// - Buffer is too small for fixed-size fields - /// - Next-hop length is invalid for the given AFI - /// - AFI/SAFI combination is unsupported (structural requirement for parsing) - /// - /// ## Session-Level Validation - /// - /// After successful parsing, session-level code must still validate that this - /// address family was negotiated with the peer via capability exchange. - /// - /// # Arguments - /// * `input` - Wire format bytes to parse - /// - /// # Returns - /// `Ok((remaining_bytes, MpReach))` on successful parse - /// `Err(Error)` if wire format is malformed or address family is unsupported - pub fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Error> { - // Parse AFI (2 bytes) - DON'T VALIDATE (store raw value) - let (input, afi_raw) = be_u16(input)?; - - // Parse SAFI (1 byte) - DON'T VALIDATE (store raw value) - let (input, safi_raw) = be_u8(input)?; - - // Parse Next-hop Length (1 byte) - let (input, nh_len) = be_u8(input)?; - - // Extract next-hop bytes (structural bounds check only - don't parse/validate) - if input.len() < nh_len as usize { - return Err(Error::TooSmall(format!( - "next-hop field too short: need {} bytes, have {}", - nh_len, - input.len() - ))); + /// Returns the number of prefixes in this MP_REACH_NLRI. + pub fn len(&self) -> usize { + match self { + Self::Ipv4Unicast(inner) => inner.nlri.len(), + Self::Ipv6Unicast(inner) => inner.nlri.len(), } - let nh_bytes = input[..nh_len as usize].to_vec(); - let input = &input[nh_len as usize..]; - - // Parse Reserved byte (1 byte) - let (input, reserved) = be_u8(input)?; + } - // Store remaining bytes as raw NLRI (don't parse) - let nlri_bytes = input.to_vec(); + /// Create an IPv4 Unicast MP_REACH_NLRI. + pub fn ipv4_unicast(nexthop: BgpNexthop, nlri: Vec) -> Self { + Self::Ipv4Unicast(MpReachIpv4Unicast { nexthop, nlri }) + } - Ok(( - &[], // All remaining bytes consumed - MpReach { - afi: afi_raw, - safi: safi_raw, - nh_len, - nh_bytes, - reserved, - nlri_bytes, - }, - )) + /// Create an IPv6 Unicast MP_REACH_NLRI. + pub fn ipv6_unicast(nexthop: BgpNexthop, nlri: Vec) -> Self { + Self::Ipv6Unicast(MpReachIpv6Unicast { nexthop, nlri }) } - /// Serialize MP_REACH_NLRI to wire format. + /// Serialize to wire format. pub fn to_wire(&self) -> Result, Error> { let mut buf = Vec::new(); // AFI (2 bytes) - buf.extend_from_slice(&self.afi.to_be_bytes()); + buf.extend_from_slice(&(self.afi() as u16).to_be_bytes()); // SAFI (1 byte) - buf.push(self.safi); + buf.push(self.safi() as u8); - // Next-hop Length (1 byte) - buf.push(self.nh_len); + // Next-hop + let nh_bytes = self.nexthop().to_bytes(); + buf.push(nh_bytes.len() as u8); // Next-hop length + buf.extend_from_slice(&nh_bytes); - // Next-hop (raw bytes) - buf.extend_from_slice(&self.nh_bytes); + // Reserved (1 byte, must be 0) + buf.push(0); - // Reserved (1 byte) - buf.push(self.reserved); - - // NLRI (raw bytes) - buf.extend_from_slice(&self.nlri_bytes); + // NLRI + match self { + Self::Ipv4Unicast(inner) => { + for prefix in &inner.nlri { + buf.extend_from_slice(&prefix.to_wire()); + } + } + Self::Ipv6Unicast(inner) => { + for prefix in &inner.nlri { + buf.extend_from_slice(&prefix.to_wire()); + } + } + } Ok(buf) } -} -impl Display for MpReach { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "MpReach[AFI={}, SAFI={}, nh_len={}, nlri_bytes={}]", - self.afi, - self.safi, - self.nh_len, - self.nlri_bytes.len() - ) - } -} + /// Parse from wire format. + /// + /// This validates the AFI/SAFI and parses the next-hop and NLRI into + /// their proper typed representations. + /// + /// Returns an error if: + /// - The AFI/SAFI combination is unsupported + /// - The next-hop length is invalid for the AFI + /// - The NLRI is malformed + pub fn from_wire( + input: &[u8], + ) -> Result<(&[u8], Self), UpdateParseErrorReason> { + // Parse AFI (2 bytes) + let (input, afi_raw) = be_u16::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + detail: format!("failed to parse AFI: {e}"), + })?; + let afi = Afi::try_from(afi_raw).map_err(|_| { + UpdateParseErrorReason::UnsupportedAfiSafi { + afi: afi_raw, + safi: 0, + } + })?; + + // Parse SAFI (1 byte) + let (input, safi_raw) = be_u8::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + detail: format!("failed to parse SAFI: {e}"), + })?; + let _safi = Safi::try_from(safi_raw).map_err(|_| { + UpdateParseErrorReason::UnsupportedAfiSafi { + afi: afi_raw, + safi: safi_raw, + } + })?; + + // Parse Next-hop Length (1 byte) + let (input, nh_len) = be_u8::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + detail: format!("failed to parse next-hop length: {e}"), + })?; + + // Extract next-hop bytes + if input.len() < nh_len as usize { + let expected = match afi { + Afi::Ipv4 => "4", + Afi::Ipv6 => "16 or 32", + }; + return Err(UpdateParseErrorReason::InvalidMpNextHopLength { + afi: afi_raw, + expected, + got: input.len(), + }); + } + let nh_bytes = &input[..nh_len as usize]; + let input = &input[nh_len as usize..]; + + // Parse next-hop + let nexthop = + BgpNexthop::from_bytes(nh_bytes, nh_len, afi).map_err(|_| { + let expected = match afi { + Afi::Ipv4 => "4", + Afi::Ipv6 => "16 or 32", + }; + UpdateParseErrorReason::InvalidMpNextHopLength { + afi: afi_raw, + expected, + got: nh_len as usize, + } + })?; + + // Parse Reserved byte (1 byte) + let (input, _reserved) = be_u8::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + detail: format!("failed to parse reserved byte: {e}"), + })?; + + // Parse NLRI based on AFI + match afi { + Afi::Ipv4 => { + let nlri = prefixes4_from_wire(input) + .map_err(|e| e.into_reason("mp_reach"))?; + Ok(( + &[], + Self::Ipv4Unicast(MpReachIpv4Unicast { nexthop, nlri }), + )) + } + Afi::Ipv6 => { + let nlri = prefixes6_from_wire(input) + .map_err(|e| e.into_reason("mp_reach"))?; + Ok(( + &[], + Self::Ipv6Unicast(MpReachIpv6Unicast { nexthop, nlri }), + )) + } + } + } +} + +impl Display for MpReachNlri { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ipv4Unicast(inner) => write!( + f, + "MpReachNlri::Ipv4Unicast[nh={}, nlri={}]", + inner.nexthop, + inner.nlri.len() + ), + Self::Ipv6Unicast(inner) => write!( + f, + "MpReachNlri::Ipv6Unicast[nh={}, nlri={}]", + inner.nexthop, + inner.nlri.len() + ), + } + } +} +/// MP_UNREACH_NLRI path attribute +/// +/// Each variant represents a specific AFI+SAFI combination, providing +/// compile-time guarantees about the address family of routes being withdrawn. +/// /// ```text /// 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15): /// @@ -2182,122 +2765,157 @@ impl Display for MpReach { /// | Withdrawn Routes (variable) | /// +---------------------------------------------------------+ /// ``` -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] -pub struct MpUnreach { - /// Raw AFI value (not validated during parsing - validation deferred to session layer) - pub afi_raw: u16, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "afi_safi", rename_all = "snake_case")] +pub enum MpUnreachNlri { + /// IPv4 Unicast routes being withdrawn (AFI=1, SAFI=1) + Ipv4Unicast(MpUnreachIpv4Unicast), + /// IPv6 Unicast routes being withdrawn (AFI=2, SAFI=1) + Ipv6Unicast(MpUnreachIpv6Unicast), +} - /// Raw SAFI value (not validated during parsing - validation deferred to session layer) - pub safi_raw: u8, +/// IPv4 Unicast MP_UNREACH_NLRI contents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpUnreachIpv4Unicast { + pub withdrawn: Vec, +} - /// Withdrawn routes bytes (raw, not parsed - parsing deferred to session layer) - pub withdrawn_bytes: Vec, +/// IPv6 Unicast MP_UNREACH_NLRI contents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpUnreachIpv6Unicast { + pub withdrawn: Vec, } -impl MpUnreach { - pub fn new(afi: Afi, _nh: BgpNexthop, withdrawn: Vec) -> Self { - // Convert withdrawn routes to bytes - let mut withdrawn_bytes = Vec::new(); - for prefix in &withdrawn { - match prefix { - Prefix::V4(p) => { - withdrawn_bytes.extend_from_slice(&p.to_wire()) - } - Prefix::V6(p) => { - withdrawn_bytes.extend_from_slice(&p.to_wire()) - } - } +impl MpUnreachNlri { + pub fn afi(&self) -> Afi { + match self { + Self::Ipv4Unicast(_) => Afi::Ipv4, + Self::Ipv6Unicast(_) => Afi::Ipv6, } + } - Self { - afi_raw: afi as u16, - safi_raw: Safi::Unicast as u8, - withdrawn_bytes, - } + pub fn safi(&self) -> Safi { + Safi::Unicast } - /// Create an MpUnreach for IPv6 unicast withdrawn routes. - /// This is the type-safe version that takes Vec directly, - /// avoiding the need for runtime type validation. - pub fn new_v6(withdrawn: Vec) -> Self { - // Convert withdrawn routes to bytes - no match needed, we know it's V6 - let mut withdrawn_bytes = Vec::new(); - for prefix in &withdrawn { - withdrawn_bytes.extend_from_slice(&prefix.to_wire()); + pub fn is_empty(&self) -> bool { + match self { + Self::Ipv4Unicast(inner) => inner.withdrawn.is_empty(), + Self::Ipv6Unicast(inner) => inner.withdrawn.is_empty(), } + } - Self { - afi_raw: Afi::Ipv6 as u16, - safi_raw: Safi::Unicast as u8, - withdrawn_bytes, + pub fn len(&self) -> usize { + match self { + Self::Ipv4Unicast(inner) => inner.withdrawn.len(), + Self::Ipv6Unicast(inner) => inner.withdrawn.len(), } } - /// Parse MP_UNREACH_NLRI from wire format. - /// - /// ## RFC 4760 Section 4: MP_UNREACH_NLRI Encoding - /// - /// This attribute is optional and non-transitive. It carries: - /// - AFI (2 octets): Address Family Identifier - /// - SAFI (1 octet): Subsequent Address Family Identifier - /// - Withdrawn Routes (variable): Routes to withdraw - /// - /// Per RFC 7606: Attribute length must be at least 3 octets (AFI + SAFI minimum). - /// - /// ## Parsing Philosophy - /// - /// Like MP_REACH_NLRI, unsupported AFI/SAFI is a parsing error because we cannot - /// parse withdrawn routes without knowing the address family format. The resulting - /// `MpUnreach` is guaranteed to contain a valid, supported address family. - /// - /// Session-level validation must still check that this address family was - /// negotiated with the peer. - pub fn from_wire(input: &[u8]) -> Result<(&[u8], Self), Error> { - // Parse AFI (2 bytes) - DON'T VALIDATE (store raw value) - let (input, afi_raw) = be_u16(input)?; - - // Parse SAFI (1 byte) - DON'T VALIDATE (store raw value) - let (input, safi_raw) = be_u8(input)?; - - // Store remaining bytes as raw withdrawn routes (don't parse) - let withdrawn_bytes = input.to_vec(); + /// Create an IPv4 Unicast MP_UNREACH_NLRI. + pub fn ipv4_unicast(withdrawn: Vec) -> Self { + Self::Ipv4Unicast(MpUnreachIpv4Unicast { withdrawn }) + } - Ok(( - &[], // All remaining bytes consumed - MpUnreach { - afi_raw, - safi_raw, - withdrawn_bytes, - }, - )) + /// Create an IPv6 Unicast MP_UNREACH_NLRI. + pub fn ipv6_unicast(withdrawn: Vec) -> Self { + Self::Ipv6Unicast(MpUnreachIpv6Unicast { withdrawn }) } - /// Serialize MP_UNREACH_NLRI to wire format. + /// Serialize to wire format. pub fn to_wire(&self) -> Result, Error> { let mut buf = Vec::new(); // AFI (2 bytes) - buf.extend_from_slice(&self.afi_raw.to_be_bytes()); + buf.extend_from_slice(&(self.afi() as u16).to_be_bytes()); // SAFI (1 byte) - buf.push(self.safi_raw); + buf.push(self.safi() as u8); - // Withdrawn routes (raw bytes) - buf.extend_from_slice(&self.withdrawn_bytes); + // Withdrawn routes + match self { + Self::Ipv4Unicast(inner) => { + for prefix in &inner.withdrawn { + buf.extend_from_slice(&prefix.to_wire()); + } + } + Self::Ipv6Unicast(inner) => { + for prefix in &inner.withdrawn { + buf.extend_from_slice(&prefix.to_wire()); + } + } + } Ok(buf) } + + /// Parse from wire format. + /// + /// This validates the AFI/SAFI and parses the withdrawn routes into + /// their proper typed representations. + /// + /// Returns an error if: + /// - The AFI/SAFI combination is unsupported + /// - The withdrawn routes are malformed + pub fn from_wire( + input: &[u8], + ) -> Result<(&[u8], Self), UpdateParseErrorReason> { + // Parse AFI (2 bytes) + let (input, afi_raw) = be_u16::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpUnreachNlri as u8), + detail: format!("failed to parse AFI: {e}"), + })?; + let afi = Afi::try_from(afi_raw).map_err(|_| { + UpdateParseErrorReason::UnsupportedAfiSafi { + afi: afi_raw, + safi: 0, + } + })?; + + // Parse SAFI (1 byte) + let (input, safi_raw) = be_u8::<_, nom::error::Error<&[u8]>>(input) + .map_err(|e| UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpUnreachNlri as u8), + detail: format!("failed to parse SAFI: {e}"), + })?; + let _safi = Safi::try_from(safi_raw).map_err(|_| { + UpdateParseErrorReason::UnsupportedAfiSafi { + afi: afi_raw, + safi: safi_raw, + } + })?; + + // Parse withdrawn routes based on AFI + match afi { + Afi::Ipv4 => { + let withdrawn = prefixes4_from_wire(input) + .map_err(|e| e.into_reason("mp_unreach"))?; + Ok((&[], Self::Ipv4Unicast(MpUnreachIpv4Unicast { withdrawn }))) + } + Afi::Ipv6 => { + let withdrawn = prefixes6_from_wire(input) + .map_err(|e| e.into_reason("mp_unreach"))?; + Ok((&[], Self::Ipv6Unicast(MpUnreachIpv6Unicast { withdrawn }))) + } + } + } } -impl Display for MpUnreach { +impl Display for MpUnreachNlri { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "MpUnreach[AFI={}, SAFI={}, withdrawn_bytes={}]", - self.afi_raw, - self.safi_raw, - self.withdrawn_bytes.len() - ) + match self { + Self::Ipv4Unicast(inner) => write!( + f, + "MpUnreachNlri::Ipv4Unicast[withdrawn={}]", + inner.withdrawn.len() + ), + Self::Ipv6Unicast(inner) => write!( + f, + "MpUnreachNlri::Ipv6Unicast[withdrawn={}]", + inner.withdrawn.len() + ), + } } } @@ -3910,111 +4528,558 @@ impl slog::Value for Safi { } // ============================================================================ -// API Compatibility Types (VERSION_INITIAL / v1.0.0) +// BGP Message Parse Error Types // ============================================================================ -// These types maintain backward compatibility with the INITIAL API version. -// They support IPv4-only prefixes as the INITIAL release predates IPv6 support. -// Used exclusively for API responses via /bgp/message-history endpoint (v1). -// Never used internally - always convert from current types at API boundary. +// These types support revised UPDATE message error handling per RFC 7606, +// which relaxes the "session reset" approach in RFC 4271 §6.3 for UPDATE +// errors. Instead, most attribute errors trigger "treat-as-withdraw" behavior +// allowing sessions to remain established while individual routes are +// withdrawn. // -// Delete these types when VERSION_INITIAL is retired (MGD_API_VERSION_INITIAL -// is no longer supported by dropping support for v1.0.0 API clients). +// Design: +// - UpdateParseErrorReason: Enum encoding all possible parse error reasons +// with Display impl for human-readable messages. +// - UpdateParseError: Fatal UPDATE errors requiring session reset. +// - MessageParseError: Wrapper for all message types' parse errors. +// - UpdateMessage.treat_as_withdraw: Bool set when treat-as-withdraw occurs +// during parsing. false = normal, true = process all NLRI as withdrawals. +// - UpdateMessage.errors: Vec collecting all non-fatal parse errors encountered. + +/// All possible reasons for UPDATE parse errors. +/// +/// This enum codifies error reasons instead of using strings, providing +/// type safety and consistent error messages via the Display impl. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateParseErrorReason { + // Frame structure errors (fatal) + /// Withdrawn routes length exceeds available bytes + InvalidWithdrawnLength { declared: u16, available: usize }, + /// Path attributes length exceeds available bytes + InvalidAttributeLength { declared: u16, available: usize }, + + // Attribute parsing errors + /// Next-hop attribute has wrong length + MalformedNextHop { expected: usize, got: usize }, + /// Origin attribute has invalid value + InvalidOriginValue { value: u8 }, + /// AS_PATH attribute is malformed + MalformedAsPath { detail: String }, + /// Attribute flags are invalid for this type + InvalidAttributeFlags { type_code: u8, flags: u8 }, + + // MP-BGP errors + /// Duplicate MP_REACH_NLRI attribute in UPDATE + DuplicateMpReachNlri, + /// Duplicate MP_UNREACH_NLRI attribute in UPDATE + DuplicateMpUnreachNlri, + /// AFI/SAFI combination not recognized (raw bytes) + UnsupportedAfiSafi { afi: u16, safi: u8 }, + /// MP next-hop has invalid length for AFI + InvalidMpNextHopLength { + afi: u16, + expected: &'static str, + got: usize, + }, -/// V1 Prefix type for API compatibility (/bgp/message-history) -/// Maintains the old serialization format: {"length": u8, "value": Vec} -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema, -)] -pub struct PrefixV1 { - pub length: u8, - pub value: Vec, -} + // Attribute-specific errors + /// Missing mandatory well-known attribute + MissingAttribute { type_code: PathAttributeTypeCode }, + /// Attribute has invalid length for its type + AttributeLengthError { + type_code: PathAttributeTypeCode, + expected: usize, + got: usize, + }, + /// Unrecognized mandatory attribute (not optional/transitive) + UnrecognizedMandatoryAttribute { type_code: u8 }, + /// Attribute parsing failed (generic fallback for nom errors) + AttributeParseError { + type_code: Option, + detail: String, + }, -impl From for PrefixV1 { - fn from(prefix: Prefix) -> Self { - // Convert new Prefix enum to old struct format using wire format: - // length byte followed by prefix octets. - let wire_bytes = match &prefix { - Prefix::V4(p) => p.to_wire(), - Prefix::V6(p) => p.to_wire(), - }; + // NLRI/prefix parsing errors + /// NLRI section is empty when prefix length byte expected + NlriMissingLength { + /// Which section: "nlri", "withdrawn", "mp_reach", "mp_unreach" + section: &'static str, + }, + /// Prefix length exceeds maximum for address family (32 for IPv4, 128 for IPv6) + InvalidNlriMask { + section: &'static str, + length: u8, + max: u8, + }, + /// Not enough bytes for declared prefix length + TruncatedNlri { + section: &'static str, + needed: usize, + available: usize, + }, - // First byte is length, remaining bytes are the address octets - let length = wire_bytes[0]; - let value = wire_bytes[1..].to_vec(); - Self { length, value } + // Generic fallback + /// Other parse error (avoid if possible; prefer specific variant) + Other { detail: String }, +} + +impl Display for UpdateParseErrorReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidWithdrawnLength { + declared, + available, + } => { + write!( + f, + "withdrawn length {} exceeds available {}", + declared, available + ) + } + Self::InvalidAttributeLength { + declared, + available, + } => { + write!( + f, + "attribute length {} exceeds available {}", + declared, available + ) + } + Self::MalformedNextHop { expected, got } => { + write!( + f, + "next-hop length mismatch: expected {}, got {}", + expected, got + ) + } + Self::InvalidOriginValue { value } => { + write!(f, "invalid ORIGIN value: {}", value) + } + Self::MalformedAsPath { detail } => { + write!(f, "malformed AS_PATH: {}", detail) + } + Self::InvalidAttributeFlags { type_code, flags } => { + write!( + f, + "invalid flags 0x{:02x} for attribute type {}", + flags, type_code + ) + } + Self::DuplicateMpReachNlri => { + write!(f, "duplicate MP_REACH_NLRI attribute") + } + Self::DuplicateMpUnreachNlri => { + write!(f, "duplicate MP_UNREACH_NLRI attribute") + } + Self::UnsupportedAfiSafi { afi, safi } => { + write!(f, "unsupported AFI/SAFI: {}/{}", afi, safi) + } + Self::InvalidMpNextHopLength { afi, expected, got } => { + write!( + f, + "invalid MP next-hop length for AFI {}: expected {}, got {}", + afi, expected, got + ) + } + Self::MissingAttribute { type_code } => { + write!(f, "missing mandatory attribute: {:?}", type_code) + } + Self::AttributeLengthError { + type_code, + expected, + got, + } => { + write!( + f, + "attribute {:?} length error: expected {}, got {}", + type_code, expected, got + ) + } + Self::UnrecognizedMandatoryAttribute { type_code } => { + write!(f, "unrecognized mandatory attribute: {}", type_code) + } + Self::AttributeParseError { type_code, detail } => { + match type_code { + Some(tc) => { + write!(f, "attribute {} parse error: {}", tc, detail) + } + None => write!(f, "attribute parse error: {}", detail), + } + } + Self::NlriMissingLength { section } => { + write!(f, "{} NLRI missing prefix length byte", section) + } + Self::InvalidNlriMask { + section, + length, + max, + } => { + write!( + f, + "{} NLRI prefix length {} exceeds maximum {}", + section, length, max + ) + } + Self::TruncatedNlri { + section, + needed, + available, + } => { + write!( + f, + "truncated {} NLRI: need {} bytes, have {}", + section, needed, available + ) + } + Self::Other { detail } => { + write!(f, "{}", detail) + } + } } } -/// V1 UpdateMessage type for API compatibility -/// Uses PrefixV1 for NLRI and withdrawn prefixes -#[derive( - Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, JsonSchema, -)] -pub struct UpdateMessageV1 { - pub withdrawn: Vec, - pub path_attributes: Vec, - pub nlri: Vec, +/// Parsed path attributes from wire format. +/// +/// Note: Existence of this struct means no fatal (SessionReset) errors occurred. +/// The parse may still have collected non-fatal errors that require +/// TreatAsWithdraw or Discard handling. +pub struct ParsedPathAttrs { + /// Successfully parsed attributes + pub attrs: Vec, + /// All non-fatal errors collected during parsing + pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, + /// True if any TreatAsWithdraw error occurred + pub treat_as_withdraw: bool, } -impl From for UpdateMessageV1 { - fn from(msg: UpdateMessage) -> Self { - Self { - withdrawn: msg - .withdrawn - .into_iter() - .map(|p| PrefixV1::from(Prefix::V4(p))) - .collect(), - path_attributes: msg.path_attributes, - nlri: msg - .nlri - .into_iter() - .map(|p| PrefixV1::from(Prefix::V4(p))) - .collect(), - } +/// Fatal UPDATE parse error requiring session reset. +/// +/// Returned by `UpdateMessage::from_wire()` when the error cannot be handled +/// via treat-as-withdraw (e.g., NLRI parse failure, frame structure errors). +#[derive(Debug, Clone)] +pub struct UpdateParseError { + pub error_code: ErrorCode, + pub error_subcode: ErrorSubcode, + pub reason: UpdateParseErrorReason, +} + +impl Display for UpdateParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?}/{:?}: {}", + self.error_code, self.error_subcode, self.reason + ) } } -/// V1 Message enum for API compatibility -/// Uses V1 types for Message variants -#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", content = "value", rename_all = "snake_case")] -pub enum MessageV1 { - Open(OpenMessage), - Update(UpdateMessageV1), - Notification(NotificationMessage), - KeepAlive, - RouteRefresh(RouteRefreshMessage), +/// All possible reasons for OPEN parse errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenParseErrorReason { + /// Version number not supported + InvalidVersion { version: u8 }, + /// Hold time is invalid + InvalidHoldTime { hold_time: u16 }, + /// Capability not supported + UnsupportedCapability { code: u8 }, + /// Message too small for required field + TooSmall { field: &'static str }, + /// Other parse error + Other { detail: String }, } -impl From for MessageV1 { - fn from(msg: Message) -> Self { - match msg { - Message::Open(open) => Self::Open(open), - Message::Update(update) => { - Self::Update(UpdateMessageV1::from(update)) +impl Display for OpenParseErrorReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidVersion { version } => { + write!(f, "unsupported version: {}", version) } - Message::Notification(notif) => Self::Notification(notif), - Message::KeepAlive => Self::KeepAlive, - Message::RouteRefresh(rr) => Self::RouteRefresh(rr), + Self::InvalidHoldTime { hold_time } => { + write!(f, "invalid hold time: {}", hold_time) + } + Self::UnsupportedCapability { code } => { + write!(f, "unsupported capability: {}", code) + } + Self::TooSmall { field } => { + write!(f, "message too small for {}", field) + } + Self::Other { detail } => write!(f, "{}", detail), } } } -#[cfg(test)] -mod tests { - use super::*; - use mg_common::{cidr, ip, parse}; - use pretty_assertions::assert_eq; - use pretty_hex::*; - use std::net::{Ipv4Addr, Ipv6Addr}; - use std::str::FromStr; +/// Fatal OPEN parse error requiring session reset. +#[derive(Debug, Clone)] +pub struct OpenParseError { + pub error_code: ErrorCode, + pub error_subcode: ErrorSubcode, + pub reason: OpenParseErrorReason, +} - #[derive(Debug)] - struct PrefixConversionTestCase { - description: &'static str, - address_family: AddressFamily, - prefix_length: u8, +impl Display for OpenParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?}/{:?}: {}", + self.error_code, self.error_subcode, self.reason + ) + } +} + +/// All possible reasons for NOTIFICATION parse errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotificationParseErrorReason { + /// Message too small for required field + TooSmall { field: &'static str }, + /// Invalid error code + InvalidErrorCode { code: u8 }, + /// Other parse error + Other { detail: String }, +} + +impl Display for NotificationParseErrorReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooSmall { field } => { + write!(f, "message too small for {}", field) + } + Self::InvalidErrorCode { code } => { + write!(f, "invalid error code: {}", code) + } + Self::Other { detail } => write!(f, "{}", detail), + } + } +} + +/// Fatal NOTIFICATION parse error. +#[derive(Debug, Clone)] +pub struct NotificationParseError { + pub error_code: ErrorCode, + pub error_subcode: ErrorSubcode, + pub reason: NotificationParseErrorReason, +} + +impl Display for NotificationParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?}/{:?}: {}", + self.error_code, self.error_subcode, self.reason + ) + } +} + +/// All possible reasons for ROUTE_REFRESH parse errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RouteRefreshParseErrorReason { + /// Message too small for required field + TooSmall { field: &'static str }, + /// Invalid AFI value + InvalidAfi { afi: u16 }, + /// Other parse error + Other { detail: String }, +} + +impl Display for RouteRefreshParseErrorReason { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::TooSmall { field } => { + write!(f, "message too small for {}", field) + } + Self::InvalidAfi { afi } => write!(f, "invalid AFI: {}", afi), + Self::Other { detail } => write!(f, "{}", detail), + } + } +} + +/// Fatal ROUTE_REFRESH parse error. +#[derive(Debug, Clone)] +pub struct RouteRefreshParseError { + pub error_code: ErrorCode, + pub error_subcode: ErrorSubcode, + pub reason: RouteRefreshParseErrorReason, +} + +impl Display for RouteRefreshParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:?}/{:?}: {}", + self.error_code, self.error_subcode, self.reason + ) + } +} + +/// Wrapper enum identifying which message type caused a fatal parse error. +/// +/// Used by the connection layer to send `ConnectionEvent::ParseError` to the +/// session FSM. All variants represent fatal errors requiring session reset. +#[derive(Debug, Clone)] +pub enum MessageParseError { + Update(UpdateParseError), + Open(OpenParseError), + Notification(NotificationParseError), + RouteRefresh(RouteRefreshParseError), +} + +impl MessageParseError { + /// Returns a human-readable description of the error for logging/history. + pub fn description(&self) -> String { + match self { + Self::Update(e) => format!("UPDATE: {}", e), + Self::Open(e) => format!("OPEN: {}", e), + Self::Notification(e) => format!("NOTIFICATION: {}", e), + Self::RouteRefresh(e) => format!("ROUTE_REFRESH: {}", e), + } + } + + /// Returns the error codes for sending a NOTIFICATION message. + pub fn error_codes(&self) -> (ErrorCode, ErrorSubcode) { + match self { + Self::Update(e) => (e.error_code, e.error_subcode), + Self::Open(e) => (e.error_code, e.error_subcode), + Self::Notification(e) => (e.error_code, e.error_subcode), + Self::RouteRefresh(e) => (e.error_code, e.error_subcode), + } + } + + /// Returns the message type that caused the error. + pub fn message_type(&self) -> &'static str { + match self { + Self::Update(_) => "UPDATE", + Self::Open(_) => "OPEN", + Self::Notification(_) => "NOTIFICATION", + Self::RouteRefresh(_) => "ROUTE_REFRESH", + } + } +} + +impl Display for MessageParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.description()) + } +} + +/// RFC 7606 action classification for path attribute errors. +/// +/// This enum carries no data - the error reason is stored separately +/// in `UpdateParseErrorReason`. Ordered from strongest to weakest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributeAction { + /// Terminate session with NOTIFICATION. + SessionReset, + /// Treat all NLRI in message as withdrawn. + TreatAsWithdraw, + /// Silently discard the attribute. + Discard, +} + +// ============================================================================ +// API Compatibility Types (VERSION_INITIAL / v1.0.0) +// ============================================================================ +// These types maintain backward compatibility with the INITIAL API version. +// They support IPv4-only prefixes as the INITIAL release predates IPv6 support. +// Used exclusively for API responses via /bgp/message-history endpoint (v1). +// Never used internally - always convert from current types at API boundary. +// +// Delete these types when VERSION_INITIAL is retired (MGD_API_VERSION_INITIAL +// is no longer supported by dropping support for v1.0.0 API clients). + +/// V1 Prefix type for API compatibility (/bgp/message-history) +/// Maintains the old serialization format: {"length": u8, "value": Vec} +#[derive( + Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema, +)] +pub struct PrefixV1 { + pub length: u8, + pub value: Vec, +} + +impl From for PrefixV1 { + fn from(prefix: Prefix) -> Self { + // Convert new Prefix enum to old struct format using wire format: + // length byte followed by prefix octets. + let wire_bytes = match &prefix { + Prefix::V4(p) => p.to_wire(), + Prefix::V6(p) => p.to_wire(), + }; + + // First byte is length, remaining bytes are the address octets + let length = wire_bytes[0]; + let value = wire_bytes[1..].to_vec(); + Self { length, value } + } +} + +/// V1 UpdateMessage type for API compatibility +/// Uses PrefixV1 for NLRI and withdrawn prefixes +#[derive( + Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, JsonSchema, +)] +pub struct UpdateMessageV1 { + pub withdrawn: Vec, + pub path_attributes: Vec, + pub nlri: Vec, +} + +impl From for UpdateMessageV1 { + fn from(msg: UpdateMessage) -> Self { + Self { + withdrawn: msg + .withdrawn + .into_iter() + .map(|p| PrefixV1::from(Prefix::V4(p))) + .collect(), + path_attributes: msg.path_attributes, + nlri: msg + .nlri + .into_iter() + .map(|p| PrefixV1::from(Prefix::V4(p))) + .collect(), + } + } +} + +/// V1 Message enum for API compatibility +/// Uses V1 types for Message variants +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum MessageV1 { + Open(OpenMessage), + Update(UpdateMessageV1), + Notification(NotificationMessage), + KeepAlive, + RouteRefresh(RouteRefreshMessage), +} + +impl From for MessageV1 { + fn from(msg: Message) -> Self { + match msg { + Message::Open(open) => Self::Open(open), + Message::Update(update) => { + Self::Update(UpdateMessageV1::from(update)) + } + Message::Notification(notif) => Self::Notification(notif), + Message::KeepAlive => Self::KeepAlive, + Message::RouteRefresh(rr) => Self::RouteRefresh(rr), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mg_common::{cidr, ip, parse}; + use pretty_assertions::assert_eq; + use pretty_hex::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + #[derive(Debug)] + struct PrefixConversionTestCase { + description: &'static str, + address_family: AddressFamily, + prefix_length: u8, input_bytes: Vec, expected_address: &'static str, } @@ -4093,21 +5158,40 @@ mod tests { std::net::Ipv4Addr::new(0, 23, 1, 12), 32, )], - path_attributes: vec![PathAttribute { - typ: PathAttributeType { - flags: path_attribute_flags::OPTIONAL - | path_attribute_flags::PARTIAL, - type_code: PathAttributeTypeCode::As4Path, + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::TRANSITIVE, + type_code: PathAttributeTypeCode::Origin, + }, + value: PathAttributeValue::Origin(PathOrigin::Igp), }, - value: PathAttributeValue::As4Path(vec![As4PathSegment { - typ: AsPathType::AsSequence, - value: vec![395849, 123456, 987654, 111111], - }]), - }], + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::TRANSITIVE, + type_code: PathAttributeTypeCode::AsPath, + }, + value: PathAttributeValue::As4Path(vec![As4PathSegment { + typ: AsPathType::AsSequence, + value: vec![395849, 123456, 987654, 111111], + }]), + }, + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::TRANSITIVE, + type_code: PathAttributeTypeCode::NextHop, + }, + value: PathAttributeValue::NextHop( + std::net::Ipv4Addr::new(192, 0, 2, 1), + ), + }, + ], nlri: vec![ rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 13), 32), rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 14), 32), ], + treat_as_withdraw: false, + errors: vec![], }; let buf = um0.to_wire().expect("update message to wire"); @@ -4426,20 +5510,58 @@ mod tests { buf.push(24); // prefix length buf.extend_from_slice(&[198, 51, 100]); // prefix bytes - // Try to parse - should fail with BadLength error + // With RFC 7606 error handling, path attribute errors result in + // TreatAsWithdraw. The parsing succeeds but returns UpdateMessage + // with treat_as_withdraw = true and error in errors vec. let result = UpdateMessage::from_wire(&buf); + assert!(result.is_ok(), "Expected Ok with treat_as_withdraw set"); + + let msg = result.unwrap(); assert!( - result.is_err(), - "Expected parsing to fail with bad NEXT_HOP length" + msg.treat_as_withdraw, + "Expected treat_as_withdraw to be true for bad NEXT_HOP length" ); - match result.unwrap_err() { - Error::BadLength { expected, found } => { - assert_eq!(expected, 4, "Expected length should be 4"); - assert_eq!(found, 16, "Found length should be 16"); - } - other => panic!("Expected BadLength error, got: {:?}", other), + // Verify errors: MalformedNextHop parse error + MissingAttribute + // (malformed NEXT_HOP doesn't count as present for mandatory attr check) + assert_eq!(msg.errors.len(), 2, "Expected two errors"); + + // First error: MalformedNextHop from parsing + let (reason, action) = &msg.errors[0]; + assert!( + matches!(action, AttributeAction::TreatAsWithdraw), + "Expected TreatAsWithdraw action" + ); + match reason { + UpdateParseErrorReason::MalformedNextHop { expected, got } => { + assert_eq!(*expected, 4, "Expected length should be 4"); + assert_eq!(*got, 16, "Got length should be 16"); + } + other => panic!( + "Expected MalformedNextHop {{ expected: 4, got: 16 }}, got {:?}", + other + ), } + + // Second error: MissingAttribute for NEXT_HOP (malformed doesn't count) + let (reason2, action2) = &msg.errors[1]; + assert!( + matches!(action2, AttributeAction::TreatAsWithdraw), + "Expected TreatAsWithdraw action for missing attr" + ); + assert!( + matches!( + reason2, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop + } + ), + "Second error should be MissingAttribute for NextHop, got {:?}", + reason2 + ); + + // The NLRI should still be parsed (for processing as withdrawals) + assert!(!msg.nlri.is_empty(), "Expected NLRI to be present"); } // ========================================================================= @@ -4514,27 +5636,34 @@ mod tests { } // ========================================================================= - // MpReach tests + // MpReachNlri tests // ========================================================================= #[test] - fn mp_reach_new_v4() { + fn mp_reach_nlri_ipv4_unicast() { let nh = BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)); let nlri = vec![ - Prefix::V4(rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)), - Prefix::V4(rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12)), + rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8), + rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12), ]; - let mp_reach = MpReach::new(Afi::Ipv4, nh, nlri); + let mp_reach = MpReachNlri::ipv4_unicast(nh, nlri.clone()); - assert_eq!(mp_reach.afi, Afi::Ipv4 as u16); - assert_eq!(mp_reach.safi, Safi::Unicast as u8); - assert_eq!(mp_reach.nh_len, 4); - assert_eq!(mp_reach.reserved, 0); + assert_eq!(mp_reach.afi(), Afi::Ipv4); + assert_eq!(mp_reach.safi(), Safi::Unicast); + assert_eq!(mp_reach.nexthop(), &nh); + assert_eq!(mp_reach.len(), 2); + + // Verify inner struct + if let MpReachNlri::Ipv4Unicast(inner) = &mp_reach { + assert_eq!(inner.nlri, nlri); + } else { + panic!("Expected Ipv4Unicast variant"); + } } #[test] - fn mp_reach_new_v6() { + fn mp_reach_nlri_ipv6_unicast() { let nh = BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); let nlri = vec![ @@ -4542,16 +5671,23 @@ mod tests { rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:2::").unwrap(), 48), ]; - let mp_reach = MpReach::new_v6(nh, nlri); + let mp_reach = MpReachNlri::ipv6_unicast(nh, nlri.clone()); + + assert_eq!(mp_reach.afi(), Afi::Ipv6); + assert_eq!(mp_reach.safi(), Safi::Unicast); + assert_eq!(mp_reach.nexthop(), &nh); + assert_eq!(mp_reach.len(), 2); - assert_eq!(mp_reach.afi, Afi::Ipv6 as u16); - assert_eq!(mp_reach.safi, Safi::Unicast as u8); - assert_eq!(mp_reach.nh_len, 16); - assert_eq!(mp_reach.reserved, 0); + // Verify inner struct + if let MpReachNlri::Ipv6Unicast(inner) = &mp_reach { + assert_eq!(inner.nlri, nlri); + } else { + panic!("Expected Ipv6Unicast variant"); + } } #[test] - fn mp_reach_round_trip() { + fn mp_reach_nlri_round_trip() { let nh = BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); let nlri = vec![rdb::Prefix6::new( @@ -4559,144 +5695,108 @@ mod tests { 48, )]; - let original = MpReach::new_v6(nh, nlri); + let original = MpReachNlri::ipv6_unicast(nh, nlri.clone()); let wire = original.to_wire().expect("to_wire should succeed"); let (remaining, parsed) = - MpReach::from_wire(&wire).expect("from_wire should succeed"); + MpReachNlri::from_wire(&wire).expect("from_wire should succeed"); assert!(remaining.is_empty(), "all bytes should be consumed"); - assert_eq!(original.afi, parsed.afi); - assert_eq!(original.safi, parsed.safi); - assert_eq!(original.nh_len, parsed.nh_len); - assert_eq!(original.nh_bytes, parsed.nh_bytes); - assert_eq!(original.nlri_bytes, parsed.nlri_bytes); + assert_eq!(original.afi(), parsed.afi()); + assert_eq!(original.safi(), parsed.safi()); + assert_eq!(original.nexthop(), parsed.nexthop()); + + // Verify the NLRI matches + if let ( + MpReachNlri::Ipv6Unicast(orig_inner), + MpReachNlri::Ipv6Unicast(parsed_inner), + ) = (&original, &parsed) + { + assert_eq!(orig_inner.nlri, parsed_inner.nlri); + } else { + panic!("Expected both to be Ipv6Unicast variants"); + } } // ========================================================================= - // MpUnreach tests + // MpUnreachNlri tests // ========================================================================= #[test] - fn mp_unreach_new_v6() { + fn mp_unreach_nlri_ipv6_unicast() { let withdrawn = vec![ rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:1::").unwrap(), 48), rdb::Prefix6::new(Ipv6Addr::from_str("2001:db8:2::").unwrap(), 48), ]; - let mp_unreach = MpUnreach::new_v6(withdrawn); + let mp_unreach = MpUnreachNlri::ipv6_unicast(withdrawn.clone()); - assert_eq!(mp_unreach.afi_raw, Afi::Ipv6 as u16); - assert_eq!(mp_unreach.safi_raw, Safi::Unicast as u8); + assert_eq!(mp_unreach.afi(), Afi::Ipv6); + assert_eq!(mp_unreach.safi(), Safi::Unicast); + assert_eq!(mp_unreach.len(), 2); + + // Verify inner struct + if let MpUnreachNlri::Ipv6Unicast(inner) = &mp_unreach { + assert_eq!(inner.withdrawn, withdrawn); + } else { + panic!("Expected Ipv6Unicast variant"); + } } #[test] - fn mp_unreach_round_trip() { + fn mp_unreach_nlri_round_trip() { let withdrawn = vec![rdb::Prefix6::new( Ipv6Addr::from_str("2001:db8:dead::").unwrap(), 48, )]; - let original = MpUnreach::new_v6(withdrawn); + let original = MpUnreachNlri::ipv6_unicast(withdrawn.clone()); let wire = original.to_wire().expect("to_wire should succeed"); let (remaining, parsed) = - MpUnreach::from_wire(&wire).expect("from_wire should succeed"); + MpUnreachNlri::from_wire(&wire).expect("from_wire should succeed"); assert!(remaining.is_empty(), "all bytes should be consumed"); - assert_eq!(original.afi_raw, parsed.afi_raw); - assert_eq!(original.safi_raw, parsed.safi_raw); - assert_eq!(original.withdrawn_bytes, parsed.withdrawn_bytes); + assert_eq!(original.afi(), parsed.afi()); + assert_eq!(original.safi(), parsed.safi()); + + // Verify the withdrawn prefixes match + if let ( + MpUnreachNlri::Ipv6Unicast(orig_inner), + MpUnreachNlri::Ipv6Unicast(parsed_inner), + ) = (&original, &parsed) + { + assert_eq!(orig_inner.withdrawn, parsed_inner.withdrawn); + } else { + panic!("Expected both to be Ipv6Unicast variants"); + } } // ========================================================================= // RFC 7606 validation tests // ========================================================================= + /// Test that MP-BGP attributes are always encoded first in the wire format, + /// regardless of their position in the path_attributes vector. + /// + /// RFC 7606 Section 5.1 requires: + /// "The MP_REACH_NLRI or MP_UNREACH_NLRI attribute (if present) SHALL + /// be encoded as the very first path attribute in an UPDATE message." #[test] - fn check_duplicate_mp_attributes_none() { - let update = UpdateMessage::default(); - assert!(update.check_duplicate_mp_attributes().is_ok()); - } - - #[test] - fn check_duplicate_mp_attributes_single_reach() { - let mp_reach = MpReach::new_v6( + fn mp_bgp_attributes_encoded_first() { + let mp_reach = MpReachNlri::ipv6_unicast( BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), vec![], ); + // Create an UpdateMessage with MP-BGP attribute NOT first in the vector let update = UpdateMessage { withdrawn: vec![], - path_attributes: vec![PathAttribute { - typ: PathAttributeType { - flags: path_attribute_flags::OPTIONAL, - type_code: PathAttributeTypeCode::MpReachNlri, - }, - value: PathAttributeValue::MpReachNlri(mp_reach), - }], - nlri: vec![], - }; - - assert!(update.check_duplicate_mp_attributes().is_ok()); - } - - #[test] - fn check_duplicate_mp_attributes_duplicate_reach() { - let mp_reach1 = MpReach::new_v6( - BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), - vec![], - ); - let mp_reach2 = MpReach::new_v6( - BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::2").unwrap()), - vec![], - ); - - let update = UpdateMessage { - withdrawn: vec![], - path_attributes: vec![ - PathAttribute { - typ: PathAttributeType { - flags: path_attribute_flags::OPTIONAL, - type_code: PathAttributeTypeCode::MpReachNlri, - }, - value: PathAttributeValue::MpReachNlri(mp_reach1), - }, - PathAttribute { - typ: PathAttributeType { - flags: path_attribute_flags::OPTIONAL, - type_code: PathAttributeTypeCode::MpReachNlri, - }, - value: PathAttributeValue::MpReachNlri(mp_reach2), - }, - ], - nlri: vec![], - }; - - assert!(update.check_duplicate_mp_attributes().is_err()); - } - - /// Test that MP-BGP attributes are always encoded first in the wire format, - /// regardless of their position in the path_attributes vector. - /// - /// RFC 7606 Section 5.1 requires: - /// "The MP_REACH_NLRI or MP_UNREACH_NLRI attribute (if present) SHALL - /// be encoded as the very first path attribute in an UPDATE message." - #[test] - fn mp_bgp_attributes_encoded_first() { - let mp_reach = MpReach::new_v6( - BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), - vec![], - ); - - // Create an UpdateMessage with MP-BGP attribute NOT first in the vector - let update = UpdateMessage { - withdrawn: vec![], - path_attributes: vec![ - PathAttribute { - typ: PathAttributeType { - flags: path_attribute_flags::TRANSITIVE, - type_code: PathAttributeTypeCode::Origin, - }, - value: PathAttributeValue::Origin(PathOrigin::Igp), + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::TRANSITIVE, + type_code: PathAttributeTypeCode::Origin, + }, + value: PathAttributeValue::Origin(PathOrigin::Igp), }, PathAttribute { typ: PathAttributeType { @@ -4707,6 +5807,8 @@ mod tests { }, ], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; // Encode to wire format @@ -4731,7 +5833,7 @@ mod tests { #[test] fn decoding_accepts_mixed_nlri_encoding() { // Create an UPDATE with both traditional NLRI and MP_REACH_NLRI - let mp_reach = MpReach::new_v6( + let mp_reach = MpReachNlri::ipv6_unicast( BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), vec![rdb::Prefix6::new( Ipv6Addr::from_str("2001:db8::").unwrap(), @@ -4749,6 +5851,8 @@ mod tests { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)], + treat_as_withdraw: false, + errors: vec![], }; // Encode to wire and decode back - should succeed @@ -4775,7 +5879,7 @@ mod tests { /// in the same UPDATE message (RFC 7606 Section 5.1 interoperability). #[test] fn decoding_accepts_reach_and_unreach_together() { - let mp_reach = MpReach::new_v6( + let mp_reach = MpReachNlri::ipv6_unicast( BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), vec![rdb::Prefix6::new( Ipv6Addr::from_str("2001:db8:1::").unwrap(), @@ -4783,7 +5887,7 @@ mod tests { )], ); - let mp_unreach = MpUnreach::new_v6(vec![rdb::Prefix6::new( + let mp_unreach = MpUnreachNlri::ipv6_unicast(vec![rdb::Prefix6::new( Ipv6Addr::from_str("2001:db8:2::").unwrap(), 48, )]); @@ -4807,6 +5911,8 @@ mod tests { }, ], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; // Encode to wire and decode back - should succeed @@ -4888,4 +5994,1249 @@ mod tests { "should keep the first ORIGIN (IGP), not the second (EGP)" ); } + + /// Tests for RFC 7606 attribute error actions. + mod rfc7606_attribute_actions { + use crate::messages::{ + AttributeAction, PathAttributeType, PathAttributeTypeCode, + path_attribute_flags, + }; + + /// Helper to construct PathAttributeType for testing + fn make_typ( + type_code: PathAttributeTypeCode, + flags: u8, + ) -> PathAttributeType { + PathAttributeType { flags, type_code } + } + + #[test] + fn well_known_mandatory_returns_treat_as_withdraw() { + // ORIGIN, AS_PATH, NEXT_HOP are well-known mandatory + // RFC 7606 Section 7.1-7.3: errors should result in treat-as-withdraw + let well_known_flags = path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ(PathAttributeTypeCode::Origin, well_known_flags) + .error_action(), + AttributeAction::TreatAsWithdraw, + "ORIGIN errors should treat-as-withdraw" + ); + assert_eq!( + make_typ(PathAttributeTypeCode::AsPath, well_known_flags) + .error_action(), + AttributeAction::TreatAsWithdraw, + "AS_PATH errors should treat-as-withdraw" + ); + assert_eq!( + make_typ(PathAttributeTypeCode::NextHop, well_known_flags) + .error_action(), + AttributeAction::TreatAsWithdraw, + "NEXT_HOP errors should treat-as-withdraw" + ); + } + + #[test] + fn multi_exit_disc_returns_treat_as_withdraw() { + // RFC 7606 Section 7.4: MED affects route selection + let optional_flags = path_attribute_flags::OPTIONAL; + + assert_eq!( + make_typ(PathAttributeTypeCode::MultiExitDisc, optional_flags) + .error_action(), + AttributeAction::TreatAsWithdraw, + "MULTI_EXIT_DISC errors should treat-as-withdraw" + ); + } + + #[test] + fn local_pref_returns_treat_as_withdraw() { + // RFC 7606 Section 7.5: LOCAL_PREF affects route selection + let well_known_flags = path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ(PathAttributeTypeCode::LocalPref, well_known_flags) + .error_action(), + AttributeAction::TreatAsWithdraw, + "LOCAL_PREF errors should treat-as-withdraw" + ); + } + + #[test] + fn communities_returns_treat_as_withdraw() { + // RFC 7606 Section 7.8: Communities affect policy/route selection + let optional_transitive_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ( + PathAttributeTypeCode::Communities, + optional_transitive_flags + ) + .error_action(), + AttributeAction::TreatAsWithdraw, + "Communities errors should treat-as-withdraw" + ); + } + + #[test] + fn as4_path_returns_treat_as_withdraw() { + // AS4_PATH is treated same as AS_PATH + let optional_transitive_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ( + PathAttributeTypeCode::As4Path, + optional_transitive_flags + ) + .error_action(), + AttributeAction::TreatAsWithdraw, + "AS4_PATH errors should treat-as-withdraw" + ); + } + + #[test] + fn atomic_aggregate_returns_discard() { + // RFC 7606 Section 7.6: ATOMIC_AGGREGATE is informational only + let well_known_flags = path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ( + PathAttributeTypeCode::AtomicAggregate, + well_known_flags + ) + .error_action(), + AttributeAction::Discard, + "ATOMIC_AGGREGATE errors should be discarded" + ); + } + + #[test] + fn aggregator_returns_discard() { + // RFC 7606 Section 7.7: AGGREGATOR is informational only + let optional_transitive_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + + assert_eq!( + make_typ( + PathAttributeTypeCode::Aggregator, + optional_transitive_flags + ) + .error_action(), + AttributeAction::Discard, + "AGGREGATOR errors should be discarded" + ); + assert_eq!( + make_typ( + PathAttributeTypeCode::As4Aggregator, + optional_transitive_flags + ) + .error_action(), + AttributeAction::Discard, + "AS4_AGGREGATOR errors should be discarded" + ); + } + + #[test] + fn mp_bgp_returns_session_reset() { + // MP-BGP errors should cause session reset since we never + // negotiate AFI/SAFIs we don't support + let optional_flags = path_attribute_flags::OPTIONAL; + + assert_eq!( + make_typ(PathAttributeTypeCode::MpReachNlri, optional_flags) + .error_action(), + AttributeAction::SessionReset, + "MP_REACH_NLRI errors should cause session reset" + ); + assert_eq!( + make_typ(PathAttributeTypeCode::MpUnreachNlri, optional_flags) + .error_action(), + AttributeAction::SessionReset, + "MP_UNREACH_NLRI errors should cause session reset" + ); + } + } + + /// Tests for RFC 7606 attribute flag validation. + mod rfc7606_flag_validation { + use crate::messages::{ + AttributeAction, PathAttributeType, PathAttributeTypeCode, + UpdateParseErrorReason, path_attribute_flags, + validate_attribute_flags, + }; + + /// Helper to construct PathAttributeType for testing + fn make_typ( + type_code: PathAttributeTypeCode, + flags: u8, + ) -> PathAttributeType { + PathAttributeType { flags, type_code } + } + + #[test] + fn well_known_attributes_require_transitive_not_optional() { + // Well-known mandatory/discretionary: Optional=0, Transitive=1 + let correct_flags = path_attribute_flags::TRANSITIVE; + + // Should accept correct flags + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Origin, + correct_flags + )) + .is_ok(), + "ORIGIN with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::AsPath, + correct_flags + )) + .is_ok(), + "AS_PATH with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::NextHop, + correct_flags + )) + .is_ok(), + "NEXT_HOP with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::LocalPref, + correct_flags + )) + .is_ok(), + "LOCAL_PREF with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::AtomicAggregate, + correct_flags + )) + .is_ok(), + "ATOMIC_AGGREGATE with correct flags should be valid" + ); + + // Should reject Optional flag being set + let optional_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Origin, + optional_flags, + )); + assert!(result.is_err(), "ORIGIN with Optional flag should fail"); + let (reason, _action) = result.unwrap_err(); + assert!( + matches!( + reason, + UpdateParseErrorReason::InvalidAttributeFlags { .. } + ), + "should return InvalidAttributeFlags error" + ); + + // Should reject missing Transitive flag + let no_transitive_flags = 0u8; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::AsPath, + no_transitive_flags, + )); + assert!( + result.is_err(), + "AS_PATH without Transitive flag should fail" + ); + } + + #[test] + fn optional_non_transitive_attributes_require_optional_not_transitive() + { + // Optional non-transitive: Optional=1, Transitive=0 + let correct_flags = path_attribute_flags::OPTIONAL; + + // Should accept correct flags + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::MultiExitDisc, + correct_flags + )) + .is_ok(), + "MULTI_EXIT_DISC with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::MpReachNlri, + correct_flags + )) + .is_ok(), + "MP_REACH_NLRI with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::MpUnreachNlri, + correct_flags + )) + .is_ok(), + "MP_UNREACH_NLRI with correct flags should be valid" + ); + + // Should reject Transitive flag being set + let transitive_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::MultiExitDisc, + transitive_flags, + )); + assert!( + result.is_err(), + "MULTI_EXIT_DISC with Transitive flag should fail" + ); + + // Should reject missing Optional flag + let no_optional_flags = 0u8; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::MpReachNlri, + no_optional_flags, + )); + assert!( + result.is_err(), + "MP_REACH_NLRI without Optional flag should fail" + ); + } + + #[test] + fn optional_transitive_attributes_require_both_flags() { + // Optional transitive: Optional=1, Transitive=1 + let correct_flags = path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE; + + // Should accept correct flags + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Aggregator, + correct_flags + )) + .is_ok(), + "AGGREGATOR with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Communities, + correct_flags + )) + .is_ok(), + "Communities with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::As4Path, + correct_flags + )) + .is_ok(), + "AS4_PATH with correct flags should be valid" + ); + assert!( + validate_attribute_flags(&make_typ( + PathAttributeTypeCode::As4Aggregator, + correct_flags + )) + .is_ok(), + "AS4_AGGREGATOR with correct flags should be valid" + ); + + // Should reject missing Optional flag + let no_optional_flags = path_attribute_flags::TRANSITIVE; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Communities, + no_optional_flags, + )); + assert!( + result.is_err(), + "Communities without Optional flag should fail" + ); + + // Should reject missing Transitive flag + let no_transitive_flags = path_attribute_flags::OPTIONAL; + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Aggregator, + no_transitive_flags, + )); + assert!( + result.is_err(), + "AGGREGATOR without Transitive flag should fail" + ); + } + + #[test] + fn invalid_flags_returns_correct_error_details() { + let bad_flags = path_attribute_flags::OPTIONAL; // Wrong for ORIGIN + let result = validate_attribute_flags(&make_typ( + PathAttributeTypeCode::Origin, + bad_flags, + )); + + let (reason, action) = result.expect_err("should return error"); + + match reason { + UpdateParseErrorReason::InvalidAttributeFlags { + type_code, + flags, + } => { + assert_eq!( + type_code, + PathAttributeTypeCode::Origin as u8, + "should include the attribute type code" + ); + assert_eq!( + flags, bad_flags, + "should include the invalid flags" + ); + } + _ => panic!("expected InvalidAttributeFlags error"), + } + + assert_eq!( + action, + AttributeAction::TreatAsWithdraw, + "ORIGIN flag errors should treat-as-withdraw" + ); + } + } + + /// Tests for RFC 7606 error collection behavior. + /// Verifies that multiple errors are collected and parsing continues + /// after non-fatal (TreatAsWithdraw/Discard) errors. + mod rfc7606_error_collection { + use crate::messages::{ + AttributeAction, PathAttributeTypeCode, UpdateMessage, + UpdateParseErrorReason, path_attribute_flags, + }; + + /// Build an UPDATE message wire format with the given path attributes bytes. + /// Handles the withdrawn and path attrs length headers automatically. + fn build_update_wire(path_attrs: &[u8], nlri: &[u8]) -> Vec { + let mut buf = Vec::new(); + // Withdrawn routes length (0) + buf.extend_from_slice(&0u16.to_be_bytes()); + // Path attributes length + buf.extend_from_slice(&(path_attrs.len() as u16).to_be_bytes()); + buf.extend_from_slice(path_attrs); + buf.extend_from_slice(nlri); + buf + } + + /// Helper to build a valid ORIGIN attribute (IGP). + fn origin_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + PathAttributeTypeCode::Origin as u8, // type 1 + 1, // length + 0, // IGP + ] + } + + /// Helper to build a valid AS_PATH attribute with single AS. + fn as_path_attr(asn: u32) -> Vec { + let mut attr = vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + PathAttributeTypeCode::AsPath as u8, // type 2 + 6, // length: 1 (segment type) + 1 (count) + 4 (ASN) + 2, // AS_SEQUENCE + 1, // 1 ASN in sequence + ]; + attr.extend_from_slice(&asn.to_be_bytes()); + attr + } + + /// Helper to build a valid NEXT_HOP attribute. + fn next_hop_attr(ip: [u8; 4]) -> Vec { + let mut attr = vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + PathAttributeTypeCode::NextHop as u8, // type 3 + 4, // length + ]; + attr.extend_from_slice(&ip); + attr + } + + /// Helper to build a malformed NEXT_HOP attribute (wrong length). + /// NEXT_HOP requires exactly 4 bytes for IPv4. + fn bad_next_hop_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + PathAttributeTypeCode::NextHop as u8, // type 3 + 16, // length - WRONG, should be 4 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 192, + 0, + 2, + 1, // 16 bytes + ] + } + + /// Helper to build a malformed MULTI_EXIT_DISC attribute (wrong length). + /// MED requires exactly 4 bytes. + fn bad_med_attr() -> Vec { + vec![ + path_attribute_flags::OPTIONAL, // flags (0x80) + PathAttributeTypeCode::MultiExitDisc as u8, // type 4 + 2, // length - WRONG, should be 4 + 0, + 100, // only 2 bytes + ] + } + + /// Helper to build a malformed AGGREGATOR attribute (wrong length). + /// AGGREGATOR requires 6 bytes (2-byte AS + 4-byte IP) or 8 bytes (4-byte AS). + fn bad_aggregator_attr() -> Vec { + vec![ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, // 0xC0 + PathAttributeTypeCode::Aggregator as u8, // type 7 + 3, // length - WRONG + 0, + 100, + 1, // only 3 bytes + ] + } + + /// Helper to build a malformed ORIGIN attribute (invalid value). + fn bad_origin_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + PathAttributeTypeCode::Origin as u8, // type 1 + 1, // length + 99, // INVALID - must be 0, 1, or 2 + ] + } + + /// Helper to build NLRI for a /24 prefix. + fn nlri_prefix(a: u8, b: u8, c: u8) -> Vec { + vec![24, a, b, c] + } + + #[test] + fn multiple_treat_as_withdraw_errors_collected() { + // Construct UPDATE with multiple TreatAsWithdraw errors: + // - Bad ORIGIN (invalid value) + // - Bad NEXT_HOP (wrong length) + // - Bad MED (wrong length) + // Plus mandatory attribute validation adds MissingAttribute errors + // for ORIGIN and NEXT_HOP since the malformed ones don't count. + let mut attrs = Vec::new(); + attrs.extend(bad_origin_attr()); // TreatAsWithdraw (parse error) + attrs.extend(as_path_attr(65000)); // Valid + attrs.extend(bad_next_hop_attr()); // TreatAsWithdraw (parse error) + attrs.extend(bad_med_attr()); // TreatAsWithdraw (parse error) + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with errors collected" + ); + let msg = result.unwrap(); + + assert!(msg.treat_as_withdraw, "treat_as_withdraw should be true"); + + // 3 parse errors + 2 missing attr errors (ORIGIN, NEXT_HOP) + assert_eq!( + msg.errors.len(), + 5, + "Expected 5 errors (3 parse + 2 missing), got {}: {:?}", + msg.errors.len(), + msg.errors + ); + + // Verify parse errors are present + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::InvalidOriginValue { value: 99 } + )), + "Should have InvalidOriginValue error" + ); + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MalformedNextHop { + expected: 4, + got: 16 + } + )), + "Should have MalformedNextHop error" + ); + + // Verify MissingAttribute errors for ORIGIN and NEXT_HOP + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin + } + )), + "Should have MissingAttribute error for Origin" + ); + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop + } + )), + "Should have MissingAttribute error for NextHop" + ); + + // Valid AS_PATH should still be parsed + assert_eq!( + msg.path_attributes.len(), + 1, + "Only valid AS_PATH should be in parsed attributes" + ); + } + + #[test] + fn discard_errors_collected_without_treat_as_withdraw() { + // AGGREGATOR errors result in Discard action (not TreatAsWithdraw) + // because AGGREGATOR is informational only. + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); // Valid + attrs.extend(as_path_attr(65000)); // Valid + attrs.extend(next_hop_attr([192, 0, 2, 1])); // Valid + attrs.extend(bad_aggregator_attr()); // Discard + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!(result.is_ok(), "Parsing should succeed"); + let msg = result.unwrap(); + + assert!( + !msg.treat_as_withdraw, + "treat_as_withdraw should be false (Discard doesn't set it)" + ); + + assert_eq!(msg.errors.len(), 1, "Expected 1 error (AGGREGATOR)"); + + let (reason, action) = &msg.errors[0]; + assert!( + matches!(action, AttributeAction::Discard), + "AGGREGATOR error should be Discard, got {:?}", + action + ); + // The actual error type may vary based on how parsing fails + // (UnrecognizedMandatoryAttribute, AttributeLengthError, or AttributeParseError) + assert!( + matches!(reason, UpdateParseErrorReason::AttributeLengthError { .. } | + UpdateParseErrorReason::AttributeParseError { .. } | + UpdateParseErrorReason::UnrecognizedMandatoryAttribute { .. }), + "Error should be one of the attribute error types, got {:?}", + reason + ); + + // All valid attributes should be parsed + assert_eq!( + msg.path_attributes.len(), + 3, + "ORIGIN, AS_PATH, NEXT_HOP should all be parsed" + ); + } + + #[test] + fn mixed_treat_as_withdraw_and_discard_errors() { + // Test that both TreatAsWithdraw and Discard errors are collected, + // and treat_as_withdraw is true when any TaW error is present. + // bad_next_hop also triggers MissingAttribute for NEXT_HOP. + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); // Valid + attrs.extend(bad_aggregator_attr()); // Discard + attrs.extend(as_path_attr(65000)); // Valid + attrs.extend(bad_next_hop_attr()); // TreatAsWithdraw (parse error) + attrs.extend(bad_med_attr()); // TreatAsWithdraw + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!(result.is_ok(), "Parsing should succeed"); + let msg = result.unwrap(); + + assert!( + msg.treat_as_withdraw, + "treat_as_withdraw should be true (TaW errors present)" + ); + + // 3 parse errors + 1 MissingAttribute for NEXT_HOP + assert_eq!( + msg.errors.len(), + 4, + "Expected 4 errors (3 parse + 1 missing), got {}: {:?}", + msg.errors.len(), + msg.errors + ); + + // Verify the different error types are present + assert!( + msg.errors + .iter() + .any(|(_, a)| matches!(a, AttributeAction::Discard)), + "Should have at least one Discard error" + ); + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MalformedNextHop { .. } + )), + "Should have MalformedNextHop error" + ); + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop + } + )), + "Should have MissingAttribute error for NextHop" + ); + + // Valid ORIGIN and AS_PATH should be parsed + assert_eq!( + msg.path_attributes.len(), + 2, + "ORIGIN and AS_PATH should be parsed" + ); + } + + #[test] + fn valid_attributes_after_errors_still_parsed() { + // Verify that valid attributes appearing AFTER errors are still parsed. + // Bad ORIGIN causes both a parse error and a MissingAttribute error. + let mut attrs = Vec::new(); + attrs.extend(bad_origin_attr()); // TreatAsWithdraw - first + attrs.extend(as_path_attr(65000)); // Valid - after error + attrs.extend(next_hop_attr([192, 0, 2, 1])); // Valid - after error + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!(result.is_ok(), "Parsing should succeed"); + let msg = result.unwrap(); + + assert!(msg.treat_as_withdraw, "treat_as_withdraw should be true"); + // 1 parse error (InvalidOriginValue) + 1 MissingAttribute (Origin) + assert_eq!( + msg.errors.len(), + 2, + "ORIGIN parse error + MissingAttribute, got: {:?}", + msg.errors + ); + + // Both valid attributes after the error should be parsed + assert_eq!( + msg.path_attributes.len(), + 2, + "AS_PATH and NEXT_HOP should both be parsed after ORIGIN error" + ); + } + + #[test] + fn no_errors_when_all_attributes_valid() { + // Baseline test: verify no errors when all attributes are valid. + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); + attrs.extend(as_path_attr(65000)); + attrs.extend(next_hop_attr([192, 0, 2, 1])); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!(result.is_ok(), "Parsing should succeed"); + let msg = result.unwrap(); + + assert!( + !msg.treat_as_withdraw, + "treat_as_withdraw should be false" + ); + assert!(msg.errors.is_empty(), "No errors expected"); + assert_eq!(msg.path_attributes.len(), 3, "All 3 attributes parsed"); + } + + #[test] + fn flag_validation_error_collected() { + // Test that flag validation errors (from validate_attribute_flags) + // are also collected as non-fatal errors when the action is not SessionReset. + // + // ORIGIN with Optional flag set is invalid (well-known must not be Optional) + // and results in TreatAsWithdraw. + // The flag-invalid ORIGIN also triggers MissingAttribute since it's skipped. + // ORIGIN with wrong flags (Optional set, should not be) + let mut attrs = vec![ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, // 0xC0 - wrong! + PathAttributeTypeCode::Origin as u8, + 1, // length + 0, // IGP value + ]; + + // Valid AS_PATH after + attrs.extend(as_path_attr(65000)); + attrs.extend(next_hop_attr([192, 0, 2, 1])); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with flag error collected" + ); + let msg = result.unwrap(); + + assert!( + msg.treat_as_withdraw, + "Flag errors on ORIGIN cause TreatAsWithdraw" + ); + // 1 flag error + 1 MissingAttribute for Origin (skipped due to bad flags) + assert_eq!( + msg.errors.len(), + 2, + "Flag error + MissingAttribute, got: {:?}", + msg.errors + ); + + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::InvalidAttributeFlags { .. } + )), + "Should have InvalidAttributeFlags error" + ); + assert!( + msg.errors.iter().any(|(r, _)| matches!( + r, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin + } + )), + "Should have MissingAttribute error for Origin" + ); + + // AS_PATH and NEXT_HOP should still be parsed + assert_eq!( + msg.path_attributes.len(), + 2, + "AS_PATH and NEXT_HOP parsed" + ); + } + } + + /// Tests for mandatory attribute validation. + /// RFC 4271 requires ORIGIN, AS_PATH, and NEXT_HOP for traditional BGP UPDATEs. + /// RFC 4760 makes NEXT_HOP optional when MP_REACH_NLRI is present (nexthop is in MP attr). + mod mandatory_attribute_validation { + use std::net::Ipv6Addr; + use std::str::FromStr; + + use crate::messages::{ + BgpNexthop, MpReachNlri, MpUnreachNlri, PathAttributeTypeCode, + PathAttributeValue, UpdateMessage, UpdateParseErrorReason, + path_attribute_flags, + }; + + /// Build an UPDATE message wire format with the given path attributes bytes. + fn build_update_wire(path_attrs: &[u8], nlri: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&0u16.to_be_bytes()); // withdrawn length + buf.extend_from_slice(&(path_attrs.len() as u16).to_be_bytes()); + buf.extend_from_slice(path_attrs); + buf.extend_from_slice(nlri); + buf + } + + fn origin_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, + 0, // IGP + ] + } + + fn as_path_attr(asn: u32) -> Vec { + let mut attr = vec![ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::AsPath as u8, + 6, + 2, // AS_SEQUENCE + 1, // 1 ASN + ]; + attr.extend_from_slice(&asn.to_be_bytes()); + attr + } + + fn next_hop_attr(ip: [u8; 4]) -> Vec { + let mut attr = vec![ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::NextHop as u8, + 4, + ]; + attr.extend_from_slice(&ip); + attr + } + + fn mp_reach_ipv6_attr() -> Vec { + // Build MP_REACH_NLRI for IPv6 unicast with nexthop and one prefix + let mp_reach = MpReachNlri::ipv6_unicast( + BgpNexthop::Ipv6Single( + Ipv6Addr::from_str("2001:db8::1").unwrap(), + ), + vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:1::").unwrap(), + 48, + )], + ); + let value_bytes = + mp_reach.to_wire().expect("MP_REACH_NLRI encoding"); + + let mut attr = vec![ + path_attribute_flags::OPTIONAL, + PathAttributeTypeCode::MpReachNlri as u8, + ]; + // Use extended length if needed + if value_bytes.len() > 255 { + attr[0] |= path_attribute_flags::EXTENDED_LENGTH; + attr.extend_from_slice( + &(value_bytes.len() as u16).to_be_bytes(), + ); + } else { + attr.push(value_bytes.len() as u8); + } + attr.extend_from_slice(&value_bytes); + attr + } + + fn nlri_prefix(a: u8, b: u8, c: u8) -> Vec { + vec![24, a, b, c] + } + + // ===================================================================== + // MP-BGP: NEXT_HOP optional with MP_REACH_NLRI, no mandatory attrs + // for MP_UNREACH_NLRI-only UPDATEs + // ===================================================================== + + #[test] + fn mp_bgp_update_without_next_hop_succeeds() { + // When MP_REACH_NLRI is present, the nexthop is carried in the MP + // attribute, so the traditional NEXT_HOP attribute is not required. + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); + attrs.extend(as_path_attr(65000)); + attrs.extend(mp_reach_ipv6_attr()); // Has nexthop inside + // No NEXT_HOP attribute - this is OK for MP-BGP + + // No traditional NLRI either (all NLRI is in MP_REACH_NLRI) + let wire = build_update_wire(&attrs, &[]); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "MP-BGP UPDATE without NEXT_HOP should succeed: {:?}", + result.err() + ); + let msg = result.unwrap(); + assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + msg.errors.is_empty(), + "Should have no errors, got: {:?}", + msg.errors + ); + } + + #[test] + fn mp_bgp_update_with_traditional_nlri_requires_next_hop() { + // Even with MP_REACH_NLRI present, if there's traditional NLRI, + // NEXT_HOP is still required for those prefixes. + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); + attrs.extend(as_path_attr(65000)); + attrs.extend(mp_reach_ipv6_attr()); + // No NEXT_HOP, but we have traditional NLRI + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with error collected" + ); + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw, + "Missing NEXT_HOP with traditional NLRI should treat-as-withdraw" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop + } + )), + "Should have MissingAttribute error for NEXT_HOP, got: {:?}", + msg.errors + ); + } + + #[test] + fn mp_unreach_only_update_does_not_require_mandatory_attrs() { + // An UPDATE that only carries MP_UNREACH_NLRI (MP-BGP withdrawals) + // doesn't need mandatory attributes because there's no reachable NLRI. + let mp_unreach = + MpUnreachNlri::ipv6_unicast(vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:1::").unwrap(), + 48, + )]); + let value_bytes = + mp_unreach.to_wire().expect("MP_UNREACH_NLRI encoding"); + + let mut attrs = vec![ + path_attribute_flags::OPTIONAL, + PathAttributeTypeCode::MpUnreachNlri as u8, + ]; + if value_bytes.len() > 255 { + attrs[0] |= path_attribute_flags::EXTENDED_LENGTH; + attrs.extend_from_slice( + &(value_bytes.len() as u16).to_be_bytes(), + ); + } else { + attrs.push(value_bytes.len() as u8); + } + attrs.extend_from_slice(&value_bytes); + + // No traditional withdrawn, no traditional NLRI + let wire = build_update_wire(&attrs, &[]); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "MP_UNREACH-only UPDATE should succeed: {:?}", + result.err() + ); + let msg = result.unwrap(); + assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + msg.errors.is_empty(), + "Should have no errors for MP_UNREACH-only UPDATE, got: {:?}", + msg.errors + ); + + // Verify MP_UNREACH_NLRI was parsed + assert!( + msg.path_attributes.iter().any(|a| matches!( + a.value, + PathAttributeValue::MpUnreachNlri(_) + )), + "MP_UNREACH_NLRI should be present in parsed attributes" + ); + } + + // ===================================================================== + // Traditional BGP: All three mandatory attributes required + // ===================================================================== + + #[test] + fn traditional_update_without_next_hop_errors() { + // Traditional UPDATE with NLRI requires NEXT_HOP + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); + attrs.extend(as_path_attr(65000)); + // Missing NEXT_HOP + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with error collected" + ); + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw, + "Missing NEXT_HOP should trigger treat-as-withdraw" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop + } + )), + "Should have MissingAttribute error for NEXT_HOP, got: {:?}", + msg.errors + ); + } + + #[test] + fn traditional_update_without_origin_errors() { + // Traditional UPDATE requires ORIGIN + let mut attrs = Vec::new(); + // Missing ORIGIN + attrs.extend(as_path_attr(65000)); + attrs.extend(next_hop_attr([192, 0, 2, 1])); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with error collected" + ); + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw, + "Missing ORIGIN should trigger treat-as-withdraw" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin + } + )), + "Should have MissingAttribute error for ORIGIN, got: {:?}", + msg.errors + ); + } + + #[test] + fn traditional_update_without_as_path_errors() { + // Traditional UPDATE requires AS_PATH + let mut attrs = Vec::new(); + attrs.extend(origin_attr()); + // Missing AS_PATH + attrs.extend(next_hop_attr([192, 0, 2, 1])); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with error collected" + ); + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw, + "Missing AS_PATH should trigger treat-as-withdraw" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::AsPath + } + )), + "Should have MissingAttribute error for AS_PATH, got: {:?}", + msg.errors + ); + } + + #[test] + fn traditional_update_missing_multiple_mandatory_attrs() { + // Missing both ORIGIN and AS_PATH - should collect both errors + let mut attrs = Vec::new(); + // Only NEXT_HOP, missing ORIGIN and AS_PATH + attrs.extend(next_hop_attr([192, 0, 2, 1])); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with errors collected" + ); + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw, + "Missing mandatory attrs should trigger treat-as-withdraw" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin + } + )), + "Should have MissingAttribute error for ORIGIN" + ); + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::AsPath + } + )), + "Should have MissingAttribute error for AS_PATH" + ); + } + + #[test] + fn withdraw_only_update_does_not_require_mandatory_attrs() { + // An UPDATE that only withdraws routes doesn't need mandatory attrs + // because there's no NLRI to apply them to. + let mut buf = Vec::new(); + // Withdrawn routes: 198.51.100.0/24 + let withdrawn = nlri_prefix(198, 51, 100); + buf.extend_from_slice(&(withdrawn.len() as u16).to_be_bytes()); + buf.extend_from_slice(&withdrawn); + // No path attributes + buf.extend_from_slice(&0u16.to_be_bytes()); + // No NLRI + + let result = UpdateMessage::from_wire(&buf); + + assert!( + result.is_ok(), + "Withdraw-only UPDATE should succeed: {:?}", + result.err() + ); + let msg = result.unwrap(); + assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + msg.errors.is_empty(), + "Should have no errors for withdraw-only UPDATE" + ); + } + + #[test] + fn empty_update_does_not_require_mandatory_attrs() { + // An UPDATE with no NLRI and no withdrawn routes (keepalive-like) + // doesn't need mandatory attributes. + let wire = build_update_wire(&[], &[]); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Empty UPDATE should succeed: {:?}", + result.err() + ); + let msg = result.unwrap(); + assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + msg.errors.is_empty(), + "Should have no errors for empty UPDATE" + ); + } + } } diff --git a/bgp/src/params.rs b/bgp/src/params.rs index a7867e56..eec38185 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -332,7 +332,7 @@ pub enum FsmStateKindV1 { /// Waiting for open message from peer. OpenSent, - /// Waiting for keepaliave or notification from peer. + /// Waiting for keepalive or notification from peer. OpenConfirm, /// Sync up with peers. diff --git a/bgp/src/proptest.rs b/bgp/src/proptest.rs index e9924dca..5bc53ddd 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -13,12 +13,12 @@ //! - RFC 7606 compliance (attribute deduplication, MP-BGP ordering) use crate::messages::{ - Afi, As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, MpReach, - MpUnreach, PathAttribute, PathAttributeType, PathAttributeTypeCode, + As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, MpReachNlri, + MpUnreachNlri, PathAttribute, PathAttributeType, PathAttributeTypeCode, PathAttributeValue, PathOrigin, UpdateMessage, path_attribute_flags, }; use proptest::prelude::*; -use rdb::types::{Prefix, Prefix4, Prefix6}; +use rdb::types::{Prefix4, Prefix6}; use std::net::{Ipv4Addr, Ipv6Addr}; // ============================================================================= @@ -139,50 +139,29 @@ fn distinct_traditional_attrs_strategy() } // ============================================================================= -// MpReach/MpUnreach Strategies +// MpReachNlri/MpUnreachNlri Strategies // ============================================================================= -/// Strategy for generating IPv4 MpReach -fn mp_reach_v4_strategy() -> impl Strategy { - (nexthop_ipv4_strategy(), ipv4_prefixes_strategy()).prop_map( - |(nexthop, nlri)| { - MpReach::new( - Afi::Ipv4, - nexthop, - nlri.into_iter().map(Prefix::V4).collect(), - ) - }, - ) +/// Strategy for generating IPv4 MpReachNlri +fn mp_reach_v4_strategy() -> impl Strategy { + (nexthop_ipv4_strategy(), ipv4_prefixes_strategy()) + .prop_map(|(nexthop, nlri)| MpReachNlri::ipv4_unicast(nexthop, nlri)) } -/// Strategy for generating IPv6 MpReach with single next-hop -fn mp_reach_v6_single_strategy() -> impl Strategy { - (nexthop_ipv6_single_strategy(), ipv6_prefixes_strategy()).prop_map( - |(nexthop, nlri)| { - MpReach::new( - Afi::Ipv6, - nexthop, - nlri.into_iter().map(Prefix::V6).collect(), - ) - }, - ) +/// Strategy for generating IPv6 MpReachNlri with single next-hop +fn mp_reach_v6_single_strategy() -> impl Strategy { + (nexthop_ipv6_single_strategy(), ipv6_prefixes_strategy()) + .prop_map(|(nexthop, nlri)| MpReachNlri::ipv6_unicast(nexthop, nlri)) } -/// Strategy for generating IPv6 MpReach with double next-hop -fn mp_reach_v6_double_strategy() -> impl Strategy { - (nexthop_ipv6_double_strategy(), ipv6_prefixes_strategy()).prop_map( - |(nexthop, nlri)| { - MpReach::new( - Afi::Ipv6, - nexthop, - nlri.into_iter().map(Prefix::V6).collect(), - ) - }, - ) +/// Strategy for generating IPv6 MpReachNlri with double next-hop +fn mp_reach_v6_double_strategy() -> impl Strategy { + (nexthop_ipv6_double_strategy(), ipv6_prefixes_strategy()) + .prop_map(|(nexthop, nlri)| MpReachNlri::ipv6_unicast(nexthop, nlri)) } -/// Strategy for generating any valid MpReach -fn mp_reach_strategy() -> impl Strategy { +/// Strategy for generating any valid MpReachNlri +fn mp_reach_strategy() -> impl Strategy { prop_oneof![ mp_reach_v4_strategy(), mp_reach_v6_single_strategy(), @@ -190,26 +169,18 @@ fn mp_reach_strategy() -> impl Strategy { ] } -/// Strategy for generating IPv4 MpUnreach -fn mp_unreach_v4_strategy() -> impl Strategy { - (nexthop_ipv4_strategy(), ipv4_prefixes_strategy()).prop_map( - |(nexthop, withdrawn)| { - MpUnreach::new( - Afi::Ipv4, - nexthop, - withdrawn.into_iter().map(Prefix::V4).collect(), - ) - }, - ) +/// Strategy for generating IPv4 MpUnreachNlri +fn mp_unreach_v4_strategy() -> impl Strategy { + ipv4_prefixes_strategy().prop_map(MpUnreachNlri::ipv4_unicast) } -/// Strategy for generating IPv6 MpUnreach -fn mp_unreach_v6_strategy() -> impl Strategy { - ipv6_prefixes_strategy().prop_map(MpUnreach::new_v6) +/// Strategy for generating IPv6 MpUnreachNlri +fn mp_unreach_v6_strategy() -> impl Strategy { + ipv6_prefixes_strategy().prop_map(MpUnreachNlri::ipv6_unicast) } -/// Strategy for generating any valid MpUnreach -fn mp_unreach_strategy() -> impl Strategy { +/// Strategy for generating any valid MpUnreachNlri +fn mp_unreach_strategy() -> impl Strategy { prop_oneof![mp_unreach_v4_strategy(), mp_unreach_v6_strategy(),] } @@ -228,6 +199,8 @@ fn update_traditional_strategy() -> impl Strategy { withdrawn, path_attributes, nlri, + treat_as_withdraw: false, + errors: vec![], }) } @@ -246,6 +219,8 @@ fn update_mp_reach_strategy() -> impl Strategy { withdrawn: vec![], path_attributes: attrs, nlri: vec![], + treat_as_withdraw: false, + errors: vec![], } }, ) @@ -263,6 +238,8 @@ fn update_mp_unreach_strategy() -> impl Strategy { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }) } @@ -318,6 +295,8 @@ proptest! { withdrawn: vec![], path_attributes: vec![], nlri: prefixes.clone(), + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); @@ -333,6 +312,8 @@ proptest! { withdrawn: prefixes.clone(), path_attributes: vec![], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); @@ -347,11 +328,7 @@ proptest! { prefixes in ipv6_prefixes_strategy(), nexthop in nexthop_ipv6_single_strategy() ) { - let mp_reach = MpReach::new( - Afi::Ipv6, - nexthop, - prefixes.iter().cloned().map(Prefix::V6).collect(), - ); + let mp_reach = MpReachNlri::ipv6_unicast(nexthop, prefixes.clone()); let update = UpdateMessage { withdrawn: vec![], @@ -363,21 +340,22 @@ proptest! { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); // Extract MP_REACH_NLRI and verify prefixes - let mp_reach_attr = decoded.path_attributes.iter() + let decoded_prefixes = decoded.path_attributes.iter() .find_map(|a| match &a.value { - PathAttributeValue::MpReachNlri(mp) => Some(mp), + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv6Unicast(inner)) => { + Some(inner.nlri.clone()) + } _ => None, }) - .expect("should have MP_REACH_NLRI"); - - let decoded_prefixes = UpdateMessage::prefixes6_from_wire(&mp_reach_attr.nlri_bytes) - .expect("should parse NLRI"); + .expect("should have MP_REACH_NLRI with IPv6 NLRI"); prop_assert_eq!(decoded_prefixes, prefixes, "IPv6 NLRI prefixes should round-trip"); } @@ -385,7 +363,7 @@ proptest! { /// Property: Multiple IPv6 withdrawn prefixes round-trip through MP_UNREACH_NLRI #[test] fn prop_ipv6_withdrawn_via_mp_unreach(prefixes in ipv6_prefixes_strategy()) { - let mp_unreach = MpUnreach::new_v6(prefixes.clone()); + let mp_unreach = MpUnreachNlri::ipv6_unicast(prefixes.clone()); let update = UpdateMessage { withdrawn: vec![], @@ -397,21 +375,22 @@ proptest! { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); // Extract MP_UNREACH_NLRI and verify prefixes - let mp_unreach_attr = decoded.path_attributes.iter() + let decoded_prefixes = decoded.path_attributes.iter() .find_map(|a| match &a.value { - PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + PathAttributeValue::MpUnreachNlri(MpUnreachNlri::Ipv6Unicast(inner)) => { + Some(inner.withdrawn.clone()) + } _ => None, }) - .expect("should have MP_UNREACH_NLRI"); - - let decoded_prefixes = UpdateMessage::prefixes6_from_wire(&mp_unreach_attr.withdrawn_bytes) - .expect("should parse withdrawn"); + .expect("should have MP_UNREACH_NLRI with IPv6 withdrawn"); prop_assert_eq!(decoded_prefixes, prefixes, "IPv6 withdrawn prefixes should round-trip"); } @@ -422,11 +401,7 @@ proptest! { prefixes in ipv4_prefixes_strategy(), nexthop in nexthop_ipv4_strategy() ) { - let mp_reach = MpReach::new( - Afi::Ipv4, - nexthop, - prefixes.iter().cloned().map(Prefix::V4).collect(), - ); + let mp_reach = MpReachNlri::ipv4_unicast(nexthop, prefixes.clone()); let update = UpdateMessage { withdrawn: vec![], @@ -438,36 +413,30 @@ proptest! { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); // Extract MP_REACH_NLRI and verify prefixes - let mp_reach_attr = decoded.path_attributes.iter() + let decoded_prefixes = decoded.path_attributes.iter() .find_map(|a| match &a.value { - PathAttributeValue::MpReachNlri(mp) => Some(mp), + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv4Unicast(inner)) => { + Some(inner.nlri.clone()) + } _ => None, }) - .expect("should have MP_REACH_NLRI"); - - let decoded_prefixes = UpdateMessage::prefixes4_from_wire(&mp_reach_attr.nlri_bytes) - .expect("should parse NLRI"); + .expect("should have MP_REACH_NLRI with IPv4 NLRI"); prop_assert_eq!(decoded_prefixes, prefixes, "IPv4 MP-BGP NLRI prefixes should round-trip"); } /// Property: Multiple IPv4 withdrawn prefixes round-trip through MP_UNREACH_NLRI (MP-BGP encoding) #[test] - fn prop_ipv4_withdrawn_via_mp_unreach( - prefixes in ipv4_prefixes_strategy(), - nexthop in nexthop_ipv4_strategy() - ) { - let mp_unreach = MpUnreach::new( - Afi::Ipv4, - nexthop, - prefixes.iter().cloned().map(Prefix::V4).collect(), - ); + fn prop_ipv4_withdrawn_via_mp_unreach(prefixes in ipv4_prefixes_strategy()) { + let mp_unreach = MpUnreachNlri::ipv4_unicast(prefixes.clone()); let update = UpdateMessage { withdrawn: vec![], @@ -479,33 +448,34 @@ proptest! { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); let decoded = UpdateMessage::from_wire(&wire).expect("should decode"); // Extract MP_UNREACH_NLRI and verify prefixes - let mp_unreach_attr = decoded.path_attributes.iter() + let decoded_prefixes = decoded.path_attributes.iter() .find_map(|a| match &a.value { - PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + PathAttributeValue::MpUnreachNlri(MpUnreachNlri::Ipv4Unicast(inner)) => { + Some(inner.withdrawn.clone()) + } _ => None, }) - .expect("should have MP_UNREACH_NLRI"); - - let decoded_prefixes = UpdateMessage::prefixes4_from_wire(&mp_unreach_attr.withdrawn_bytes) - .expect("should parse withdrawn"); + .expect("should have MP_UNREACH_NLRI with IPv4 withdrawn"); prop_assert_eq!(decoded_prefixes, prefixes, "IPv4 MP-BGP withdrawn prefixes should round-trip"); } // ------------------------------------------------------------------------- - // BgpNexthop Round-Trip Tests (via MpReach) + // BgpNexthop Round-Trip Tests (via MpReachNlri) // ------------------------------------------------------------------------- - /// Property: BgpNexthop IPv4 round-trip through MpReach preserves next-hop + /// Property: BgpNexthop IPv4 round-trip through MpReachNlri preserves next-hop #[test] fn prop_nexthop_ipv4_via_mp_reach(nexthop in nexthop_ipv4_strategy()) { - let mp_reach = MpReach::new(Afi::Ipv4, nexthop, vec![]); + let mp_reach = MpReachNlri::ipv4_unicast(nexthop, vec![]); let attr = PathAttribute { typ: PathAttributeType { flags: path_attribute_flags::OPTIONAL, @@ -518,6 +488,8 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); @@ -528,10 +500,10 @@ proptest! { prop_assert_eq!(decoded_nexthop, nexthop, "IPv4 nexthop should round-trip"); } - /// Property: BgpNexthop IPv6 single round-trip through MpReach preserves next-hop + /// Property: BgpNexthop IPv6 single round-trip through MpReachNlri preserves next-hop #[test] fn prop_nexthop_ipv6_single_via_mp_reach(nexthop in nexthop_ipv6_single_strategy()) { - let mp_reach = MpReach::new(Afi::Ipv6, nexthop, vec![]); + let mp_reach = MpReachNlri::ipv6_unicast(nexthop, vec![]); let attr = PathAttribute { typ: PathAttributeType { flags: path_attribute_flags::OPTIONAL, @@ -544,6 +516,8 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); @@ -553,10 +527,10 @@ proptest! { prop_assert_eq!(decoded_nexthop, nexthop, "IPv6 single nexthop should round-trip"); } - /// Property: BgpNexthop IPv6 double round-trip through MpReach preserves next-hop + /// Property: BgpNexthop IPv6 double round-trip through MpReachNlri preserves next-hop #[test] fn prop_nexthop_ipv6_double_via_mp_reach(nexthop in nexthop_ipv6_double_strategy()) { - let mp_reach = MpReach::new(Afi::Ipv6, nexthop, vec![]); + let mp_reach = MpReachNlri::ipv6_unicast(nexthop, vec![]); let attr = PathAttribute { typ: PathAttributeType { flags: path_attribute_flags::OPTIONAL, @@ -569,6 +543,8 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], + treat_as_withdraw: false, + errors: vec![], }; let wire = update.to_wire().expect("should encode"); diff --git a/bgp/src/router.rs b/bgp/src/router.rs index e0f3514f..dd2cb9e4 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -70,7 +70,9 @@ pub struct Router { /// to all others. If/when we do that, there will need to be export /// policy that governs what updates fan out to what peers. /// Note: Since peers can have any combination of address families enabled, - /// fanout must be maintained per address family. + /// fanout must be maintained per address family. A peer session is + /// inserted into an address-family's fanout when it moves into + /// Established after negotiating that AFI/SAFI with the peer. pub fanout4: Arc>>, pub fanout6: Arc>>, } @@ -171,16 +173,7 @@ impl Router { // Hold lock during entire iteration to prevent concurrent modifications let sessions = lock!(self.sessions); - for (addr, session) in sessions.iter() { - // Ensure fanout is set up for this session (needed for restart scenario) - if let Some(endpoint) = lock!(self.addr_to_session).get(addr) { - if lock!(endpoint.config).ipv4_enabled { - self.add_fanout4(*addr, endpoint.event_tx.clone()); - } - if lock!(endpoint.config).ipv6_enabled { - self.add_fanout6(*addr, endpoint.event_tx.clone()); - } - } + for session in sessions.values() { self.spawn_session_thread(session.clone()); } } @@ -289,9 +282,6 @@ impl Router { session_info.resolution = Duration::from_millis(peer.resolution); session_info.bind_addr = bind_addr; - let ipv4 = session_info.ipv4_enabled; - let ipv6 = session_info.ipv6_enabled; - let session = Arc::new(Mutex::new(session_info)); a2s.insert( @@ -317,12 +307,6 @@ impl Router { )); self.spawn_session_thread(runner.clone()); - if ipv4 { - self.add_fanout4(neighbor.host.ip(), event_tx.clone()); - } - if ipv6 { - self.add_fanout6(neighbor.host.ip(), event_tx.clone()); - } lock!(self.sessions).insert(neighbor.host.ip(), runner.clone()); Ok(runner) diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 243d4794..859518fc 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -13,9 +13,10 @@ use crate::{ log::{collision_log, session_log, session_log_lite}, messages::{ AddPathElement, Afi, BgpNexthop, Capability, CeaseErrorSubcode, - Community, ErrorCode, ErrorSubcode, Message, MessageKind, MpReach, - MpUnreach, NotificationMessage, OpenMessage, PathAttributeValue, - RouteRefreshMessage, Safi, UpdateErrorSubcode, UpdateMessage, + Community, ErrorCode, ErrorSubcode, Message, MessageKind, + MessageParseError, MpReachNlri, MpUnreachNlri, NotificationMessage, + OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, + UpdateMessage, }, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, @@ -43,6 +44,50 @@ use std::{ const UNIT_SESSION_RUNNER: &str = "session_runner"; +/// The runtime state of an address-family for a given peer. +/// This is instantiated after capability negotiation has completed. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum AfiSafiState { + /// Not configured for this session. We did not advertise this capability + /// in our OPEN message, so the peer's support is irrelevant. + #[default] + Unconfigured, + + /// We advertised this capability but the peer did not. Routes for this + /// AFI/SAFI will be ignored. + Advertised, + + /// Successfully negotiated with peer. Routes for this AFI/SAFI will be + /// processed. + Negotiated, +} + +impl AfiSafiState { + pub fn negotiated(&self) -> bool { + matches!(self, Self::Negotiated) + } +} + +impl Display for AfiSafiState { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Unconfigured => write!(f, "Unconfigured"), + Self::Advertised => write!(f, "Advertised"), + Self::Negotiated => write!(f, "Negotiated"), + } + } +} + /// This wraps a BgpConnection with runtime state learned from the peer's Open /// message. This encodes all the dynamic (but non-timer related) information /// for a given connection into the type system (rather than wrapping each @@ -60,10 +105,10 @@ pub struct PeerConnection { pub asn: u32, /// The actual capabilities received from the peer (runtime state) pub caps: BTreeSet, - /// The IPv4 Unicast AFI/SAFI was negotiated (advertised by both sides) - pub ipv4: bool, - /// The IPv6 Unicast AFI/SAFI was negotiated (advertised by both sides) - pub ipv6: bool, + /// This peer's AFI/SAFI state for IPv4 Unicast + pub ipv4_unicast: AfiSafiState, + /// This peer's AFI/SAFI state for IPv6 Unicast + pub ipv6_unicast: AfiSafiState, } impl Clone for PeerConnection { @@ -73,8 +118,8 @@ impl Clone for PeerConnection { id: self.id, asn: self.asn, caps: self.caps.clone(), - ipv4: self.ipv4, - ipv6: self.ipv6, + ipv4_unicast: self.ipv4_unicast, + ipv6_unicast: self.ipv6_unicast, } } } @@ -193,7 +238,7 @@ pub enum FsmState { /// Waiting for open message from peer. OpenSent(Arc), - /// Waiting for keepaliave or notification from peer. + /// Waiting for keepalive or notification from peer. OpenConfirm(PeerConnection), /// Waiting for Open from incoming connection to perform Collision Resolution. @@ -202,7 +247,7 @@ pub enum FsmState { /// Sync up with peers. SessionSetup(PeerConnection), - /// Able to exchange update, notification and keepliave messages with peers. + /// Able to exchange update, notification and keepalive messages with peers. Established(PeerConnection), } @@ -237,7 +282,7 @@ pub enum FsmStateKind { /// Waiting for open message from peer. OpenSent, - /// Waiting for keepaliave or notification from peer. + /// Waiting for keepalive or notification from peer. OpenConfirm, /// Handler for Connection Collisions (RFC 4271 6.8) @@ -426,6 +471,13 @@ pub enum StopReason { ConnectionRejected, CollisionResolution, IoError, + /// A parse error occurred that requires session reset with specific + /// error codes (per RFC 7606). This is used when the connection layer + /// reports a `ParseErrorAction::SessionReset`. + ParseError { + error_code: ErrorCode, + error_subcode: ErrorSubcode, + }, } /// FsmEvents pertaining to a specific Connection @@ -442,12 +494,29 @@ pub enum ConnectionEvent { /// Fires when the connection's delay open timer expires. DelayOpenTimerExpires(ConnectionId), + + /// Fatal message parse error. + /// Connection layer has logged the error and sends this event for + /// fatal errors. All ParseError events are fatal - session should send + /// NOTIFICATION and reset. + /// Note: If we decide to implement RFC 4760 "AFI/SAFI disable" error + /// handling, we likely would just update our response to this. + ParseError { + conn_id: ConnectionId, + error: MessageParseError, + }, + + /// Fires when the recv loop exits due to IO error (connection closed, + /// timeout, etc.) This signals that the underlying TCP connection + /// has failed and the SessionRunner should handle the connection loss + /// appropriately. The specific error is logged at the connection layer. + TcpConnectionFails(ConnectionId), } impl ConnectionEvent { fn title(&self) -> &'static str { match self { - ConnectionEvent::Message { .. } => "message", + ConnectionEvent::Message { msg, .. } => msg.title(), ConnectionEvent::HoldTimerExpires(_) => "hold timer expires", ConnectionEvent::KeepaliveTimerExpires(_) => { "keepalive timer expires" @@ -455,6 +524,8 @@ impl ConnectionEvent { ConnectionEvent::DelayOpenTimerExpires(_) => { "delay open timer expires" } + ConnectionEvent::ParseError { .. } => "parse error", + ConnectionEvent::TcpConnectionFails(_) => "tcp connection fails", } } } @@ -600,10 +671,6 @@ pub enum UnusedEvent { /// source/destination IP address/port. TcpConnectionInvalid, - /// Fires when the remote peer sends a TCP fin or the local connection times - /// out. - TcpConnectionFails, - /// Fires when a connection has been detected while processing an open /// message. We implement Collision handling in FsmState::ConnectionCollision OpenCollisionDump, @@ -623,7 +690,6 @@ impl UnusedEvent { Self::DelayOpenTimerExpires => "delay open timer expires", Self::TcpConnectionValid => "tcp connection valid", Self::TcpConnectionInvalid => "tcp connection invalid", - Self::TcpConnectionFails => "tcp connection fails", Self::BgpOpen => "bgp open", Self::DelayedBgpOpen => "delay bgp open", Self::BgpHeaderErr => "bgp header err", @@ -1032,15 +1098,39 @@ macro_rules! connect_timeout { }; } +/// Determines AFI/SAFI state based on capability negotiation. +/// +/// Returns (ipv4_unicast, ipv6_unicast) states: +/// - Negotiated: Both sides advertised the capability +/// - Advertised: We advertised but peer did not +/// - Unconfigured: We did not advertise (not configured) macro_rules! active_afi { ($self:expr, $their_caps:ident) => {{ let cap4 = Capability::ipv4_unicast(); let cap6 = Capability::ipv6_unicast(); let our_caps = lock!($self.caps_tx); - ( - our_caps.contains(&cap4) && $their_caps.contains(&cap4), - our_caps.contains(&cap6) && $their_caps.contains(&cap6), - ) + + let ipv4_state = if our_caps.contains(&cap4) { + if $their_caps.contains(&cap4) { + AfiSafiState::Negotiated + } else { + AfiSafiState::Advertised + } + } else { + AfiSafiState::Unconfigured + }; + + let ipv6_state = if our_caps.contains(&cap6) { + if $their_caps.contains(&cap6) { + AfiSafiState::Negotiated + } else { + AfiSafiState::Advertised + } + } else { + AfiSafiState::Unconfigured + }; + + (ipv4_state, ipv6_state) }}; } @@ -1657,9 +1747,19 @@ impl SessionRunner { Some(details), ); } + ConnectionEvent::ParseError { conn_id, error } => { + return ( + FsmEventCategory::Connection, + Some(*conn_id), + Some(error.description()), + ); + } ConnectionEvent::HoldTimerExpires(id) | ConnectionEvent::KeepaliveTimerExpires(id) | ConnectionEvent::DelayOpenTimerExpires(id) => Some(*id), + ConnectionEvent::TcpConnectionFails(conn_id) => { + Some(*conn_id) + } }; (FsmEventCategory::Connection, conn_id, None) } @@ -2123,6 +2223,20 @@ impl SessionRunner { self.bump_msg_counter(msg.kind(), true); continue; } + + ConnectionEvent::TcpConnectionFails(ref conn_id) + | ConnectionEvent::ParseError { ref conn_id, .. } => { + // Idle doesn't own a connection, so this can't be + // related to the current FSM. Log and ignore. + session_log_lite!( + self, + debug, + "{} (conn_id: {}) in Idle state, ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } } } } @@ -2448,6 +2562,24 @@ impl SessionRunner { continue; } + + ConnectionEvent::TcpConnectionFails(ref conn_id) + | ConnectionEvent::ParseError { ref conn_id, .. } => { + // Connect doesn't own a connection, so this can't + // be related to the current FSM. Log and ignore. + // Note: If we add support for DelayOpen, we could + // own a BgpConnection while DelayOpenTimer is + // running. This would need to be updated to + // do conn_id validation. + session_log_lite!( + self, + debug, + "{} (conn_id: {}) in Connect state, ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } } } } @@ -2681,6 +2813,24 @@ impl SessionRunner { continue; } + + ConnectionEvent::TcpConnectionFails(ref conn_id) + | ConnectionEvent::ParseError { ref conn_id, .. } => { + // Active doesn't own a connection, so this can't + // be related to the current FSM. Log and ignore. + // Note: If we add support for DelayOpen, we could + // own a BgpConnection while DelayOpenTimer is + // running. This would need to be updated to + // do conn_id validation. + session_log_lite!( + self, + debug, + "{} (conn_id: {}) in Active state, ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } } } @@ -3251,6 +3401,64 @@ impl SessionRunner { self.stop(Some(&conn), None, StopReason::FsmError); return FsmState::Idle; } + + ConnectionEvent::ParseError { + ref conn_id, + ref error, + } => { + if !self.validate_active_connection( + conn_id, + &conn, + connection_event.title(), + ) { + continue; + } + + // In OpenSent state, all parse errors are fatal. + // We haven't completed the handshake yet. + let (error_code, error_subcode) = + error.error_codes(); + session_log!( + self, + warn, + conn, + "rx {} (conn_id: {}): {}, fsm transition to idle", + connection_event.title(), conn_id.short(), error; + "error_code" => format!("{error_code:?}"), + "error_subcode" => format!("{error_subcode:?}") + ); + let stop_reason = StopReason::ParseError { + error_code, + error_subcode, + }; + + session_timer!(self, connect_retry).stop(); + self.connect_retry_counter + .fetch_add(1, Ordering::Relaxed); + self.stop(Some(&conn), None, stop_reason); + + return FsmState::Idle; + } + + ConnectionEvent::TcpConnectionFails(ref conn_id) => { + if !self.validate_active_connection( + conn_id, + &conn, + connection_event.title(), + ) { + continue; + } + + session_log!( + self, + warn, + conn, + "rx {} (conn_id: {}), fsm transition to idle", + connection_event.title(), conn_id.short(); + ); + self.stop(Some(&conn), None, StopReason::IoError); + return FsmState::Idle; + } } } } @@ -3333,15 +3541,15 @@ impl SessionRunner { conn_timer!(conn, hold).enable(); let caps = om.get_capabilities(); - let (ipv4, ipv6) = active_afi!(self, caps); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); let pc = PeerConnection { conn, id: om.id, asn: om.asn(), caps, - ipv4, - ipv6, + ipv4_unicast, + ipv6_unicast, }; // Upgrade this connection from Partial to Full in the registry @@ -3605,6 +3813,60 @@ impl SessionRunner { FsmState::Idle } } + + ConnectionEvent::ParseError { + ref conn_id, + ref error, + } => { + if !self.validate_active_connection( + conn_id, + &pc.conn, + connection_event.title(), + ) { + return FsmState::OpenConfirm(pc); + } + + // In OpenConfirm state, all parse errors are fatal. + // We haven't completed the handshake yet. + let (error_code, error_subcode) = error.error_codes(); + session_log!( + self, + warn, + pc.conn, + "rx {} (conn_id: {}): {}, fsm transition to idle", + connection_event.title(), conn_id.short(), error; + "error_code" => format!("{error_code:?}"), + "error_subcode" => format!("{error_subcode:?}") + ); + let stop_reason = StopReason::ParseError { + error_code, + error_subcode, + }; + + session_timer!(self, connect_retry).stop(); + self.stop(Some(&pc.conn), None, stop_reason); + FsmState::Idle + } + + ConnectionEvent::TcpConnectionFails(ref conn_id) => { + if !self.validate_active_connection( + conn_id, + &pc.conn, + connection_event.title(), + ) { + return FsmState::OpenConfirm(pc); + } + + session_log!( + self, + warn, + pc.conn, + "rx {} (conn_id: {}), fsm transition to idle", + connection_event.title(), conn_id.short(); + ); + self.stop(Some(&pc.conn), None, StopReason::IoError); + FsmState::Idle + } }, FsmEvent::Session(session_event) => match session_event { @@ -4413,78 +4675,238 @@ impl SessionRunner { } } } - } - } - } - }; - - collision_log!( - self, - info, - &exist.conn, - &new, - "collision detected: local id {}, remote id {}", - self.id, - om.id - ); - - match collision_resolution(exist.conn.direction(), self.id, om.id) { - CollisionResolution::ExistWins => { - // Existing connection wins - collision_log!( - self, - info, - &exist.conn, - &new, - "collision resolution: local system wins with higher RID ({} > {})", - self.id, - om.id - ); - - self.stop(Some(&new), None, StopReason::CollisionResolution); - - conn_timer!(exist.conn, hold).restart(); - conn_timer!(exist.conn, keepalive).restart(); - self.send_keepalive(&exist.conn); - - // Upgrade existing connection from Partial to Full in the registry - lock!(self.connection_registry).upgrade_to_full(exist.clone()); - - FsmState::OpenConfirm(exist) - } - CollisionResolution::NewWins => { - // New connection wins - collision_log!( - self, - info, - &exist.conn, - &new, - "collision resolution: peer wins with higher RID ({} >= {})", - om.id, - self.id - ); - self.stop( - Some(&exist.conn), - None, - StopReason::CollisionResolution, - ); + ConnectionEvent::ParseError { + ref conn_id, + ref error, + } => { + // In ConnectionCollision state, a parse error on + // either connection should result in that + // connection being closed and the other connection + // proceeding. + // All ParseError events are fatal - get error codes for NOTIFICATION. + let (error_code, error_subcode) = + error.error_codes(); + let stop_reason = StopReason::ParseError { + error_code, + error_subcode, + }; - session_timer!(self, connect_retry).stop(); - self.counters - .connection_retries - .fetch_add(1, Ordering::Relaxed); + match self.collision_conn_kind( + conn_id, + exist.conn.id(), + new.id(), + ) { + CollisionConnectionKind::New => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} on new connection (conn_id: {}): {}, keeping existing", + connection_event.title(), conn_id.short(), error; + ); + self.stop(Some(&new), None, stop_reason); + return FsmState::OpenConfirm(exist); + } + CollisionConnectionKind::Exist => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} on existing connection (conn_id: {}): {}, keeping new", + connection_event.title(), conn_id.short(), error; + ); + self.stop( + Some(&exist.conn), + None, + stop_reason, + ); + return FsmState::OpenSent(new); + } + CollisionConnectionKind::Unexpected( + unknown, + ) => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} for known connection (conn_id: {}) not in collision, closing", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&unknown), + None, + stop_reason, + ); + continue; + } + CollisionConnectionKind::Missing => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} for unknown connection (conn_id: {}), ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } + } + } - let caps = om.get_capabilities(); - let (ipv4, ipv6) = active_afi!(self, caps); + // In ConnectionCollision state, a failed TCP connection + // on either connection should result in that connection + // being closed and the other connection proceeding. + ConnectionEvent::TcpConnectionFails(ref conn_id) => { + match self.collision_conn_kind( + conn_id, + exist.conn.id(), + new.id(), + ) { + CollisionConnectionKind::New => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} on new connection (conn_id: {}), keeping existing", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&new), + None, + StopReason::IoError, + ); + return FsmState::OpenConfirm(exist); + } + CollisionConnectionKind::Exist => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} on existing connection (conn_id: {}), keeping new", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&exist.conn), + None, + StopReason::IoError, + ); + return FsmState::OpenSent(new); + } + CollisionConnectionKind::Unexpected( + unknown, + ) => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} for known connection (conn_id: {}) not in collision, closing", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&unknown), + None, + StopReason::IoError, + ); + continue; + } + CollisionConnectionKind::Missing => { + collision_log!( + self, + warn, + new, + exist.conn, + "rx {} for unknown connection (conn_id: {}), ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } + } + } + } + } + } + }; + + collision_log!( + self, + info, + &exist.conn, + &new, + "collision detected: local id {}, remote id {}", + self.id, + om.id + ); + + match collision_resolution(exist.conn.direction(), self.id, om.id) { + CollisionResolution::ExistWins => { + // Existing connection wins + collision_log!( + self, + info, + &exist.conn, + &new, + "collision resolution: local system wins with higher RID ({} > {})", + self.id, + om.id + ); + + self.stop(Some(&new), None, StopReason::CollisionResolution); + + conn_timer!(exist.conn, hold).restart(); + conn_timer!(exist.conn, keepalive).restart(); + self.send_keepalive(&exist.conn); + + // Upgrade existing connection from Partial to Full in the registry + lock!(self.connection_registry).upgrade_to_full(exist.clone()); + + FsmState::OpenConfirm(exist) + } + CollisionResolution::NewWins => { + // New connection wins + collision_log!( + self, + info, + &exist.conn, + &new, + "collision resolution: peer wins with higher RID ({} >= {})", + om.id, + self.id + ); + + self.stop( + Some(&exist.conn), + None, + StopReason::CollisionResolution, + ); + + session_timer!(self, connect_retry).stop(); + self.counters + .connection_retries + .fetch_add(1, Ordering::Relaxed); + + let caps = om.get_capabilities(); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); let new_pc = PeerConnection { conn: new, id: om.id, asn: om.asn(), caps, - ipv4, - ipv6, + ipv4_unicast, + ipv6_unicast, }; conn_timer!(new_pc.conn, hold).restart(); @@ -4704,7 +5126,6 @@ impl SessionRunner { FsmEvent::Connection(connection_event) => { match connection_event { - // XXX: Make sure we always log the message type + conn_id ConnectionEvent::Message { msg, ref conn_id } => { let msg_kind = msg.kind(); match self.collision_conn_kind(conn_id, exist.id(), new.id()) { @@ -4791,15 +5212,15 @@ impl SessionRunner { conn_timer!(exist, keepalive).restart(); let caps = om.get_capabilities(); - let (ipv4, ipv6) = active_afi!(self, caps); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); let exist_pc = PeerConnection { conn: exist.clone(), id: om.id, asn: om.asn(), caps, - ipv4, - ipv6, + ipv4_unicast, + ipv6_unicast, }; // Upgrade existing connection from Partial to Full in the registry @@ -4927,15 +5348,15 @@ impl SessionRunner { self.stop(Some(&exist), None, StopReason::CollisionResolution); let caps = om.get_capabilities(); - let (ipv4, ipv6) = active_afi!(self, caps); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); let new_pc = PeerConnection { conn: new.clone(), id: om.id, asn: om.asn(), caps, - ipv4, - ipv6 + ipv4_unicast, + ipv6_unicast, }; conn_timer!(new, hold).restart(); @@ -5125,6 +5546,165 @@ impl SessionRunner { }, } } + + ConnectionEvent::ParseError { + ref conn_id, + ref error, + } => { + // In ConnectionCollision (OpenSent) state, a parse + // error on either connection should result in that + // connection being closed and the other connection + // proceeding. + // All ParseError events are fatal - get error codes for NOTIFICATION. + let (error_code, error_subcode) = error.error_codes(); + let stop_reason = StopReason::ParseError { + error_code, + error_subcode, + }; + + match self.collision_conn_kind( + conn_id, + exist.id(), + new.id(), + ) { + CollisionConnectionKind::New => { + collision_log!( + self, + warn, + new, + exist, + "rx {} on new connection (conn_id: {}): {}, keeping existing", + connection_event.title(), conn_id.short(), error; + ); + self.stop( + Some(&new), + None, + stop_reason, + ); + return FsmState::OpenSent(exist); + } + CollisionConnectionKind::Exist => { + collision_log!( + self, + warn, + new, + exist, + "rx {} on existing connection (conn_id: {}): {}, keeping new", + connection_event.title(), conn_id.short(), error; + ); + self.stop( + Some(&exist), + None, + stop_reason, + ); + return FsmState::OpenSent(new); + } + CollisionConnectionKind::Unexpected(unknown) => { + collision_log!( + self, + warn, + new, + exist, + "rx {} for known connection (conn_id: {}) not in collision, closing", + connection_event.title(), conn_id.short() + ); + self.stop( + Some(&unknown), + None, + stop_reason, + ); + continue; + } + CollisionConnectionKind::Missing => { + collision_log!( + self, + warn, + new, + exist, + "rx {} for unknown connection (conn_id: {}), ignoring", + connection_event.title(), conn_id.short() + ); + continue; + } + } + } + + // In ConnectionCollision state, a failed TCP connection + // on either connection should result in that connection + // being closed and the other connection proceeding. + ConnectionEvent::TcpConnectionFails(ref conn_id) => { + match self.collision_conn_kind( + conn_id, + exist.id(), + new.id(), + ) { + CollisionConnectionKind::New => { + collision_log!( + self, + warn, + new, + exist, + "rx {} on new connection (conn_id: {}), keeping existing", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&new), + None, + StopReason::IoError, + ); + return FsmState::OpenSent(exist); + } + CollisionConnectionKind::Exist => { + collision_log!( + self, + warn, + new, + exist, + "rx {} on existing connection (conn_id: {}), keeping new", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&exist), + None, + StopReason::IoError, + ); + return FsmState::OpenSent(new); + } + CollisionConnectionKind::Unexpected( + unknown, + ) => { + collision_log!( + self, + warn, + new, + exist, + "rx {} for known connection (conn_id: {}) not in collision, closing", + connection_event.title(), + conn_id.short() + ); + self.stop( + Some(&unknown), + None, + StopReason::IoError, + ); + continue; + } + CollisionConnectionKind::Missing => { + collision_log!( + self, + warn, + new, + exist, + "rx {} for unknown connection (conn_id: {}), ignoring", + connection_event.title(), + conn_id.short() + ); + continue; + } + } + } } } } @@ -5169,7 +5749,7 @@ impl SessionRunner { } // Collect the prefixes this router is originating. - let originated4 = if pc.ipv4 { + let originated4 = if pc.ipv4_unicast.negotiated() { match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -5188,7 +5768,7 @@ impl SessionRunner { Vec::new() }; - let originated6 = if pc.ipv6 { + let originated6 = if pc.ipv6_unicast.negotiated() { match self.db.get_origin6() { Ok(value) => value, Err(e) => { @@ -5208,7 +5788,7 @@ impl SessionRunner { }; // Ensure the router has a fanout entry for this peer. - if pc.ipv4 { + if pc.ipv4_unicast.negotiated() { write_lock!(self.fanout4).add_egress( self.neighbor.host.ip(), crate::fanout::Egress { @@ -5217,7 +5797,7 @@ impl SessionRunner { }, ); } - if pc.ipv6 { + if pc.ipv6_unicast.negotiated() { write_lock!(self.fanout6).add_egress( self.neighbor.host.ip(), crate::fanout::Egress { @@ -5847,7 +6427,7 @@ impl SessionRunner { ); let caps = om.get_capabilities(); - let (ipv4, ipv6) = + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); let new_pc = PeerConnection { @@ -5855,8 +6435,8 @@ impl SessionRunner { id: om.id, asn: om.asn(), caps, - ipv4, - ipv6, + ipv4_unicast, + ipv6_unicast, }; // Clean up the old established connection @@ -6085,6 +6665,54 @@ impl SessionRunner { } } } + + ConnectionEvent::ParseError { + ref conn_id, + ref error, + } => { + if !self.validate_active_connection( + conn_id, + &pc.conn, + connection_event.title(), + ) { + return FsmState::Established(pc); + } + + // All ParseError events are fatal - send NOTIFICATION and reset. + // TreatAsWithdraw is now handled via UpdateMessage.treat_as_withdraw flag. + let (error_code, error_subcode) = error.error_codes(); + session_log!( + self, + error, + pc.conn, + "rx {} (conn_id: {}): {}, session reset", + connection_event.title(), conn_id.short(), error; + "error_code" => format!("{error_code:?}"), + "error_subcode" => format!("{error_subcode:?}") + ); + self.send_notification(&pc.conn, error_code, error_subcode); + self.exit_established(pc) + } + + ConnectionEvent::TcpConnectionFails(ref conn_id) => { + if !self.validate_active_connection( + conn_id, + &pc.conn, + connection_event.title(), + ) { + return FsmState::Established(pc); + } + + session_log!( + self, + warn, + pc.conn, + "rx {} (conn_id: {}), fsm transition to idle", + connection_event.title(), conn_id.short(); + ); + self.stop(Some(&pc.conn), None, StopReason::IoError); + self.exit_established(pc) + } }, } } @@ -6678,6 +7306,8 @@ impl SessionRunner { withdrawn, nlri, path_attributes: path_attrs, + treat_as_withdraw: false, + errors: vec![], }; (Afi::Ipv4, update) @@ -6708,14 +7338,14 @@ impl SessionRunner { // Add MP_REACH_NLRI for announcements if !nlri.is_empty() { - let reach = MpReach::new_v6(nexthop, nlri); + let reach = MpReachNlri::ipv6_unicast(nexthop, nlri); path_attrs .push(PathAttributeValue::MpReachNlri(reach).into()); } // Add MP_UNREACH_NLRI for withdrawals if !withdrawn.is_empty() { - let unreach = MpUnreach::new_v6(withdrawn); + let unreach = MpUnreachNlri::ipv6_unicast(withdrawn); path_attrs.push( PathAttributeValue::MpUnreachNlri(unreach).into(), ); @@ -6725,6 +7355,8 @@ impl SessionRunner { withdrawn: vec![], // Traditional fields empty for IPv6 nlri: vec![], path_attributes: path_attrs, + treat_as_withdraw: false, + errors: vec![], }; (Afi::Ipv6, update) @@ -6855,10 +7487,10 @@ impl SessionRunner { session_timer!(self, connect_retry).stop(); self.connect_retry_counter.fetch_add(1, Ordering::Relaxed); - if pc.ipv4 { + if pc.ipv4_unicast.negotiated() { write_lock!(self.fanout4).remove_egress(self.neighbor.host.ip()); } - if pc.ipv6 { + if pc.ipv6_unicast.negotiated() { write_lock!(self.fanout6).remove_egress(self.neighbor.host.ip()); } @@ -7031,6 +7663,25 @@ impl SessionRunner { } } StopReason::IoError => {} + + StopReason::ParseError { + error_code, + error_subcode, + } => { + if let Some(c1) = conn1 { + self.send_notification(c1, error_code, error_subcode); + } + if let Some(c2) = conn2 { + self.send_notification(c2, error_code, error_subcode); + } + self.counters + .connect_retry_counter + .fetch_add(1, Ordering::Relaxed); + self.counters + .connection_retries + .fetch_add(1, Ordering::Relaxed); + session_timer!(self, connect_retry).stop(); + } } if let Some(c1) = conn1 { @@ -7061,19 +7712,19 @@ impl SessionRunner { return; } - // Validate MP-BGP attributes before processing - // This now does parsing and validation of MP-BGP attributes from raw bytes - if let Err(e) = self.validate_mp_attributes(&mut update, pc) { + // Filter MP-BGP attributes based on negotiation state. + // Attributes for unnegotiated AFI/SAFIs are silently removed. + // Note: This function currently never fails, but we keep the error + // handling pattern for future extensibility. + if let Err(e) = self.check_afi_safi_negotiation(&mut update, pc) { session_log!( self, error, pc.conn, - "MP-BGP attribute validation failed: {e}"; + "AFI/SAFI negotiation check failed: {e}"; "error" => format!("{e}"), "message" => "update" ); - // Notification already sent by validate_mp_attributes() - // Don't process this UPDATE return; } @@ -7131,123 +7782,48 @@ impl SessionRunner { // self.fanout_update(&update); } - /// Validate MP-BGP attributes (MP_REACH_NLRI, MP_UNREACH_NLRI). - /// - /// This implements RFC 4760 Section 7 and RFC 7606 error handling for MP-BGP. - /// - /// ## RFC 4760 Section 7: Error Handling - /// - /// When an UPDATE message with incorrect MP_REACH_NLRI or MP_UNREACH_NLRI - /// is received: - /// 1. Delete all BGP routes from that peer with the same AFI/SAFI - /// 2. Ignore all subsequent routes with that AFI/SAFI for the session duration - /// 3. Optionally terminate the session with NOTIFICATION: - /// - Error Code: UPDATE Message Error - /// - Subcode: Optional Attribute Error + /// Filter MP-BGP attributes based on AFI/SAFI negotiation state. /// - /// ## RFC 7606: Revised Error Handling + /// This checks whether the AFI/SAFI in MP_REACH_NLRI and MP_UNREACH_NLRI + /// attributes was negotiated with the peer during capability exchange. + /// Attributes for unnegotiated AFI/SAFIs are silently filtered out + /// (logged as warnings but not treated as errors). /// - /// The approach used depends on the error severity: - /// - **Session reset**: Most severe, terminates BGP session - /// - **AFI/SAFI disable**: Ignore subsequent routes for specific address family - /// - **Treat-as-withdraw**: Process as route withdrawal - /// - **Attribute discard**: Remove attribute, continue processing + /// This approach aligns with RFC 4760 Section 7's "AFI/SAFI disable" + /// concept: routes for unnegotiated address families are simply ignored. /// - /// For MP_REACH_NLRI/MP_UNREACH_NLRI: - /// - Malformed attributes that prevent NLRI location → Session reset or AFI/SAFI disable - /// - Unsupported AFI/SAFI → AFI/SAFI disable (our implementation: discard + log) - /// - Unnegotiated AFI/SAFI → AFI/SAFI disable (our implementation: discard + log) + /// ## Note on Error Handling /// - /// ## Implementation Details - /// - /// This performs semantic validation: - /// 1. Check that AFI/SAFI is supported by this implementation - /// 2. Check that AFI/SAFI was negotiated with this peer - /// - /// Invalid attributes are logged and removed from the update message, effectively - /// implementing an "AFI/SAFI disable" approach by consistently filtering out - /// unsupported/unnegotiated address families. - /// - /// ## Error Handling at Parse Time - /// - /// Note that structural errors (duplicate attributes, malformed wire format) - /// are caught earlier during parsing in `UpdateMessage::from_wire()` and - /// `connection_tcp.rs`, which sends appropriate NOTIFICATION messages per - /// RFC 7606 Section 5.2. - fn validate_mp_attributes( + /// Structural errors (duplicate attributes, malformed wire format, + /// unsupported AFI/SAFI values) are caught during parsing in + /// `UpdateMessage::from_wire()` and `connection_tcp.rs`, which triggers + /// appropriate error handling per RFC 7606. This function only handles + /// the negotiation state check. + fn check_afi_safi_negotiation( &self, update: &mut UpdateMessage, pc: &PeerConnection, ) -> Result<(), Error> { - use crate::messages::{ - Afi, BgpNexthop, PathAttributeValue, Safi, UpdateMessage as UM, - }; - use rdb::types::AddressFamily; - - // We'll rebuild the attributes list with validated/parsed MP-BGP attributes + // We'll rebuild the attributes list, filtering out unnegotiated AFI/SAFIs. + // Note: AFI/SAFI and NLRI validation happens during parsing (from_wire), + // so we only need to check negotiation state here. let mut validated_attributes = Vec::new(); - let mut had_error = false; for attr in &update.path_attributes { match &attr.value { PathAttributeValue::MpReachNlri(mp_reach) => { - // Step 1: Validate AFI/SAFI support - let afi = match Afi::try_from(mp_reach.afi) { - Ok(afi) => afi, - Err(_) => { - session_log!( - self, - error, - pc.conn, - "Unsupported AFI {} in MP_REACH_NLRI", - mp_reach.afi; - ); - - // Send notification for unsupported AFI/SAFI - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::OptionalAttribute, - ), - ); - - had_error = true; - continue; // Skip this attribute - } - }; - - let safi = match Safi::try_from(mp_reach.safi) { - Ok(safi) => safi, - Err(_) => { - session_log!( - self, - error, - pc.conn, - "Unsupported SAFI {} in MP_REACH_NLRI", - mp_reach.safi; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::OptionalAttribute, - ), - ); + // AFI/SAFI and nexthop/NLRI are already validated during + // parsing (from_wire). We only need to check negotiation. + let afi = mp_reach.afi(); + let safi = mp_reach.safi(); - had_error = true; - continue; - } - }; - - // Step 2: Check if AFI/SAFI was negotiated - let negotiated = match (afi, safi) { - (Afi::Ipv4, Safi::Unicast) => pc.ipv4, - (Afi::Ipv6, Safi::Unicast) => pc.ipv6, + // Check if AFI/SAFI was negotiated + let afi_state = match (afi, safi) { + (Afi::Ipv4, Safi::Unicast) => pc.ipv4_unicast, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6_unicast, }; - if !negotiated { + if !afi_state.negotiated() { session_log!( self, warn, @@ -7256,128 +7832,27 @@ impl SessionRunner { afi, safi; ); - // Don't send notification - just filter silently per existing pattern + // Don't send notification - just filter silently continue; } - // Step 3: Parse next-hop with validated AFI - let _nexthop = match BgpNexthop::from_bytes( - &mp_reach.nh_bytes, - mp_reach.nh_len, - afi, - ) { - Ok(nh) => nh, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "Invalid next-hop in MP_REACH_NLRI: {e}"; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::OptionalAttribute, - ), - ); - - had_error = true; - continue; - } - }; - - // Step 4: Parse NLRI with validated AFI - let address_family: AddressFamily = afi.into(); - let _nlri = match UM::prefixes_from_wire( - &mp_reach.nlri_bytes, - address_family, - ) { - Ok(prefixes) => prefixes, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "Invalid NLRI in MP_REACH_NLRI: {e}"; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::MalformedAttributeList, - ), - ); - - had_error = true; - continue; - } - }; - - // Note: For now, we keep the attribute with raw bytes - // Future enhancement: store parsed values in a new struct variant + // The attribute is already validated, keep it validated_attributes.push(attr.clone()); } PathAttributeValue::MpUnreachNlri(mp_unreach) => { - // Similar validation for MP_UNREACH_NLRI - let afi = match Afi::try_from(mp_unreach.afi_raw) { - Ok(afi) => afi, - Err(_) => { - session_log!( - self, - error, - pc.conn, - "Unsupported AFI {} in MP_UNREACH_NLRI", - mp_unreach.afi_raw; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::OptionalAttribute, - ), - ); - - had_error = true; - continue; - } - }; - - let safi = match Safi::try_from(mp_unreach.safi_raw) { - Ok(safi) => safi, - Err(_) => { - session_log!( - self, - error, - pc.conn, - "Unsupported SAFI {} in MP_UNREACH_NLRI", - mp_unreach.safi_raw; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::OptionalAttribute, - ), - ); - - had_error = true; - continue; - } - }; + // AFI/SAFI and withdrawn routes are already validated during + // parsing (from_wire). We only need to check negotiation. + let afi = mp_unreach.afi(); + let safi = mp_unreach.safi(); // Check if AFI/SAFI was negotiated - let negotiated = match (afi, safi) { - (Afi::Ipv4, Safi::Unicast) => pc.ipv4, - (Afi::Ipv6, Safi::Unicast) => pc.ipv6, + let afi_state = match (afi, safi) { + (Afi::Ipv4, Safi::Unicast) => pc.ipv4_unicast, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6_unicast, }; - if !negotiated { + if !afi_state.negotiated() { session_log!( self, warn, @@ -7390,35 +7865,7 @@ impl SessionRunner { continue; } - // Parse withdrawn routes with validated AFI - let address_family: AddressFamily = afi.into(); - let _withdrawn = match UM::prefixes_from_wire( - &mp_unreach.withdrawn_bytes, - address_family, - ) { - Ok(prefixes) => prefixes, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "Invalid withdrawn routes in MP_UNREACH_NLRI: {e}"; - ); - - self.send_notification( - &pc.conn, - ErrorCode::Update, - ErrorSubcode::Update( - UpdateErrorSubcode::MalformedAttributeList, - ), - ); - - had_error = true; - continue; - } - }; - - // Keep the attribute with raw bytes + // The attribute is already validated, keep it validated_attributes.push(attr.clone()); } @@ -7429,12 +7876,6 @@ impl SessionRunner { } } - if had_error { - return Err(Error::MalformedAttributeList( - "MP-BGP attribute validation failed".into(), - )); - } - update.path_attributes = validated_attributes; Ok(()) } @@ -7636,9 +8077,6 @@ impl SessionRunner { update: &UpdateMessage, peer_as: u32, ) -> Result<(), Error> { - // RFC 7606 Section 3(g): Reject duplicate MP-BGP attributes - update.check_duplicate_mp_attributes()?; - // Path vector routing and prefix validation self.check_for_self_in_path(update)?; diff --git a/rdb-types/src/lib.rs b/rdb-types/src/lib.rs index 174afa8b..99fdf415 100644 --- a/rdb-types/src/lib.rs +++ b/rdb-types/src/lib.rs @@ -441,4 +441,3 @@ pub enum ProtocolFilter { /// Static routes only Static, } - From 534b01be13c8afb4644f4245747bdbe3ebec529a Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 10 Dec 2025 21:50:25 -0700 Subject: [PATCH 03/20] bgp: split RouteUpdate into specific variants Splits RouteUpdate into V4(RouteUpdate4) and V6(RouteUpdate6) variants, each of which have an IP-version specific Announce/Withdraw variant. This uses the type system to ensure that any sender of a RouteUpdate will be forced to use multiple events if they want to trigger both an announcement and a withdrawal, upholding the RFC 7606 requirement that Updates cannot be encoded with more than one of the following populated: - Traditional Withdrawn field (IPv4) - Traditional NLRI field (IPv4) - MP_REACH_NLRI (IPv4 or IPv6) - MP_UNREACH_NLRI (IPv4 or IPv6) Additionally, this cleans up some unhelpful tests (like verifying length or emptiness of nlri/withdrawn fields) and adds several more: - round-trip codec tests for route-refresh and notification - simultaneous traditional/mp-bgp encoding of ipv4 unicast - making sure tests covering mp-bgp enums cover all variants - making sure tests covering BgpNexthop enum cover all variants --- bgp/src/fanout.rs | 93 +++++++--- bgp/src/messages.rs | 346 ++++++++++++++++++++++++++++++++++- bgp/src/proptest.rs | 116 ++++++++++++ bgp/src/router.rs | 4 +- bgp/src/session.rs | 432 ++++++++++++++++++++------------------------ bgp/src/test.rs | 268 +++++++++++++++++++++++++++ 6 files changed, 994 insertions(+), 265 deletions(-) diff --git a/bgp/src/fanout.rs b/bgp/src/fanout.rs index b5c21eae..ebce1458 100644 --- a/bgp/src/fanout.rs +++ b/bgp/src/fanout.rs @@ -3,7 +3,9 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::connection::BgpConnection; -use crate::session::{AdminEvent, FsmEvent, RouteUpdate}; +use crate::session::{ + AdminEvent, FsmEvent, RouteUpdate, RouteUpdate4, RouteUpdate6, +}; use crate::{COMPONENT_BGP, MOD_NEIGHBOR}; use rdb::types::{Ipv4Marker, Ipv6Marker, Prefix4, Prefix6}; use slog::Logger; @@ -47,58 +49,98 @@ pub struct Egress { // IPv4-specific implementation impl Fanout { - /// Announce IPv4 routes to all peers. + /// Announce and/or withdraw IPv4 routes to all peers. + /// + /// Per RFC 7606, announcements and withdrawals are sent as separate + /// UPDATE messages to avoid mixing reachable and unreachable NLRI. pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { - let route_update = RouteUpdate::V4 { nlri, withdrawn }; - for egress in self.egress.values() { - egress.announce_routes(route_update.clone()); + if !nlri.is_empty() { + let announce = + RouteUpdate::V4(RouteUpdate4::Announce(nlri.clone())); + egress.send_route_update(announce); + } + if !withdrawn.is_empty() { + let withdraw = + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn.clone())); + egress.send_route_update(withdraw); + } } } - /// Announce IPv4 routes to all peers except the origin. + /// Announce and/or withdraw IPv4 routes to all peers except the origin. + /// + /// Per RFC 7606, announcements and withdrawals are sent as separate + /// UPDATE messages to avoid mixing reachable and unreachable NLRI. pub fn announce_except( &self, origin: IpAddr, nlri: Vec, withdrawn: Vec, ) { - let route_update = RouteUpdate::V4 { nlri, withdrawn }; - for (peer_addr, egress) in &self.egress { if *peer_addr == origin { continue; } - egress.announce_routes(route_update.clone()); + if !nlri.is_empty() { + let announce = + RouteUpdate::V4(RouteUpdate4::Announce(nlri.clone())); + egress.send_route_update(announce); + } + if !withdrawn.is_empty() { + let withdraw = + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn.clone())); + egress.send_route_update(withdraw); + } } } } // IPv6-specific implementation impl Fanout { - /// Announce IPv6 routes to all peers. + /// Announce and/or withdraw IPv6 routes to all peers. + /// + /// Per RFC 7606, announcements and withdrawals are sent as separate + /// UPDATE messages to avoid mixing reachable and unreachable NLRI. pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { - let route_update = RouteUpdate::V6 { nlri, withdrawn }; - for egress in self.egress.values() { - egress.announce_routes(route_update.clone()); + if !nlri.is_empty() { + let announce = + RouteUpdate::V6(RouteUpdate6::Announce(nlri.clone())); + egress.send_route_update(announce); + } + if !withdrawn.is_empty() { + let withdraw = + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn.clone())); + egress.send_route_update(withdraw); + } } } - /// Announce IPv6 routes to all peers except the origin. + /// Announce and/or withdraw IPv6 routes to all peers except the origin. + /// + /// Per RFC 7606, announcements and withdrawals are sent as separate + /// UPDATE messages to avoid mixing reachable and unreachable NLRI. pub fn announce_except( &self, origin: IpAddr, nlri: Vec, withdrawn: Vec, ) { - let route_update = RouteUpdate::V6 { nlri, withdrawn }; - for (peer_addr, egress) in &self.egress { if *peer_addr == origin { continue; } - egress.announce_routes(route_update.clone()); + if !nlri.is_empty() { + let announce = + RouteUpdate::V6(RouteUpdate6::Announce(nlri.clone())); + egress.send_route_update(announce); + } + if !withdrawn.is_empty() { + let withdraw = + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn.clone())); + egress.send_route_update(withdraw); + } } } } @@ -119,31 +161,24 @@ impl Fanout { } impl Egress { - fn announce_routes(&self, route_update: RouteUpdate) { + fn send_route_update(&self, route_update: RouteUpdate) { let Some(tx) = self.event_tx.as_ref() else { return; }; - // Extract summary info before send() consumes route_update. - // This avoids expensive formatting when send succeeds (common case). - let (af, nlri_count, withdrawn_count) = ( - route_update.afi(), - route_update.nlri_count(), - route_update.withdrawn_count(), - ); + // Capture Display output before send() consumes route_update. + let update_desc = format!("{route_update}"); if let Err(e) = tx.send(FsmEvent::Admin(AdminEvent::Announce(route_update))) { slog::error!( self.log, - "failed to send routes to egress: {e}"; + "failed to send route update to egress: {e}"; "component" => COMPONENT_BGP, "module" => MOD_NEIGHBOR, "unit" => UNIT_FANOUT, - "address_family" => af, - "nlri_count" => nlri_count, - "withdrawn_count" => withdrawn_count, + "route_update" => update_desc, "error" => format!("{e}"), ); } diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index ced1c993..f196fb89 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -1469,6 +1469,15 @@ impl UpdateMessage { self.path_attributes .push(PathAttributeValue::Communities(vec![community]).into()); } + + pub fn mp_reach(&mut self) -> Option<&mut MpReachNlri> { + self.path_attributes + .iter_mut() + .find_map(|a| match &mut a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) + } } impl Display for UpdateMessage { @@ -5202,6 +5211,52 @@ mod tests { assert_eq!(um0, um1); } + #[test] + fn notification_round_trip() { + // Note: NotificationMessage::to_wire() does not yet serialize the data field + // (see TODO in the impl), so we test with empty data. + let nm0 = NotificationMessage { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::InvalidOriginAttribute, + ), + data: vec![], + }; + + let buf = nm0.to_wire().expect("notification message to wire"); + let nm1 = NotificationMessage::from_wire(&buf) + .expect("notification message from wire"); + + assert_eq!(nm0.error_code, nm1.error_code); + assert_eq!(nm0.error_subcode, nm1.error_subcode); + assert_eq!(nm0.data, nm1.data); + } + + #[test] + fn route_refresh_round_trip() { + // IPv4 Unicast route refresh + let rr0 = RouteRefreshMessage { + afi: Afi::Ipv4 as u16, + safi: Safi::Unicast as u8, + }; + + let buf = rr0.to_wire().expect("route refresh to wire"); + let rr1 = RouteRefreshMessage::from_wire(&buf) + .expect("route refresh from wire"); + assert_eq!(rr0, rr1); + + // IPv6 Unicast route refresh + let rr2 = RouteRefreshMessage { + afi: Afi::Ipv6 as u16, + safi: Safi::Unicast as u8, + }; + + let buf = rr2.to_wire().expect("route refresh to wire"); + let rr3 = RouteRefreshMessage::from_wire(&buf) + .expect("route refresh from wire"); + assert_eq!(rr2, rr3); + } + #[test] fn prefix_within() { // Test IPv4 prefix containment @@ -5613,10 +5668,40 @@ mod tests { #[test] fn bgp_nexthop_length_mismatch() { - // nh_bytes.len() != nh_len + // Test that nh_bytes.len() != nh_len is always rejected. + // The function checks bytes.len() == nh_len first before parsing. + + // IPv4: 4 bytes provided, but nh_len claims 8 let bytes = [192, 0, 2, 1]; let result = BgpNexthop::from_bytes(&bytes, 8, Afi::Ipv4); - assert!(result.is_err()); + assert!( + result.is_err(), + "IPv4: should reject when nh_len > bytes.len()" + ); + + // IPv6 single: 16 bytes provided, but nh_len claims 32 + let bytes = [0u8; 16]; + let result = BgpNexthop::from_bytes(&bytes, 32, Afi::Ipv6); + assert!( + result.is_err(), + "IPv6 single: should reject when nh_len > bytes.len()" + ); + + // IPv6: 32 bytes provided, but nh_len claims 16 (mismatch) + let bytes = [0u8; 32]; + let result = BgpNexthop::from_bytes(&bytes, 16, Afi::Ipv6); + assert!( + result.is_err(), + "IPv6: should reject when nh_len != bytes.len()" + ); + + // IPv6 double: 32 bytes provided, but nh_len claims 48 + let bytes = [0u8; 32]; + let result = BgpNexthop::from_bytes(&bytes, 48, Afi::Ipv6); + assert!( + result.is_err(), + "IPv6 double: should reject when nh_len > bytes.len()" + ); } #[test] @@ -5635,6 +5720,45 @@ mod tests { assert_eq!(ipv6_double.byte_len(), 32); } + #[test] + fn bgp_nexthop_round_trip() { + // Test all BgpNexthop variants survive encoding/decoding + + // IPv4 + let ipv4 = BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)); + let wire = ipv4.to_bytes(); + let decoded = + BgpNexthop::from_bytes(&wire, wire.len() as u8, Afi::Ipv4) + .expect("IPv4 should decode"); + assert_eq!(ipv4, decoded, "IPv4 nexthop should round-trip"); + + // IPv6 single + let ipv6_single = + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); + let wire = ipv6_single.to_bytes(); + let decoded = + BgpNexthop::from_bytes(&wire, wire.len() as u8, Afi::Ipv6) + .expect("IPv6 single should decode"); + assert_eq!( + ipv6_single, decoded, + "IPv6 single nexthop should round-trip" + ); + + // IPv6 double + let ipv6_double = BgpNexthop::Ipv6Double(( + Ipv6Addr::from_str("2001:db8::1").unwrap(), + Ipv6Addr::from_str("fe80::1").unwrap(), + )); + let wire = ipv6_double.to_bytes(); + let decoded = + BgpNexthop::from_bytes(&wire, wire.len() as u8, Afi::Ipv6) + .expect("IPv6 double should decode"); + assert_eq!( + ipv6_double, decoded, + "IPv6 double nexthop should round-trip" + ); + } + // ========================================================================= // MpReachNlri tests // ========================================================================= @@ -5721,6 +5845,27 @@ mod tests { // MpUnreachNlri tests // ========================================================================= + #[test] + fn mp_unreach_nlri_ipv4_unicast() { + let withdrawn = vec![ + rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8), + rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12), + ]; + + let mp_unreach = MpUnreachNlri::ipv4_unicast(withdrawn.clone()); + + assert_eq!(mp_unreach.afi(), Afi::Ipv4); + assert_eq!(mp_unreach.safi(), Safi::Unicast); + assert_eq!(mp_unreach.len(), 2); + + // Verify inner struct + if let MpUnreachNlri::Ipv4Unicast(inner) = &mp_unreach { + assert_eq!(inner.withdrawn, withdrawn); + } else { + panic!("Expected Ipv4Unicast variant"); + } + } + #[test] fn mp_unreach_nlri_ipv6_unicast() { let withdrawn = vec![ @@ -5937,6 +6082,203 @@ mod tests { assert!(has_unreach, "MP_UNREACH_NLRI should be present"); } + /// Test that we can handle IPv4 Unicast routes encoded using both methods: + /// traditional NLRI/withdrawn fields AND MP-BGP path attributes in the same UPDATE. + #[test] + fn ipv4_unicast_dual_encoding() { + // Traditional IPv4 prefixes + let traditional_nlri = + vec![rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)]; + let traditional_withdrawn = + vec![rdb::Prefix4::new(Ipv4Addr::new(192, 168, 0, 0), 16)]; + + // MP-BGP IPv4 prefixes (different from traditional) + let mp_nlri = vec![rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12)]; + let mp_withdrawn = + vec![rdb::Prefix4::new(Ipv4Addr::new(10, 10, 0, 0), 16)]; + + let mp_reach = MpReachNlri::ipv4_unicast( + BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)), + mp_nlri.clone(), + ); + let mp_unreach = MpUnreachNlri::ipv4_unicast(mp_withdrawn.clone()); + + let update = UpdateMessage { + withdrawn: traditional_withdrawn.clone(), + path_attributes: vec![ + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }, + PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }, + ], + nlri: traditional_nlri.clone(), + treat_as_withdraw: false, + errors: vec![], + }; + + // Round-trip through wire format + let wire = update.to_wire().expect("encoding should succeed"); + let decoded = + UpdateMessage::from_wire(&wire).expect("decoding should succeed"); + + // Verify traditional encoding is preserved + assert_eq!( + decoded.nlri, traditional_nlri, + "traditional NLRI should be preserved" + ); + assert_eq!( + decoded.withdrawn, traditional_withdrawn, + "traditional withdrawn should be preserved" + ); + + // Verify MP-BGP encoding is preserved + let decoded_mp_reach = decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv4Unicast( + inner, + )) => Some(inner.nlri.clone()), + _ => None, + }) + .expect("MP_REACH_NLRI should be present"); + assert_eq!( + decoded_mp_reach, mp_nlri, + "MP-BGP NLRI should be preserved" + ); + + let decoded_mp_unreach = decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri( + MpUnreachNlri::Ipv4Unicast(inner), + ) => Some(inner.withdrawn.clone()), + _ => None, + }) + .expect("MP_UNREACH_NLRI should be present"); + assert_eq!( + decoded_mp_unreach, mp_withdrawn, + "MP-BGP withdrawn should be preserved" + ); + } + + /// Test that an empty UPDATE message (End-of-RIB marker) can be encoded and decoded. + /// + /// Per RFC 4724 Section 2, an End-of-RIB marker is an UPDATE message with: + /// - No withdrawn routes + /// - No path attributes (for traditional IPv4) + /// - No NLRI + /// + /// For MP-BGP, End-of-RIB uses an UPDATE with only MP_UNREACH_NLRI containing + /// zero withdrawn routes. + #[test] + fn empty_update_end_of_rib() { + // Traditional IPv4 End-of-RIB: completely empty UPDATE + let empty_update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![], + nlri: vec![], + treat_as_withdraw: false, + errors: vec![], + }; + + let wire = empty_update.to_wire().expect("encoding should succeed"); + let decoded = + UpdateMessage::from_wire(&wire).expect("decoding should succeed"); + + assert!(decoded.withdrawn.is_empty(), "withdrawn should be empty"); + assert!( + decoded.path_attributes.is_empty(), + "path_attributes should be empty" + ); + assert!(decoded.nlri.is_empty(), "nlri should be empty"); + + // MP-BGP IPv6 End-of-RIB: UPDATE with MP_UNREACH_NLRI containing zero prefixes + let mp_eor = MpUnreachNlri::ipv6_unicast(vec![]); + let mp_eor_update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_eor), + }], + nlri: vec![], + treat_as_withdraw: false, + errors: vec![], + }; + + let wire = mp_eor_update.to_wire().expect("encoding should succeed"); + let decoded = + UpdateMessage::from_wire(&wire).expect("decoding should succeed"); + + // Verify MP_UNREACH_NLRI is present with zero prefixes + let mp_unreach = decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(u) => Some(u), + _ => None, + }) + .expect("MP_UNREACH_NLRI should be present"); + assert_eq!( + mp_unreach.len(), + 0, + "MP_UNREACH_NLRI should have 0 prefixes" + ); + assert_eq!(mp_unreach.afi(), Afi::Ipv6); + assert_eq!(mp_unreach.safi(), Safi::Unicast); + + // MP-BGP IPv4 Unicast End-of-RIB: UPDATE with MP_UNREACH_NLRI for IPv4 + let mp_eor_v4 = MpUnreachNlri::ipv4_unicast(vec![]); + let mp_eor_v4_update = UpdateMessage { + withdrawn: vec![], + path_attributes: vec![PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_eor_v4), + }], + nlri: vec![], + treat_as_withdraw: false, + errors: vec![], + }; + + let wire = mp_eor_v4_update.to_wire().expect("encoding should succeed"); + let decoded = + UpdateMessage::from_wire(&wire).expect("decoding should succeed"); + + // Verify MP_UNREACH_NLRI is present with zero prefixes and correct AFI/SAFI + let mp_unreach_v4 = decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(u) => Some(u), + _ => None, + }) + .expect("MP_UNREACH_NLRI should be present for IPv4 EOR"); + assert_eq!( + mp_unreach_v4.len(), + 0, + "MP_UNREACH_NLRI should have 0 prefixes" + ); + assert_eq!(mp_unreach_v4.afi(), Afi::Ipv4); + assert_eq!(mp_unreach_v4.safi(), Safi::Unicast); + } + /// Test that duplicate non-MP-BGP path attributes are deduplicated during /// decoding, keeping only the first occurrence (RFC 7606 Section 3g). #[test] diff --git a/bgp/src/proptest.rs b/bgp/src/proptest.rs index 5bc53ddd..0f7ceeb8 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -705,4 +705,120 @@ proptest! { ); } } + + // ------------------------------------------------------------------------- + // IPv4 Encoding Equivalence Tests + // ------------------------------------------------------------------------- + + /// Property: Two UPDATE messages carrying the same IPv4 Unicast routes should + /// be functionally equivalent regardless of whether they use traditional encoding + /// (NLRI/withdrawn fields) or MP-BGP encoding (MP_REACH_NLRI/MP_UNREACH_NLRI). + /// + /// This test generates random IPv4 prefixes, encodes them using both methods, + /// and verifies that the decoded routes are equivalent. + #[test] + fn prop_ipv4_traditional_vs_mp_bgp_equivalence( + nlri_prefixes in ipv4_prefixes_strategy(), + withdrawn_prefixes in ipv4_prefixes_strategy(), + nexthop in nexthop_ipv4_strategy() + ) { + // Create UPDATE using traditional encoding + let traditional_update = UpdateMessage { + withdrawn: withdrawn_prefixes.clone(), + path_attributes: vec![ + PathAttribute::from(PathAttributeValue::Origin(PathOrigin::Igp)), + PathAttribute::from(PathAttributeValue::AsPath(vec![])), + PathAttribute::from(PathAttributeValue::NextHop( + match nexthop { + BgpNexthop::Ipv4(addr) => addr, + _ => unreachable!("nexthop_ipv4_strategy only generates IPv4"), + } + )), + ], + nlri: nlri_prefixes.clone(), + treat_as_withdraw: false, + errors: vec![], + }; + + // Create UPDATE using MP-BGP encoding + let mp_reach = MpReachNlri::ipv4_unicast(nexthop, nlri_prefixes.clone()); + let mp_unreach = MpUnreachNlri::ipv4_unicast(withdrawn_prefixes.clone()); + + let mut mp_attrs = vec![ + PathAttribute::from(PathAttributeValue::Origin(PathOrigin::Igp)), + PathAttribute::from(PathAttributeValue::AsPath(vec![])), + ]; + if !nlri_prefixes.is_empty() { + mp_attrs.push(PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpReachNlri, + }, + value: PathAttributeValue::MpReachNlri(mp_reach), + }); + } + if !withdrawn_prefixes.is_empty() { + mp_attrs.push(PathAttribute { + typ: PathAttributeType { + flags: path_attribute_flags::OPTIONAL, + type_code: PathAttributeTypeCode::MpUnreachNlri, + }, + value: PathAttributeValue::MpUnreachNlri(mp_unreach), + }); + } + + let mp_bgp_update = UpdateMessage { + withdrawn: vec![], + path_attributes: mp_attrs, + nlri: vec![], + treat_as_withdraw: false, + errors: vec![], + }; + + // Encode and decode both + let traditional_wire = traditional_update.to_wire().expect("traditional encode"); + let mp_bgp_wire = mp_bgp_update.to_wire().expect("mp-bgp encode"); + + let traditional_decoded = UpdateMessage::from_wire(&traditional_wire) + .expect("traditional decode"); + let mp_bgp_decoded = UpdateMessage::from_wire(&mp_bgp_wire) + .expect("mp-bgp decode"); + + // Extract the effective NLRI from both (traditional uses nlri field, + // MP-BGP uses MP_REACH_NLRI attribute) + let traditional_effective_nlri = traditional_decoded.nlri.clone(); + let mp_bgp_effective_nlri: Vec = mp_bgp_decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv4Unicast(inner)) => { + Some(inner.nlri.clone()) + } + _ => None, + }) + .unwrap_or_default(); + + // Extract the effective withdrawn from both + let traditional_effective_withdrawn = traditional_decoded.withdrawn.clone(); + let mp_bgp_effective_withdrawn: Vec = mp_bgp_decoded + .path_attributes + .iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(MpUnreachNlri::Ipv4Unicast(inner)) => { + Some(inner.withdrawn.clone()) + } + _ => None, + }) + .unwrap_or_default(); + + // The routes should be functionally equivalent + prop_assert_eq!( + traditional_effective_nlri, mp_bgp_effective_nlri, + "NLRI prefixes should be equivalent regardless of encoding" + ); + prop_assert_eq!( + traditional_effective_withdrawn, mp_bgp_effective_withdrawn, + "Withdrawn prefixes should be equivalent regardless of encoding" + ); + } } diff --git a/bgp/src/router.rs b/bgp/src/router.rs index dd2cb9e4..f9094edc 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -429,7 +429,7 @@ impl Router { read_lock!(self.fanout4).announce_all(prefixes, vec![]); } - pub fn withdraw_origin4(&self, prefixes: Vec) { + fn withdraw_origin4(&self, prefixes: Vec) { if prefixes.is_empty() { return; } @@ -533,7 +533,7 @@ impl Router { read_lock!(self.fanout6).announce_all(prefixes, vec![]); } - pub fn withdraw_origin6(&self, prefixes: Vec) { + fn withdraw_origin6(&self, prefixes: Vec) { if prefixes.is_empty() { return; } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 859518fc..6ad43169 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -333,71 +333,111 @@ impl From<&FsmState> for FsmStateKind { } } -/// Type-safe route update that enforces IP version consistency at compile time. -/// This eliminates the need for runtime Afi validation and prevents -/// mixing IPv4 routes with IPv6 encoding or vice versa. +/// IPv4 route update - either an announcement or withdrawal, never both. +/// +/// RFC 7606 requires that UPDATE messages not mix reachable and unreachable +/// NLRI. This type enforces that constraint at compile time. +#[derive(Clone, Debug)] +pub enum RouteUpdate4 { + Announce(Vec), + Withdraw(Vec), +} + +/// IPv6 route update - either an announcement or withdrawal, never both. +/// +/// RFC 7606 requires that UPDATE messages not mix reachable and unreachable +/// NLRI. This type enforces that constraint at compile time. +#[derive(Clone, Debug)] +pub enum RouteUpdate6 { + Announce(Vec), + Withdraw(Vec), +} + +/// Route update for a specific address family. +/// +/// RFC 7606 requires that UPDATE messages not mix reachable and unreachable +/// NLRI. The inner `RouteUpdate4`/`RouteUpdate6` types enforce this by only +/// allowing either an announcement OR a withdrawal, never both. #[derive(Clone, Debug)] pub enum RouteUpdate { - V4 { - withdrawn: Vec, - nlri: Vec, - }, - V6 { - withdrawn: Vec, - nlri: Vec, - }, + V4(RouteUpdate4), + V6(RouteUpdate6), } impl RouteUpdate { pub fn is_empty(&self) -> bool { match self { - RouteUpdate::V4 { withdrawn, nlri } => { - withdrawn.is_empty() && nlri.is_empty() + RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => nlri.is_empty(), + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { + withdrawn.is_empty() } - RouteUpdate::V6 { withdrawn, nlri } => { - withdrawn.is_empty() && nlri.is_empty() + RouteUpdate::V6(RouteUpdate6::Announce(nlri)) => nlri.is_empty(), + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn)) => { + withdrawn.is_empty() } } } pub fn afi(&self) -> Afi { match self { - RouteUpdate::V4 { .. } => Afi::Ipv4, - RouteUpdate::V6 { .. } => Afi::Ipv6, + RouteUpdate::V4(_) => Afi::Ipv4, + RouteUpdate::V6(_) => Afi::Ipv6, } } pub fn nlri_count(&self) -> usize { match self { - RouteUpdate::V4 { withdrawn: _, nlri } => nlri.len(), - RouteUpdate::V6 { withdrawn: _, nlri } => nlri.len(), + RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => nlri.len(), + RouteUpdate::V4(RouteUpdate4::Withdraw(_)) => 0, + RouteUpdate::V6(RouteUpdate6::Announce(nlri)) => nlri.len(), + RouteUpdate::V6(RouteUpdate6::Withdraw(_)) => 0, } } pub fn withdrawn_count(&self) -> usize { match self { - RouteUpdate::V4 { withdrawn, .. } => withdrawn.len(), - RouteUpdate::V6 { withdrawn, .. } => withdrawn.len(), + RouteUpdate::V4(RouteUpdate4::Announce(_)) => 0, + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { + withdrawn.len() + } + RouteUpdate::V6(RouteUpdate6::Announce(_)) => 0, + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn)) => { + withdrawn.len() + } } } + + pub fn is_announcement(&self) -> bool { + matches!( + self, + RouteUpdate::V4(RouteUpdate4::Announce(_)) + | RouteUpdate::V6(RouteUpdate6::Announce(_)) + ) + } + + pub fn is_withdrawal(&self) -> bool { + matches!( + self, + RouteUpdate::V4(RouteUpdate4::Withdraw(_)) + | RouteUpdate::V6(RouteUpdate6::Withdraw(_)) + ) + } } impl Display for RouteUpdate { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { - RouteUpdate::V4 { withdrawn, nlri } => { - write!( - f, - "RouteUpdate::V4 {{ nlri: {:?}, withdrawn: {:?} }}", - nlri, withdrawn - ) + RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => { + write!(f, "ipv4 announce {} prefixes", nlri.len()) } - RouteUpdate::V6 { withdrawn, nlri } => { - write!( - f, - "RouteUpdate::V6 {{ nlri: {:?}, withdrawn: {:?} }}", - nlri, withdrawn - ) + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { + write!(f, "ipv4 withdraw {} prefixes", withdrawn.len()) + } + RouteUpdate::V6(RouteUpdate6::Announce(nlri)) => { + write!(f, "ipv6 announce {} prefixes", nlri.len()) + } + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn)) => { + write!(f, "ipv6 withdraw {} prefixes", withdrawn.len()) } } } @@ -5814,10 +5854,7 @@ impl SessionRunner { // is originating. if !originated4.is_empty() && let Err(e) = self.send_update( - RouteUpdate::V4 { - nlri: originated4, - withdrawn: vec![], - }, + RouteUpdate::V4(RouteUpdate4::Announce(originated4)), &pc, ShaperApplication::Current, ) @@ -5835,10 +5872,7 @@ impl SessionRunner { // Send IPv6 Unicast prefixes using MP-BGP encoding if !originated6.is_empty() && let Err(e) = self.send_update( - RouteUpdate::V6 { - nlri: originated6, - withdrawn: vec![], - }, + RouteUpdate::V6(RouteUpdate6::Announce(originated6)), &pc, ShaperApplication::Current, ) @@ -5872,10 +5906,7 @@ impl SessionRunner { if !originated4.is_empty() { self.send_update( - RouteUpdate::V4 { - nlri: originated4, - withdrawn: vec![], - }, + RouteUpdate::V4(RouteUpdate4::Announce(originated4)), pc, ShaperApplication::Current, )?; @@ -5891,10 +5922,7 @@ impl SessionRunner { if !originated6.is_empty() { self.send_update( - RouteUpdate::V6 { - nlri: originated6, - withdrawn: vec![], - }, + RouteUpdate::V6(RouteUpdate6::Announce(originated6)), pc, ShaperApplication::Current, )?; @@ -5941,24 +5969,11 @@ impl SessionRunner { // adj-rib-in pre-policy, so a route-refresh is the // only mechanism we really have to trigger a re-learn // of the route without changing the current design. - let (afi, nlri_count, withdrawn_count) = match &route_update - { - RouteUpdate::V4 { nlri, withdrawn } => { - (Afi::Ipv4, nlri.len(), withdrawn.len()) - } - RouteUpdate::V6 { nlri, withdrawn } => { - (Afi::Ipv6, nlri.len(), withdrawn.len()) - } - }; - session_log!( self, debug, pc.conn, - "received announce-routes event: afi={}, nlri={}, withdrawn={}", - afi, - nlri_count, - withdrawn_count; + "received route-update event: {route_update}" ); if let Err(e) = self.send_update( @@ -6049,12 +6064,13 @@ impl SessionRunner { .cloned() .collect(); - if (!to_withdraw.is_empty() || !to_announce.is_empty()) + // Per RFC 7606, send announcements and withdrawals as + // separate UPDATE messages. + if !to_announce.is_empty() && let Err(e) = self.send_update( - RouteUpdate::V4 { - nlri: to_announce, - withdrawn: to_withdraw, - }, + RouteUpdate::V4(RouteUpdate4::Announce( + to_announce, + )), &pc, ShaperApplication::Current, ) @@ -6063,7 +6079,26 @@ impl SessionRunner { self, error, pc.conn, - "failed to send export policy update: {e}"; + "failed to send export policy announce: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); + } + + if !to_withdraw.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V4(RouteUpdate4::Withdraw( + to_withdraw, + )), + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send export policy withdraw: {e}"; "error" => format!("{e}") ); return self.exit_established(pc); @@ -7163,14 +7198,14 @@ impl SessionRunner { // Standard behavior: use local IP as next-hop match (afi, local_ip) { - (Afi::Ipv4, std::net::IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), - (Afi::Ipv6, std::net::IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), - (Afi::Ipv4, std::net::IpAddr::V6(_)) => { + (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), + (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), + (Afi::Ipv4, IpAddr::V6(_)) => { Err(Error::InvalidAddress( "IPv4 routes require IPv4 next-hop (Extended Next-Hop not negotiated)".into() )) } - (Afi::Ipv6, std::net::IpAddr::V4(_)) => { + (Afi::Ipv6, IpAddr::V4(_)) => { Err(Error::InvalidAddress( "IPv6 routes require IPv6 next-hop".into() )) @@ -7233,11 +7268,21 @@ impl SessionRunner { // Filter traditional NLRI field update.nlri.retain(|p| allowed.contains(&Prefix::V4(*p))); - // TODO: Filter MP-BGP NLRI (if present) - // Since MP-BGP attributes now store raw bytes, filtering requires - // parsing NLRI first. This should be handled during UPDATE construction - // in send_update_v2() instead of post-hoc filtering. - // See docs/update-construction-layered-design-plan.md for details. + // Filter MP_REACH_NLRI + if let Some(reach) = update.mp_reach() { + match reach { + MpReachNlri::Ipv4Unicast(reach4) => { + reach4 + .nlri + .retain(|p| allowed.contains(&Prefix::V4(*p))); + } + MpReachNlri::Ipv6Unicast(reach6) => { + reach6 + .nlri + .retain(|p| allowed.contains(&Prefix::V6(*p))); + } + } + } } Ok(()) @@ -7262,26 +7307,18 @@ impl SessionRunner { pc: &PeerConnection, shaper_application: ShaperApplication, ) -> Result<(), Error> { + // XXX: Handle more originated routes than can fit in a single Update + // Early exit if nothing to send if route_update.is_empty() { return Ok(()); } - // Extract AFI and build the UpdateMessage based on variant - let (afi, mut update) = match route_update { - RouteUpdate::V4 { nlri, withdrawn } => { - session_log!( - self, - debug, - pc.conn, - "building IPv4 update: nlri={}, withdrawn={}", - nlri.len(), - withdrawn.len(); - ); - - // Derive IPv4 next-hop - let nexthop = self.derive_nexthop(Afi::Ipv4, pc)?; - let nh4 = match nexthop { + // Build the UpdateMessage based on variant. RFC 7606 compliance: + // Each RouteUpdate is either an announcement OR withdrawal, never both. + let mut update = match route_update { + RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => { + let nh4 = match self.derive_nexthop(Afi::Ipv4, pc)? { BgpNexthop::Ipv4(addr) => addr, _ => { return Err(Error::InvalidAddress( @@ -7290,92 +7327,59 @@ impl SessionRunner { } }; - session_log!( - self, - debug, - pc.conn, - "derived next-hop: {}", - nh4; - ); + let mut path_attributes = self.router.base_attributes(); + path_attributes.push(PathAttributeValue::NextHop(nh4).into()); - // Build UpdateMessage directly with type-safe Vec - let mut path_attrs = self.router.base_attributes(); - path_attrs.push(PathAttributeValue::NextHop(nh4).into()); - - let update = UpdateMessage { - withdrawn, + UpdateMessage { + withdrawn: vec![], + path_attributes, nlri, - path_attributes: path_attrs, - treat_as_withdraw: false, - errors: vec![], - }; - - (Afi::Ipv4, update) + ..Default::default() + } } - RouteUpdate::V6 { nlri, withdrawn } => { - session_log!( - self, - debug, - pc.conn, - "building IPv6 update: nlri={}, withdrawn={}", - nlri.len(), - withdrawn.len(); - ); - - // Derive IPv6 next-hop - let nexthop = self.derive_nexthop(Afi::Ipv6, pc)?; - - session_log!( - self, - debug, - pc.conn, - "derived next-hop: {:?}", - nexthop; - ); + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { + // Traditional withdrawals don't need path attributes + UpdateMessage { + withdrawn, + path_attributes: vec![], + nlri: vec![], + ..Default::default() + } + } + RouteUpdate::V6(RouteUpdate6::Announce(nlri)) => { + let nh6 = self.derive_nexthop(Afi::Ipv6, pc)?; + if matches!(nh6, BgpNexthop::Ipv4(_)) { + return Err(Error::InvalidAddress( + "IPv6 routes require IPv6 next-hop".into(), + )); + } - // Build UpdateMessage with MP-BGP attributes let mut path_attrs = self.router.base_attributes(); + let reach = MpReachNlri::ipv6_unicast(nh6, nlri); + path_attrs.push(PathAttributeValue::MpReachNlri(reach).into()); - // Add MP_REACH_NLRI for announcements - if !nlri.is_empty() { - let reach = MpReachNlri::ipv6_unicast(nexthop, nlri); - path_attrs - .push(PathAttributeValue::MpReachNlri(reach).into()); - } - - // Add MP_UNREACH_NLRI for withdrawals - if !withdrawn.is_empty() { - let unreach = MpUnreachNlri::ipv6_unicast(withdrawn); - path_attrs.push( - PathAttributeValue::MpUnreachNlri(unreach).into(), - ); + UpdateMessage { + withdrawn: vec![], + path_attributes: path_attrs, + nlri: vec![], + ..Default::default() } + } + RouteUpdate::V6(RouteUpdate6::Withdraw(withdrawn)) => { + // MP_UNREACH_NLRI for IPv6 withdrawals + let unreach = MpUnreachNlri::ipv6_unicast(withdrawn); + let path_attrs = + vec![PathAttributeValue::MpUnreachNlri(unreach).into()]; - let update = UpdateMessage { - withdrawn: vec![], // Traditional fields empty for IPv6 - nlri: vec![], + UpdateMessage { + withdrawn: vec![], path_attributes: path_attrs, - treat_as_withdraw: false, - errors: vec![], - }; - - (Afi::Ipv6, update) + nlri: vec![], + ..Default::default() + } } }; - session_log!( - self, - debug, - pc.conn, - "built update skeleton: afi={}, traditional_nlri={}, mp_bgp={}", - afi, - !update.nlri.is_empty(), - update.path_attributes.iter().any(|a| matches!( - a.value, - PathAttributeValue::MpReachNlri(_) | PathAttributeValue::MpUnreachNlri(_) - )); - ); - // 3. Add peer-specific enrichments self.enrich_update(&mut update, pc)?; @@ -7901,10 +7905,7 @@ impl SessionRunner { if !originated.is_empty() { self.send_update( - RouteUpdate::V4 { - nlri: originated, - withdrawn: vec![], - }, + RouteUpdate::V4(RouteUpdate4::Announce(originated)), pc, ShaperApplication::Current, )?; @@ -7933,10 +7934,7 @@ impl SessionRunner { if !originated.is_empty() { self.send_update( - RouteUpdate::V6 { - nlri: originated, - withdrawn: vec![], - }, + RouteUpdate::V6(RouteUpdate6::Announce(originated)), pc, ShaperApplication::Current, )?; @@ -8581,71 +8579,41 @@ mod tests { // ========================================================================= #[test] - fn route_update_is_empty() { - // Empty V4 - let empty_v4 = RouteUpdate::V4 { - nlri: vec![], - withdrawn: vec![], - }; - assert!(empty_v4.is_empty()); - - // Empty V6 - let empty_v6 = RouteUpdate::V6 { - nlri: vec![], - withdrawn: vec![], - }; - assert!(empty_v6.is_empty()); - - // Non-empty V4 with NLRI - let non_empty_v4 = RouteUpdate::V4 { - nlri: vec![Prefix4::new(std::net::Ipv4Addr::new(10, 0, 0, 0), 8)], - withdrawn: vec![], - }; - assert!(!non_empty_v4.is_empty()); - - // Non-empty V6 with withdrawn - let non_empty_v6 = RouteUpdate::V6 { - nlri: vec![], - withdrawn: vec![Prefix6::new( + fn route_update_is_announcement_and_withdrawal() { + let v4_announce = + RouteUpdate::V4(RouteUpdate4::Announce(vec![Prefix4::new( + std::net::Ipv4Addr::new(10, 0, 0, 0), + 8, + )])); + assert!(v4_announce.is_announcement()); + assert!(!v4_announce.is_withdrawal()); + + let v4_withdraw = + RouteUpdate::V4(RouteUpdate4::Withdraw(vec![Prefix4::new( + std::net::Ipv4Addr::new(10, 0, 0, 0), + 8, + )])); + assert!(!v4_withdraw.is_announcement()); + assert!(v4_withdraw.is_withdrawal()); + + let v6_announce = + RouteUpdate::V6(RouteUpdate6::Announce(vec![Prefix6::new( std::net::Ipv6Addr::from([ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]), 32, - )], - }; - assert!(!non_empty_v6.is_empty()); - } - - #[test] - fn route_update_display_v4() { - let update = RouteUpdate::V4 { - nlri: vec![Prefix4::new(std::net::Ipv4Addr::new(10, 0, 0, 0), 8)], - withdrawn: vec![Prefix4::new( - std::net::Ipv4Addr::new(192, 168, 0, 0), - 16, - )], - }; - let display = format!("{}", update); - // Uses Debug format {:?} for prefix contents, which shows struct fields - assert!(display.contains("RouteUpdate::V4")); - assert!(display.contains("10.0.0.0")); - assert!(display.contains("192.168.0.0")); - } + )])); + assert!(v6_announce.is_announcement()); + assert!(!v6_announce.is_withdrawal()); - #[test] - fn route_update_display_v6() { - let update = RouteUpdate::V6 { - nlri: vec![Prefix6::new( + let v6_withdraw = + RouteUpdate::V6(RouteUpdate6::Withdraw(vec![Prefix6::new( std::net::Ipv6Addr::from([ 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]), 32, - )], - withdrawn: vec![], - }; - let display = format!("{}", update); - // Uses Debug format {:?} for prefix contents - assert!(display.contains("RouteUpdate::V6")); - assert!(display.contains("2001:db8")); + )])); + assert!(!v6_withdraw.is_announcement()); + assert!(v6_withdraw.is_withdrawal()); } } diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 5dc80133..e6b2eff2 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -925,3 +925,271 @@ fn test_neighbor_thread_lifecycle_no_leaks() { "BGP thread count should return to baseline after delete" ); } + +/// Test import/export policy filtering. +/// +/// This test verifies that: +/// 1. Export policy on the sender filters prefixes before transmission +/// 2. Import policy on the receiver filters prefixes after reception +/// 3. Removing export policy allows filtered prefixes through +/// 4. Removing import policy allows filtered prefixes through +/// 5. Path attributes are correctly preserved through filtering +#[test] +fn test_import_export_policy_filtering() { + use rdb::ImportExportPolicy; + use std::collections::BTreeSet; + + let r1_addr: SocketAddr = sockaddr!(&format!("127.0.0.12:{TEST_BGP_PORT}")); + let r2_addr: SocketAddr = sockaddr!(&format!("127.0.0.13:{TEST_BGP_PORT}")); + + // Ensure loopback IPs are available for this test + let _ip_guard = ensure_loop_ips(&[r1_addr.ip(), r2_addr.ip()]); + + // Define our test prefixes + let prefix_a = ip!("10.1.0.0/24"); // Will pass both export and import + let prefix_b = ip!("10.2.0.0/24"); // Will be filtered by export, passes import + let prefix_c = ip!("10.3.0.0/24"); // Will pass export but filtered by import + + // Build export policy for r1: allow prefix_a and prefix_c, deny prefix_b + let export_allow: BTreeSet = [ + Prefix::V4(cidr!("10.1.0.0/24")), + Prefix::V4(cidr!("10.3.0.0/24")), + ] + .into_iter() + .collect(); + + // Build import policy for r2: allow prefix_a and prefix_b, deny prefix_c + let import_allow: BTreeSet = [ + Prefix::V4(cidr!("10.1.0.0/24")), + Prefix::V4(cidr!("10.2.0.0/24")), + ] + .into_iter() + .collect(); + + // Configure r1 with export policy + let r1_peer_config = PeerConfig { + name: "r2".into(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let r1_session_info = { + let mut info = SessionInfo::from_peer_config(&r1_peer_config); + info.allow_export = ImportExportPolicy::Allow(export_allow.clone()); + info + }; + + // Configure r2 with import policy + let r2_peer_config = PeerConfig { + name: "r1".into(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let r2_session_info = { + let mut info = SessionInfo::from_peer_config(&r2_peer_config); + info.allow_import = ImportExportPolicy::Allow(import_allow.clone()); + info + }; + + let routers = vec![ + LogicalRouter { + name: "r1".to_string(), + asn: Asn::FourOctet(4200000001), + id: 1, + listen_addr: r1_addr, + bind_addr: Some(r1_addr), + neighbors: vec![Neighbor { + peer_config: r1_peer_config.clone(), + session_info: Some(r1_session_info), + }], + }, + LogicalRouter { + name: "r2".to_string(), + asn: Asn::FourOctet(4200000002), + id: 2, + listen_addr: r2_addr, + bind_addr: Some(r2_addr), + neighbors: vec![Neighbor { + peer_config: r2_peer_config.clone(), + session_info: Some(r2_session_info), + }], + }, + ]; + + let (test_routers, _ip_guard2) = test_setup::< + BgpConnectionTcp, + BgpListenerTcp, + >("import_export_policy", &routers); + + let r1 = &test_routers[0]; + let r2 = &test_routers[1]; + + // Wait for session establishment + let r1_session = r1 + .router + .get_session(r2_addr.ip()) + .expect("get r1->r2 session"); + let r2_session = r2 + .router + .get_session(r1_addr.ip()) + .expect("get r2->r1 session"); + wait_for_eq!(r1_session.state(), FsmStateKind::Established); + wait_for_eq!(r2_session.state(), FsmStateKind::Established); + + // Originate all 3 prefixes from r1 + r1.router + .create_origin4(vec![prefix_a, prefix_b, prefix_c]) + .expect("originate prefixes"); + + // Wait for routes to propagate - r2 should only see prefix_a + // (prefix_b filtered by export, prefix_c filtered by import) + let prefix_a_rdb = Prefix::V4(cidr!("10.1.0.0/24")); + let prefix_b_rdb = Prefix::V4(cidr!("10.2.0.0/24")); + let prefix_c_rdb = Prefix::V4(cidr!("10.3.0.0/24")); + + wait_for!( + !r2.router.db.get_prefix_paths(&prefix_a_rdb).is_empty(), + "r2 should receive prefix_a" + ); + + // Verify r2's RIB state with policies active + // prefix_a: should be present (passes both export and import) + let paths_a = r2.router.db.get_prefix_paths(&prefix_a_rdb); + assert_eq!(paths_a.len(), 1, "prefix_a should have exactly one path"); + let path_a = &paths_a[0]; + assert_eq!(path_a.nexthop, r1_addr.ip(), "nexthop should be r1"); + let bgp_props_a = path_a.bgp.as_ref().expect("should have BGP properties"); + assert_eq!( + bgp_props_a.origin_as, 4200000001, + "origin AS should be r1's ASN" + ); + assert_eq!( + bgp_props_a.as_path, + vec![4200000001], + "AS path should contain r1's ASN" + ); + + // prefix_b: should NOT be present (filtered by r1's export policy) + let paths_b = r2.router.db.get_prefix_paths(&prefix_b_rdb); + assert!( + paths_b.is_empty(), + "prefix_b should be filtered by export policy" + ); + + // prefix_c: should NOT be present (filtered by r2's import policy) + let paths_c = r2.router.db.get_prefix_paths(&prefix_c_rdb); + assert!( + paths_c.is_empty(), + "prefix_c should be filtered by import policy" + ); + + // Remove r1's export policy - prefix_b should now be sent to r2 + // The ExportPolicyChanged handler will automatically send the newly-allowed + // prefix without requiring manual re-origination. + assert_eq!( + r1_session.state(), + FsmStateKind::Established, + "r1 session should be in Established state before policy update" + ); + let r1_session_info_no_export = { + let mut info = SessionInfo::from_peer_config(&r1_peer_config); + info.allow_export = ImportExportPolicy::NoFiltering; + info + }; + r1.router + .update_session(r1_peer_config.clone(), r1_session_info_no_export) + .expect("update r1 session to remove export policy"); + + // prefix_b should now appear (was filtered by export, now allowed) + // but prefix_c is still filtered by r2's import policy + wait_for!( + !r2.router.db.get_prefix_paths(&prefix_b_rdb).is_empty(), + "r2 should receive prefix_b after removing export policy" + ); + + // Verify prefix_b arrived with correct attributes + let paths_b = r2.router.db.get_prefix_paths(&prefix_b_rdb); + assert_eq!( + paths_b.len(), + 1, + "prefix_b should have exactly one path after export policy removal" + ); + let path_b = &paths_b[0]; + assert_eq!( + path_b.nexthop, + r1_addr.ip(), + "prefix_b nexthop should be r1" + ); + let bgp_props_b = path_b.bgp.as_ref().expect("should have BGP properties"); + assert_eq!( + bgp_props_b.origin_as, 4200000001, + "prefix_b origin AS should be r1's ASN" + ); + + // prefix_c should still be filtered by r2's import policy + let paths_c = r2.router.db.get_prefix_paths(&prefix_c_rdb); + assert!( + paths_c.is_empty(), + "prefix_c should still be filtered by import policy" + ); + + // Now remove r2's import policy - prefix_c should appear via route-refresh + let r2_session_info_no_import = { + let mut info = SessionInfo::from_peer_config(&r2_peer_config); + info.allow_import = ImportExportPolicy::NoFiltering; + info + }; + r2.router + .update_session(r2_peer_config.clone(), r2_session_info_no_import) + .expect("update r2 session to remove import policy"); + + // Wait for prefix_c to appear after import policy removal + // The import policy change triggers a route-refresh request to r1 + wait_for!( + !r2.router.db.get_prefix_paths(&prefix_c_rdb).is_empty(), + "r2 should receive prefix_c after removing import policy" + ); + + // Final verification: all 3 prefixes present with correct attributes + for (prefix_name, prefix_rdb) in [ + ("prefix_a", &prefix_a_rdb), + ("prefix_b", &prefix_b_rdb), + ("prefix_c", &prefix_c_rdb), + ] { + let paths = r2.router.db.get_prefix_paths(prefix_rdb); + assert_eq!( + paths.len(), + 1, + "{prefix_name} should have exactly one path after policy removal" + ); + let path = &paths[0]; + assert_eq!( + path.nexthop, + r1_addr.ip(), + "{prefix_name} nexthop should be r1" + ); + let bgp_props = path.bgp.as_ref().expect("should have BGP properties"); + assert_eq!( + bgp_props.origin_as, 4200000001, + "{prefix_name} origin AS should be r1's ASN" + ); + assert_eq!( + bgp_props.as_path, + vec![4200000001], + "{prefix_name} AS path should contain r1's ASN" + ); + } + + // Clean up + r1.shutdown(); + r2.shutdown(); +} From 01ed779fd8e76e486bb9e56551f5d7f5849664ee Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 11 Dec 2025 13:57:55 -0700 Subject: [PATCH 04/20] bgp: add per-address-family import/export policies Split ImportExportPolicy into typed IPv4 and IPv6 variants (ImportExportPolicy4, ImportExportPolicy6) to enable proper per-AF filtering. This adds IPv6 import filtering. Key changes: - Add ImportExportPolicy4/6 types for compile-time type safety - Add ImportExportPolicyV1 for API backward compatibility - Update SessionInfo to use per-AF policy fields - Add per-AF export policy change handling - Add per-AF route refresh (used for import policy changes) - Add mark_bgp_peer_stale4/6 for per-AF stale marking The Neighbor struct retains legacy allow_import/allow_export fields for API compatibility, with conversion helpers to combine per-AF policies back to the legacy format. Closes: #211 --- bgp/src/messages.rs | 25 +- bgp/src/params.rs | 45 ++- bgp/src/session.rs | 725 ++++++++++++++++++++++++++++++++----------- bgp/src/test.rs | 34 +- mgd/src/bgp_admin.rs | 61 ++-- mgd/src/main.rs | 11 +- rdb/src/db.rs | 13 +- rdb/src/types.rs | 155 ++++++++- 8 files changed, 824 insertions(+), 245 deletions(-) diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index f196fb89..b1593a80 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -1470,7 +1470,14 @@ impl UpdateMessage { .push(PathAttributeValue::Communities(vec![community]).into()); } - pub fn mp_reach(&mut self) -> Option<&mut MpReachNlri> { + pub fn mp_reach(&self) -> Option<&MpReachNlri> { + self.path_attributes.iter().find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => None, + }) + } + + pub fn mp_reach_mut(&mut self) -> Option<&mut MpReachNlri> { self.path_attributes .iter_mut() .find_map(|a| match &mut a.value { @@ -1478,6 +1485,22 @@ impl UpdateMessage { _ => None, }) } + + pub fn mp_unreach(&self) -> Option<&MpUnreachNlri> { + self.path_attributes.iter().find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + _ => None, + }) + } + + pub fn mp_unreach_mut(&mut self) -> Option<&mut MpUnreachNlri> { + self.path_attributes + .iter_mut() + .find_map(|a| match &mut a.value { + PathAttributeValue::MpUnreachNlri(mp) => Some(mp), + _ => None, + }) + } } impl Display for UpdateMessage { diff --git a/bgp/src/params.rs b/bgp/src/params.rs index eec38185..fb4d4be0 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -4,7 +4,10 @@ use crate::config::PeerConfig; use crate::session::FsmStateKind; -use rdb::{ImportExportPolicy, PolicyAction, Prefix4, Prefix6}; +use rdb::{ + ImportExportPolicy4, ImportExportPolicy6, ImportExportPolicyV1, + PolicyAction, Prefix4, Prefix6, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -56,8 +59,8 @@ pub struct Neighbor { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + pub allow_import: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, } @@ -101,8 +104,15 @@ impl Neighbor { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - allow_import: rq.allow_import, - allow_export: rq.allow_export, + // Combine per-AF policies into legacy format for API compatibility + allow_import: ImportExportPolicyV1::from_per_af_policies( + &rq.allow_import4, + &rq.allow_import6, + ), + allow_export: ImportExportPolicyV1::from_per_af_policies( + &rq.allow_export4, + &rq.allow_export6, + ), vlan_id: rq.vlan_id, } } @@ -127,8 +137,15 @@ impl Neighbor { communities: rq.communities.clone(), local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - allow_import: rq.allow_import.clone(), - allow_export: rq.allow_export.clone(), + // Combine per-AF policies into legacy format for API compatibility + allow_import: ImportExportPolicyV1::from_per_af_policies( + &rq.allow_import4, + &rq.allow_import6, + ), + allow_export: ImportExportPolicyV1::from_per_af_policies( + &rq.allow_export4, + &rq.allow_export6, + ), vlan_id: rq.vlan_id, } } @@ -286,8 +303,18 @@ pub struct BgpPeerConfig { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + /// Per-address-family import policy for IPv4 routes. + #[serde(default)] + pub allow_import4: ImportExportPolicy4, + /// Per-address-family export policy for IPv4 routes. + #[serde(default)] + pub allow_export4: ImportExportPolicy4, + /// Per-address-family import policy for IPv6 routes. + #[serde(default)] + pub allow_import6: ImportExportPolicy6, + /// Per-address-family export policy for IPv6 routes. + #[serde(default)] + pub allow_export6: ImportExportPolicy6, pub vlan_id: Option, } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 6ad43169..0e493364 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -24,7 +24,8 @@ use crate::{ }; use mg_common::{lock, read_lock, write_lock}; use rdb::{ - Asn, BgpPathProperties, Db, ImportExportPolicy, Prefix, Prefix4, Prefix6, + AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy, + ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, Prefix6, }; pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY}; use schemars::JsonSchema; @@ -453,7 +454,8 @@ pub enum AdminEvent { // Current shaper is available in the router policy object. ShaperChanged(Option), - /// Fires when export policy has changed. + /// Fires when an export policy has changed. + /// Contains the previous policy for determining routes to re-advertise. ExportPolicyChanged(ImportExportPolicy), // The checker for the router has changed. Event contains previous checker. @@ -472,10 +474,10 @@ pub enum AdminEvent { ManualStop, /// Fires when we need to ask the peer for a route refresh. - SendRouteRefresh, + SendRouteRefresh(Afi), /// Fires when we need to re-send our routes to the peer. - ReAdvertiseRoutes, + ReAdvertiseRoutes(Afi), /// Fires when path attributes have changed. PathAttributesChanged, @@ -487,12 +489,21 @@ impl AdminEvent { AdminEvent::Announce(_) => "announce routes", AdminEvent::ShaperChanged(_) => "shaper changed", AdminEvent::CheckerChanged(_) => "checker changed", - AdminEvent::ExportPolicyChanged(_) => "export policy changed", + AdminEvent::ExportPolicyChanged(p) => match p { + ImportExportPolicy::V4(_) => "ipv4 export policy changed", + ImportExportPolicy::V6(_) => "ipv6 export policy changed", + }, AdminEvent::Reset => "reset", AdminEvent::ManualStart => "manual start", AdminEvent::ManualStop => "manual stop", - AdminEvent::SendRouteRefresh => "route refresh needed", - AdminEvent::ReAdvertiseRoutes => "re-advertise routes", + AdminEvent::SendRouteRefresh(af) => match af { + Afi::Ipv4 => "route refresh needed (ipv4 unicast)", + Afi::Ipv6 => "route refresh needed (ipv6 unicast)", + }, + AdminEvent::ReAdvertiseRoutes(af) => match af { + Afi::Ipv4 => "re-advertise routes (ipv4 unicast)", + Afi::Ipv6 => "re-advertise routes (ipv6 unicast)", + }, AdminEvent::PathAttributesChanged => "path attributes changed", } } @@ -775,10 +786,14 @@ pub struct SessionInfo { /// Ensure that routes received from eBGP peers have the peer's ASN as the /// first element in the AS path. pub enforce_first_as: bool, - /// Policy governing imported routes. - pub allow_import: ImportExportPolicy, - /// Policy governing exported routes. - pub allow_export: ImportExportPolicy, + /// Per-address-family import policy for IPv4 routes. + pub allow_import4: ImportExportPolicy4, + /// Per-address-family export policy for IPv4 routes. + pub allow_export4: ImportExportPolicy4, + /// Per-address-family import policy for IPv6 routes. + pub allow_import6: ImportExportPolicy6, + /// Per-address-family export policy for IPv6 routes. + pub allow_export6: ImportExportPolicy6, /// Vlan tag to assign to data plane routes created by this session. pub vlan_id: Option, /// Timer intervals for session and connection management @@ -827,8 +842,10 @@ impl SessionInfo { communities: BTreeSet::new(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::default(), - allow_export: ImportExportPolicy::default(), + allow_import4: ImportExportPolicy4::default(), + allow_export4: ImportExportPolicy4::default(), + allow_import6: ImportExportPolicy6::default(), + allow_export6: ImportExportPolicy6::default(), vlan_id: None, connect_retry_time: Duration::from_secs(peer_config.connect_retry), keepalive_time: Duration::from_secs(peer_config.keepalive), @@ -2095,8 +2112,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); session_log_lite!( @@ -2341,8 +2358,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); session_log_lite!( @@ -2704,8 +2721,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); session_log_lite!( @@ -3079,8 +3096,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); session_log!( @@ -3660,8 +3677,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); session_log!( @@ -4186,8 +4203,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); collision_log!( @@ -5042,8 +5059,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) | AdminEvent::PathAttributesChanged => { let title = admin_event.title(); collision_log!( @@ -6014,99 +6031,204 @@ impl SessionRunner { } AdminEvent::ExportPolicyChanged(previous) => { - let originated = match self.db.get_origin4() { - Ok(value) => value, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to get originated IPv4 routes from db"; - "error" => format!("{e}") - ); - return FsmState::SessionSetup(pc); - } - }; + match previous { + ImportExportPolicy::V4(previous4) => { + let originated = match self.db.get_origin4() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated IPv4 routes from db"; + "error" => format!("{e}") + ); + return FsmState::SessionSetup(pc); + } + }; - // Determine which routes to announce/withdraw based on policy change - let session = lock!(self.session); - let originated_before: BTreeSet = match previous { - ImportExportPolicy::NoFiltering => { - originated.iter().cloned().collect() - } - ImportExportPolicy::Allow(ref list) => originated - .clone() - .into_iter() - .filter(|x| list.contains(&Prefix::from(*x))) - .collect(), - }; + // Determine which routes to announce/withdraw based on policy change + let session = lock!(self.session); + let originated_before: BTreeSet = + match previous4 { + ImportExportPolicy4::NoFiltering => { + originated.iter().cloned().collect() + } + ImportExportPolicy4::Allow(ref list) => { + originated + .iter() + .cloned() + .filter(|x| list.contains(x)) + .collect() + } + }; - let originated_after: BTreeSet = - match &session.allow_export { - ImportExportPolicy::NoFiltering => { - originated.iter().cloned().collect() + let originated_after: BTreeSet = + match &session.allow_export4 { + ImportExportPolicy4::NoFiltering => { + originated.iter().cloned().collect() + } + ImportExportPolicy4::Allow(list) => { + originated + .clone() + .into_iter() + .filter(|x| list.contains(x)) + .collect() + } + }; + drop(session); + + let to_withdraw: Vec = originated_before + .difference(&originated_after) + .cloned() + .collect(); + + let to_announce: Vec = originated_after + .difference(&originated_before) + .cloned() + .collect(); + + // Per RFC 7606, send announcements and withdrawals as + // separate UPDATE messages. + if !to_announce.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V4(RouteUpdate4::Announce( + to_announce, + )), + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send IPv4 export policy announce: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); } - ImportExportPolicy::Allow(list) => originated - .clone() - .into_iter() - .filter(|x| list.contains(&Prefix::from(*x))) - .collect(), - }; - drop(session); - let to_withdraw: Vec = originated_before - .difference(&originated_after) - .cloned() - .collect(); + if !to_withdraw.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V4(RouteUpdate4::Withdraw( + to_withdraw, + )), + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send IPv4 export policy withdraw: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); + } - let to_announce: Vec = originated_after - .difference(&originated_before) - .cloned() - .collect(); + FsmState::Established(pc) + } + ImportExportPolicy::V6(previous6) => { + let originated = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated IPv6 routes from db"; + "error" => format!("{e}") + ); + return FsmState::SessionSetup(pc); + } + }; - // Per RFC 7606, send announcements and withdrawals as - // separate UPDATE messages. - if !to_announce.is_empty() - && let Err(e) = self.send_update( - RouteUpdate::V4(RouteUpdate4::Announce( - to_announce, - )), - &pc, - ShaperApplication::Current, - ) - { - session_log!( - self, - error, - pc.conn, - "failed to send export policy announce: {e}"; - "error" => format!("{e}") - ); - return self.exit_established(pc); - } + // Determine which routes to announce/withdraw based on policy change + let session = lock!(self.session); + let originated_before: BTreeSet = + match previous6 { + ImportExportPolicy6::NoFiltering => { + originated.iter().cloned().collect() + } + ImportExportPolicy6::Allow(ref list) => { + originated + .iter() + .cloned() + .filter(|x| list.contains(x)) + .collect() + } + }; - if !to_withdraw.is_empty() - && let Err(e) = self.send_update( - RouteUpdate::V4(RouteUpdate4::Withdraw( - to_withdraw, - )), - &pc, - ShaperApplication::Current, - ) - { - session_log!( - self, - error, - pc.conn, - "failed to send export policy withdraw: {e}"; - "error" => format!("{e}") - ); - return self.exit_established(pc); - } + let originated_after: BTreeSet = + match &session.allow_export6 { + ImportExportPolicy6::NoFiltering => { + originated.iter().cloned().collect() + } + ImportExportPolicy6::Allow(list) => { + originated + .clone() + .into_iter() + .filter(|x| list.contains(x)) + .collect() + } + }; + drop(session); + + let to_withdraw: Vec = originated_before + .difference(&originated_after) + .cloned() + .collect(); + + let to_announce: Vec = originated_after + .difference(&originated_before) + .cloned() + .collect(); + + // Per RFC 7606, send announcements and withdrawals as + // separate UPDATE messages. + if !to_announce.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V6(RouteUpdate6::Announce( + to_announce, + )), + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send IPv6 export policy announce: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); + } - // TODO: Also handle IPv6 originated routes when needed + if !to_withdraw.is_empty() + && let Err(e) = self.send_update( + RouteUpdate::V6(RouteUpdate6::Withdraw( + to_withdraw, + )), + &pc, + ShaperApplication::Current, + ) + { + session_log!( + self, + error, + pc.conn, + "failed to send IPv6 export policy withdraw: {e}"; + "error" => format!("{e}") + ); + return self.exit_established(pc); + } - FsmState::Established(pc) + FsmState::Established(pc) + } + } } AdminEvent::CheckerChanged(_previous) => { @@ -6114,15 +6236,17 @@ impl SessionRunner { FsmState::Established(pc) } - AdminEvent::SendRouteRefresh => { - self.db.mark_bgp_peer_stale(pc.conn.peer().ip()); - // XXX: Update for IPv6 - self.send_route_refresh(&pc.conn); + AdminEvent::SendRouteRefresh(af) => { + self.db.mark_bgp_peer_stale( + pc.conn.peer().ip(), + AddressFamily::from(af), + ); + self.send_route_refresh(&pc.conn, af); FsmState::Established(pc) } - AdminEvent::ReAdvertiseRoutes => { - if let Err(e) = self.refresh_react4(&pc) { + AdminEvent::ReAdvertiseRoutes(af) => { + if let Err(e) = self.refresh_react(af, &pc) { session_log!( self, error, @@ -6913,7 +7037,7 @@ impl SessionRunner { } } - fn send_route_refresh(&self, conn: &Cnx) { + fn send_route_refresh(&self, conn: &Cnx, af: Afi) { session_log!( self, info, @@ -6921,10 +7045,11 @@ impl SessionRunner { "sending route refresh"; "message" => "route refresh" ); - if let Err(e) = conn.send(Message::RouteRefresh(RouteRefreshMessage { - afi: Afi::Ipv4 as u16, + let rr = Message::RouteRefresh(RouteRefreshMessage { + afi: af as u16, safi: Safi::Unicast as u8, - })) { + }); + if let Err(e) = conn.send(rr) { session_log!( self, error, @@ -6933,11 +7058,11 @@ impl SessionRunner { "error" => format!("{e}") ); self.counters - .keepalive_send_failure + .route_refresh_send_failure .fetch_add(1, Ordering::Relaxed); } else { self.counters - .keepalives_sent + .route_refresh_sent .fetch_add(1, Ordering::Relaxed); } } @@ -7260,26 +7385,28 @@ impl SessionRunner { &self, update: &mut UpdateMessage, ) -> Result<(), Error> { - if let ImportExportPolicy::Allow(ref policy) = - lock!(self.session).allow_export - { - let allowed: BTreeSet = policy.iter().copied().collect(); + let session = lock!(self.session); - // Filter traditional NLRI field - update.nlri.retain(|p| allowed.contains(&Prefix::V4(*p))); + // Filter traditional NLRI field (IPv4) using IPv4 export policy + if let ImportExportPolicy4::Allow(ref policy4) = session.allow_export4 { + update.nlri.retain(|p| policy4.contains(p)); + } - // Filter MP_REACH_NLRI - if let Some(reach) = update.mp_reach() { - match reach { - MpReachNlri::Ipv4Unicast(reach4) => { - reach4 - .nlri - .retain(|p| allowed.contains(&Prefix::V4(*p))); + // Filter MP_REACH_NLRI using the appropriate per-AF policy + if let Some(reach) = update.mp_reach_mut() { + match reach { + MpReachNlri::Ipv4Unicast(reach4) => { + if let ImportExportPolicy4::Allow(ref policy4) = + session.allow_export4 + { + reach4.nlri.retain(|p| policy4.contains(p)); } - MpReachNlri::Ipv6Unicast(reach6) => { - reach6 - .nlri - .retain(|p| allowed.contains(&Prefix::V6(*p))); + } + MpReachNlri::Ipv6Unicast(reach6) => { + if let ImportExportPolicy6::Allow(ref policy6) = + session.allow_export6 + { + reach6.nlri.retain(|p| policy6.contains(p)); } } } @@ -7760,22 +7887,36 @@ impl SessionRunner { } } - if let ImportExportPolicy::Allow(ref policy) = - lock!(self.session).allow_import { - let message_policy = policy - .iter() - .filter_map(|x| match x { - rdb::Prefix::V4(x) => Some(x), - _ => None, - }) - .map(|x| crate::messages::Prefix::from(*x)) - .collect::>(); + let session = lock!(self.session); - update - .nlri - .retain(|x| message_policy.contains(&Prefix::V4(*x))); - }; + // Filter traditional NLRI field (IPv4) using IPv4 import policy + if let ImportExportPolicy4::Allow(ref policy4) = + session.allow_import4 + { + update.nlri.retain(|p| policy4.contains(p)); + } + + // Filter MP_REACH_NLRI using the appropriate per-AF policy + if let Some(reach) = update.mp_reach_mut() { + match reach { + MpReachNlri::Ipv4Unicast(reach4) => { + if let ImportExportPolicy4::Allow(ref policy4) = + session.allow_import4 + { + reach4.nlri.retain(|p| policy4.contains(p)); + } + } + MpReachNlri::Ipv6Unicast(reach6) => { + if let ImportExportPolicy6::Allow(ref policy6) = + session.allow_import6 + { + reach6.nlri.retain(|p| policy6.contains(p)); + } + } + } + } + } self.update_rib(&update, pc); @@ -7821,6 +7962,21 @@ impl SessionRunner { let afi = mp_reach.afi(); let safi = mp_reach.safi(); + // RFC 4760 §3: Check reserved byte (must be 0, but must be ignored) + let reserved = match mp_reach { + crate::messages::MpReachNlri::Ipv4Unicast(inner) => inner.reserved, + crate::messages::MpReachNlri::Ipv6Unicast(inner) => inner.reserved, + }; + if reserved != 0 { + session_log!( + self, + warn, + pc.conn, + "MP_REACH_NLRI reserved byte is non-zero: {} (RFC 2858 'Number of SNPAs', obsoleted by RFC 4760)", + reserved; + ); + } + // Check if AFI/SAFI was negotiated let afi_state = match (afi, safi) { (Afi::Ipv4, Safi::Unicast) => pc.ipv4_unicast, @@ -7942,6 +8098,17 @@ impl SessionRunner { Ok(()) } + fn refresh_react( + &self, + af: Afi, + pc: &PeerConnection, + ) -> Result<(), Error> { + match af { + Afi::Ipv4 => self.refresh_react4(pc), + Afi::Ipv6 => self.refresh_react6(pc), + } + } + fn handle_refresh( &self, msg: RouteRefreshMessage, @@ -7958,10 +8125,7 @@ impl SessionRunner { } }; - match af { - Afi::Ipv4 => self.refresh_react4(pc), - Afi::Ipv6 => self.refresh_react6(pc), - } + self.refresh_react(af, pc) } /// Update this router's RIB based on an update message from a peer. @@ -7973,21 +8137,7 @@ impl SessionRunner { self, error, pc.conn, - "failed to get originated ipv4 routes from db"; - "error" => format!("{e}") - ); - Vec::new() - } - }; - - let _originated6 = match self.db.get_origin6() { - Ok(value) => value, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to get originated ipv6 routes from db"; + "failed to get originated ipv4 routes from db: {e}"; "error" => format!("{e}") ); Vec::new() @@ -8066,7 +8216,180 @@ impl SessionRunner { self.db.add_bgp_prefixes(&nlri, path.clone()); } - //TODO(IPv6) iterate through MpReachNlri attributes for IPv6 + // Process MP_REACH_NLRI for IPv4 and IPv6 routes + if let Some(reach) = update.mp_reach() { + match reach { + MpReachNlri::Ipv4Unicast(reach4) => { + let mp_nexthop = match &reach4.nexthop { + BgpNexthop::Ipv4(ip4) => IpAddr::V4(*ip4), + BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), + BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(*gua), + }; + + let mp_nlri4: Vec = reach4 + .nlri + .iter() + .filter(|p| { + !originated4.contains(p) + && p.valid_for_rib() + && !self.prefix_via_self( + Prefix::V4(**p), + mp_nexthop, + ) + }) + .copied() + .map(Prefix::V4) + .collect(); + + if !mp_nlri4.is_empty() { + let mut as_path = Vec::new(); + if let Some(segments_list) = update.as_path() { + for segments in &segments_list { + as_path.extend(segments.value.iter()); + } + } + let path4 = rdb::Path { + nexthop: mp_nexthop, + shutdown: update.graceful_shutdown(), + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + bgp: Some(BgpPathProperties { + origin_as: pc.asn, + peer: pc.conn.peer().ip(), + id: pc.id, + med: update.multi_exit_discriminator(), + local_pref: update.local_pref(), + as_path, + stale: None, + }), + vlan_id: lock!(self.session).vlan_id, + }; + + self.db.add_bgp_prefixes(&mp_nlri4, path4); + } + } + MpReachNlri::Ipv6Unicast(reach6) => { + let originated6 = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv6 routes from db: {e}"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + + let nexthop6 = match &reach6.nexthop { + BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), + BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(*gua), + BgpNexthop::Ipv4(ip4) => { + // IPv4 nexthop for IPv6 routes is unusual but possible + // in some configurations (e.g., IPv4-mapped IPv6) + session_log!( + self, + warn, + pc.conn, + "IPv4 nexthop in IPv6 MP_REACH_NLRI"; + "nexthop" => format!("{ip4}") + ); + IpAddr::V4(*ip4) + } + }; + + let nlri6: Vec = reach6 + .nlri + .iter() + .filter(|p| { + !originated6.contains(p) + && p.valid_for_rib() + && !self + .prefix_via_self(Prefix::V6(**p), nexthop6) + }) + .copied() + .map(Prefix::V6) + .collect(); + + if !nlri6.is_empty() { + let mut as_path = Vec::new(); + if let Some(segments_list) = update.as_path() { + for segments in &segments_list { + as_path.extend(segments.value.iter()); + } + } + let path6 = rdb::Path { + nexthop: nexthop6, + shutdown: update.graceful_shutdown(), + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + bgp: Some(BgpPathProperties { + origin_as: pc.asn, + peer: pc.conn.peer().ip(), + id: pc.id, + med: update.multi_exit_discriminator(), + local_pref: update.local_pref(), + as_path, + stale: None, + }), + vlan_id: lock!(self.session).vlan_id, + }; + + self.db.add_bgp_prefixes(&nlri6, path6); + } + } + } + } + + // Process MP_UNREACH_NLRI for IPv4 and IPv6 withdrawals + if let Some(unreach) = update.mp_unreach() { + match unreach { + MpUnreachNlri::Ipv4Unicast(unreach4) => { + let mp_withdrawn4: Vec = unreach4 + .withdrawn + .iter() + .filter(|p| { + !originated4.contains(p) && p.valid_for_rib() + }) + .copied() + .map(Prefix::V4) + .collect(); + + self.db.remove_bgp_prefixes( + &mp_withdrawn4, + &pc.conn.peer().ip(), + ); + } + MpUnreachNlri::Ipv6Unicast(unreach6) => { + let originated6 = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv6 routes for withdrawal: {e}"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + + let withdrawn6: Vec = unreach6 + .withdrawn + .iter() + .filter(|p| { + !originated6.contains(p) && p.valid_for_rib() + }) + .copied() + .map(Prefix::V6) + .collect(); + + self.db + .remove_bgp_prefixes(&withdrawn6, &pc.conn.peer().ip()); + } + } + } } /// Perform a set of checks on an update to see if we can accept it. @@ -8238,7 +8561,8 @@ impl SessionRunner { ) -> Result { let mut reset_needed = false; let mut path_attributes_changed = false; - let mut refresh_needed = false; + let mut refresh_needed4 = false; + let mut refresh_needed6 = false; let mut current = lock!(self.session); current.passive_tcp_establishment = info.passive_tcp_establishment; @@ -8275,17 +8599,27 @@ impl SessionRunner { if current.local_pref != info.local_pref { current.local_pref = info.local_pref; - refresh_needed = true; + refresh_needed4 = true; + refresh_needed6 = true; } if current.enforce_first_as != info.enforce_first_as { current.enforce_first_as = info.enforce_first_as; + // XXX: handle more gracefully. + // disabling = send route refresh + // enabling = run rib walker + delete paths failing check reset_needed = true; } - if current.allow_import != info.allow_import { - current.allow_import = info.allow_import; - refresh_needed = true; + // Handle per-AF import policy changes (trigger route refresh) + if current.allow_import4 != info.allow_import4 { + current.allow_import4 = info.allow_import4; + refresh_needed4 = true; + } + + if current.allow_import6 != info.allow_import6 { + current.allow_import6 = info.allow_import6; + refresh_needed6 = true; } if current.vlan_id != info.vlan_id { @@ -8306,28 +8640,43 @@ impl SessionRunner { .set_jitter_range(info.idle_hold_jitter); } - if current.allow_export != info.allow_export { - let previous = current.allow_export.clone(); - current.allow_export = info.allow_export; - drop(current); + if current.allow_export4 != info.allow_export4 { + let previous4 = current.allow_export4.clone(); + current.allow_export4 = info.allow_export4; self.event_tx .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - previous, + ImportExportPolicy::V4(previous4), + ))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } + + if current.allow_export6 != info.allow_export6 { + let previous6 = current.allow_export6.clone(); + current.allow_export6 = info.allow_export6; + self.event_tx + .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( + ImportExportPolicy::V6(previous6), ))) .map_err(|e| Error::EventSend(e.to_string()))?; - } else { - drop(current); } + drop(current); + if path_attributes_changed { self.event_tx .send(FsmEvent::Admin(AdminEvent::PathAttributesChanged)) .map_err(|e| Error::EventSend(e.to_string()))?; } - if refresh_needed { + if refresh_needed4 { + self.event_tx + .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh(Afi::Ipv4))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } + + if refresh_needed6 { self.event_tx - .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh)) + .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh(Afi::Ipv6))) .map_err(|e| Error::EventSend(e.to_string()))?; } diff --git a/bgp/src/test.rs b/bgp/src/test.rs index e6b2eff2..046420f8 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -18,7 +18,7 @@ use lazy_static::lazy_static; use mg_common::log::init_file_logger; use mg_common::test::{IpAllocation, LoopbackIpManager}; use mg_common::*; -use rdb::{Asn, Prefix}; +use rdb::{Asn, Prefix, Prefix4}; use std::{ collections::BTreeMap, net::{IpAddr, SocketAddr}, @@ -936,7 +936,7 @@ fn test_neighbor_thread_lifecycle_no_leaks() { /// 5. Path attributes are correctly preserved through filtering #[test] fn test_import_export_policy_filtering() { - use rdb::ImportExportPolicy; + use rdb::ImportExportPolicy4; use std::collections::BTreeSet; let r1_addr: SocketAddr = sockaddr!(&format!("127.0.0.12:{TEST_BGP_PORT}")); @@ -951,20 +951,16 @@ fn test_import_export_policy_filtering() { let prefix_c = ip!("10.3.0.0/24"); // Will pass export but filtered by import // Build export policy for r1: allow prefix_a and prefix_c, deny prefix_b - let export_allow: BTreeSet = [ - Prefix::V4(cidr!("10.1.0.0/24")), - Prefix::V4(cidr!("10.3.0.0/24")), - ] - .into_iter() - .collect(); + let export_allow: BTreeSet = + [cidr!("10.1.0.0/24"), cidr!("10.3.0.0/24")] + .into_iter() + .collect(); // Build import policy for r2: allow prefix_a and prefix_b, deny prefix_c - let import_allow: BTreeSet = [ - Prefix::V4(cidr!("10.1.0.0/24")), - Prefix::V4(cidr!("10.2.0.0/24")), - ] - .into_iter() - .collect(); + let import_allow: BTreeSet = + [cidr!("10.1.0.0/24"), cidr!("10.2.0.0/24")] + .into_iter() + .collect(); // Configure r1 with export policy let r1_peer_config = PeerConfig { @@ -979,7 +975,7 @@ fn test_import_export_policy_filtering() { }; let r1_session_info = { let mut info = SessionInfo::from_peer_config(&r1_peer_config); - info.allow_export = ImportExportPolicy::Allow(export_allow.clone()); + info.allow_export4 = ImportExportPolicy4::Allow(export_allow.clone()); info }; @@ -996,7 +992,7 @@ fn test_import_export_policy_filtering() { }; let r2_session_info = { let mut info = SessionInfo::from_peer_config(&r2_peer_config); - info.allow_import = ImportExportPolicy::Allow(import_allow.clone()); + info.allow_import4 = ImportExportPolicy4::Allow(import_allow.clone()); info }; @@ -1093,7 +1089,7 @@ fn test_import_export_policy_filtering() { ); // Remove r1's export policy - prefix_b should now be sent to r2 - // The ExportPolicyChanged handler will automatically send the newly-allowed + // The ExportPolicy4Changed handler will automatically send the newly-allowed // prefix without requiring manual re-origination. assert_eq!( r1_session.state(), @@ -1102,7 +1098,7 @@ fn test_import_export_policy_filtering() { ); let r1_session_info_no_export = { let mut info = SessionInfo::from_peer_config(&r1_peer_config); - info.allow_export = ImportExportPolicy::NoFiltering; + info.allow_export4 = ImportExportPolicy4::NoFiltering; info }; r1.router @@ -1145,7 +1141,7 @@ fn test_import_export_policy_filtering() { // Now remove r2's import policy - prefix_c should appear via route-refresh let r2_session_info_no_import = { let mut info = SessionInfo::from_peer_config(&r2_peer_config); - info.allow_import = ImportExportPolicy::NoFiltering; + info.allow_import4 = ImportExportPolicy4::NoFiltering; info }; r2.router diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index df36eaf9..f4b1fad9 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -5,17 +5,16 @@ #![allow(clippy::type_complexity)] use crate::validation::{validate_prefixes_v4, validate_prefixes_v6}; use crate::{admin::HandlerContext, error::Error, log::bgp_log}; -use bgp::params::*; -use bgp::router::LoadPolicyError; -use bgp::session::FsmStateKind; use bgp::{ BGP_PORT, config::RouterConfig, connection::BgpConnection, connection_tcp::BgpConnectionTcp, - router::Router, + messages::Afi, + params::*, + router::{LoadPolicyError, Router}, session::{ - AdminEvent, FsmEvent, MessageHistory, MessageHistoryV1, + AdminEvent, FsmEvent, FsmStateKind, MessageHistory, MessageHistoryV1, SessionEndpoint, SessionInfo, }, }; @@ -30,7 +29,7 @@ use mg_api::{ MessageHistoryResponseV1, NeighborResetRequest, NeighborSelector, Rib, }; use mg_common::lock; -use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicy, Prefix}; +use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicyV1, Prefix}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}; @@ -417,9 +416,14 @@ pub async fn get_exported( .map(|p| rdb::Prefix::from(*p)) .collect(); - let mut exported_routes: Vec = match n.allow_export { - ImportExportPolicy::NoFiltering => orig_routes, - ImportExportPolicy::Allow(epol) => { + // Combine per-AF export policies into legacy format for filtering + let allow_export = ImportExportPolicyV1::from_per_af_policies( + &n.allow_export4, + &n.allow_export6, + ); + let mut exported_routes: Vec = match allow_export { + ImportExportPolicyV1::NoFiltering => orig_routes, + ImportExportPolicyV1::Allow(epol) => { orig_routes.retain(|p| epol.contains(p)); orig_routes } @@ -1039,6 +1043,11 @@ pub(crate) mod helpers { let (event_tx, event_rx) = channel(); + let allow_import4 = rq.allow_import.as_ipv4_policy(); + let allow_export4 = rq.allow_export.as_ipv4_policy(); + let allow_import6 = rq.allow_import.as_ipv6_policy(); + let allow_export6 = rq.allow_export.as_ipv6_policy(); + // XXX: Do we really want both rq and info? // SessionInfo and Neighbor types could probably be merged. let info = SessionInfo { @@ -1050,8 +1059,11 @@ pub(crate) mod helpers { communities: rq.communities.clone().into_iter().collect(), local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - allow_import: rq.allow_import.clone(), - allow_export: rq.allow_export.clone(), + // Derive per-AF policies from legacy fields + allow_import4: allow_import4.clone(), + allow_export4: allow_export4.clone(), + allow_import6: allow_import6.clone(), + allow_export6: allow_export6.clone(), vlan_id: rq.vlan_id, remote_id: None, bind_addr: None, @@ -1109,8 +1121,10 @@ pub(crate) mod helpers { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - allow_import: rq.allow_import.clone(), - allow_export: rq.allow_export.clone(), + allow_import4, + allow_export4, + allow_import6, + allow_export6, vlan_id: rq.vlan_id, })?; @@ -1135,6 +1149,7 @@ pub(crate) mod helpers { .get_session(addr) .ok_or(Error::NotFound("session for bgp peer not found".into()))?; + // XXX: Add IPv6 support -- needs API update match op { NeighborResetOp::Hard => session .event_tx @@ -1148,7 +1163,9 @@ pub(crate) mod helpers { // XXX: check if neighbor has negotiated route refresh cap session .event_tx - .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh)) + .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh( + Afi::Ipv4, + ))) .map_err(|e| { Error::InternalCommunication(format!( "failed to generate route refresh {e}" @@ -1157,7 +1174,7 @@ pub(crate) mod helpers { } NeighborResetOp::SoftOutbound => session .event_tx - .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes)) + .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes(Afi::Ipv4))) .map_err(|e| { Error::InternalCommunication(format!( "failed to trigger outbound update {e}" @@ -1328,7 +1345,7 @@ mod tests { }; use bgp::params::{ApplyRequest, BgpPeerConfig}; use mg_common::stats::MgLowerStats; - use rdb::{Db, ImportExportPolicy}; + use rdb::{Db, ImportExportPolicy4, ImportExportPolicy6}; use std::{ collections::{BTreeMap, HashMap}, env::temp_dir, @@ -1384,8 +1401,10 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::NoFiltering, - allow_export: ImportExportPolicy::NoFiltering, + allow_import4: ImportExportPolicy4::NoFiltering, + allow_export4: ImportExportPolicy4::NoFiltering, + allow_import6: ImportExportPolicy6::NoFiltering, + allow_export6: ImportExportPolicy6::NoFiltering, vlan_id: None, }], ); @@ -1408,8 +1427,10 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::NoFiltering, - allow_export: ImportExportPolicy::NoFiltering, + allow_import4: ImportExportPolicy4::NoFiltering, + allow_export4: ImportExportPolicy4::NoFiltering, + allow_import6: ImportExportPolicy6::NoFiltering, + allow_export6: ImportExportPolicy6::NoFiltering, vlan_id: None, }], ); diff --git a/mgd/src/main.rs b/mgd/src/main.rs index 96fac887..c788ceab 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -315,8 +315,15 @@ fn start_bgp_routers( communities: nbr.communities.clone(), local_pref: nbr.local_pref, enforce_first_as: nbr.enforce_first_as, - allow_import: nbr.allow_import.clone(), - allow_export: nbr.allow_export.clone(), + // Combine per-AF policies into legacy format for API compatibility + allow_import: rdb::ImportExportPolicyV1::from_per_af_policies( + &nbr.allow_import4, + &nbr.allow_import6, + ), + allow_export: rdb::ImportExportPolicyV1::from_per_af_policies( + &nbr.allow_export4, + &nbr.allow_export6, + ), vlan_id: nbr.vlan_id, }, true, diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 52c7c119..ac68c792 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -1228,9 +1228,7 @@ impl Db { Ok(()) } - pub fn mark_bgp_peer_stale(&self, peer: IpAddr) { - // TODO(ipv6): call this just for enabled address-families. - // no need to walk the full rib for an AF that isn't affected + pub fn mark_bgp_peer_stale4(&self, peer: IpAddr) { let mut rib = lock!(self.rib4_loc); rib.iter_mut().for_each(|(_prefix, path)| { let targets: Vec = path @@ -1250,7 +1248,9 @@ impl Db { path.replace(t); } }); + } + pub fn mark_bgp_peer_stale6(&self, peer: IpAddr) { let mut rib = lock!(self.rib6_loc); rib.iter_mut().for_each(|(_prefix, path)| { let targets: Vec = path @@ -1286,6 +1286,13 @@ impl Db { let mut value = self.slot.write().unwrap(); *value = slot; } + + pub fn mark_bgp_peer_stale(&self, peer: IpAddr, af: AddressFamily) { + match af { + AddressFamily::Ipv4 => self.mark_bgp_peer_stale4(peer), + AddressFamily::Ipv6 => self.mark_bgp_peer_stale6(peer), + } + } } struct Reaper { diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 9feed3e5..ec4e91ae 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -354,15 +354,154 @@ pub struct BgpRouterInfo { pub graceful_shutdown: bool, } +// ============================================================================ +// API Compatibility Type (ImportExportPolicyV1) +// ============================================================================ +// This type maintains backward compatibility with the existing API encoding. +// It uses the mixed Prefix type (V4/V6) and is used at the API boundary. +// Internally, code should use ImportExportPolicy (V4/V6 enum) for type safety. + +/// Legacy import/export policy type for API compatibility. +/// +/// This type uses mixed IPv4/IPv6 prefixes and is used at the API boundary. +/// For internal use, convert to `ImportExportPolicy` variants using +/// `as_ipv4_policy()` and `as_ipv6_policy()`. #[derive( Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, )] -pub enum ImportExportPolicy { +pub enum ImportExportPolicyV1 { #[default] NoFiltering, Allow(BTreeSet), } +impl ImportExportPolicyV1 { + /// Extract IPv4 prefixes from this policy as a typed IPv4 policy. + /// + /// If this policy is `NoFiltering`, returns `ImportExportPolicy4::NoFiltering`. + /// If this policy is `Allow(prefixes)`, returns only the IPv4 prefixes. + /// If the policy has prefixes but none are IPv4, returns `NoFiltering` for IPv4. + pub fn as_ipv4_policy(&self) -> ImportExportPolicy4 { + match self { + ImportExportPolicyV1::NoFiltering => { + ImportExportPolicy4::NoFiltering + } + ImportExportPolicyV1::Allow(prefixes) => { + let v4_prefixes: BTreeSet = prefixes + .iter() + .filter_map(|p| match p { + Prefix::V4(p4) => Some(*p4), + Prefix::V6(_) => None, + }) + .collect(); + if v4_prefixes.is_empty() { + // Policy had prefixes but none were V4 - treat as no filtering for V4 + ImportExportPolicy4::NoFiltering + } else { + ImportExportPolicy4::Allow(v4_prefixes) + } + } + } + } + + /// Extract IPv6 prefixes from this policy as a typed IPv6 policy. + /// + /// If this policy is `NoFiltering`, returns `ImportExportPolicy6::NoFiltering`. + /// If this policy is `Allow(prefixes)`, returns only the IPv6 prefixes. + /// If the policy has prefixes but none are IPv6, returns `NoFiltering` for IPv6. + pub fn as_ipv6_policy(&self) -> ImportExportPolicy6 { + match self { + ImportExportPolicyV1::NoFiltering => { + ImportExportPolicy6::NoFiltering + } + ImportExportPolicyV1::Allow(prefixes) => { + let v6_prefixes: BTreeSet = prefixes + .iter() + .filter_map(|p| match p { + Prefix::V4(_) => None, + Prefix::V6(p6) => Some(*p6), + }) + .collect(); + if v6_prefixes.is_empty() { + // Policy had prefixes but none were V6 - treat as no filtering for V6 + ImportExportPolicy6::NoFiltering + } else { + ImportExportPolicy6::Allow(v6_prefixes) + } + } + } + } + + /// Combine IPv4 and IPv6 policies into a legacy mixed-AF policy. + /// + /// - If both are `NoFiltering`, returns `NoFiltering` + /// - Otherwise, combines the allowed prefixes from both into a single set + pub fn from_per_af_policies( + v4: &ImportExportPolicy4, + v6: &ImportExportPolicy6, + ) -> Self { + match (v4, v6) { + ( + ImportExportPolicy4::NoFiltering, + ImportExportPolicy6::NoFiltering, + ) => ImportExportPolicyV1::NoFiltering, + ( + ImportExportPolicy4::Allow(v4_prefixes), + ImportExportPolicy6::NoFiltering, + ) => { + let prefixes: BTreeSet = + v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); + ImportExportPolicyV1::Allow(prefixes) + } + ( + ImportExportPolicy4::NoFiltering, + ImportExportPolicy6::Allow(v6_prefixes), + ) => { + let prefixes: BTreeSet = + v6_prefixes.iter().map(|p| Prefix::V6(*p)).collect(); + ImportExportPolicyV1::Allow(prefixes) + } + ( + ImportExportPolicy4::Allow(v4_prefixes), + ImportExportPolicy6::Allow(v6_prefixes), + ) => { + let mut prefixes: BTreeSet = + v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); + prefixes.extend(v6_prefixes.iter().map(|p| Prefix::V6(*p))); + ImportExportPolicyV1::Allow(prefixes) + } + } + } +} + +/// Import/Export policy for IPv4 prefixes only. +#[derive( + Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, +)] +pub enum ImportExportPolicy4 { + #[default] + NoFiltering, + Allow(BTreeSet), +} + +/// Import/Export policy for IPv6 prefixes only. +#[derive( + Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, +)] +pub enum ImportExportPolicy6 { + #[default] + NoFiltering, + Allow(BTreeSet), +} + +/// Address-family-specific import/export policy. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ImportExportPolicy { + V4(ImportExportPolicy4), + V6(ImportExportPolicy6), +} + +/// BGP neighbor configuration stored in the database and used at API boundary. #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] pub struct BgpNeighborInfo { pub asn: u32, @@ -383,8 +522,18 @@ pub struct BgpNeighborInfo { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + /// Per-address-family import policy for IPv4 routes. + #[serde(default)] + pub allow_import4: ImportExportPolicy4, + /// Per-address-family export policy for IPv4 routes. + #[serde(default)] + pub allow_export4: ImportExportPolicy4, + /// Per-address-family import policy for IPv6 routes. + #[serde(default)] + pub allow_import6: ImportExportPolicy6, + /// Per-address-family export policy for IPv6 routes. + #[serde(default)] + pub allow_export6: ImportExportPolicy6, pub vlan_id: Option, } From 3fe984de626901e1fbe737c351e0853e42c59bd0 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 11 Dec 2025 13:57:55 -0700 Subject: [PATCH 05/20] bgp: validate message and attribute lengths - Adds length checks for message and path attribute parsing - Adds AtomicAggregate mandatory path attribute (basic support) - Updates Aggregator/As4Aggregator path attribute types to use parsed fields instead of raw bytes and have a real Display impl - Adds logging of non-zero reserved field in MP_REACH_NLRI - Adds unit tests to ensure length validation works properly --- bgp/src/connection_tcp.rs | 38 +- bgp/src/messages.rs | 759 ++++++++++++++++++++++++++++++++++++-- bgp/src/session.rs | 8 +- 3 files changed, 766 insertions(+), 39 deletions(-) diff --git a/bgp/src/connection_tcp.rs b/bgp/src/connection_tcp.rs index 18b86c71..788aca0d 100644 --- a/bgp/src/connection_tcp.rs +++ b/bgp/src/connection_tcp.rs @@ -12,10 +12,10 @@ use crate::{ error::Error, log::{connection_log, connection_log_lite}, messages::{ - ErrorCode, ErrorSubcode, Header, Message, MessageParseError, - MessageType, NotificationMessage, NotificationParseError, - NotificationParseErrorReason, OpenErrorSubcode, OpenMessage, - OpenParseError, OpenParseErrorReason, RouteRefreshMessage, + ErrorCode, ErrorSubcode, Header, HeaderErrorSubcode, Message, + MessageParseError, MessageType, NotificationMessage, + NotificationParseError, NotificationParseErrorReason, OpenErrorSubcode, + OpenMessage, OpenParseError, OpenParseErrorReason, RouteRefreshMessage, RouteRefreshParseError, RouteRefreshParseErrorReason, UpdateMessage, }, session::{ @@ -852,7 +852,35 @@ impl BgpConnectionTcp { } } } - MessageType::KeepAlive => return Ok(Message::KeepAlive), + MessageType::KeepAlive => { + // RFC 4271 §4.4: KEEPALIVE must be exactly 19 bytes (16-byte header + 0-byte body) + if !msgbuf.is_empty() { + connection_log_lite!(log, + error, + "KEEPALIVE parse error: message body not empty"; + "direction" => direction, + "connection" => format!("{stream:?}"), + "body_length" => msgbuf.len() + ); + return Err(RecvError::Parse( + MessageParseError::Notification( + NotificationParseError { + error_code: ErrorCode::Header, + error_subcode: ErrorSubcode::Header( + HeaderErrorSubcode::BadMessageLength, + ), + reason: NotificationParseErrorReason::Other { + detail: format!( + "KEEPALIVE message must have zero body, got {} bytes", + msgbuf.len() + ), + }, + }, + ), + )); + } + return Ok(Message::KeepAlive); + } MessageType::RouteRefresh => { match RouteRefreshMessage::from_wire(&msgbuf) { Ok(m) => m.into(), diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index b1593a80..07f2e6f9 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -653,6 +653,12 @@ impl OpenMessage { /// Deserialize an open message from wire format. pub fn from_wire(input: &[u8]) -> Result { + // RFC 4271 §4.2: OPEN minimum 10 bytes body + // (1 version + 2 ASN + 2 hold_time + 4 ID + 1 opt_param_len) + if input.len() < 10 { + return Err(Error::TooSmall("open message body".into())); + } + let (input, version) = parse_u8(input)?; let (input, asn) = be_u16(input)?; let (input, hold_time) = be_u16(input)?; @@ -887,6 +893,21 @@ impl UpdateMessage { /// field set), or `Err(UpdateParseError)` for fatal errors requiring session /// reset. pub fn from_wire(input: &[u8]) -> Result { + // RFC 4271 §4.3: UPDATE minimum 4 bytes body + // (2 bytes withdrawn length + 2 bytes path attributes length) + if input.len() < 4 { + return Err(UpdateParseError { + error_code: ErrorCode::Header, + error_subcode: ErrorSubcode::Header( + HeaderErrorSubcode::BadMessageLength, + ), + reason: UpdateParseErrorReason::MessageTooShort { + expected_min: 4, + got: input.len(), + }, + }); + } + // 1. Parse withdrawn routes length and extract bytes let (input, len) = be_u16::<_, nom::error::Error<&[u8]>>(input) .map_err(|e| UpdateParseError { @@ -1799,6 +1820,9 @@ impl From for PathAttributeTypeCode { PathAttributeValue::Communities(_) => { PathAttributeTypeCode::Communities } + PathAttributeValue::AtomicAggregate => { + PathAttributeTypeCode::AtomicAggregate + } PathAttributeValue::MpReachNlri(_) => { PathAttributeTypeCode::MpReachNlri } @@ -1818,6 +1842,92 @@ impl From for PathAttributeTypeCode { } } +/// AGGREGATOR path attribute (RFC 4271 §5.1.8) +/// +/// The AGGREGATOR attribute is an optional transitive attribute that contains +/// the AS number and IP address of the last BGP speaker that formed the aggregate route. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, JsonSchema, +)] +pub struct Aggregator { + /// Autonomous System Number that formed the aggregate (2-octet) + pub asn: u16, + /// IP address of the BGP speaker that formed the aggregate + pub address: Ipv4Addr, +} + +impl Display for Aggregator { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "AS{} ({})", self.asn, self.address) + } +} + +impl Aggregator { + /// Parse AGGREGATOR from wire format (6 bytes: 2-byte ASN + 4-byte IP) + pub fn from_wire(input: &[u8]) -> Result { + if input.len() != 6 { + return Err(format!( + "AGGREGATOR attribute length must be 6, got {}", + input.len() + )); + } + let asn = u16::from_be_bytes([input[0], input[1]]); + let address = Ipv4Addr::new(input[2], input[3], input[4], input[5]); + Ok(Aggregator { asn, address }) + } + + /// Serialize AGGREGATOR to wire format + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::with_capacity(6); + buf.extend_from_slice(&self.asn.to_be_bytes()); + buf.extend_from_slice(&self.address.octets()); + buf + } +} + +/// AS4_AGGREGATOR path attribute (RFC 6793) +/// +/// The AS4_AGGREGATOR attribute is an optional transitive attribute with the same +/// semantics as AGGREGATOR, but carries a 4-octet AS number instead of 2-octet. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, JsonSchema, +)] +pub struct As4Aggregator { + /// Autonomous System Number that formed the aggregate (4-octet) + pub asn: u32, + /// IP address of the BGP speaker that formed the aggregate + pub address: Ipv4Addr, +} + +impl Display for As4Aggregator { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "AS{} ({})", self.asn, self.address) + } +} + +impl As4Aggregator { + /// Parse AS4_AGGREGATOR from wire format (8 bytes: 4-byte ASN + 4-byte IP) + pub fn from_wire(input: &[u8]) -> Result { + if input.len() != 8 { + return Err(format!( + "AS4_AGGREGATOR attribute length must be 8, got {}", + input.len() + )); + } + let asn = u32::from_be_bytes([input[0], input[1], input[2], input[3]]); + let address = Ipv4Addr::new(input[4], input[5], input[6], input[7]); + Ok(As4Aggregator { asn, address }) + } + + /// Serialize AS4_AGGREGATOR to wire format + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&self.asn.to_be_bytes()); + buf.extend_from_slice(&self.address.octets()); + buf + } +} + /// The value encoding of a path attribute. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -1838,14 +1948,16 @@ pub enum PathAttributeValue { /// Local pref is included in update messages sent to internal peers and /// indicates a degree of preference. LocalPref(u32), - /// This attribute is included in routes that are formed by aggregation. - Aggregator([u8; 6]), + /// AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (2-octet ASN) + Aggregator(Aggregator), /// Indicates communities associated with a path. Communities(Vec), + /// Indicates this route was formed via aggregation (RFC 4271 §5.1.7) + AtomicAggregate, /// The 4-byte encoded AS set associated with a path As4Path(Vec), - /// This attribute is included in routes that are formed by aggregation. - As4Aggregator([u8; 8]), + /// AS4_AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (4-octet ASN) + As4Aggregator(As4Aggregator), /// Carries reachable MP-BGP NLRI and Next-hop (advertisement). MpReachNlri(MpReachNlri), /// Carries unreachable MP-BGP NLRI (withdrawal). @@ -1884,9 +1996,11 @@ impl PathAttributeValue { } Self::LocalPref(value) => Ok(value.to_be_bytes().into()), Self::MultiExitDisc(value) => Ok(value.to_be_bytes().into()), + Self::Aggregator(agg) => Ok(agg.to_wire()), + Self::As4Aggregator(agg) => Ok(agg.to_wire()), + Self::AtomicAggregate => Ok(Vec::new()), // Zero-length attribute Self::MpReachNlri(mp) => mp.to_wire(), Self::MpUnreachNlri(mp) => mp.to_wire(), - x => Err(Error::UnsupportedPathAttributeValue(x.clone())), } } @@ -1897,8 +2011,33 @@ impl PathAttributeValue { // Helper for nom type annotation type NomErr<'a> = nom::error::Error<&'a [u8]>; + // RFC 7606 §3: Zero-length attributes only valid for AS_PATH and ATOMIC_AGGREGATE + if input.is_empty() { + match type_code { + PathAttributeTypeCode::AsPath + | PathAttributeTypeCode::AtomicAggregate => { + // These attributes are allowed to be zero-length + } + _ => { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code, + expected: 1, // Most attributes require at least 1 byte + got: 0, + }); + } + } + } + match type_code { PathAttributeTypeCode::Origin => { + // RFC 4271 §5.1.2: ORIGIN must be exactly 1 octet + if input.len() != 1 { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::Origin, + expected: 1, + got: input.len(), + }); + } let (_input, origin) = be_u8::<_, NomErr<'_>>(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { @@ -1949,6 +2088,14 @@ impl PathAttributeValue { ))) } PathAttributeTypeCode::MultiExitDisc => { + // RFC 4271 §5.1.5: MULTI_EXIT_DISC must be exactly 4 octets + if input.len() != 4 { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::MultiExitDisc, + expected: 4, + got: input.len(), + }); + } let (_input, v) = be_u32::<_, NomErr<'_>>(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { @@ -1976,6 +2123,15 @@ impl PathAttributeValue { Ok(PathAttributeValue::As4Path(segments)) } PathAttributeTypeCode::Communities => { + // RFC 4271 §5.1.9 (via RFC 1997): COMMUNITIES length must be multiple of 4 + if !input.len().is_multiple_of(4) { + return Err(UpdateParseErrorReason::Other { + detail: format!( + "COMMUNITIES attribute length must be multiple of 4, got {}", + input.len() + ), + }); + } let mut communities = Vec::new(); loop { if input.is_empty() { @@ -1994,6 +2150,14 @@ impl PathAttributeValue { Ok(PathAttributeValue::Communities(communities)) } PathAttributeTypeCode::LocalPref => { + // RFC 4271 §5.1.6: LOCAL_PREF must be exactly 4 octets + if input.len() != 4 { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::LocalPref, + expected: 4, + got: input.len(), + }); + } let (_input, v) = be_u32::<_, NomErr<'_>>(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { @@ -2011,9 +2175,54 @@ impl PathAttributeValue { let (_remaining, mp_unreach) = MpUnreachNlri::from_wire(input)?; Ok(PathAttributeValue::MpUnreachNlri(mp_unreach)) } - x => Err(UpdateParseErrorReason::UnrecognizedMandatoryAttribute { - type_code: x as u8, - }), + PathAttributeTypeCode::AtomicAggregate => { + // RFC 4271 §5.1.7: ATOMIC_AGGREGATE must be zero-length + // (This is also checked earlier at function entry for zero-length validation) + if !input.is_empty() { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::AtomicAggregate, + expected: 0, + got: input.len(), + }); + } + Ok(PathAttributeValue::AtomicAggregate) + } + PathAttributeTypeCode::Aggregator => { + // RFC 4271 §5.1.8: AGGREGATOR must be exactly 6 octets + // (2 octets AS number + 4 octets IP address) + if input.len() != 6 { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::Aggregator, + expected: 6, + got: input.len(), + }); + } + let agg = Aggregator::from_wire(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: e, + } + })?; + Ok(PathAttributeValue::Aggregator(agg)) + } + PathAttributeTypeCode::As4Aggregator => { + // RFC 6793: AS4_AGGREGATOR must be exactly 8 octets + // (4 octets AS number + 4 octets IP address) + if input.len() != 8 { + return Err(UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::As4Aggregator, + expected: 8, + got: input.len(), + }); + } + let agg = As4Aggregator::from_wire(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code as u8), + detail: e, + } + })?; + Ok(PathAttributeValue::As4Aggregator(agg)) + } } } } @@ -2048,10 +2257,7 @@ impl Display for PathAttributeValue { * the one used for the BGP Identifier of the speaker. */ PathAttributeValue::Aggregator(agg) => { - let [a0, a1, a2, a3, a4, a5] = *agg; - let asn = u16::from_be_bytes([a0, a1]); - let ip = Ipv4Addr::from([a2, a3, a4, a5]); - write!(f, "aggregator: [ asn: {asn}, ip: {ip} ]",) + write!(f, "aggregator: {}", agg) } PathAttributeValue::Communities(comms) => { let comms = comms @@ -2061,6 +2267,9 @@ impl Display for PathAttributeValue { .join(" "); write!(f, "communities: [{comms}]") } + PathAttributeValue::AtomicAggregate => { + write!(f, "atomic-aggregate") + } PathAttributeValue::MpReachNlri(reach) => { write!(f, "mp-reach-nlri: {}", reach) } @@ -2084,10 +2293,7 @@ impl Display for PathAttributeValue { * AGGREGATOR attribute, except that it carries a four-octet AS number. */ PathAttributeValue::As4Aggregator(agg) => { - let [a0, a1, a2, a3, a4, a5, a6, a7] = *agg; - let asn = u32::from_be_bytes([a0, a1, a2, a3]); - let ip = Ipv4Addr::from([a4, a5, a6, a7]); - write!(f, "as4-aggregator: [ asn: {asn}, ip: {ip} ]") + write!(f, "as4-aggregator: {}", agg) } } } @@ -2204,17 +2410,26 @@ impl As4PathSegment { let (input, typ) = parse_u8(input)?; let typ = AsPathType::try_from(typ)?; - let (input, len) = parse_u8(input)?; - let len = (len as usize) * 4; + let (input, len_u8) = parse_u8(input)?; + + // RFC 4271 §5.1.3: Check for overflow when calculating byte length from segment count + // Each AS number is 4 bytes, so byte_len = len_u8 * 4 + // Note: This is technically safe (max 255 * 4 = 1020), but we validate for defense-in-depth + let byte_len = (len_u8 as usize).checked_mul(4).ok_or_else(|| { + Error::TooLarge( + "AS path segment length calculation overflow".into(), + ) + })?; + let mut segment = As4PathSegment { typ, value: Vec::new(), }; - if len == 0 { + if byte_len == 0 { return Ok((input, segment)); } - let (input, mut value_input) = take(len)(input)?; + let (input, mut value_input) = take(byte_len)(input)?; loop { if value_input.is_empty() { break; @@ -2552,6 +2767,11 @@ pub struct MpReachIpv4Unicast { /// Currently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops /// when extended next-hop capability (RFC 8950) is implemented. pub nexthop: BgpNexthop, + /// Reserved byte from RFC 4760 §3 (historically "Number of SNPAs" in RFC 2858). + /// MUST be 0 per RFC 4760, but MUST be ignored by receiver. + /// Stored for validation logging in session layer. + /// This field is positioned before NLRI to match the wire format encoding. + pub reserved: u8, /// IPv4 prefixes being announced pub nlri: Vec, } @@ -2567,6 +2787,11 @@ pub struct MpReachIpv6Unicast { /// Can be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` /// (32 bytes with link-local address). pub nexthop: BgpNexthop, + /// Reserved byte from RFC 4760 §3 (historically "Number of SNPAs" in RFC 2858). + /// MUST be 0 per RFC 4760, but MUST be ignored by receiver. + /// Stored for validation logging in session layer. + /// This field is positioned before NLRI to match the wire format encoding. + pub reserved: u8, /// IPv6 prefixes being announced pub nlri: Vec, } @@ -2611,12 +2836,20 @@ impl MpReachNlri { /// Create an IPv4 Unicast MP_REACH_NLRI. pub fn ipv4_unicast(nexthop: BgpNexthop, nlri: Vec) -> Self { - Self::Ipv4Unicast(MpReachIpv4Unicast { nexthop, nlri }) + Self::Ipv4Unicast(MpReachIpv4Unicast { + nexthop, + reserved: 0, // Always send 0 per RFC 4760 + nlri, + }) } /// Create an IPv6 Unicast MP_REACH_NLRI. pub fn ipv6_unicast(nexthop: BgpNexthop, nlri: Vec) -> Self { - Self::Ipv6Unicast(MpReachIpv6Unicast { nexthop, nlri }) + Self::Ipv6Unicast(MpReachIpv6Unicast { + nexthop, + reserved: 0, // Always send 0 per RFC 4760 + nlri, + }) } /// Serialize to wire format. @@ -2634,8 +2867,12 @@ impl MpReachNlri { buf.push(nh_bytes.len() as u8); // Next-hop length buf.extend_from_slice(&nh_bytes); - // Reserved (1 byte, must be 0) - buf.push(0); + // Reserved (1 byte from RFC 4760 §3, historically "Number of SNPAs") + let reserved = match self { + Self::Ipv4Unicast(inner) => inner.reserved, + Self::Ipv6Unicast(inner) => inner.reserved, + }; + buf.push(reserved); // NLRI match self { @@ -2728,8 +2965,12 @@ impl MpReachNlri { } })?; - // Parse Reserved byte (1 byte) - let (input, _reserved) = be_u8::<_, nom::error::Error<&[u8]>>(input) + // Parse Reserved byte (1 byte from RFC 4760 §3) + // RFC 4760 §3: "This field is reserved for future use. It MUST be set to 0 by + // the sender and MUST be ignored by the receiver." + // Historical note: In RFC 2858 (obsoleted by RFC 4760), this was "Number of SNPAs". + // Store the value for session layer validation/logging, but don't error here. + let (input, reserved) = be_u8::<_, nom::error::Error<&[u8]>>(input) .map_err(|e| UpdateParseErrorReason::AttributeParseError { type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), detail: format!("failed to parse reserved byte: {e}"), @@ -2742,7 +2983,11 @@ impl MpReachNlri { .map_err(|e| e.into_reason("mp_reach"))?; Ok(( &[], - Self::Ipv4Unicast(MpReachIpv4Unicast { nexthop, nlri }), + Self::Ipv4Unicast(MpReachIpv4Unicast { + nexthop, + reserved, + nlri, + }), )) } Afi::Ipv6 => { @@ -2750,7 +2995,11 @@ impl MpReachNlri { .map_err(|e| e.into_reason("mp_reach"))?; Ok(( &[], - Self::Ipv6Unicast(MpReachIpv6Unicast { nexthop, nlri }), + Self::Ipv6Unicast(MpReachIpv6Unicast { + nexthop, + reserved, + nlri, + }), )) } } @@ -3054,6 +3303,14 @@ impl NotificationMessage { } pub fn from_wire(input: &[u8]) -> Result { + // RFC 4271 §4.5: NOTIFICATION minimum 2 bytes body + // (1 error code + 1 error subcode, plus 0 or more bytes of data) + if input.len() < 2 { + return Err(Error::TooSmall( + "notification message body".to_string(), + )); + } + let (input, error_code) = parse_u8(input)?; let error_code = ErrorCode::try_from(error_code)?; @@ -4584,6 +4841,8 @@ impl slog::Value for Safi { #[derive(Debug, Clone, PartialEq, Eq)] pub enum UpdateParseErrorReason { // Frame structure errors (fatal) + /// UPDATE message body is too short for frame structure parsing + MessageTooShort { expected_min: usize, got: usize }, /// Withdrawn routes length exceeds available bytes InvalidWithdrawnLength { declared: u16, available: usize }, /// Path attributes length exceeds available bytes @@ -4657,6 +4916,13 @@ pub enum UpdateParseErrorReason { impl Display for UpdateParseErrorReason { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { + Self::MessageTooShort { expected_min, got } => { + write!( + f, + "UPDATE message too short: expected minimum {}, got {}", + expected_min, got + ) + } Self::InvalidWithdrawnLength { declared, available, @@ -7212,13 +7478,14 @@ mod tests { /// RFC 4271 requires ORIGIN, AS_PATH, and NEXT_HOP for traditional BGP UPDATEs. /// RFC 4760 makes NEXT_HOP optional when MP_REACH_NLRI is present (nexthop is in MP attr). mod mandatory_attribute_validation { - use std::net::Ipv6Addr; + use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use crate::messages::{ - BgpNexthop, MpReachNlri, MpUnreachNlri, PathAttributeTypeCode, - PathAttributeValue, UpdateMessage, UpdateParseErrorReason, - path_attribute_flags, + Aggregator, As4Aggregator, BgpNexthop, Error, MpReachNlri, + MpUnreachNlri, NotificationMessage, OpenMessage, + PathAttributeTypeCode, PathAttributeValue, UpdateMessage, + UpdateParseErrorReason, path_attribute_flags, }; /// Build an UPDATE message wire format with the given path attributes bytes. @@ -7603,5 +7870,433 @@ mod tests { "Should have no errors for empty UPDATE" ); } + + // ===================================================================== + // Phase 2 - Message Length Validation Tests + // ===================================================================== + + #[test] + fn open_message_too_short() { + // OPEN message with insufficient body (< 10 bytes) + let input = vec![1, 0, 0]; // Only 3 bytes (need 10 minimum) + let result = OpenMessage::from_wire(&input); + assert!(result.is_err(), "OPEN with too short body should fail"); + match result { + Err(Error::TooSmall(msg)) => { + assert!(msg.contains("open message body")); + } + other => panic!("Expected TooSmall error, got: {:?}", other), + } + } + + #[test] + fn update_message_minimum_length() { + // UPDATE message with exactly 4 bytes (minimum valid) + // 2 bytes withdrawn length (0) + 2 bytes path attributes length (0) + let input = vec![0u8, 0, 0, 0]; + let result = UpdateMessage::from_wire(&input); + assert!( + result.is_ok(), + "UPDATE with minimum 4 bytes should succeed: {:?}", + result.err() + ); + } + + #[test] + fn update_message_too_short() { + // UPDATE message with only 3 bytes (< 4 minimum) + let input = vec![0u8, 0, 0]; + let result = UpdateMessage::from_wire(&input); + assert!(result.is_err(), "UPDATE with < 4 bytes should fail"); + match result { + Err(err) => { + assert!(matches!( + err.reason, + UpdateParseErrorReason::MessageTooShort { .. } + )); + } + Ok(_) => panic!("Expected error for too-short UPDATE"), + } + } + + #[test] + fn notification_message_minimum_length() { + // NOTIFICATION message with exactly 2 bytes (error code + subcode) + let input = vec![1, 1]; // Error code 1, subcode 1 + let result = NotificationMessage::from_wire(&input); + assert!( + result.is_ok(), + "NOTIFICATION with minimum 2 bytes should succeed" + ); + } + + #[test] + fn notification_message_too_short() { + // NOTIFICATION message with only 1 byte (< 2 minimum) + let input = vec![1u8]; + let result = NotificationMessage::from_wire(&input); + assert!(result.is_err(), "NOTIFICATION with < 2 bytes should fail"); + } + + // ===================================================================== + // Phase 3 - Aggregator and AtomicAggregate Tests + // ===================================================================== + + #[test] + fn aggregator_structure_parsing() { + let asn = 65000u16; + let address = Ipv4Addr::new(192, 0, 2, 1); + let agg = Aggregator { asn, address }; + + // Test to_wire and from_wire round-trip + let wire = agg.to_wire(); + assert_eq!(wire.len(), 6, "AGGREGATOR should serialize to 6 bytes"); + + let parsed = Aggregator::from_wire(&wire) + .expect("Should parse valid AGGREGATOR wire format"); + assert_eq!(parsed.asn, asn, "ASN should match"); + assert_eq!(parsed.address, address, "Address should match"); + } + + #[test] + fn aggregator_wire_format() { + let wire = vec![0xFDu8, 0xE8, 192, 0, 2, 1]; // ASN 65000 in big-endian + let agg = Aggregator::from_wire(&wire) + .expect("Should parse valid wire format"); + assert_eq!(agg.asn, 65000); + assert_eq!(agg.address, Ipv4Addr::new(192, 0, 2, 1)); + } + + #[test] + fn aggregator_invalid_length() { + // Too short + let wire = vec![0xFDu8, 0xE8, 192, 0, 2]; + let result = Aggregator::from_wire(&wire); + assert!(result.is_err(), "AGGREGATOR with 5 bytes should fail"); + + // Too long + let wire = vec![0xFDu8, 0xE8, 192, 0, 2, 1, 2]; + let result = Aggregator::from_wire(&wire); + assert!(result.is_err(), "AGGREGATOR with 7 bytes should fail"); + } + + #[test] + fn aggregator_display() { + let agg = Aggregator { + asn: 65000, + address: Ipv4Addr::new(192, 0, 2, 1), + }; + let display = format!("{}", agg); + assert_eq!(display, "AS65000 (192.0.2.1)"); + } + + #[test] + fn as4_aggregator_structure_parsing() { + let asn = 4200000000u32; + let address = Ipv4Addr::new(203, 0, 113, 1); + let agg = As4Aggregator { asn, address }; + + // Test to_wire and from_wire round-trip + let wire = agg.to_wire(); + assert_eq!( + wire.len(), + 8, + "AS4_AGGREGATOR should serialize to 8 bytes" + ); + + let parsed = As4Aggregator::from_wire(&wire) + .expect("Should parse valid AS4_AGGREGATOR wire format"); + assert_eq!(parsed.asn, asn, "ASN should match"); + assert_eq!(parsed.address, address, "Address should match"); + } + + #[test] + fn as4_aggregator_wire_format() { + let asn = 4200000000u32; + let mut wire = asn.to_be_bytes().to_vec(); + wire.extend_from_slice(&[203, 0, 113, 1]); + + let agg = As4Aggregator::from_wire(&wire) + .expect("Should parse valid wire format"); + assert_eq!(agg.asn, asn); + assert_eq!(agg.address, Ipv4Addr::new(203, 0, 113, 1)); + } + + #[test] + fn as4_aggregator_invalid_length() { + // Too short + let wire = vec![0xFAu8, 0x0Du8, 0x18, 0x00, 203, 0, 113]; + let result = As4Aggregator::from_wire(&wire); + assert!(result.is_err(), "AS4_AGGREGATOR with 7 bytes should fail"); + + // Too long + let wire = vec![0xFAu8, 0x0Du8, 0x18, 0x00, 203, 0, 113, 1, 2]; + let result = As4Aggregator::from_wire(&wire); + assert!(result.is_err(), "AS4_AGGREGATOR with 9 bytes should fail"); + } + + #[test] + fn as4_aggregator_display() { + let agg = As4Aggregator { + asn: 4200000000, + address: Ipv4Addr::new(203, 0, 113, 1), + }; + let display = format!("{}", agg); + assert_eq!(display, "AS4200000000 (203.0.113.1)"); + } + + #[test] + fn atomic_aggregate_zero_length() { + // ATOMIC_AGGREGATE must be exactly zero bytes + let input: &[u8] = &[]; + let result = PathAttributeValue::from_wire( + input, + PathAttributeTypeCode::AtomicAggregate, + ); + assert!( + result.is_ok(), + "Zero-length ATOMIC_AGGREGATE should parse: {:?}", + result.err() + ); + match result.unwrap() { + PathAttributeValue::AtomicAggregate => {} + other => panic!("Expected AtomicAggregate, got: {:?}", other), + } + } + + #[test] + fn atomic_aggregate_non_zero_length_error() { + // ATOMIC_AGGREGATE with any data should fail + let input = vec![1u8]; + let result = PathAttributeValue::from_wire( + &input, + PathAttributeTypeCode::AtomicAggregate, + ); + assert!( + result.is_err(), + "Non-zero length ATOMIC_AGGREGATE should fail" + ); + match result { + Err(UpdateParseErrorReason::AttributeLengthError { + expected, + got, + .. + }) => { + assert_eq!(expected, 0); + assert_eq!(got, 1); + } + other => { + panic!("Expected AttributeLengthError, got: {:?}", other) + } + } + } + + #[test] + fn atomic_aggregate_to_wire() { + let attr = PathAttributeValue::AtomicAggregate; + let wire = attr.to_wire().expect("Should serialize"); + assert_eq!( + wire.len(), + 0, + "ATOMIC_AGGREGATE should serialize to empty" + ); + } + + #[test] + fn aggregator_in_update_message() { + // Test AGGREGATOR attribute in a complete UPDATE message + let mut attrs = Vec::new(); + + // ORIGIN + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, + 0, // IGP + ]); + + // AS_PATH (empty) + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::AsPath as u8, + 0, // empty + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::NextHop as u8, + 4, + 192, + 0, + 2, + 1, + ]); + + // AGGREGATOR + attrs.extend([ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Aggregator as u8, + 6, + 0xFDu8, + 0xE8, // ASN 65000 + 192, + 0, + 2, + 1, // Address + ]); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!(result.is_ok(), "Should parse UPDATE with AGGREGATOR"); + let msg = result.unwrap(); + + // Find AGGREGATOR attribute + let agg_attr = msg + .path_attributes + .iter() + .find(|attr| { + matches!(attr.value, PathAttributeValue::Aggregator(_)) + }) + .expect("Should have AGGREGATOR attribute"); + + match &agg_attr.value { + PathAttributeValue::Aggregator(agg) => { + assert_eq!(agg.asn, 65000); + assert_eq!(agg.address, Ipv4Addr::new(192, 0, 2, 1)); + } + _ => panic!("Wrong attribute type"), + } + } + + #[test] + fn atomic_aggregate_in_update_message() { + // Test ATOMIC_AGGREGATE attribute in a complete UPDATE message + let mut attrs = Vec::new(); + + // ORIGIN + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, + 0, // IGP + ]); + + // AS_PATH + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::AsPath as u8, + 0, + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::NextHop as u8, + 4, + 192, + 0, + 2, + 1, + ]); + + // ATOMIC_AGGREGATE (zero-length) + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::AtomicAggregate as u8, + 0, // zero-length + ]); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Should parse UPDATE with ATOMIC_AGGREGATE" + ); + let msg = result.unwrap(); + + // Find ATOMIC_AGGREGATE attribute + let atomic_attr = msg + .path_attributes + .iter() + .find(|attr| { + matches!(attr.value, PathAttributeValue::AtomicAggregate) + }) + .expect("Should have ATOMIC_AGGREGATE attribute"); + + assert!(matches!( + atomic_attr.value, + PathAttributeValue::AtomicAggregate + )); + } + + #[test] + fn aggregator_length_validation_in_parsing() { + // Test that length validation happens during parsing + let mut attrs = Vec::new(); + + // ORIGIN + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Origin as u8, + 1, + 0, + ]); + + // AS_PATH + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::AsPath as u8, + 0, + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::NextHop as u8, + 4, + 192, + 0, + 2, + 1, + ]); + + // AGGREGATOR with WRONG length (5 bytes instead of 6) + attrs.extend([ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, + PathAttributeTypeCode::Aggregator as u8, + 5, // WRONG! + 0xFDu8, + 0xE8, + 192, + 0, + 2, // Missing last octet + ]); + + let wire = build_update_wire(&attrs, &nlri_prefix(198, 51, 100)); + let result = UpdateMessage::from_wire(&wire); + + assert!( + result.is_ok(), + "Parsing should succeed with error collected" + ); + let msg = result.unwrap(); + + // Should have AttributeLengthError for AGGREGATOR + assert!( + msg.errors.iter().any(|(reason, _)| matches!( + reason, + UpdateParseErrorReason::AttributeLengthError { + type_code: PathAttributeTypeCode::Aggregator, + .. + } + )), + "Should have AttributeLengthError for AGGREGATOR" + ); + } } } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 0e493364..c332f031 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -7964,8 +7964,12 @@ impl SessionRunner { // RFC 4760 §3: Check reserved byte (must be 0, but must be ignored) let reserved = match mp_reach { - crate::messages::MpReachNlri::Ipv4Unicast(inner) => inner.reserved, - crate::messages::MpReachNlri::Ipv6Unicast(inner) => inner.reserved, + crate::messages::MpReachNlri::Ipv4Unicast(inner) => { + inner.reserved + } + crate::messages::MpReachNlri::Ipv6Unicast(inner) => { + inner.reserved + } }; if reserved != 0 { session_log!( From 810aa34f96edc8d51d9407939b62ab41ffcfdcfa Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Fri, 12 Dec 2025 20:18:20 -0700 Subject: [PATCH 06/20] bgp: remove update filtering of originated routes Removes the filtering of originated routes from a received UPDATE. Scenario: 1. Peer advertises us a route that we are originating and we discard it. 2. We stop advertising that route. 3. Us withdrawing our advertisement doesn't cause the peer to choose a different bestpath for that route. 4. We no longer have a path for that route from this peer. This could also be addressed by: A) Sending a route-refresh to the peer when we stop originating routes. B) Inserting a Local path into the RIB for routes we are originating which is preferred over BGP paths learned from peers during bestpath. (A) is not a good idea because we don't know how large the peer's RIB will be and we'd have to re-process their entire RIB every time we stop advertising even a single route. (B) is fine conceptually, as most routing stacks use this approach: When using a "network" statement to pull routes from other protocols into the BGP topology or creating an aggregate, a locally originated path generally goes into the BGP LOC-RIB that is preferred over any peer-learned path. If an existing routing table entry doesn't exist, then this generally creates a blackhole route for the originated prefix (in order to prevent routing loops from occurring as a result of missing entries for the component routes). In the case of maghemite, well, really in the case of dendrite, this routing loop prevention is done without using blackhole routes: packets arriving on a front panel port that enocounter a NAT "miss" are dropped rather than submitted to an LPM lookup in the External routing table (avoiding us becoming a transit router + hairpinning packets sent to us from outside the rack). All that to say: we don't need local routes for any kind of forwarding (or blackholing) data plane behavior. This brings us to the crux of the issue: Inter-VPC routing. Since we don't currently have a native inter-VPC data plane within the rack, then we need to have installed an External route that covers our originated prefix regardless of what mask is used. If we do an exact match check and filtering, then we just make it harder for our peer to advertise us a route we need. We effectively require the peer to advertise us reachability to our own prefix, so long as the mask doesn't match what we're using (e.g. if we send a /24, they have to send us any mask other than a /24 that covers the original IP range). So while removing this filter doesn't address the issues inherent to us needing the peer to provide us a route back to our own address space, it does make it simpler for us to learn that route (no longer needing a route with anything but our own mask) and to avoid extra logic to ensure we can recover routes that we filtered. --- bgp/src/session.rs | 61 +++++----------------------------------------- 1 file changed, 6 insertions(+), 55 deletions(-) diff --git a/bgp/src/session.rs b/bgp/src/session.rs index c332f031..58cd558c 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -8134,20 +8134,6 @@ impl SessionRunner { /// Update this router's RIB based on an update message from a peer. fn update_rib(&self, update: &UpdateMessage, pc: &PeerConnection) { - let originated4 = match self.db.get_origin4() { - Ok(value) => value, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to get originated ipv4 routes from db: {e}"; - "error" => format!("{e}") - ); - Vec::new() - } - }; - let nexthop = match update.nexthop() { Ok(nh) => match nh { BgpNexthop::Ipv4(ip4) => IpAddr::V4(ip4), @@ -8174,7 +8160,7 @@ impl SessionRunner { let withdrawn: Vec = update .withdrawn .iter() - .filter(|p| !originated4.contains(p) && p.valid_for_rib()) + .filter(|p| p.valid_for_rib()) .copied() .map(Prefix::V4) .collect(); @@ -8186,8 +8172,7 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - !originated4.contains(p) - && p.valid_for_rib() + p.valid_for_rib() && !self.prefix_via_self(Prefix::V4(**p), nexthop) }) .copied() @@ -8234,8 +8219,7 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - !originated4.contains(p) - && p.valid_for_rib() + p.valid_for_rib() && !self.prefix_via_self( Prefix::V4(**p), mp_nexthop, @@ -8272,20 +8256,6 @@ impl SessionRunner { } } MpReachNlri::Ipv6Unicast(reach6) => { - let originated6 = match self.db.get_origin6() { - Ok(value) => value, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to get originated ipv6 routes from db: {e}"; - "error" => format!("{e}") - ); - Vec::new() - } - }; - let nexthop6 = match &reach6.nexthop { BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(*gua), @@ -8307,8 +8277,7 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - !originated6.contains(p) - && p.valid_for_rib() + p.valid_for_rib() && !self .prefix_via_self(Prefix::V6(**p), nexthop6) }) @@ -8352,9 +8321,7 @@ impl SessionRunner { let mp_withdrawn4: Vec = unreach4 .withdrawn .iter() - .filter(|p| { - !originated4.contains(p) && p.valid_for_rib() - }) + .filter(|p| p.valid_for_rib()) .copied() .map(Prefix::V4) .collect(); @@ -8365,26 +8332,10 @@ impl SessionRunner { ); } MpUnreachNlri::Ipv6Unicast(unreach6) => { - let originated6 = match self.db.get_origin6() { - Ok(value) => value, - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to get originated ipv6 routes for withdrawal: {e}"; - "error" => format!("{e}") - ); - Vec::new() - } - }; - let withdrawn6: Vec = unreach6 .withdrawn .iter() - .filter(|p| { - !originated6.contains(p) && p.valid_for_rib() - }) + .filter(|p| p.valid_for_rib()) .copied() .map(Prefix::V6) .collect(); From e00e6a20a5f2bd0e8eb68d6d6994827e45cda6bb Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Sun, 14 Dec 2025 23:12:40 +0100 Subject: [PATCH 07/20] Bump API for MP-BGP Adds a new API version for MP-BGP. Updates mgadm to use the new API version. Adds backwards compatible type and conversion for peers created with old API versions. --- bgp/src/messages.rs | 280 +- bgp/src/params.rs | 312 +- bgp/src/proptest.rs | 17 +- bgp/src/session.rs | 175 +- bgp/src/test.rs | 245 +- mg-api/src/lib.rs | 69 +- mg-common/src/test.rs | 51 +- mgadm/src/bgp.rs | 105 +- mgd/src/admin.rs | 52 +- mgd/src/bgp_admin.rs | 265 +- mgd/src/main.rs | 8 +- openapi/mg-admin/mg-admin-4.0.0-016211.json | 4331 +++++++++++++++++++ openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/src/db.rs | 2 +- rdb/src/types.rs | 52 +- 15 files changed, 5656 insertions(+), 310 deletions(-) create mode 100644 openapi/mg-admin/mg-admin-4.0.0-016211.json diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index 07f2e6f9..152a53d6 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -767,6 +767,7 @@ pub struct UpdateMessage { /// SessionReset errors cause early return and are not collected here. /// Not serialized - only used for internal signaling. #[serde(skip)] + #[schemars(skip)] pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, } @@ -1883,6 +1884,20 @@ impl Aggregator { buf.extend_from_slice(&self.address.octets()); buf } + + /// Serialize AGGREGATOR to fixed-size byte array + pub fn to_bytes(&self) -> [u8; 6] { + let asn_bytes = self.asn.to_be_bytes(); + let addr_bytes = self.address.octets(); + [ + asn_bytes[0], + asn_bytes[1], + addr_bytes[0], + addr_bytes[1], + addr_bytes[2], + addr_bytes[3], + ] + } } /// AS4_AGGREGATOR path attribute (RFC 6793) @@ -1926,6 +1941,22 @@ impl As4Aggregator { buf.extend_from_slice(&self.address.octets()); buf } + + /// Serialize AS4_AGGREGATOR to fixed-size byte array + pub fn to_bytes(&self) -> [u8; 8] { + let asn_bytes = self.asn.to_be_bytes(); + let addr_bytes = self.address.octets(); + [ + asn_bytes[0], + asn_bytes[1], + asn_bytes[2], + asn_bytes[3], + addr_bytes[0], + addr_bytes[1], + addr_bytes[2], + addr_bytes[3], + ] + } } /// The value encoding of a path attribute. @@ -2490,6 +2521,18 @@ pub enum AsPathType { AsSequence = 2, } +/// IPv6 double nexthop: global unicast address + link-local address. +/// Per RFC 2545, when advertising IPv6 routes, both addresses may be present. +#[derive( + Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +pub struct Ipv6DoubleNexthop { + /// Global unicast address + pub global: Ipv6Addr, + /// Link-local address + pub link_local: Ipv6Addr, +} + /// BGP next-hops can come in multiple forms, defined in several different RFCs. /// This enum represents the forms supported by this implementation. /// @@ -2571,7 +2614,7 @@ pub enum AsPathType { pub enum BgpNexthop { Ipv4(Ipv4Addr), Ipv6Single(Ipv6Addr), - Ipv6Double((Ipv6Addr, Ipv6Addr)), + Ipv6Double(Ipv6DoubleNexthop), } impl BgpNexthop { @@ -2621,10 +2664,10 @@ impl BgpNexthop { let mut bytes2 = [0u8; 16]; bytes1.copy_from_slice(&nh_bytes[..16]); bytes2.copy_from_slice(&nh_bytes[16..32]); - Ok(BgpNexthop::Ipv6Double(( - Ipv6Addr::from(bytes1), - Ipv6Addr::from(bytes2), - ))) + Ok(BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: Ipv6Addr::from(bytes1), + link_local: Ipv6Addr::from(bytes2), + })) } _ => Err(Error::InvalidAddress(format!( "invalid next-hop length {} for AFI {:?}", @@ -2650,10 +2693,10 @@ impl BgpNexthop { match self { BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Double((addr1, addr2)) => { + BgpNexthop::Ipv6Double(addrs) => { let mut buf = Vec::new(); - buf.extend_from_slice(&addr1.octets()); - buf.extend_from_slice(&addr2.octets()); + buf.extend_from_slice(&addrs.global.octets()); + buf.extend_from_slice(&addrs.link_local.octets()); buf } } @@ -2665,7 +2708,9 @@ impl Display for BgpNexthop { match self { BgpNexthop::Ipv4(a4) => write!(f, "{a4}"), BgpNexthop::Ipv6Single(a6) => write!(f, "{a6}"), - BgpNexthop::Ipv6Double((a, b)) => write!(f, "({a}, {b})"), + BgpNexthop::Ipv6Double(addrs) => { + write!(f, "({}, {})", addrs.global, addrs.link_local) + } } } } @@ -2684,7 +2729,10 @@ impl From for BgpNexthop { impl From<(Ipv6Addr, Ipv6Addr)> for BgpNexthop { fn from(value: (Ipv6Addr, Ipv6Addr)) -> Self { - BgpNexthop::Ipv6Double(value) + BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: value.0, + link_local: value.1, + }) } } @@ -5310,13 +5358,13 @@ impl From for PrefixV1 { } /// V1 UpdateMessage type for API compatibility -/// Uses PrefixV1 for NLRI and withdrawn prefixes +/// Uses PrefixV1 for NLRI and withdrawn prefixes, PathAttributeV1 for attributes #[derive( Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, JsonSchema, )] pub struct UpdateMessageV1 { pub withdrawn: Vec, - pub path_attributes: Vec, + pub path_attributes: Vec, pub nlri: Vec, } @@ -5328,7 +5376,12 @@ impl From for UpdateMessageV1 { .into_iter() .map(|p| PrefixV1::from(Prefix::V4(p))) .collect(), - path_attributes: msg.path_attributes, + // Filter out attributes that don't have V1 equivalents (MP-BGP, AtomicAggregate) + path_attributes: msg + .path_attributes + .into_iter() + .filter_map(Option::::from) + .collect(), nlri: msg .nlri .into_iter() @@ -5364,6 +5417,186 @@ impl From for MessageV1 { } } +// ============================================================================ +// V1 API Compatibility Types for PathAttribute +// ============================================================================ +// These types maintain backward compatibility with the v1/v2 API. +// - PathAttributeValueV1: Without AtomicAggregate, MpReachNlri, MpUnreachNlri +// - PathAttributeTypeCodeV1: Without MpReachNlri, MpUnreachNlri type codes +// - PathAttributeV1: Uses PathAttributeValueV1 +// - Aggregator is [u8; 6], NextHop is IpAddr (matching v1/v2 schema) + +/// An enumeration describing available path attribute type codes. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, JsonSchema, +)] +#[schemars(rename = "PathAttributeTypeCode")] +#[serde(rename_all = "snake_case")] +pub enum PathAttributeTypeCodeV1 { + /// RFC 4271 + Origin = 1, + AsPath = 2, + NextHop = 3, + MultiExitDisc = 4, + LocalPref = 5, + AtomicAggregate = 6, + Aggregator = 7, + Communities = 8, + /// RFC 6793 + As4Path = 17, + As4Aggregator = 18, +} + +impl From for PathAttributeTypeCodeV1 { + fn from(code: PathAttributeTypeCode) -> Self { + match code { + PathAttributeTypeCode::Origin => PathAttributeTypeCodeV1::Origin, + PathAttributeTypeCode::AsPath => PathAttributeTypeCodeV1::AsPath, + PathAttributeTypeCode::NextHop => PathAttributeTypeCodeV1::NextHop, + PathAttributeTypeCode::MultiExitDisc => { + PathAttributeTypeCodeV1::MultiExitDisc + } + PathAttributeTypeCode::LocalPref => { + PathAttributeTypeCodeV1::LocalPref + } + PathAttributeTypeCode::AtomicAggregate => { + PathAttributeTypeCodeV1::AtomicAggregate + } + PathAttributeTypeCode::Aggregator => { + PathAttributeTypeCodeV1::Aggregator + } + PathAttributeTypeCode::Communities => { + PathAttributeTypeCodeV1::Communities + } + // MP-BGP type codes map to As4Path as a fallback (they won't appear in V1) + PathAttributeTypeCode::MpReachNlri => { + PathAttributeTypeCodeV1::As4Path + } + PathAttributeTypeCode::MpUnreachNlri => { + PathAttributeTypeCodeV1::As4Path + } + PathAttributeTypeCode::As4Path => PathAttributeTypeCodeV1::As4Path, + PathAttributeTypeCode::As4Aggregator => { + PathAttributeTypeCodeV1::As4Aggregator + } + } + } +} + +/// The value encoding of a path attribute. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PathAttributeValue")] +#[serde(rename_all = "snake_case")] +pub enum PathAttributeValueV1 { + /// The type of origin associated with a path + Origin(PathOrigin), + /// The AS set associated with a path + AsPath(Vec), + /// The nexthop associated with a path + NextHop(IpAddr), + /// A metric used for external (inter-AS) links to discriminate among + /// multiple entry or exit points. + MultiExitDisc(u32), + /// Local pref is included in update messages sent to internal peers and + /// indicates a degree of preference. + LocalPref(u32), + /// This attribute is included in routes that are formed by aggregation. + Aggregator([u8; 6]), + /// Indicates communities associated with a path. + Communities(Vec), + /// The 4-byte encoded AS set associated with a path + As4Path(Vec), + /// This attribute is included in routes that are formed by aggregation. + As4Aggregator([u8; 8]), +} + +impl From for Option { + fn from(val: PathAttributeValue) -> Self { + match val { + PathAttributeValue::Origin(o) => { + Some(PathAttributeValueV1::Origin(o)) + } + PathAttributeValue::AsPath(p) => { + Some(PathAttributeValueV1::AsPath(p)) + } + PathAttributeValue::NextHop(nh) => { + Some(PathAttributeValueV1::NextHop(IpAddr::V4(nh))) + } + PathAttributeValue::MultiExitDisc(m) => { + Some(PathAttributeValueV1::MultiExitDisc(m)) + } + PathAttributeValue::LocalPref(l) => { + Some(PathAttributeValueV1::LocalPref(l)) + } + PathAttributeValue::Aggregator(a) => { + Some(PathAttributeValueV1::Aggregator(a.to_bytes())) + } + PathAttributeValue::Communities(c) => { + Some(PathAttributeValueV1::Communities(c)) + } + PathAttributeValue::AtomicAggregate => { + // AtomicAggregate was not in v1/v2 API + None + } + PathAttributeValue::As4Path(p) => { + Some(PathAttributeValueV1::As4Path(p)) + } + PathAttributeValue::As4Aggregator(a) => { + Some(PathAttributeValueV1::As4Aggregator(a.to_bytes())) + } + PathAttributeValue::MpReachNlri(_) => { + // MP-BGP attributes not in v1/v2 API + None + } + PathAttributeValue::MpUnreachNlri(_) => { + // MP-BGP attributes not in v1/v2 API + None + } + } + } +} + +/// Type encoding for a path attribute. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, JsonSchema, +)] +#[schemars(rename = "PathAttributeType")] +pub struct PathAttributeTypeV1 { + /// Flags may include, Optional, Transitive, Partial and Extended Length. + pub flags: u8, + /// Type code for the path attribute. + pub type_code: PathAttributeTypeCodeV1, +} + +impl From for PathAttributeTypeV1 { + fn from(t: PathAttributeType) -> Self { + Self { + flags: t.flags, + type_code: PathAttributeTypeCodeV1::from(t.type_code), + } + } +} + +/// A self-describing BGP path attribute +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PathAttribute")] +pub struct PathAttributeV1 { + /// Type encoding for the attribute + pub typ: PathAttributeTypeV1, + /// Value of the attribute + pub value: PathAttributeValueV1, +} + +impl From for Option { + fn from(attr: PathAttribute) -> Self { + let value_opt: Option = attr.value.into(); + value_opt.map(|value| PathAttributeV1 { + typ: PathAttributeTypeV1::from(attr.typ), + value, + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -5939,7 +6172,10 @@ mod tests { let nh = BgpNexthop::from_bytes(&bytes, 32, Afi::Ipv6) .expect("valid IPv6 double nexthop"); - assert_eq!(nh, BgpNexthop::Ipv6Double((global, link_local))); + assert_eq!( + nh, + BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { global, link_local }) + ); } #[test] @@ -6002,10 +6238,10 @@ mod tests { BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()); assert_eq!(ipv6_single.byte_len(), 16); - let ipv6_double = BgpNexthop::Ipv6Double(( - Ipv6Addr::from_str("2001:db8::1").unwrap(), - Ipv6Addr::from_str("fe80::1").unwrap(), - )); + let ipv6_double = BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: Ipv6Addr::from_str("2001:db8::1").unwrap(), + link_local: Ipv6Addr::from_str("fe80::1").unwrap(), + }); assert_eq!(ipv6_double.byte_len(), 32); } @@ -6034,10 +6270,10 @@ mod tests { ); // IPv6 double - let ipv6_double = BgpNexthop::Ipv6Double(( - Ipv6Addr::from_str("2001:db8::1").unwrap(), - Ipv6Addr::from_str("fe80::1").unwrap(), - )); + let ipv6_double = BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: Ipv6Addr::from_str("2001:db8::1").unwrap(), + link_local: Ipv6Addr::from_str("fe80::1").unwrap(), + }); let wire = ipv6_double.to_bytes(); let decoded = BgpNexthop::from_bytes(&wire, wire.len() as u8, Afi::Ipv6) diff --git a/bgp/src/params.rs b/bgp/src/params.rs index fb4d4be0..3040fe54 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -5,8 +5,8 @@ use crate::config::PeerConfig; use crate::session::FsmStateKind; use rdb::{ - ImportExportPolicy4, ImportExportPolicy6, ImportExportPolicyV1, - PolicyAction, Prefix4, Prefix6, + ImportExportPolicy, ImportExportPolicy4, ImportExportPolicy6, PolicyAction, + Prefix4, Prefix6, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -39,6 +39,21 @@ pub enum NeighborResetOp { SoftOutbound, } +/// Per-address-family configuration for IPv4 Unicast +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct Ipv4UnicastConfig { + pub import_policy: ImportExportPolicy4, + pub export_policy: ImportExportPolicy4, +} + +/// Per-address-family configuration for IPv6 Unicast +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct Ipv6UnicastConfig { + pub import_policy: ImportExportPolicy6, + pub export_policy: ImportExportPolicy6, +} + +/// Neighbor configuration with explicit per-address-family enablement (v3 API) #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] pub struct Neighbor { pub asn: u32, @@ -59,8 +74,46 @@ pub struct Neighbor { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicyV1, - pub allow_export: ImportExportPolicyV1, + /// IPv4 Unicast address family configuration (None = disabled) + pub ipv4_unicast: Option, + /// IPv6 Unicast address family configuration (None = disabled) + pub ipv6_unicast: Option, + pub vlan_id: Option, +} + +impl Neighbor { + /// Validate that at least one address family is enabled + pub fn validate(&self) -> Result<(), String> { + if self.ipv4_unicast.is_none() && self.ipv6_unicast.is_none() { + return Err("at least one address family must be enabled".into()); + } + Ok(()) + } +} + +/// Legacy neighbor configuration (v1/v2 API compatibility) +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct NeighborV1 { + pub asn: u32, + pub name: String, + pub host: SocketAddr, + pub hold_time: u64, + pub idle_hold_time: u64, + pub delay_open: u64, + pub connect_retry: u64, + pub keepalive: u64, + pub resolution: u64, + pub group: String, + pub passive: bool, + pub remote_asn: Option, + pub min_ttl: Option, + pub md5_auth_key: Option, + pub multi_exit_discriminator: Option, + pub communities: Vec, + pub local_pref: Option, + pub enforce_first_as: bool, + pub allow_import: ImportExportPolicy, + pub allow_export: ImportExportPolicy, pub vlan_id: Option, } @@ -79,11 +132,26 @@ impl From for PeerConfig { } } -impl Neighbor { - pub fn from_bgp_peer_config( +impl From for PeerConfig { + fn from(rq: NeighborV1) -> Self { + Self { + name: rq.name.clone(), + host: rq.host, + hold_time: rq.hold_time, + idle_hold_time: rq.idle_hold_time, + delay_open: rq.delay_open, + connect_retry: rq.connect_retry, + keepalive: rq.keepalive, + resolution: rq.resolution, + } + } +} + +impl NeighborV1 { + pub fn from_bgp_peer_config_v1( asn: u32, group: String, - rq: BgpPeerConfig, + rq: BgpPeerConfigV1, ) -> Self { Self { asn, @@ -104,15 +172,8 @@ impl Neighbor { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - // Combine per-AF policies into legacy format for API compatibility - allow_import: ImportExportPolicyV1::from_per_af_policies( - &rq.allow_import4, - &rq.allow_import6, - ), - allow_export: ImportExportPolicyV1::from_per_af_policies( - &rq.allow_export4, - &rq.allow_export6, - ), + allow_import: rq.allow_import, + allow_export: rq.allow_export, vlan_id: rq.vlan_id, } } @@ -138,11 +199,11 @@ impl Neighbor { local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, // Combine per-AF policies into legacy format for API compatibility - allow_import: ImportExportPolicyV1::from_per_af_policies( + allow_import: ImportExportPolicy::from_per_af_policies( &rq.allow_import4, &rq.allow_import6, ), - allow_export: ImportExportPolicyV1::from_per_af_policies( + allow_export: ImportExportPolicy::from_per_af_policies( &rq.allow_export4, &rq.allow_export6, ), @@ -151,6 +212,105 @@ impl Neighbor { } } +impl Neighbor { + /// Create a Neighbor from a BgpPeerConfig. + /// + /// Uses the `ipv4_enabled` and `ipv6_enabled` flags from the config to + /// determine which address families are enabled. + pub fn from_bgp_peer_config( + asn: u32, + group: String, + rq: BgpPeerConfig, + ) -> Self { + let ipv4_unicast = if rq.ipv4_enabled { + Some(Ipv4UnicastConfig { + import_policy: rq.allow_import4, + export_policy: rq.allow_export4, + }) + } else { + None + }; + + let ipv6_unicast = if rq.ipv6_enabled { + Some(Ipv6UnicastConfig { + import_policy: rq.allow_import6, + export_policy: rq.allow_export6, + }) + } else { + None + }; + + Self { + asn, + remote_asn: rq.remote_asn, + min_ttl: rq.min_ttl, + name: rq.name.clone(), + host: rq.host, + hold_time: rq.hold_time, + idle_hold_time: rq.idle_hold_time, + delay_open: rq.delay_open, + connect_retry: rq.connect_retry, + keepalive: rq.keepalive, + resolution: rq.resolution, + passive: rq.passive, + group: group.clone(), + md5_auth_key: rq.md5_auth_key, + multi_exit_discriminator: rq.multi_exit_discriminator, + communities: rq.communities, + local_pref: rq.local_pref, + enforce_first_as: rq.enforce_first_as, + ipv4_unicast, + ipv6_unicast, + vlan_id: rq.vlan_id, + } + } + + pub fn from_rdb_neighbor_info(asn: u32, rq: &rdb::BgpNeighborInfo) -> Self { + // Use explicit enablement flags from the database + let ipv4_unicast = if rq.ipv4_enabled { + Some(Ipv4UnicastConfig { + import_policy: rq.allow_import4.clone(), + export_policy: rq.allow_export4.clone(), + }) + } else { + None + }; + + let ipv6_unicast = if rq.ipv6_enabled { + Some(Ipv6UnicastConfig { + import_policy: rq.allow_import6.clone(), + export_policy: rq.allow_export6.clone(), + }) + } else { + None + }; + + Self { + asn, + remote_asn: rq.remote_asn, + min_ttl: rq.min_ttl, + name: rq.name.clone(), + host: rq.host, + hold_time: rq.hold_time, + idle_hold_time: rq.idle_hold_time, + delay_open: rq.delay_open, + connect_retry: rq.connect_retry, + keepalive: rq.keepalive, + resolution: rq.resolution, + passive: rq.passive, + group: rq.group.clone(), + md5_auth_key: rq.md5_auth_key.clone(), + multi_exit_discriminator: rq.multi_exit_discriminator, + communities: rq.communities.clone(), + local_pref: rq.local_pref, + enforce_first_as: rq.enforce_first_as, + ipv4_unicast, + ipv6_unicast, + vlan_id: rq.vlan_id, + } + } +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct AddExportPolicyRequest { /// ASN of the router to apply the export policy to. @@ -257,9 +417,10 @@ pub struct ShaperSource { pub code: String, } -/// Apply changes to an ASN. +/// Apply changes to an ASN (v1/v2 API - legacy format). #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] -pub struct ApplyRequest { +#[schemars(rename = "ApplyRequest")] +pub struct ApplyRequestV1 { /// ASN to apply changes to. pub asn: u32, /// Complete set of prefixes to originate. Any active prefixes not in this @@ -282,9 +443,35 @@ pub struct ApplyRequest { /// Means that the peer group "foo" only contains the peers `a`, `b` and /// `d`. If there is a peer `c` currently in the peer group "foo", it will /// be removed. - pub peers: HashMap>, + pub peers: HashMap>, } +/// BGP peer configuration for v1/v2 API (legacy format with combined import/export). +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[schemars(rename = "BgpPeerConfig")] +pub struct BgpPeerConfigV1 { + pub host: SocketAddr, + pub name: String, + pub hold_time: u64, + pub idle_hold_time: u64, + pub delay_open: u64, + pub connect_retry: u64, + pub keepalive: u64, + pub resolution: u64, + pub passive: bool, + pub remote_asn: Option, + pub min_ttl: Option, + pub md5_auth_key: Option, + pub multi_exit_discriminator: Option, + pub communities: Vec, + pub local_pref: Option, + pub enforce_first_as: bool, + pub allow_import: ImportExportPolicy, + pub allow_export: ImportExportPolicy, + pub vlan_id: Option, +} + +/// BGP peer configuration (current version with per-address-family policies). #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] pub struct BgpPeerConfig { pub host: SocketAddr, @@ -294,7 +481,7 @@ pub struct BgpPeerConfig { pub delay_open: u64, pub connect_retry: u64, pub keepalive: u64, - pub resolution: u64, //Create then read only + pub resolution: u64, pub passive: bool, pub remote_asn: Option, pub min_ttl: Option, @@ -303,21 +490,56 @@ pub struct BgpPeerConfig { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - /// Per-address-family import policy for IPv4 routes. + /// Whether IPv4 unicast is enabled for this peer. + pub ipv4_enabled: bool, + /// Whether IPv6 unicast is enabled for this peer. + pub ipv6_enabled: bool, + /// Per-address-family import policy for IPv4 routes (only used if ipv4_enabled). #[serde(default)] pub allow_import4: ImportExportPolicy4, - /// Per-address-family export policy for IPv4 routes. + /// Per-address-family export policy for IPv4 routes (only used if ipv4_enabled). #[serde(default)] pub allow_export4: ImportExportPolicy4, - /// Per-address-family import policy for IPv6 routes. + /// Per-address-family import policy for IPv6 routes (only used if ipv6_enabled). #[serde(default)] pub allow_import6: ImportExportPolicy6, - /// Per-address-family export policy for IPv6 routes. + /// Per-address-family export policy for IPv6 routes (only used if ipv6_enabled). #[serde(default)] pub allow_export6: ImportExportPolicy6, pub vlan_id: Option, } +impl From for BgpPeerConfig { + fn from(cfg: BgpPeerConfigV1) -> Self { + // Legacy BgpPeerConfigV1 is IPv4-only + Self { + host: cfg.host, + name: cfg.name, + hold_time: cfg.hold_time, + idle_hold_time: cfg.idle_hold_time, + delay_open: cfg.delay_open, + connect_retry: cfg.connect_retry, + keepalive: cfg.keepalive, + resolution: cfg.resolution, + passive: cfg.passive, + remote_asn: cfg.remote_asn, + min_ttl: cfg.min_ttl, + md5_auth_key: cfg.md5_auth_key, + multi_exit_discriminator: cfg.multi_exit_discriminator, + communities: cfg.communities, + local_pref: cfg.local_pref, + enforce_first_as: cfg.enforce_first_as, + ipv4_enabled: true, + ipv6_enabled: false, + allow_import4: cfg.allow_import.as_ipv4_policy(), + allow_export4: cfg.allow_export.as_ipv4_policy(), + allow_import6: ImportExportPolicy6::NoFiltering, + allow_export6: ImportExportPolicy6::NoFiltering, + vlan_id: cfg.vlan_id, + } + } +} + pub enum PolicySource { Checker(String), Shaper(String), @@ -407,3 +629,41 @@ impl From for PeerInfoV1 { } } } + +// ============================================================================ +// API Types for VERSION_MP_BGP / v3.0.0 +// ============================================================================ +// These types are for the v3+ API with per-address-family import/export policies. + +/// Apply changes to an ASN (current version with per-AF policies). +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct ApplyRequest { + /// ASN to apply changes to. + pub asn: u32, + /// Complete set of prefixes to originate. + pub originate: Vec, + /// Checker rhai code to apply to ingress open and update messages. + pub checker: Option, + /// Checker rhai code to apply to egress open and update messages. + pub shaper: Option, + /// Lists of peers indexed by peer group. + pub peers: HashMap>, +} + +impl From for ApplyRequest { + fn from(req: ApplyRequestV1) -> Self { + Self { + asn: req.asn, + originate: req.originate, + checker: req.checker, + shaper: req.shaper, + peers: req + .peers + .into_iter() + .map(|(k, v)| { + (k, v.into_iter().map(BgpPeerConfig::from).collect()) + }) + .collect(), + } + } +} diff --git a/bgp/src/proptest.rs b/bgp/src/proptest.rs index 0f7ceeb8..32d4f196 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -13,9 +13,10 @@ //! - RFC 7606 compliance (attribute deduplication, MP-BGP ordering) use crate::messages::{ - As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, MpReachNlri, - MpUnreachNlri, PathAttribute, PathAttributeType, PathAttributeTypeCode, - PathAttributeValue, PathOrigin, UpdateMessage, path_attribute_flags, + As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, Ipv6DoubleNexthop, + MpReachNlri, MpUnreachNlri, PathAttribute, PathAttributeType, + PathAttributeTypeCode, PathAttributeValue, PathOrigin, UpdateMessage, + path_attribute_flags, }; use proptest::prelude::*; use rdb::types::{Prefix4, Prefix6}; @@ -65,11 +66,11 @@ fn nexthop_ipv6_single_strategy() -> impl Strategy { /// Strategy for generating IPv6 double next-hops (global + link-local) fn nexthop_ipv6_double_strategy() -> impl Strategy { - (any::(), any::()).prop_map(|(global, link_local)| { - BgpNexthop::Ipv6Double(( - Ipv6Addr::from(global), - Ipv6Addr::from(link_local), - )) + (any::(), any::()).prop_map(|(global_bits, link_local_bits)| { + BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: Ipv6Addr::from(global_bits), + link_local: Ipv6Addr::from(link_local_bits), + }) }) } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 58cd558c..c950e933 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -18,14 +18,15 @@ use crate::{ OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, UpdateMessage, }, + params::{Ipv4UnicastConfig, Ipv6UnicastConfig}, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, router::Router, }; use mg_common::{lock, read_lock, write_lock}; use rdb::{ - AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy, - ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, Prefix6, + AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy4, + ImportExportPolicy6, Prefix, Prefix4, Prefix6, TypedImportExportPolicy, }; pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY}; use schemars::JsonSchema; @@ -456,7 +457,7 @@ pub enum AdminEvent { /// Fires when an export policy has changed. /// Contains the previous policy for determining routes to re-advertise. - ExportPolicyChanged(ImportExportPolicy), + ExportPolicyChanged(TypedImportExportPolicy), // The checker for the router has changed. Event contains previous checker. // Current checker is available in the router policy object. @@ -490,8 +491,8 @@ impl AdminEvent { AdminEvent::ShaperChanged(_) => "shaper changed", AdminEvent::CheckerChanged(_) => "checker changed", AdminEvent::ExportPolicyChanged(p) => match p { - ImportExportPolicy::V4(_) => "ipv4 export policy changed", - ImportExportPolicy::V6(_) => "ipv6 export policy changed", + TypedImportExportPolicy::V4(_) => "ipv4 export policy changed", + TypedImportExportPolicy::V6(_) => "ipv6 export policy changed", }, AdminEvent::Reset => "reset", AdminEvent::ManualStart => "manual start", @@ -786,14 +787,10 @@ pub struct SessionInfo { /// Ensure that routes received from eBGP peers have the peer's ASN as the /// first element in the AS path. pub enforce_first_as: bool, - /// Per-address-family import policy for IPv4 routes. - pub allow_import4: ImportExportPolicy4, - /// Per-address-family export policy for IPv4 routes. - pub allow_export4: ImportExportPolicy4, - /// Per-address-family import policy for IPv6 routes. - pub allow_import6: ImportExportPolicy6, - /// Per-address-family export policy for IPv6 routes. - pub allow_export6: ImportExportPolicy6, + /// IPv4 Unicast address family configuration (None = disabled) + pub ipv4_unicast: Option, + /// IPv6 Unicast address family configuration (None = disabled) + pub ipv6_unicast: Option, /// Vlan tag to assign to data plane routes created by this session. pub vlan_id: Option, /// Timer intervals for session and connection management @@ -821,10 +818,6 @@ pub struct SessionInfo { /// resolution even when one connection is already in Established state. /// When false, Established connection always wins (timing-based resolution). pub deterministic_collision_resolution: bool, - /// This session is configured to support the IPv4 Unicast MP-BGP AFI/SAFI - pub ipv4_enabled: bool, - /// This session is configured to support the IPv6 Unicast MP-BGP AFI/SAFI - pub ipv6_enabled: bool, } impl SessionInfo { @@ -842,10 +835,11 @@ impl SessionInfo { communities: BTreeSet::new(), local_pref: None, enforce_first_as: false, - allow_import4: ImportExportPolicy4::default(), - allow_export4: ImportExportPolicy4::default(), - allow_import6: ImportExportPolicy6::default(), - allow_export6: ImportExportPolicy6::default(), + ipv4_unicast: Some(Ipv4UnicastConfig { + import_policy: ImportExportPolicy4::default(), + export_policy: ImportExportPolicy4::default(), + }), + ipv6_unicast: None, vlan_id: None, connect_retry_time: Duration::from_secs(peer_config.connect_retry), keepalive_time: Duration::from_secs(peer_config.keepalive), @@ -857,10 +851,6 @@ impl SessionInfo { connect_retry_jitter: None, // XXX: plumb this through to the neighbor API endpoint deterministic_collision_resolution: false, - // XXX: plumb this through to the neighbor API endpoint - ipv4_enabled: true, - // XXX: plumb this through to the neighbor API endpoint - ipv6_enabled: false, } } } @@ -2042,10 +2032,10 @@ impl SessionRunner { }, Capability::RouteRefresh {}, ]); - if lock!(self.session).ipv4_enabled { + if lock!(self.session).ipv4_unicast.is_some() { caps.insert(Capability::ipv4_unicast()); } - if lock!(self.session).ipv6_enabled { + if lock!(self.session).ipv6_unicast.is_some() { caps.insert(Capability::ipv6_unicast()); } *lock!(self.caps_tx) = caps; @@ -6032,7 +6022,7 @@ impl SessionRunner { AdminEvent::ExportPolicyChanged(previous) => { match previous { - ImportExportPolicy::V4(previous4) => { + TypedImportExportPolicy::V4(previous4) => { let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -6064,11 +6054,16 @@ impl SessionRunner { }; let originated_after: BTreeSet = - match &session.allow_export4 { - ImportExportPolicy4::NoFiltering => { + match session + .ipv4_unicast + .as_ref() + .map(|c| &c.export_policy) + { + Some(ImportExportPolicy4::NoFiltering) + | None => { originated.iter().cloned().collect() } - ImportExportPolicy4::Allow(list) => { + Some(ImportExportPolicy4::Allow(list)) => { originated .clone() .into_iter() @@ -6130,7 +6125,7 @@ impl SessionRunner { FsmState::Established(pc) } - ImportExportPolicy::V6(previous6) => { + TypedImportExportPolicy::V6(previous6) => { let originated = match self.db.get_origin6() { Ok(value) => value, Err(e) => { @@ -6162,11 +6157,16 @@ impl SessionRunner { }; let originated_after: BTreeSet = - match &session.allow_export6 { - ImportExportPolicy6::NoFiltering => { + match session + .ipv6_unicast + .as_ref() + .map(|c| &c.export_policy) + { + Some(ImportExportPolicy6::NoFiltering) + | None => { originated.iter().cloned().collect() } - ImportExportPolicy6::Allow(list) => { + Some(ImportExportPolicy6::Allow(list)) => { originated .clone() .into_iter() @@ -7380,7 +7380,7 @@ impl SessionRunner { } /// Apply export policy filtering to UPDATE message. - /// Filters NLRI based on allow_export configuration. + /// Filters NLRI based on per-AF export policy configuration. fn apply_export_policy( &self, update: &mut UpdateMessage, @@ -7388,7 +7388,10 @@ impl SessionRunner { let session = lock!(self.session); // Filter traditional NLRI field (IPv4) using IPv4 export policy - if let ImportExportPolicy4::Allow(ref policy4) = session.allow_export4 { + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.export_policy + { update.nlri.retain(|p| policy4.contains(p)); } @@ -7396,15 +7399,17 @@ impl SessionRunner { if let Some(reach) = update.mp_reach_mut() { match reach { MpReachNlri::Ipv4Unicast(reach4) => { - if let ImportExportPolicy4::Allow(ref policy4) = - session.allow_export4 + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.export_policy { reach4.nlri.retain(|p| policy4.contains(p)); } } MpReachNlri::Ipv6Unicast(reach6) => { - if let ImportExportPolicy6::Allow(ref policy6) = - session.allow_export6 + if let Some(config6) = &session.ipv6_unicast + && let ImportExportPolicy6::Allow(ref policy6) = + config6.export_policy { reach6.nlri.retain(|p| policy6.contains(p)); } @@ -7891,8 +7896,9 @@ impl SessionRunner { let session = lock!(self.session); // Filter traditional NLRI field (IPv4) using IPv4 import policy - if let ImportExportPolicy4::Allow(ref policy4) = - session.allow_import4 + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.import_policy { update.nlri.retain(|p| policy4.contains(p)); } @@ -7901,15 +7907,17 @@ impl SessionRunner { if let Some(reach) = update.mp_reach_mut() { match reach { MpReachNlri::Ipv4Unicast(reach4) => { - if let ImportExportPolicy4::Allow(ref policy4) = - session.allow_import4 + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.import_policy { reach4.nlri.retain(|p| policy4.contains(p)); } } MpReachNlri::Ipv6Unicast(reach6) => { - if let ImportExportPolicy6::Allow(ref policy6) = - session.allow_import6 + if let Some(config6) = &session.ipv6_unicast + && let ImportExportPolicy6::Allow(ref policy6) = + config6.import_policy { reach6.nlri.retain(|p| policy6.contains(p)); } @@ -8138,7 +8146,7 @@ impl SessionRunner { Ok(nh) => match nh { BgpNexthop::Ipv4(ip4) => IpAddr::V4(ip4), BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(ip6), - BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(gua), + BgpNexthop::Ipv6Double(addrs) => IpAddr::V6(addrs.global), }, Err(e) => { session_log!( @@ -8212,7 +8220,9 @@ impl SessionRunner { let mp_nexthop = match &reach4.nexthop { BgpNexthop::Ipv4(ip4) => IpAddr::V4(*ip4), BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), - BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(*gua), + BgpNexthop::Ipv6Double(addrs) => { + IpAddr::V6(addrs.global) + } }; let mp_nlri4: Vec = reach4 @@ -8258,7 +8268,9 @@ impl SessionRunner { MpReachNlri::Ipv6Unicast(reach6) => { let nexthop6 = match &reach6.nexthop { BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), - BgpNexthop::Ipv6Double((gua, _ll)) => IpAddr::V6(*gua), + BgpNexthop::Ipv6Double(addrs) => { + IpAddr::V6(addrs.global) + } BgpNexthop::Ipv4(ip4) => { // IPv4 nexthop for IPv6 routes is unusual but possible // in some configurations (e.g., IPv4-mapped IPv6) @@ -8567,13 +8579,17 @@ impl SessionRunner { } // Handle per-AF import policy changes (trigger route refresh) - if current.allow_import4 != info.allow_import4 { - current.allow_import4 = info.allow_import4; + if current.ipv4_unicast.as_ref().map(|c| &c.import_policy) + != info.ipv4_unicast.as_ref().map(|c| &c.import_policy) + { + current.ipv4_unicast = info.ipv4_unicast.clone(); refresh_needed4 = true; } - if current.allow_import6 != info.allow_import6 { - current.allow_import6 = info.allow_import6; + if current.ipv6_unicast.as_ref().map(|c| &c.import_policy) + != info.ipv6_unicast.as_ref().map(|c| &c.import_policy) + { + current.ipv6_unicast = info.ipv6_unicast.clone(); refresh_needed6 = true; } @@ -8595,24 +8611,43 @@ impl SessionRunner { .set_jitter_range(info.idle_hold_jitter); } - if current.allow_export4 != info.allow_export4 { - let previous4 = current.allow_export4.clone(); - current.allow_export4 = info.allow_export4; - self.event_tx - .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - ImportExportPolicy::V4(previous4), - ))) - .map_err(|e| Error::EventSend(e.to_string()))?; + // Handle per-AF export policy changes + if current.ipv4_unicast.as_ref().map(|c| &c.export_policy) + != info.ipv4_unicast.as_ref().map(|c| &c.export_policy) + { + if let Some(previous4) = current + .ipv4_unicast + .as_ref() + .map(|c| c.export_policy.clone()) + { + current.ipv4_unicast = info.ipv4_unicast.clone(); + self.event_tx + .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( + TypedImportExportPolicy::V4(previous4), + ))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } else { + current.ipv4_unicast = info.ipv4_unicast.clone(); + } } - if current.allow_export6 != info.allow_export6 { - let previous6 = current.allow_export6.clone(); - current.allow_export6 = info.allow_export6; - self.event_tx - .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - ImportExportPolicy::V6(previous6), - ))) - .map_err(|e| Error::EventSend(e.to_string()))?; + if current.ipv6_unicast.as_ref().map(|c| &c.export_policy) + != info.ipv6_unicast.as_ref().map(|c| &c.export_policy) + { + if let Some(previous6) = current + .ipv6_unicast + .as_ref() + .map(|c| c.export_policy.clone()) + { + current.ipv6_unicast = info.ipv6_unicast.clone(); + self.event_tx + .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( + TypedImportExportPolicy::V6(previous6), + ))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } else { + current.ipv6_unicast = info.ipv6_unicast.clone(); + } } drop(current); diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 046420f8..543844fd 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -8,6 +8,7 @@ use crate::{ connection_channel::{BgpConnectionChannel, BgpListenerChannel}, connection_tcp::{BgpConnectionTcp, BgpListenerTcp}, dispatcher::Dispatcher, + params::Ipv6UnicastConfig, router::Router, session::{ AdminEvent, ConnectionKind, FsmEvent, FsmStateKind, SessionEndpoint, @@ -18,7 +19,7 @@ use lazy_static::lazy_static; use mg_common::log::init_file_logger; use mg_common::test::{IpAllocation, LoopbackIpManager}; use mg_common::*; -use rdb::{Asn, Prefix, Prefix4}; +use rdb::{Asn, ImportExportPolicy6, Prefix, Prefix4}; use std::{ collections::BTreeMap, net::{IpAddr, SocketAddr}, @@ -260,11 +261,36 @@ fn basic_peering_helper< r2_addr: SocketAddr, ) { let is_tcp = std::any::type_name::().contains("Tcp"); - let test_str = match (passive, is_tcp) { - (true, true) => "basic_peering_passive_tcp", - (false, true) => "basic_peering_active_tcp", - (true, false) => "basic_peering_passive", - (false, false) => "basic_peering_active", + let is_ipv6 = r1_addr.ip().is_ipv6(); + let test_str = match (passive, is_tcp, is_ipv6) { + (true, true, true) => "basic_peering_passive_tcp_ipv6", + (false, true, true) => "basic_peering_active_tcp_ipv6", + (true, false, true) => "basic_peering_passive_ipv6", + (false, false, true) => "basic_peering_active_ipv6", + (true, true, false) => "basic_peering_passive_tcp", + (false, true, false) => "basic_peering_active_tcp", + (true, false, false) => "basic_peering_passive", + (false, false, false) => "basic_peering_active", + }; + + // Helper to create session_info with appropriate AF config based on address family + let create_session_info = |peer_config: &PeerConfig, passive: bool| { + let mut info = SessionInfo::from_peer_config(peer_config); + info.passive_tcp_establishment = passive; + match peer_config.host.ip() { + IpAddr::V4(_) => { + // IPv4: keep default (IPv4 enabled, IPv6 disabled) + } + IpAddr::V6(_) => { + // IPv6-only: disable IPv4, enable IPv6 + info.ipv4_unicast = None; + info.ipv6_unicast = Some(Ipv6UnicastConfig { + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }); + } + } + info }; let peer_config_r1 = PeerConfig { @@ -278,6 +304,17 @@ fn basic_peering_helper< resolution: 100, }; + let peer_config_r2 = PeerConfig { + name: "r1".into(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let routers = vec![ LogicalRouter { name: "r1".to_string(), @@ -287,12 +324,10 @@ fn basic_peering_helper< bind_addr: Some(r1_addr), neighbors: vec![Neighbor { peer_config: peer_config_r1.clone(), - session_info: Some({ - let mut info = - SessionInfo::from_peer_config(&peer_config_r1); - info.passive_tcp_establishment = passive; - info - }), + session_info: Some(create_session_info( + &peer_config_r1, + passive, + )), }], }, LogicalRouter { @@ -302,17 +337,11 @@ fn basic_peering_helper< listen_addr: r2_addr, bind_addr: Some(r2_addr), neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r1".into(), - host: r1_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, + peer_config: peer_config_r2.clone(), + session_info: Some(create_session_info( + &peer_config_r2, + !passive, + )), }], }, ]; @@ -451,10 +480,53 @@ fn basic_update_helper< r2_addr: SocketAddr, ) { let is_tcp = std::any::type_name::().contains("Tcp"); - let test_name = if is_tcp { - "basic_update_tcp" - } else { - "basic_update" + let is_ipv6 = r1_addr.ip().is_ipv6(); + let test_name = match (is_tcp, is_ipv6) { + (true, true) => "basic_update_ipv6_tcp", + (true, false) => "basic_update_tcp", + (false, true) => "basic_update_ipv6", + (false, false) => "basic_update", + }; + + // Helper to create session_info with appropriate AF config based on address family + let create_session_info = |peer_config: &PeerConfig| { + let mut info = SessionInfo::from_peer_config(peer_config); + match peer_config.host.ip() { + IpAddr::V4(_) => { + // IPv4: keep default (IPv4 enabled, IPv6 disabled) + } + IpAddr::V6(_) => { + // IPv6-only: disable IPv4, enable IPv6 + info.ipv4_unicast = None; + info.ipv6_unicast = Some(Ipv6UnicastConfig { + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }); + } + } + info + }; + + let peer_config_r1 = PeerConfig { + name: "r2".into(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let peer_config_r2 = PeerConfig { + name: "r1".into(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, }; let routers = vec![ @@ -465,17 +537,8 @@ fn basic_update_helper< listen_addr: r1_addr, bind_addr: Some(r1_addr), neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r2".into(), - host: r2_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, + peer_config: peer_config_r1.clone(), + session_info: Some(create_session_info(&peer_config_r1)), }], }, LogicalRouter { @@ -485,17 +548,8 @@ fn basic_update_helper< listen_addr: r2_addr, bind_addr: Some(r2_addr), neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r1".into(), - host: r1_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, + peer_config: peer_config_r2.clone(), + session_info: Some(create_session_info(&peer_config_r2)), }], }, ]; @@ -518,14 +572,18 @@ fn basic_update_helper< wait_for_eq!(r1_session.state(), FsmStateKind::Established); wait_for_eq!(r2_session.state(), FsmStateKind::Established); - // originate a prefix - r1.router - .create_origin4(vec![ip!("1.2.3.0/24")]) - .expect("originate"); - - // create handle to rdb::Prefix -- create_origin4 takes messages::Prefix, - // so we can't pass this same handle to the earlier method call. - let prefix = Prefix::V4(cidr!("1.2.3.0/24")); + // originate a prefix (IPv4 for IPv4 tests, IPv6 for IPv6 tests) + let prefix = if is_ipv6 { + r1.router + .create_origin6(vec![ip!("3fff:db8::/32")]) + .expect("originate"); + Prefix::V6(cidr!("3fff:db8::/32")) + } else { + r1.router + .create_origin4(vec![ip!("1.2.3.0/24")]) + .expect("originate"); + Prefix::V4(cidr!("1.2.3.0/24")) + }; wait_for!(!r2.router.db.get_prefix_paths(&prefix).is_empty()); @@ -975,7 +1033,10 @@ fn test_import_export_policy_filtering() { }; let r1_session_info = { let mut info = SessionInfo::from_peer_config(&r1_peer_config); - info.allow_export4 = ImportExportPolicy4::Allow(export_allow.clone()); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.export_policy = + ImportExportPolicy4::Allow(export_allow.clone()); + } info }; @@ -992,7 +1053,10 @@ fn test_import_export_policy_filtering() { }; let r2_session_info = { let mut info = SessionInfo::from_peer_config(&r2_peer_config); - info.allow_import4 = ImportExportPolicy4::Allow(import_allow.clone()); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.import_policy = + ImportExportPolicy4::Allow(import_allow.clone()); + } info }; @@ -1098,7 +1162,9 @@ fn test_import_export_policy_filtering() { ); let r1_session_info_no_export = { let mut info = SessionInfo::from_peer_config(&r1_peer_config); - info.allow_export4 = ImportExportPolicy4::NoFiltering; + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.export_policy = ImportExportPolicy4::NoFiltering; + } info }; r1.router @@ -1141,7 +1207,9 @@ fn test_import_export_policy_filtering() { // Now remove r2's import policy - prefix_c should appear via route-refresh let r2_session_info_no_import = { let mut info = SessionInfo::from_peer_config(&r2_peer_config); - info.allow_import4 = ImportExportPolicy4::NoFiltering; + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.import_policy = ImportExportPolicy4::NoFiltering; + } info }; r2.router @@ -1189,3 +1257,58 @@ fn test_import_export_policy_filtering() { r1.shutdown(); r2.shutdown(); } + +// IPv6-only tests added via basic_update and basic_peering helpers +// Tests with IPv6 addresses will have IPv6-only config automatically applied + +#[test] +fn test_basic_update_ipv6() { + basic_update_helper::( + sockaddr!(&format!("[3fff::]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::1]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_basic_update_ipv6_tcp() { + basic_update_helper::( + sockaddr!(&format!("[3fff::2]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::3]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_passive() { + basic_peering_helper::( + true, + sockaddr!(&format!("[3fff::4]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::5]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_active() { + basic_peering_helper::( + false, + sockaddr!(&format!("[3fff::6]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::7]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_passive_tcp() { + basic_peering_helper::( + true, + sockaddr!(&format!("[3fff::8]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::9]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_active_tcp() { + basic_peering_helper::( + false, + sockaddr!(&format!("[3fff::a]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::b]:{TEST_BGP_PORT}")), + ) +} diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 69721dab..7a7cdbac 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -11,8 +11,9 @@ use std::{ use bfd::BfdPeerState; use bgp::{ params::{ - ApplyRequest, CheckerSource, Neighbor, NeighborResetOp, Origin4, - Origin6, PeerInfo, PeerInfoV1, Router, ShaperSource, + ApplyRequest, ApplyRequestV1, CheckerSource, Neighbor, NeighborResetOp, + NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, Router, + ShaperSource, }, session::{FsmEventRecord, MessageHistory, MessageHistoryV1}, }; @@ -40,6 +41,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (4, MP_BGP), (3, SWITCH_IDENTIFIERS), (2, IPV6_BASIC), (1, INITIAL), @@ -113,36 +115,69 @@ pub trait MgAdminApi { request: Query, ) -> Result; - #[endpoint { method = GET, path = "/bgp/config/neighbors" }] + // V1/V2 API - legacy Neighbor type with combined import/export policies + #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] + async fn create_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { method = GET, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] + async fn read_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result, HttpError>; + + #[endpoint { method = GET, path = "/bgp/config/neighbors", versions = ..VERSION_MP_BGP }] async fn read_neighbors( rqctx: RequestContext, request: Query, - ) -> Result>, HttpError>; + ) -> Result>, HttpError>; - #[endpoint { method = PUT, path = "/bgp/config/neighbor" }] - async fn create_neighbor( + #[endpoint { method = POST, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] + async fn update_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { method = DELETE, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] + async fn delete_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result; + + // V3 API - new Neighbor type with explicit per-AF configuration + #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + async fn create_neighbor_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; - #[endpoint { method = GET, path = "/bgp/config/neighbor" }] - async fn read_neighbor( + #[endpoint { method = GET, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + async fn read_neighbor_v2( rqctx: RequestContext, request: Query, ) -> Result, HttpError>; - #[endpoint { method = POST, path = "/bgp/config/neighbor" }] - async fn update_neighbor( + #[endpoint { method = GET, path = "/bgp/config/neighbors", versions = VERSION_MP_BGP.. }] + async fn read_neighbors_v2( + rqctx: RequestContext, + request: Query, + ) -> Result>, HttpError>; + + #[endpoint { method = POST, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + async fn update_neighbor_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; - #[endpoint { method = DELETE, path = "/bgp/config/neighbor" }] - async fn delete_neighbor( + #[endpoint { method = DELETE, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + async fn delete_neighbor_v2( rqctx: RequestContext, request: Query, ) -> Result; + // Common operations (not versioned - works with both APIs) #[endpoint { method = POST, path = "/bgp/clear/neighbor" }] async fn clear_neighbor( rqctx: RequestContext, @@ -243,8 +278,16 @@ pub trait MgAdminApi { request: Query, ) -> Result>, HttpError>; - #[endpoint { method = POST, path = "/bgp/omicron/apply" }] + // V1/V2 API - ApplyRequestV1 with combined import/export policies + #[endpoint { method = POST, path = "/bgp/omicron/apply", versions = ..VERSION_MP_BGP }] async fn bgp_apply( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + // V3 API - ApplyRequest with per-AF policies + #[endpoint { method = POST, path = "/bgp/omicron/apply", versions = VERSION_MP_BGP.. }] + async fn bgp_apply_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index b0f027de..b2e5e177 100644 --- a/mg-common/src/test.rs +++ b/mg-common/src/test.rs @@ -271,15 +271,16 @@ impl LoopbackIpManager { } /// Install a single IP address with proper refcount management - /// Skips 127.0.0.1 as it's always present on loopback interfaces + /// Skips 127.0.0.1/::1 as they're always present on loopback interfaces fn install_single_ip_static( ifname: &str, log: &Logger, ip: &mut ManagedIp, ) -> Result<(), std::io::Error> { - // Skip 127.0.0.1 as it's always present on loopback interfaces by default - if ip.address.to_string() == "127.0.0.1" { - info!(log, "skipping 127.0.0.1 (always present on loopback)"); + // Skip 127.0.0.1/::1 as they're always present on loopback interfaces by default + let ip_str = ip.address.to_string(); + if ip_str == "127.0.0.1" || ip_str == "::1" { + info!(log, "skipping {ip_str} (always present on loopback)"); ip.installed = true; // Mark as installed but don't create lockfile return Ok(()); } @@ -317,7 +318,11 @@ impl LoopbackIpManager { log: &Logger, ip: &ManagedIp, ) -> Result<(), std::io::Error> { - let addr_str = format!("{}/32", ip.address); + let mask = match ip.address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{}/{mask}", ip.address); #[cfg(target_os = "illumos")] let output = { @@ -342,9 +347,15 @@ impl LoopbackIpManager { .output()?; #[cfg(target_os = "macos")] - let output = Command::new("sudo") - .args(["ifconfig", ifname, "alias", &addr_str]) - .output()?; + let output = { + let af = match ip.address { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args(["ifconfig", ifname, af, &addr_str, "alias"]) + .output()? + }; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -467,16 +478,32 @@ impl LoopbackIpManager { #[cfg(target_os = "linux")] let output = { - let addr_str = format!("{}/32", ip.address); + let mask = match ip.address { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let addr_str = format!("{}/{mask}", ip.address); Command::new("sudo") .args(["ip", "addr", "del", &addr_str, "dev", ifname]) .output() }; #[cfg(target_os = "macos")] - let output = Command::new("sudo") - .args(["ifconfig", ifname, "-alias", &ip.address.to_string()]) - .output(); + let output = { + let af = match ip.address { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + Command::new("sudo") + .args([ + "ifconfig", + ifname, + af, + &ip.address.to_string(), + "-alias", + ]) + .output() + }; match output { Ok(output) => { diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index 2e94bde1..eb1f10e1 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -8,11 +8,12 @@ use colored::*; use mg_admin_client::{ Client, types::{ - self, ImportExportPolicy, NeighborResetOp as MgdNeighborResetOp, + self, ImportExportPolicy4, ImportExportPolicy6, Ipv4UnicastConfig, + Ipv6UnicastConfig, NeighborResetOp as MgdNeighborResetOp, NeighborResetRequest, }, }; -use rdb::types::{PolicyAction, Prefix, Prefix4, Prefix6}; +use rdb::types::{PolicyAction, Prefix4, Prefix6}; use std::fs::read_to_string; use std::io::{Write, stdout}; use std::net::{IpAddr, SocketAddr}; @@ -543,11 +544,29 @@ pub struct Neighbor { #[arg(long)] pub vlan_id: Option, + /// Enable IPv4 unicast address family. #[arg(long)] - pub allow_export: Option>, + pub enable_ipv4: bool, + /// Enable IPv6 unicast address family. #[arg(long)] - pub allow_import: Option>, + pub enable_ipv6: bool, + + /// IPv4 prefixes to allow importing (requires --enable-ipv4). + #[arg(long)] + pub allow_import4: Option>, + + /// IPv4 prefixes to allow exporting (requires --enable-ipv4). + #[arg(long)] + pub allow_export4: Option>, + + /// IPv6 prefixes to allow importing (requires --enable-ipv6). + #[arg(long)] + pub allow_import6: Option>, + + /// IPv6 prefixes to allow exporting (requires --enable-ipv6). + #[arg(long)] + pub allow_export6: Option>, /// Autonomous system number for the router to add the neighbor to. #[clap(env)] @@ -556,6 +575,50 @@ pub struct Neighbor { impl From for types::Neighbor { fn from(n: Neighbor) -> types::Neighbor { + // Build IPv4 unicast config if enabled + let ipv4_unicast = if n.enable_ipv4 { + let import_policy = match n.allow_import4 { + Some(prefixes) => { + ImportExportPolicy4::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy4::NoFiltering, + }; + let export_policy = match n.allow_export4 { + Some(prefixes) => { + ImportExportPolicy4::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy4::NoFiltering, + }; + Some(Ipv4UnicastConfig { + import_policy, + export_policy, + }) + } else { + None + }; + + // Build IPv6 unicast config if enabled + let ipv6_unicast = if n.enable_ipv6 { + let import_policy = match n.allow_import6 { + Some(prefixes) => { + ImportExportPolicy6::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy6::NoFiltering, + }; + let export_policy = match n.allow_export6 { + Some(prefixes) => { + ImportExportPolicy6::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy6::NoFiltering, + }; + Some(Ipv6UnicastConfig { + import_policy, + export_policy, + }) + } else { + None + }; + types::Neighbor { asn: n.asn, remote_asn: n.remote_asn, @@ -575,26 +638,8 @@ impl From for types::Neighbor { communities: n.communities, local_pref: n.local_pref, enforce_first_as: n.enforce_first_as, - allow_export: match n.allow_export { - Some(prefixes) => ImportExportPolicy::Allow( - prefixes - .clone() - .into_iter() - .map(|x| Prefix::V4(Prefix4::new(x.value, x.length))) - .collect(), - ), - None => ImportExportPolicy::NoFiltering, - }, - allow_import: match n.allow_import { - Some(prefixes) => ImportExportPolicy::Allow( - prefixes - .clone() - .into_iter() - .map(|x| Prefix::V4(Prefix4::new(x.value, x.length))) - .collect(), - ), - None => ImportExportPolicy::NoFiltering, - }, + ipv4_unicast, + ipv6_unicast, vlan_id: n.vlan_id, } } @@ -819,29 +864,29 @@ async fn get_exported(c: Client, asn: u32) -> Result<()> { } async fn list_nbr(asn: u32, c: Client) -> Result<()> { - let nbrs = c.read_neighbors(asn).await?; + let nbrs = c.read_neighbors_v2(asn).await?; println!("{nbrs:#?}"); Ok(()) } async fn create_nbr(nbr: Neighbor, c: Client) -> Result<()> { - c.create_neighbor(&nbr.into()).await?; + c.create_neighbor_v2(&nbr.into()).await?; Ok(()) } async fn read_nbr(asn: u32, addr: IpAddr, c: Client) -> Result<()> { - let nbr = c.read_neighbor(&addr, asn).await?.into_inner(); + let nbr = c.read_neighbor_v2(&addr, asn).await?.into_inner(); println!("{nbr:#?}"); Ok(()) } async fn update_nbr(nbr: Neighbor, c: Client) -> Result<()> { - c.update_neighbor(&nbr.into()).await?; + c.update_neighbor_v2(&nbr.into()).await?; Ok(()) } async fn delete_nbr(asn: u32, addr: IpAddr, c: Client) -> Result<()> { - c.delete_neighbor(&addr, asn).await?; + c.delete_neighbor_v2(&addr, asn).await?; Ok(()) } @@ -941,7 +986,7 @@ async fn read_origin6(asn: u32, c: Client) -> Result<()> { async fn apply(filename: String, c: Client) -> Result<()> { let contents = read_to_string(filename)?; let request: types::ApplyRequest = serde_json::from_str(&contents)?; - c.bgp_apply(&request).await?; + c.bgp_apply_v2(&request).await?; Ok(()) } diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 63a0cd0f..79dd1e75 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -145,13 +145,13 @@ impl MgAdminApi for MgAdminApiImpl { async fn read_neighbors( ctx: RequestContext, request: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { bgp_admin::read_neighbors(ctx, request).await } async fn create_neighbor( ctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { bgp_admin::create_neighbor(ctx, request).await } @@ -159,13 +159,13 @@ impl MgAdminApi for MgAdminApiImpl { async fn read_neighbor( ctx: RequestContext, request: Query, - ) -> Result, HttpError> { + ) -> Result, HttpError> { bgp_admin::read_neighbor(ctx, request).await } async fn update_neighbor( ctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { bgp_admin::update_neighbor(ctx, request).await } @@ -177,6 +177,41 @@ impl MgAdminApi for MgAdminApiImpl { bgp_admin::delete_neighbor(ctx, request).await } + async fn read_neighbors_v2( + ctx: RequestContext, + request: Query, + ) -> Result>, HttpError> { + bgp_admin::read_neighbors_v2(ctx, request).await + } + + async fn create_neighbor_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::create_neighbor_v2(ctx, request).await + } + + async fn read_neighbor_v2( + ctx: RequestContext, + request: Query, + ) -> Result, HttpError> { + bgp_admin::read_neighbor_v2(ctx, request).await + } + + async fn update_neighbor_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::update_neighbor_v2(ctx, request).await + } + + async fn delete_neighbor_v2( + ctx: RequestContext, + request: Query, + ) -> Result { + bgp_admin::delete_neighbor_v2(ctx, request).await + } + async fn clear_neighbor( ctx: RequestContext, request: TypedBody, @@ -291,11 +326,18 @@ impl MgAdminApi for MgAdminApiImpl { async fn bgp_apply( ctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { bgp_admin::bgp_apply(ctx, request).await } + async fn bgp_apply_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::bgp_apply_v2(ctx, request).await + } + async fn message_history( ctx: RequestContext, request: TypedBody, diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index f4b1fad9..c6acdcbb 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -29,7 +29,8 @@ use mg_api::{ MessageHistoryResponseV1, NeighborResetRequest, NeighborSelector, Rib, }; use mg_common::lock; -use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicyV1, Prefix}; +use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicy, Prefix}; +use rdb::{ImportExportPolicy4, ImportExportPolicy6}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}; @@ -167,7 +168,7 @@ pub async fn delete_router( pub async fn read_neighbors( ctx: RequestContext>, request: Query, -) -> Result>, HttpError> { +) -> Result>, HttpError> { let rq = request.into_inner(); let ctx = ctx.context(); @@ -179,7 +180,7 @@ pub async fn read_neighbors( let result = nbrs .into_iter() .filter(|x| x.asn == rq.asn) - .map(|x| Neighbor::from_rdb_neighbor_info(rq.asn, &x)) + .map(|x| NeighborV1::from_rdb_neighbor_info(rq.asn, &x)) .collect(); Ok(HttpResponseOk(result)) @@ -187,18 +188,18 @@ pub async fn read_neighbors( pub async fn create_neighbor( ctx: RequestContext>, - request: TypedBody, + request: TypedBody, ) -> Result { let rq = request.into_inner(); let ctx = ctx.context(); - helpers::add_neighbor(ctx.clone(), rq, false)?; + helpers::add_neighbor_v1(ctx.clone(), rq, false)?; Ok(HttpResponseUpdatedNoContent()) } pub async fn read_neighbor( ctx: RequestContext>, request: Query, -) -> Result, HttpError> { +) -> Result, HttpError> { let rq = request.into_inner(); let db_neighbors = ctx.context().db.get_bgp_neighbors().map_err(|e| { HttpError::for_internal_error(format!("get neighbors kv tree: {e}")) @@ -211,17 +212,17 @@ pub async fn read_neighbor( format!("neighbor {} not found in db", rq.addr), ))?; - let result = Neighbor::from_rdb_neighbor_info(rq.asn, neighbor_info); + let result = NeighborV1::from_rdb_neighbor_info(rq.asn, neighbor_info); Ok(HttpResponseOk(result)) } pub async fn update_neighbor( ctx: RequestContext>, - request: TypedBody, + request: TypedBody, ) -> Result { let rq = request.into_inner(); let ctx = ctx.context(); - helpers::add_neighbor(ctx.clone(), rq, true)?; + helpers::add_neighbor_v1(ctx.clone(), rq, true)?; Ok(HttpResponseUpdatedNoContent()) } @@ -243,6 +244,77 @@ pub async fn clear_neighbor( Ok(helpers::reset_neighbor(ctx.clone(), rq.asn, rq.addr, rq.op).await?) } +// V3 API handlers (new Neighbor type with optional per-AF configs) +pub async fn read_neighbors_v2( + ctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + let nbrs = ctx + .db + .get_bgp_neighbors() + .map_err(|e| HttpError::for_internal_error(e.to_string()))?; + + let result = nbrs + .into_iter() + .filter(|x| x.asn == rq.asn) + .map(|x| Neighbor::from_rdb_neighbor_info(rq.asn, &x)) + .collect(); + + Ok(HttpResponseOk(result)) +} + +pub async fn create_neighbor_v2( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = ctx.context(); + helpers::add_neighbor(ctx.clone(), rq, false)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn read_neighbor_v2( + ctx: RequestContext>, + request: Query, +) -> Result, HttpError> { + let rq = request.into_inner(); + let db_neighbors = ctx.context().db.get_bgp_neighbors().map_err(|e| { + HttpError::for_internal_error(format!("get neighbors kv tree: {e}")) + })?; + let neighbor_info = db_neighbors + .iter() + .find(|n| n.host.ip() == rq.addr) + .ok_or(HttpError::for_not_found( + None, + format!("neighbor {} not found in db", rq.addr), + ))?; + + let result = Neighbor::from_rdb_neighbor_info(rq.asn, neighbor_info); + Ok(HttpResponseOk(result)) +} + +pub async fn update_neighbor_v2( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = ctx.context(); + helpers::add_neighbor(ctx.clone(), rq, true)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn delete_neighbor_v2( + ctx: RequestContext>, + request: Query, +) -> Result { + let rq = request.into_inner(); + let ctx = ctx.context(); + Ok(helpers::remove_neighbor(ctx.clone(), rq.asn, rq.addr).await?) +} + pub async fn create_origin4( ctx: RequestContext>, request: TypedBody, @@ -417,13 +489,13 @@ pub async fn get_exported( .collect(); // Combine per-AF export policies into legacy format for filtering - let allow_export = ImportExportPolicyV1::from_per_af_policies( + let allow_export = ImportExportPolicy::from_per_af_policies( &n.allow_export4, &n.allow_export6, ); let mut exported_routes: Vec = match allow_export { - ImportExportPolicyV1::NoFiltering => orig_routes, - ImportExportPolicyV1::Allow(epol) => { + ImportExportPolicy::NoFiltering => orig_routes, + ImportExportPolicy::Allow(epol) => { orig_routes.retain(|p| epol.contains(p)); orig_routes } @@ -580,6 +652,14 @@ pub async fn get_neighbors_v2( } pub async fn bgp_apply( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + // Convert v1 request to current format (hardcodes IPv4-only) + do_bgp_apply(ctx.context(), ApplyRequest::from(request.into_inner())).await +} + +pub async fn bgp_apply_v2( ctx: RequestContext>, request: TypedBody, ) -> Result { @@ -1031,9 +1111,9 @@ pub(crate) mod helpers { Ok(HttpResponseDeleted()) } - pub(crate) fn add_neighbor( + pub(crate) fn add_neighbor_v1( ctx: Arc, - rq: Neighbor, + rq: NeighborV1, ensure: bool, ) -> Result<(), Error> { let log = &ctx.log; @@ -1043,10 +1123,9 @@ pub(crate) mod helpers { let (event_tx, event_rx) = channel(); + // V1 API is IPv4-only; extract only IPv4 policies let allow_import4 = rq.allow_import.as_ipv4_policy(); let allow_export4 = rq.allow_export.as_ipv4_policy(); - let allow_import6 = rq.allow_import.as_ipv6_policy(); - let allow_export6 = rq.allow_export.as_ipv6_policy(); // XXX: Do we really want both rq and info? // SessionInfo and Neighbor types could probably be merged. @@ -1059,11 +1138,12 @@ pub(crate) mod helpers { communities: rq.communities.clone().into_iter().collect(), local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - // Derive per-AF policies from legacy fields - allow_import4: allow_import4.clone(), - allow_export4: allow_export4.clone(), - allow_import6: allow_import6.clone(), - allow_export6: allow_export6.clone(), + // V1 API is IPv4-only; IPv6 support didn't exist in legacy API + ipv4_unicast: Some(Ipv4UnicastConfig { + import_policy: allow_import4.clone(), + export_policy: allow_export4.clone(), + }), + ipv6_unicast: None, vlan_id: rq.vlan_id, remote_id: None, bind_addr: None, @@ -1076,8 +1156,105 @@ pub(crate) mod helpers { idle_hold_jitter: Some((0.75, 1.0)), connect_retry_jitter: None, deterministic_collision_resolution: false, + }; + + let start_session = if ensure { + match get_router!(&ctx, rq.asn)?.ensure_session( + rq.clone().into(), + None, + event_tx.clone(), + event_rx, + info, + )? { + EnsureSessionResult::New(_) => true, + EnsureSessionResult::Updated(_) => false, + } + } else { + get_router!(&ctx, rq.asn)?.new_session( + rq.clone().into(), + None, + event_tx.clone(), + event_rx, + info, + )?; + true + }; + + ctx.db.add_bgp_neighbor(rdb::BgpNeighborInfo { + asn: rq.asn, + remote_asn: rq.remote_asn, + min_ttl: rq.min_ttl, + name: rq.name.clone(), + host: rq.host, + hold_time: rq.hold_time, + idle_hold_time: rq.idle_hold_time, + delay_open: rq.delay_open, + connect_retry: rq.connect_retry, + keepalive: rq.keepalive, + resolution: rq.resolution, + group: rq.group.clone(), + passive: rq.passive, + md5_auth_key: rq.md5_auth_key, + multi_exit_discriminator: rq.multi_exit_discriminator, + communities: rq.communities, + local_pref: rq.local_pref, + enforce_first_as: rq.enforce_first_as, + // V1 API is IPv4-only; IPv6 support didn't exist in legacy API ipv4_enabled: true, ipv6_enabled: false, + allow_import4, + allow_export4, + allow_import6: ImportExportPolicy6::NoFiltering, + allow_export6: ImportExportPolicy6::NoFiltering, + vlan_id: rq.vlan_id, + })?; + + if start_session { + start_bgp_session(&event_tx)?; + } + + Ok(()) + } + + pub(crate) fn add_neighbor( + ctx: Arc, + rq: Neighbor, + ensure: bool, + ) -> Result<(), Error> { + let log = &ctx.log; + bgp_log!(log, info, "add neighbor {}", rq.host.ip(); + "params" => format!("{rq:#?}") + ); + + // Validate that at least one AF is enabled + rq.validate().map_err(Error::Conflict)?; + + let (event_tx, event_rx) = channel(); + + // Build SessionInfo with optional per-AF config directly from the new Neighbor type + let info = SessionInfo { + passive_tcp_establishment: rq.passive, + remote_asn: rq.remote_asn, + min_ttl: rq.min_ttl, + md5_auth_key: rq.md5_auth_key.clone(), + multi_exit_discriminator: rq.multi_exit_discriminator, + communities: rq.communities.clone().into_iter().collect(), + local_pref: rq.local_pref, + enforce_first_as: rq.enforce_first_as, + ipv4_unicast: rq.ipv4_unicast.clone(), + ipv6_unicast: rq.ipv6_unicast.clone(), + vlan_id: rq.vlan_id, + remote_id: None, + bind_addr: None, + connect_retry_time: Duration::from_secs(rq.connect_retry), + keepalive_time: Duration::from_secs(rq.keepalive), + hold_time: Duration::from_secs(rq.hold_time), + idle_hold_time: Duration::from_secs(rq.idle_hold_time), + delay_open_time: Duration::from_secs(rq.delay_open), + resolution: Duration::from_millis(rq.resolution), + idle_hold_jitter: Some((0.75, 1.0)), + connect_retry_jitter: None, + deterministic_collision_resolution: false, }; let start_session = if ensure { @@ -1102,6 +1279,23 @@ pub(crate) mod helpers { true }; + // Extract per-AF policies for database storage + let (allow_import4, allow_export4) = match &rq.ipv4_unicast { + Some(cfg) => (cfg.import_policy.clone(), cfg.export_policy.clone()), + None => ( + ImportExportPolicy4::NoFiltering, + ImportExportPolicy4::NoFiltering, + ), + }; + + let (allow_import6, allow_export6) = match &rq.ipv6_unicast { + Some(cfg) => (cfg.import_policy.clone(), cfg.export_policy.clone()), + None => ( + ImportExportPolicy6::NoFiltering, + ImportExportPolicy6::NoFiltering, + ), + }; + ctx.db.add_bgp_neighbor(rdb::BgpNeighborInfo { asn: rq.asn, remote_asn: rq.remote_asn, @@ -1121,6 +1315,9 @@ pub(crate) mod helpers { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, + // Derive enablement from whether the AF config is present + ipv4_enabled: rq.ipv4_unicast.is_some(), + ipv6_enabled: rq.ipv6_unicast.is_some(), allow_import4, allow_export4, allow_import6, @@ -1343,9 +1540,9 @@ mod tests { use crate::{ admin::HandlerContext, bfd_admin::BfdContext, bgp_admin::BgpContext, }; - use bgp::params::{ApplyRequest, BgpPeerConfig}; + use bgp::params::{ApplyRequestV1, BgpPeerConfigV1}; use mg_common::stats::MgLowerStats; - use rdb::{Db, ImportExportPolicy4, ImportExportPolicy6}; + use rdb::Db; use std::{ collections::{BTreeMap, HashMap}, env::temp_dir, @@ -1384,7 +1581,7 @@ mod tests { let mut peers = HashMap::new(); peers.insert( String::from("qsfp0"), - vec![BgpPeerConfig { + vec![BgpPeerConfigV1 { host: SocketAddr::new("203.0.113.1".parse().unwrap(), 179), name: String::from("bob"), hold_time: 3, @@ -1401,16 +1598,14 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import4: ImportExportPolicy4::NoFiltering, - allow_export4: ImportExportPolicy4::NoFiltering, - allow_import6: ImportExportPolicy6::NoFiltering, - allow_export6: ImportExportPolicy6::NoFiltering, + allow_import: rdb::ImportExportPolicy::NoFiltering, + allow_export: rdb::ImportExportPolicy::NoFiltering, vlan_id: None, }], ); peers.insert( String::from("qsfp1"), - vec![BgpPeerConfig { + vec![BgpPeerConfigV1 { host: SocketAddr::new("203.0.113.2".parse().unwrap(), 179), name: String::from("alice"), hold_time: 3, @@ -1427,15 +1622,13 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import4: ImportExportPolicy4::NoFiltering, - allow_export4: ImportExportPolicy4::NoFiltering, - allow_import6: ImportExportPolicy6::NoFiltering, - allow_export6: ImportExportPolicy6::NoFiltering, + allow_import: rdb::ImportExportPolicy::NoFiltering, + allow_export: rdb::ImportExportPolicy::NoFiltering, vlan_id: None, }], ); - let mut req = ApplyRequest { + let mut req = ApplyRequestV1 { asn: 47, originate: Vec::default(), checker: None, @@ -1443,7 +1636,7 @@ mod tests { peers, }; - do_bgp_apply(&ctx, req.clone()) + do_bgp_apply(&ctx, req.clone().into()) .await .expect("bgp apply request"); @@ -1454,7 +1647,7 @@ mod tests { req.peers.remove("qsfp0"); - do_bgp_apply(&ctx, req.clone()) + do_bgp_apply(&ctx, req.clone().into()) .await .expect("bgp apply request"); diff --git a/mgd/src/main.rs b/mgd/src/main.rs index c788ceab..db05172b 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -294,9 +294,9 @@ fn start_bgp_routers( drop(guard); for nbr in neighbors { - bgp_admin::helpers::add_neighbor( + bgp_admin::helpers::add_neighbor_v1( context.clone(), - bgp::params::Neighbor { + bgp::params::NeighborV1 { asn: nbr.asn, remote_asn: nbr.remote_asn, min_ttl: nbr.min_ttl, @@ -316,11 +316,11 @@ fn start_bgp_routers( local_pref: nbr.local_pref, enforce_first_as: nbr.enforce_first_as, // Combine per-AF policies into legacy format for API compatibility - allow_import: rdb::ImportExportPolicyV1::from_per_af_policies( + allow_import: rdb::ImportExportPolicy::from_per_af_policies( &nbr.allow_import4, &nbr.allow_import6, ), - allow_export: rdb::ImportExportPolicyV1::from_per_af_policies( + allow_export: rdb::ImportExportPolicy::from_per_af_policies( &nbr.allow_export4, &nbr.allow_export6, ), diff --git a/openapi/mg-admin/mg-admin-4.0.0-016211.json b/openapi/mg-admin/mg-admin-4.0.0-016211.json new file mode 100644 index 00000000..bd953d0d --- /dev/null +++ b/openapi/mg-admin/mg-admin-4.0.0-016211.json @@ -0,0 +1,4331 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Maghemite Admin", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "4.0.0" + }, + "paths": { + "/bfd/peers": { + "get": { + "summary": "Get all the peers and their associated BFD state. Peers are identified by IP", + "description": "address.", + "operationId": "get_bfd_peers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_BfdPeerInfo", + "type": "array", + "items": { + "$ref": "#/components/schemas/BfdPeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Add a new peer to the daemon. A session for the specified peer will start", + "description": "immediately.", + "operationId": "add_bfd_peer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BfdPeerConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bfd/peers/{addr}": { + "delete": { + "summary": "Remove the specified peer from the daemon. The associated peer session will", + "description": "be stopped immediately.", + "operationId": "remove_bfd_peer", + "parameters": [ + { + "in": "path", + "name": "addr", + "description": "Address of the peer to remove.", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/clear/neighbor": { + "post": { + "operationId": "clear_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborResetRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/checker": { + "get": { + "operationId": "read_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbor": { + "get": { + "operationId": "read_neighbor_v2", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_neighbor_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_neighbor_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_neighbor_v2", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbors": { + "get": { + "operationId": "read_neighbors_v2", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Neighbor", + "type": "array", + "items": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin4": { + "get": { + "operationId": "read_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin6": { + "get": { + "operationId": "read_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/router": { + "get": { + "operationId": "read_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/routers": { + "get": { + "operationId": "read_routers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Router", + "type": "array", + "items": { + "$ref": "#/components/schemas/Router" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/shaper": { + "get": { + "operationId": "read_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/fsm": { + "get": { + "operationId": "fsm_history", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/message": { + "get": { + "operationId": "message_history_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/omicron/apply": { + "post": { + "operationId": "bgp_apply_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/exported": { + "get": { + "operationId": "get_exported", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsnSelector" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Array_of_Prefix", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + } + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/neighbors": { + "get": { + "operationId": "get_neighbors_v2", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_PeerInfo", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/config/bestpath/fanout": { + "get": { + "operationId": "read_rib_bestpath_fanout", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_rib_bestpath_fanout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/imported": { + "get": { + "operationId": "get_rib_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/selected": { + "get": { + "operationId": "get_rib_selected", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route4": { + "get": { + "operationId": "static_list_v4_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route6": { + "get": { + "operationId": "static_list_v6_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/switch/identifiers": { + "get": { + "operationId": "switch_identifiers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchIdentifiers" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AddPathElement": { + "description": "The add path element comes as a BGP capability extension as described in RFC 7911.", + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier. ", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier. There are a large pile of these ", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "send_receive": { + "description": "This field indicates whether the sender is (a) able to receive multiple paths from its peer (value 1), (b) able to send multiple paths to its peer (value 2), or (c) both (value 3) for the .", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi", + "send_receive" + ] + }, + "AddStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "AddStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Aggregator": { + "description": "AGGREGATOR path attribute (RFC 4271 §5.1.8)\n\nThe AGGREGATOR attribute is an optional transitive attribute that contains the AS number and IP address of the last BGP speaker that formed the aggregate route.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (2-octet)", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "ApplyRequest": { + "description": "Apply changes to an ASN (current version with per-AF policies).", + "type": "object", + "properties": { + "asn": { + "description": "ASN to apply changes to.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "checker": { + "nullable": true, + "description": "Checker rhai code to apply to ingress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/CheckerSource" + } + ] + }, + "originate": { + "description": "Complete set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "peers": { + "description": "Lists of peers indexed by peer group.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerConfig" + } + } + }, + "shaper": { + "nullable": true, + "description": "Checker rhai code to apply to egress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/ShaperSource" + } + ] + } + }, + "required": [ + "asn", + "originate", + "peers" + ] + }, + "As4Aggregator": { + "description": "AS4_AGGREGATOR path attribute (RFC 6793)\n\nThe AS4_AGGREGATOR attribute is an optional transitive attribute with the same semantics as AGGREGATOR, but carries a 4-octet AS number instead of 2-octet.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (4-octet)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "As4PathSegment": { + "type": "object", + "properties": { + "typ": { + "$ref": "#/components/schemas/AsPathType" + }, + "value": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + }, + "required": [ + "typ", + "value" + ] + }, + "AsPathType": { + "description": "Enumeration describes possible AS path types", + "oneOf": [ + { + "description": "The path is to be interpreted as a set", + "type": "string", + "enum": [ + "as_set" + ] + }, + { + "description": "The path is to be interpreted as a sequence", + "type": "string", + "enum": [ + "as_sequence" + ] + } + ] + }, + "AsnSelector": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to get imported prefixes from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + }, + "BestpathFanoutRequest": { + "type": "object", + "properties": { + "fanout": { + "description": "Maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BestpathFanoutResponse": { + "type": "object", + "properties": { + "fanout": { + "description": "Current maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BfdPeerConfig": { + "type": "object", + "properties": { + "detection_threshold": { + "description": "Detection threshold for connectivity as a multipler to required_rx", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "listen": { + "description": "Address to listen on for control messages from the peer.", + "type": "string", + "format": "ip" + }, + "mode": { + "description": "Mode is single-hop (RFC 5881) or multi-hop (RFC 5883).", + "allOf": [ + { + "$ref": "#/components/schemas/SessionMode" + } + ] + }, + "peer": { + "description": "Address of the peer to add.", + "type": "string", + "format": "ip" + }, + "required_rx": { + "description": "Acceptable time between control messages in microseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "detection_threshold", + "listen", + "mode", + "peer", + "required_rx" + ] + }, + "BfdPeerInfo": { + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/BfdPeerConfig" + }, + "state": { + "$ref": "#/components/schemas/BfdPeerState" + } + }, + "required": [ + "config", + "state" + ] + }, + "BfdPeerState": { + "description": "The possible peer states. See the `State` trait implementations `Down`, `Init`, and `Up` for detailed semantics. Data representation is u8 as this enum is used as a part of the BFD wire protocol.", + "oneOf": [ + { + "description": "A stable down state. Non-responsive to incoming messages.", + "type": "string", + "enum": [ + "AdminDown" + ] + }, + { + "description": "The initial state.", + "type": "string", + "enum": [ + "Down" + ] + }, + { + "description": "The peer has detected a remote peer in the down state.", + "type": "string", + "enum": [ + "Init" + ] + }, + { + "description": "The peer has detected a remote peer in the up or init state while in the init state.", + "type": "string", + "enum": [ + "Up" + ] + } + ] + }, + "BgpNexthop": { + "description": "BGP next-hops can come in multiple forms, defined in several different RFCs. This enum represents the forms supported by this implementation.\n\nIn the case of IPv6, RFC 2545 defined the use of either: 1) A single non-link-local next-hop (length=16) 2) A non-link-local plus a link-local next-hop (length=32)\n\nThis does not account for only a link-local address as the sole next-hop. As such, many different implementations decided they would encode this in a variety of ways (since there was no canonical source of truth): a) Single-address encoding just the link-local (length=16) b) Double-address encoding the link-local in both positions (length=32) c) Double-address encoding the link-local in its normal position, but 0's in the non-link-local position (length=32) etc. This led to `draft-ietf-idr-linklocal-capability` which specifies more detailed encoding and error handling standards, signaled via a new Link-Local Next Hop Capability.\n\nIn addition to this, RFC 8950 (formerly RFC 5549) specified the advertisement of IPv4 NLRI via an IPv6 next-hop, enabled via the Extended Next Hop capability. This excerpt contains the encoding logic from RFC 8950: ```text Specifically, this document allows advertising the MP_REACH_NLRI attribute [RFC4760] with this content:\n\n* AFI = 1\n\n* SAFI = 1, 2, or 4\n\n* Length of Next Hop Address = 16 or 32\n\n* Next Hop Address = IPv6 address of a next hop (potentially followed by the link-local IPv6 address of the next hop). This field is to be constructed as per Section 3 of [RFC2545].\n\n* NLRI = NLRI as per the AFI/SAFI definition\n\n[..]\n\nThis is in addition to the existing mode of operation allowing advertisement of NLRI for of <1/1>, <1/2>, and <1/4> with a next-hop address of an IPv4 type and advertisement of NLRI for an of <1/128> and <1/129> with a next-hop address of a VPN- IPv4 type.\n\nThe BGP speaker receiving the advertisement MUST use the Length of Next Hop Address field to determine which network-layer protocol the next-hop address belongs to.\n\n* When the AFI/SAFI is <1/1>, <1/2>, or <1/4> and when the Length of Next Hop Address field is equal to 16 or 32, the next-hop address is of type IPv6.\n\n* When the AFI/SAFI is <1/128> or <1/129> and when the Length of Next Hop Address field is equal to 24 or 48, the next-hop address is of type VPN-IPv6. ```\n\nRFC 8950 also goes on to state that Extended Next Hop is not specified for any AFI/SAFI other than IPv4 {Unicast, Multicast, Labeled Unicast, Unicast VPN, Multicast VPN}, because IPv4 next-hops can already be signaled within IPv6 or VPN-IPv6 encoding (via IPv4-mapped IPv6 addresses).\n\nSo for our purposes, IPv4 Unicast NLRI may have Next-hops in the form of: a) IPv4 nexthop b) IPv6 single GUA (w/ Extended Next-Hop) c) IPv6 single LL (w/ Extended Next-Hop + Link-Local Next Hop) d) IPv6 double (w/ Extended Next-Hop)\n\nand IPv6 Unicast NLRI may have Next-hops in the form of: a) IPv6 single (IPv4-mapped) b) IPv6 single GUA c) IPv6 single LL (w/ Link-Local Next Hop) d) IPv6 double", + "oneOf": [ + { + "type": "object", + "properties": { + "Ipv4": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "Ipv4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Single": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "Ipv6Single" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Double": { + "$ref": "#/components/schemas/Ipv6DoubleNexthop" + } + }, + "required": [ + "Ipv6Double" + ], + "additionalProperties": false + } + ] + }, + "BgpPathProperties": { + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "med": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "origin_as": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "peer": { + "type": "string", + "format": "ip" + }, + "stale": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "as_path", + "id", + "origin_as", + "peer" + ] + }, + "BgpPeerConfig": { + "description": "BGP peer configuration (current version with per-address-family policies).", + "type": "object", + "properties": { + "allow_export4": { + "description": "Per-address-family export policy for IPv4 routes (only used if ipv4_enabled).", + "default": "NoFiltering", + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy4" + } + ] + }, + "allow_export6": { + "description": "Per-address-family export policy for IPv6 routes (only used if ipv6_enabled).", + "default": "NoFiltering", + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy6" + } + ] + }, + "allow_import4": { + "description": "Per-address-family import policy for IPv4 routes (only used if ipv4_enabled).", + "default": "NoFiltering", + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy4" + } + ] + }, + "allow_import6": { + "description": "Per-address-family import policy for IPv6 routes (only used if ipv6_enabled).", + "default": "NoFiltering", + "allOf": [ + { + "$ref": "#/components/schemas/ImportExportPolicy6" + } + ] + }, + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_enabled": { + "description": "Whether IPv4 unicast is enabled for this peer.", + "type": "boolean" + }, + "ipv6_enabled": { + "description": "Whether IPv6 unicast is enabled for this peer.", + "type": "boolean" + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "communities", + "connect_retry", + "delay_open", + "enforce_first_as", + "hold_time", + "host", + "idle_hold_time", + "ipv4_enabled", + "ipv6_enabled", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "Capability": { + "description": "Optional capabilities supported by a BGP implementation.", + "oneOf": [ + { + "description": "Multiprotocol extensions as defined in RFC 2858", + "type": "object", + "properties": { + "multiprotocol_extensions": { + "type": "object", + "properties": { + "afi": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + } + }, + "required": [ + "multiprotocol_extensions" + ], + "additionalProperties": false + }, + { + "description": "Route refresh capability as defined in RFC 2918.", + "type": "object", + "properties": { + "route_refresh": { + "type": "object" + } + }, + "required": [ + "route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Outbound filtering capability as defined in RFC 5291. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Multiple routes to destination capability as defined in RFC 8277 (deprecated). Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_routes_to_destination": { + "type": "object" + } + }, + "required": [ + "multiple_routes_to_destination" + ], + "additionalProperties": false + }, + { + "description": "Multiple nexthop encoding capability as defined in RFC 8950. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "extended_next_hop_encoding": { + "type": "object" + } + }, + "required": [ + "extended_next_hop_encoding" + ], + "additionalProperties": false + }, + { + "description": "Extended message capability as defined in RFC 8654. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "b_g_p_extended_message": { + "type": "object" + } + }, + "required": [ + "b_g_p_extended_message" + ], + "additionalProperties": false + }, + { + "description": "BGPSec as defined in RFC 8205. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_sec": { + "type": "object" + } + }, + "required": [ + "bgp_sec" + ], + "additionalProperties": false + }, + { + "description": "Multiple label support as defined in RFC 8277. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_labels": { + "type": "object" + } + }, + "required": [ + "multiple_labels" + ], + "additionalProperties": false + }, + { + "description": "BGP role capability as defined in RFC 9234. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_role": { + "type": "object" + } + }, + "required": [ + "bgp_role" + ], + "additionalProperties": false + }, + { + "description": "Graceful restart as defined in RFC 4724. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "graceful_restart": { + "type": "object" + } + }, + "required": [ + "graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Four octet AS numbers as defined in RFC 6793.", + "type": "object", + "properties": { + "four_octet_as": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + } + }, + "required": [ + "four_octet_as" + ], + "additionalProperties": false + }, + { + "description": "Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "dynamic_capability": { + "type": "object" + } + }, + "required": [ + "dynamic_capability" + ], + "additionalProperties": false + }, + { + "description": "Multi session support as defined in draft-ietf-idr-bgp-multisession. Note this capability is not yet supported.", + "type": "object", + "properties": { + "multisession_bgp": { + "type": "object" + } + }, + "required": [ + "multisession_bgp" + ], + "additionalProperties": false + }, + { + "description": "Add path capability as defined in RFC 7911.", + "type": "object", + "properties": { + "add_path": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddPathElement" + }, + "uniqueItems": true + } + }, + "required": [ + "elements" + ] + } + }, + "required": [ + "add_path" + ], + "additionalProperties": false + }, + { + "description": "Enhanced route refresh as defined in RFC 7313. Note this capability is not yet supported.", + "type": "object", + "properties": { + "enhanced_route_refresh": { + "type": "object" + } + }, + "required": [ + "enhanced_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Long-lived graceful restart as defined in draft-uttaro-idr-bgp-persistence. Note this capability is not yet supported.", + "type": "object", + "properties": { + "long_lived_graceful_restart": { + "type": "object" + } + }, + "required": [ + "long_lived_graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note this capability is not yet supported.", + "type": "object", + "properties": { + "routing_policy_distribution": { + "type": "object" + } + }, + "required": [ + "routing_policy_distribution" + ], + "additionalProperties": false + }, + { + "description": "Fully qualified domain names as defined intdraft-walton-bgp-hostname-capability. Note this capability is not yet supported.", + "type": "object", + "properties": { + "fqdn": { + "type": "object" + } + }, + "required": [ + "fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard route refresh as defined in RFC 8810 (deprecated). Note this capability is not yet supported.", + "type": "object", + "properties": { + "prestandard_route_refresh": { + "type": "object" + } + }, + "required": [ + "prestandard_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard prefix-based outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_orf_and_pd": { + "type": "object" + } + }, + "required": [ + "prestandard_orf_and_pd" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "prestandard_outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard multisession as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_multisession": { + "type": "object" + } + }, + "required": [ + "prestandard_multisession" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard fully qualified domain names as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_fqdn": { + "type": "object" + } + }, + "required": [ + "prestandard_fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard operational messages as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_operational_message": { + "type": "object" + } + }, + "required": [ + "prestandard_operational_message" + ], + "additionalProperties": false + }, + { + "description": "Experimental capability as defined in RFC 8810.", + "type": "object", + "properties": { + "experimental": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "experimental" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "unassigned": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "unassigned" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reserved": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "reserved" + ], + "additionalProperties": false + } + ] + }, + "CeaseErrorSubcode": { + "description": "Cease error subcode types from RFC 4486", + "type": "string", + "enum": [ + "unspecific", + "maximum_numberof_prefixes_reached", + "administrative_shutdown", + "peer_deconfigured", + "administrative_reset", + "connection_rejected", + "other_configuration_change", + "connection_collision_resolution", + "out_of_resources" + ] + }, + "CheckerSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "Community": { + "description": "BGP community value", + "oneOf": [ + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised outside a BGP confederation boundary (a stand-alone autonomous system that is not part of a confederation should be considered a confederation itself)", + "type": "string", + "enum": [ + "no_export" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to other BGP peers.", + "type": "string", + "enum": [ + "no_advertise" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to external BGP peers (this includes peers in other members autonomous systems inside a BGP confederation).", + "type": "string", + "enum": [ + "no_export_sub_confed" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value must set the local preference for the received routes to a low value, preferably zero.", + "type": "string", + "enum": [ + "graceful_shutdown" + ] + }, + { + "description": "A user defined community", + "type": "object", + "properties": { + "user_defined": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "user_defined" + ], + "additionalProperties": false + } + ] + }, + "ConnectionId": { + "description": "Unique identifier for a BGP connection instance", + "type": "object", + "properties": { + "local": { + "description": "Local socket address for this connection", + "type": "string" + }, + "remote": { + "description": "Remote socket address for this connection", + "type": "string" + }, + "uuid": { + "description": "Unique identifier for this connection instance", + "type": "string", + "format": "uuid" + } + }, + "required": [ + "local", + "remote", + "uuid" + ] + }, + "DeleteStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "DeleteStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, + "DynamicTimerInfo": { + "type": "object", + "properties": { + "configured": { + "$ref": "#/components/schemas/Duration" + }, + "negotiated": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "negotiated" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "ErrorCode": { + "description": "This enumeration contains possible notification error codes.", + "type": "string", + "enum": [ + "header", + "open", + "update", + "hold_timer_expired", + "fsm", + "cease" + ] + }, + "ErrorSubcode": { + "description": "This enumeration contains possible notification error subcodes.", + "oneOf": [ + { + "type": "object", + "properties": { + "header": { + "$ref": "#/components/schemas/HeaderErrorSubcode" + } + }, + "required": [ + "header" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "open": { + "$ref": "#/components/schemas/OpenErrorSubcode" + } + }, + "required": [ + "open" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "update": { + "$ref": "#/components/schemas/UpdateErrorSubcode" + } + }, + "required": [ + "update" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hold_time": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "hold_time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "fsm": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "fsm" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "cease": { + "$ref": "#/components/schemas/CeaseErrorSubcode" + } + }, + "required": [ + "cease" + ], + "additionalProperties": false + } + ] + }, + "FsmEventBuffer": { + "oneOf": [ + { + "description": "All FSM events (high frequency, includes all timers)", + "type": "string", + "enum": [ + "all" + ] + }, + { + "description": "Major events only (state transitions, admin, new connections)", + "type": "string", + "enum": [ + "major" + ] + } + ] + }, + "FsmEventCategory": { + "description": "Category of FSM event for filtering and display purposes", + "type": "string", + "enum": [ + "Admin", + "Connection", + "Session", + "StateTransition" + ] + }, + "FsmEventRecord": { + "description": "Serializable record of an FSM event with full context", + "type": "object", + "properties": { + "connection_id": { + "nullable": true, + "description": "Connection ID if event is connection-specific", + "allOf": [ + { + "$ref": "#/components/schemas/ConnectionId" + } + ] + }, + "current_state": { + "description": "FSM state at time of event", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "details": { + "nullable": true, + "description": "Additional event details (e.g., \"Received OPEN\", \"Admin command\")", + "type": "string" + }, + "event_category": { + "description": "High-level event category", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventCategory" + } + ] + }, + "event_type": { + "description": "Specific event type as string (e.g., \"ManualStart\", \"HoldTimerExpires\")", + "type": "string" + }, + "previous_state": { + "nullable": true, + "description": "Previous state if this caused a transition", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "timestamp": { + "description": "UTC timestamp when event occurred", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "current_state", + "event_category", + "event_type", + "timestamp" + ] + }, + "FsmHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "buffer": { + "nullable": true, + "description": "Which buffer to retrieve - if None, returns major buffer", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventBuffer" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "FsmHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "description": "Events organized by peer address Each peer's value contains only the events from the requested buffer", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsmEventRecord" + } + } + } + }, + "required": [ + "by_peer" + ] + }, + "FsmStateKind": { + "description": "Simplified representation of a BGP state without having to carry a connection.", + "oneOf": [ + { + "description": "Initial state. Refuse all incomming BGP connections. No resources allocated to peer.", + "type": "string", + "enum": [ + "Idle" + ] + }, + { + "description": "Waiting for the TCP connection to be completed.", + "type": "string", + "enum": [ + "Connect" + ] + }, + { + "description": "Trying to acquire peer by listening for and accepting a TCP connection.", + "type": "string", + "enum": [ + "Active" + ] + }, + { + "description": "Waiting for open message from peer.", + "type": "string", + "enum": [ + "OpenSent" + ] + }, + { + "description": "Waiting for keepalive or notification from peer.", + "type": "string", + "enum": [ + "OpenConfirm" + ] + }, + { + "description": "Handler for Connection Collisions (RFC 4271 6.8)", + "type": "string", + "enum": [ + "ConnectionCollision" + ] + }, + { + "description": "Sync up with peers.", + "type": "string", + "enum": [ + "SessionSetup" + ] + }, + { + "description": "Able to exchange update, notification and keepliave messages with peers.", + "type": "string", + "enum": [ + "Established" + ] + } + ] + }, + "HeaderErrorSubcode": { + "description": "Header error subcode types", + "type": "string", + "enum": [ + "unspecific", + "connection_not_synchronized", + "bad_message_length", + "bad_message_type" + ] + }, + "ImportExportPolicy4": { + "description": "Import/Export policy for IPv4 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "ImportExportPolicy6": { + "description": "Import/Export policy for IPv6 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "Ipv4UnicastConfig": { + "description": "Per-address-family configuration for IPv4 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "Ipv6DoubleNexthop": { + "description": "IPv6 double nexthop: global unicast address + link-local address. Per RFC 2545, when advertising IPv6 routes, both addresses may be present.", + "type": "object", + "properties": { + "global": { + "description": "Global unicast address", + "type": "string", + "format": "ipv6" + }, + "link_local": { + "description": "Link-local address", + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "global", + "link_local" + ] + }, + "Ipv6UnicastConfig": { + "description": "Per-address-family configuration for IPv6 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "Message": { + "description": "Holds a BGP message. May be an Open, Update, Notification or Keep Alive message.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "open" + ] + }, + "value": { + "$ref": "#/components/schemas/OpenMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "value": { + "$ref": "#/components/schemas/UpdateMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "notification" + ] + }, + "value": { + "$ref": "#/components/schemas/NotificationMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "keep_alive" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "route_refresh" + ] + }, + "value": { + "$ref": "#/components/schemas/RouteRefreshMessage" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "MessageDirection": { + "type": "string", + "enum": [ + "sent", + "received" + ] + }, + "MessageHistory": { + "description": "Message history for a BGP session", + "type": "object", + "properties": { + "received": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + }, + "sent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + } + }, + "required": [ + "received", + "sent" + ] + }, + "MessageHistoryEntry": { + "description": "A message history entry is a BGP message with an associated timestamp and connection ID", + "type": "object", + "properties": { + "connection_id": { + "$ref": "#/components/schemas/ConnectionId" + }, + "message": { + "$ref": "#/components/schemas/Message" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "connection_id", + "message", + "timestamp" + ] + }, + "MessageHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "direction": { + "nullable": true, + "description": "Optional direction filter - if None, returns both sent and received", + "allOf": [ + { + "$ref": "#/components/schemas/MessageDirection" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "MessageHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MessageHistory" + } + } + }, + "required": [ + "by_peer" + ] + }, + "MpReachNlri": { + "description": "MP_REACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being announced.\n\n```text 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14):\n\nThis is an optional non-transitive attribute that can be used for the following purposes:\n\n(a) to advertise a feasible route to a peer\n\n(b) to permit a router to advertise the Network Layer address of the router that should be used as the next hop to the destinations listed in the Network Layer Reachability Information field of the MP_NLRI attribute.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv4 routes.\n\nCurrently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops when extended next-hop capability (RFC 8950) is implemented.", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv4 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + }, + { + "description": "IPv6 Unicast routes (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv6 routes.\n\nCan be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` (32 bytes with link-local address).", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv6 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + } + ] + }, + "MpUnreachNlri": { + "description": "MP_UNREACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being withdrawn.\n\n```text 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15):\n\nThis is an optional non-transitive attribute that can be used for the purpose of withdrawing multiple unfeasible routes from service.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ | Subsequent Address Family Identifier (1 octet) | +---------------------------------------------------------+ | Withdrawn Routes (variable) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes being withdrawn (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + }, + { + "description": "IPv6 Unicast routes being withdrawn (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + } + ] + }, + "Neighbor": { + "description": "Neighbor configuration with explicit per-address-family enablement (v3 API)", + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "asn", + "communities", + "connect_retry", + "delay_open", + "enforce_first_as", + "group", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "NeighborResetOp": { + "type": "string", + "enum": [ + "Hard", + "SoftInbound", + "SoftOutbound" + ] + }, + "NeighborResetRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "format": "ip" + }, + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "op": { + "$ref": "#/components/schemas/NeighborResetOp" + } + }, + "required": [ + "addr", + "asn", + "op" + ] + }, + "NotificationMessage": { + "description": "Notification messages are exchanged between BGP peers when an exceptional event has occurred.", + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "error_code": { + "description": "Error code associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorCode" + } + ] + }, + "error_subcode": { + "description": "Error subcode associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorSubcode" + } + ] + } + }, + "required": [ + "data", + "error_code", + "error_subcode" + ] + }, + "OpenErrorSubcode": { + "description": "Open message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "unsupported_version_number", + "bad_peer_a_s", + "bad_bgp_identifier", + "unsupported_optional_parameter", + "deprecated", + "unacceptable_hold_time", + "unsupported_capability" + ] + }, + "OpenMessage": { + "description": "The first message sent by each side once a TCP connection is established.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Version | My Autonomous System | Hold Time : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | BGP Identifier : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | Opt Parm Len | Optional Parameters : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Optional Parameters (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.2", + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number of the sender. When 4-byte ASNs are in use this value is set to AS_TRANS which has a value of 23456.\n\nRef: RFC 4893 §7", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "hold_time": { + "description": "Number of seconds the sender proposes for the hold timer.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "id": { + "description": "BGP identifier of the sender", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "parameters": { + "description": "A list of optional parameters.", + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionalParameter" + } + }, + "version": { + "description": "BGP protocol version.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "asn", + "hold_time", + "id", + "parameters", + "version" + ] + }, + "OptionalParameter": { + "description": "The IANA/IETF currently defines the following optional parameter types.", + "oneOf": [ + { + "description": "Code 0", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reserved" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 1: RFC 4217, RFC 5492 (deprecated)", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "authentication" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 2: RFC 5492", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "capabilities" + ] + }, + "value": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + }, + "uniqueItems": true + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Unassigned", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unassigned" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 255: RFC 9072", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "extended_length" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "Origin4": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Origin6": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Path": { + "type": "object", + "properties": { + "bgp": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/BgpPathProperties" + } + ] + }, + "nexthop": { + "type": "string", + "format": "ip" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "shutdown": { + "type": "boolean" + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "rib_priority", + "shutdown" + ] + }, + "PathAttribute": { + "description": "A self-describing BGP path attribute", + "type": "object", + "properties": { + "typ": { + "description": "Type encoding for the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeType" + } + ] + }, + "value": { + "description": "Value of the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeValue" + } + ] + } + }, + "required": [ + "typ", + "value" + ] + }, + "PathAttributeType": { + "description": "Type encoding for a path attribute.", + "type": "object", + "properties": { + "flags": { + "description": "Flags may include, Optional, Transitive, Partial and Extended Length.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "type_code": { + "description": "Type code for the path attribute.", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeTypeCode" + } + ] + } + }, + "required": [ + "flags", + "type_code" + ] + }, + "PathAttributeTypeCode": { + "description": "An enumeration describing available path attribute type codes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "as_path", + "next_hop", + "multi_exit_disc", + "local_pref", + "atomic_aggregate", + "aggregator", + "communities", + "mp_unreach_nlri", + "as4_aggregator" + ] + }, + { + "description": "RFC 4271", + "type": "string", + "enum": [ + "origin" + ] + }, + { + "description": "RFC 4760", + "type": "string", + "enum": [ + "mp_reach_nlri" + ] + }, + { + "description": "RFC 6793", + "type": "string", + "enum": [ + "as4_path" + ] + } + ] + }, + "PathAttributeValue": { + "description": "The value encoding of a path attribute.", + "oneOf": [ + { + "description": "The type of origin associated with a path", + "type": "object", + "properties": { + "origin": { + "$ref": "#/components/schemas/PathOrigin" + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + }, + { + "description": "The AS set associated with a path", + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as_path" + ], + "additionalProperties": false + }, + { + "description": "The nexthop associated with a path (IPv4 only for traditional BGP)", + "type": "object", + "properties": { + "next_hop": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "next_hop" + ], + "additionalProperties": false + }, + { + "description": "A metric used for external (inter-AS) links to discriminate among multiple entry or exit points.", + "type": "object", + "properties": { + "multi_exit_disc": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "multi_exit_disc" + ], + "additionalProperties": false + }, + { + "description": "Local pref is included in update messages sent to internal peers and indicates a degree of preference.", + "type": "object", + "properties": { + "local_pref": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "local_pref" + ], + "additionalProperties": false + }, + { + "description": "AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (2-octet ASN)", + "type": "object", + "properties": { + "aggregator": { + "$ref": "#/components/schemas/Aggregator" + } + }, + "required": [ + "aggregator" + ], + "additionalProperties": false + }, + { + "description": "Indicates communities associated with a path.", + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Community" + } + } + }, + "required": [ + "communities" + ], + "additionalProperties": false + }, + { + "description": "Indicates this route was formed via aggregation (RFC 4271 §5.1.7)", + "type": "string", + "enum": [ + "atomic_aggregate" + ] + }, + { + "description": "The 4-byte encoded AS set associated with a path", + "type": "object", + "properties": { + "as4_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as4_path" + ], + "additionalProperties": false + }, + { + "description": "AS4_AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (4-octet ASN)", + "type": "object", + "properties": { + "as4_aggregator": { + "$ref": "#/components/schemas/As4Aggregator" + } + }, + "required": [ + "as4_aggregator" + ], + "additionalProperties": false + }, + { + "description": "Carries reachable MP-BGP NLRI and Next-hop (advertisement).", + "type": "object", + "properties": { + "mp_reach_nlri": { + "$ref": "#/components/schemas/MpReachNlri" + } + }, + "required": [ + "mp_reach_nlri" + ], + "additionalProperties": false + }, + { + "description": "Carries unreachable MP-BGP NLRI (withdrawal).", + "type": "object", + "properties": { + "mp_unreach_nlri": { + "$ref": "#/components/schemas/MpUnreachNlri" + } + }, + "required": [ + "mp_unreach_nlri" + ], + "additionalProperties": false + } + ] + }, + "PathOrigin": { + "description": "An enumeration indicating the origin type of a path.", + "oneOf": [ + { + "description": "Interior gateway protocol", + "type": "string", + "enum": [ + "igp" + ] + }, + { + "description": "Exterior gateway protocol", + "type": "string", + "enum": [ + "egp" + ] + }, + { + "description": "Incomplete path origin", + "type": "string", + "enum": [ + "incomplete" + ] + } + ] + }, + "PeerInfo": { + "type": "object", + "properties": { + "asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "duration_millis": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "timers": { + "$ref": "#/components/schemas/PeerTimers" + } + }, + "required": [ + "duration_millis", + "state", + "timers" + ] + }, + "PeerTimers": { + "type": "object", + "properties": { + "hold": { + "$ref": "#/components/schemas/DynamicTimerInfo" + }, + "keepalive": { + "$ref": "#/components/schemas/DynamicTimerInfo" + } + }, + "required": [ + "hold", + "keepalive" + ] + }, + "Prefix": { + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "Prefix4": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "length", + "value" + ] + }, + "Prefix6": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "length", + "value" + ] + }, + "Rib": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + }, + "RouteRefreshMessage": { + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + }, + "Router": { + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "graceful_shutdown": { + "description": "Gracefully shut this router down.", + "type": "boolean" + }, + "id": { + "description": "Id for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "listen": { + "description": "Listening address :", + "type": "string" + } + }, + "required": [ + "asn", + "graceful_shutdown", + "id", + "listen" + ] + }, + "SessionMode": { + "type": "string", + "enum": [ + "SingleHop", + "MultiHop" + ] + }, + "ShaperSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "StaticRoute4": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv4" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix4" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute4List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute4" + } + } + }, + "required": [ + "list" + ] + }, + "StaticRoute6": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv6" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix6" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute6List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute6" + } + } + }, + "required": [ + "list" + ] + }, + "SwitchIdentifiers": { + "description": "Identifiers for a switch.", + "type": "object", + "properties": { + "slot": { + "nullable": true, + "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + } + }, + "UpdateErrorSubcode": { + "description": "Update message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "malformed_attribute_list", + "unrecognized_well_known_attribute", + "missing_well_known_attribute", + "attribute_flags", + "attribute_length", + "invalid_origin_attribute", + "deprecated", + "invalid_nexthop_attribute", + "optional_attribute", + "invalid_network_field", + "malformed_as_path" + ] + }, + "UpdateMessage": { + "description": "An update message is used to advertise feasible routes that share common path attributes to a peer, or to withdraw multiple unfeasible routes from service.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Witdrawn Length | Withdrawn Routes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Withdrawn Routes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Path Attribute Length | Path Attributes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Path Attributes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Network Layer Reachability Information (variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.3", + "type": "object", + "properties": { + "nlri": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "path_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathAttribute" + } + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "nlri", + "path_attributes", + "withdrawn" + ] + }, + "AddressFamily": { + "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n``` use rdb_types::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", + "oneOf": [ + { + "description": "Internet Protocol Version 4 (IPv4)", + "type": "string", + "enum": [ + "Ipv4" + ] + }, + { + "description": "Internet Protocol Version 6 (IPv6)", + "type": "string", + "enum": [ + "Ipv6" + ] + } + ] + }, + "ProtocolFilter": { + "oneOf": [ + { + "description": "BGP routes only", + "type": "string", + "enum": [ + "Bgp" + ] + }, + { + "description": "Static routes only", + "type": "string", + "enum": [ + "Static" + ] + } + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 64bfad20..8ebcbd08 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-3.0.0-08e2f2.json \ No newline at end of file +mg-admin-4.0.0-016211.json \ No newline at end of file diff --git a/rdb/src/db.rs b/rdb/src/db.rs index ac68c792..9ba4fc7c 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -1163,7 +1163,7 @@ impl Db { .copied() .collect(); let peer_routes6: Vec<_> = self - .full_rib(Some(AddressFamily::Ipv4)) + .full_rib(Some(AddressFamily::Ipv6)) .keys() .copied() .collect(); diff --git a/rdb/src/types.rs b/rdb/src/types.rs index ec4e91ae..5048c189 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -355,27 +355,27 @@ pub struct BgpRouterInfo { } // ============================================================================ -// API Compatibility Type (ImportExportPolicyV1) +// API Compatibility Type (ImportExportPolicy) // ============================================================================ -// This type maintains backward compatibility with the existing API encoding. +// This type maintains backward compatibility with the existing v1/v2 API. // It uses the mixed Prefix type (V4/V6) and is used at the API boundary. -// Internally, code should use ImportExportPolicy (V4/V6 enum) for type safety. +// Internally, code should use ImportExportPolicy4/6 for type safety. -/// Legacy import/export policy type for API compatibility. +/// Legacy import/export policy type for v1/v2 API compatibility. /// /// This type uses mixed IPv4/IPv6 prefixes and is used at the API boundary. -/// For internal use, convert to `ImportExportPolicy` variants using +/// For internal use, convert to typed variants using /// `as_ipv4_policy()` and `as_ipv6_policy()`. #[derive( Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, )] -pub enum ImportExportPolicyV1 { +pub enum ImportExportPolicy { #[default] NoFiltering, Allow(BTreeSet), } -impl ImportExportPolicyV1 { +impl ImportExportPolicy { /// Extract IPv4 prefixes from this policy as a typed IPv4 policy. /// /// If this policy is `NoFiltering`, returns `ImportExportPolicy4::NoFiltering`. @@ -383,10 +383,8 @@ impl ImportExportPolicyV1 { /// If the policy has prefixes but none are IPv4, returns `NoFiltering` for IPv4. pub fn as_ipv4_policy(&self) -> ImportExportPolicy4 { match self { - ImportExportPolicyV1::NoFiltering => { - ImportExportPolicy4::NoFiltering - } - ImportExportPolicyV1::Allow(prefixes) => { + ImportExportPolicy::NoFiltering => ImportExportPolicy4::NoFiltering, + ImportExportPolicy::Allow(prefixes) => { let v4_prefixes: BTreeSet = prefixes .iter() .filter_map(|p| match p { @@ -411,10 +409,8 @@ impl ImportExportPolicyV1 { /// If the policy has prefixes but none are IPv6, returns `NoFiltering` for IPv6. pub fn as_ipv6_policy(&self) -> ImportExportPolicy6 { match self { - ImportExportPolicyV1::NoFiltering => { - ImportExportPolicy6::NoFiltering - } - ImportExportPolicyV1::Allow(prefixes) => { + ImportExportPolicy::NoFiltering => ImportExportPolicy6::NoFiltering, + ImportExportPolicy::Allow(prefixes) => { let v6_prefixes: BTreeSet = prefixes .iter() .filter_map(|p| match p { @@ -444,14 +440,14 @@ impl ImportExportPolicyV1 { ( ImportExportPolicy4::NoFiltering, ImportExportPolicy6::NoFiltering, - ) => ImportExportPolicyV1::NoFiltering, + ) => ImportExportPolicy::NoFiltering, ( ImportExportPolicy4::Allow(v4_prefixes), ImportExportPolicy6::NoFiltering, ) => { let prefixes: BTreeSet = v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); - ImportExportPolicyV1::Allow(prefixes) + ImportExportPolicy::Allow(prefixes) } ( ImportExportPolicy4::NoFiltering, @@ -459,7 +455,7 @@ impl ImportExportPolicyV1 { ) => { let prefixes: BTreeSet = v6_prefixes.iter().map(|p| Prefix::V6(*p)).collect(); - ImportExportPolicyV1::Allow(prefixes) + ImportExportPolicy::Allow(prefixes) } ( ImportExportPolicy4::Allow(v4_prefixes), @@ -468,7 +464,7 @@ impl ImportExportPolicyV1 { let mut prefixes: BTreeSet = v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); prefixes.extend(v6_prefixes.iter().map(|p| Prefix::V6(*p))); - ImportExportPolicyV1::Allow(prefixes) + ImportExportPolicy::Allow(prefixes) } } } @@ -494,9 +490,10 @@ pub enum ImportExportPolicy6 { Allow(BTreeSet), } -/// Address-family-specific import/export policy. +/// Address-family-specific import/export policy wrapper for internal use. +/// This is distinct from the API-facing `ImportExportPolicy` type. #[derive(Debug, Clone, Eq, PartialEq)] -pub enum ImportExportPolicy { +pub enum TypedImportExportPolicy { V4(ImportExportPolicy4), V6(ImportExportPolicy6), } @@ -522,6 +519,14 @@ pub struct BgpNeighborInfo { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, + /// Whether IPv4 unicast is enabled for this neighbor. + /// Defaults to true for backward compatibility with legacy data. + #[serde(default = "default_ipv4_enabled")] + pub ipv4_enabled: bool, + /// Whether IPv6 unicast is enabled for this neighbor. + /// Defaults to false for backward compatibility with legacy data. + #[serde(default)] + pub ipv6_enabled: bool, /// Per-address-family import policy for IPv4 routes. #[serde(default)] pub allow_import4: ImportExportPolicy4, @@ -537,6 +542,11 @@ pub struct BgpNeighborInfo { pub vlan_id: Option, } +/// Default value for ipv4_enabled - true for backward compatibility +fn default_ipv4_enabled() -> bool { + true +} + #[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema)] pub struct BfdPeerConfig { /// Address of the peer to add. From 490a0d0f7d0c75f3c30a4d910ae5e57cc20d3233 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 31 Dec 2025 14:51:21 -0700 Subject: [PATCH 08/20] Revert "bgp: remove update filtering of originated routes" This reverts commit 7260db34432d07a28f20fcf252aa17096baadb8d. --- bgp/src/session.rs | 61 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/bgp/src/session.rs b/bgp/src/session.rs index c950e933..971a3b6f 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -8142,6 +8142,20 @@ impl SessionRunner { /// Update this router's RIB based on an update message from a peer. fn update_rib(&self, update: &UpdateMessage, pc: &PeerConnection) { + let originated4 = match self.db.get_origin4() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv4 routes from db: {e}"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + let nexthop = match update.nexthop() { Ok(nh) => match nh { BgpNexthop::Ipv4(ip4) => IpAddr::V4(ip4), @@ -8168,7 +8182,7 @@ impl SessionRunner { let withdrawn: Vec = update .withdrawn .iter() - .filter(|p| p.valid_for_rib()) + .filter(|p| !originated4.contains(p) && p.valid_for_rib()) .copied() .map(Prefix::V4) .collect(); @@ -8180,7 +8194,8 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - p.valid_for_rib() + !originated4.contains(p) + && p.valid_for_rib() && !self.prefix_via_self(Prefix::V4(**p), nexthop) }) .copied() @@ -8229,7 +8244,8 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - p.valid_for_rib() + !originated4.contains(p) + && p.valid_for_rib() && !self.prefix_via_self( Prefix::V4(**p), mp_nexthop, @@ -8266,6 +8282,20 @@ impl SessionRunner { } } MpReachNlri::Ipv6Unicast(reach6) => { + let originated6 = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv6 routes from db: {e}"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + let nexthop6 = match &reach6.nexthop { BgpNexthop::Ipv6Single(ip6) => IpAddr::V6(*ip6), BgpNexthop::Ipv6Double(addrs) => { @@ -8289,7 +8319,8 @@ impl SessionRunner { .nlri .iter() .filter(|p| { - p.valid_for_rib() + !originated6.contains(p) + && p.valid_for_rib() && !self .prefix_via_self(Prefix::V6(**p), nexthop6) }) @@ -8333,7 +8364,9 @@ impl SessionRunner { let mp_withdrawn4: Vec = unreach4 .withdrawn .iter() - .filter(|p| p.valid_for_rib()) + .filter(|p| { + !originated4.contains(p) && p.valid_for_rib() + }) .copied() .map(Prefix::V4) .collect(); @@ -8344,10 +8377,26 @@ impl SessionRunner { ); } MpUnreachNlri::Ipv6Unicast(unreach6) => { + let originated6 = match self.db.get_origin6() { + Ok(value) => value, + Err(e) => { + session_log!( + self, + error, + pc.conn, + "failed to get originated ipv6 routes for withdrawal: {e}"; + "error" => format!("{e}") + ); + Vec::new() + } + }; + let withdrawn6: Vec = unreach6 .withdrawn .iter() - .filter(|p| p.valid_for_rib()) + .filter(|p| { + !originated6.contains(p) && p.valid_for_rib() + }) .copied() .map(Prefix::V6) .collect(); From 890affba81d13fccaa2653844444241728b04544 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 31 Dec 2025 17:14:23 -0700 Subject: [PATCH 09/20] Introduce next-hop override configuration Adds an optional nexthop parameter to the address-family config for BGP peers. This allows an operator to define the nexthop used in route advertisements on a per AFI/SAFI basis, rather than unconditionally deriving the next-hop from the connection (SIP of the TCP session). The current implementation enforces that the NLRI address-family and the next-hop address-family are homogenous. However, this is intended to be changed when Extended Next-Hop support is added. Adds Unit tests for the next-hop derivation function. Adds Integration test cases: - IPv4 NLRI (only) over IPv6 connection (explicit IPv4 next-hop) - IPv6 NLRI (only) over IPv4 connection (explicit IPv6 next-hop) - Dual Stack NLRI over IPv4 connection (explicit IPv6 next-hop) - Dual Stack NLRI over IPv6 connection (explicit IPv4 next-hop) Refactors test helpers to allow caller to specify what NLRI should be exchanged between peers in the test (v4 only, v6 only, or Dual Stack). Updates Neighbor create/update API and bumps mgadm to use it. --- Cargo.lock | 1 + Cargo.toml | 1 + bgp/src/clock.rs | 23 +- bgp/src/messages.rs | 2 +- bgp/src/params.rs | 167 +- bgp/src/session.rs | 241 +- bgp/src/test.rs | 809 ++-- mg-admin-client/Cargo.toml | 1 + mg-admin-client/src/lib.rs | 1 + mgadm/src/bgp.rs | 49 +- mgd/src/bgp_admin.rs | 50 +- mgd/src/error.rs | 6 + openapi/mg-admin/mg-admin-3.0.0-61ffa9.json | 4376 +++++++++++++++++++ openapi/mg-admin/mg-admin-4.0.0-016211.json | 133 +- rdb/src/proptest.rs | 199 +- rdb/src/types.rs | 8 + 16 files changed, 5601 insertions(+), 466 deletions(-) create mode 100644 openapi/mg-admin/mg-admin-3.0.0-61ffa9.json diff --git a/Cargo.lock b/Cargo.lock index a6310aa6..a8acac45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3311,6 +3311,7 @@ dependencies = [ name = "mg-admin-client" version = "0.1.0" dependencies = [ + "bgp", "chrono", "colored", "progenitor 0.11.2", diff --git a/Cargo.toml b/Cargo.toml index 61679d87..e2164bae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ rand = "0.8.5" backoff = "0.4" mg-api = { path = "mg-api" } mg-common = { path = "mg-common" } +bgp = { path = "bgp" } rdb-types = { path = "rdb-types" } chrono = { version = "0.4.42", features = ["serde"] } oxide-tokio-rt = "0.1.2" diff --git a/bgp/src/clock.rs b/bgp/src/clock.rs index 4d11be0e..a1e867e9 100644 --- a/bgp/src/clock.rs +++ b/bgp/src/clock.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::connection::{BgpConnection, ConnectionId}; +use crate::params::JitterRange; use crate::session::{ConnectionEvent, FsmEvent, SessionEvent}; use mg_common::lock; use mg_common::thread::ManagedThread; @@ -68,9 +69,8 @@ pub struct Timer { pub interval: Duration, /// Optional jitter range applied on restart. None = no jitter. - /// Some((min, max)) applies a random factor in [min, max] to the interval. - /// RFC 4271 recommends (0.75, 1.0) for ConnectRetryTimer and related timers. - jitter_range: Option<(f64, f64)>, + /// RFC 4271 recommends min: 0.75, max: 1.0 for ConnectRetryTimer and related timers. + jitter_range: Option, /// Timer state. The first value indicates if the timer is enabled. The /// second value indicates how much time is left. @@ -91,12 +91,11 @@ impl Timer { } /// Create a new timer with the specified interval and jitter range. - /// The jitter_range parameter expects (min, max) where both values are - /// factors to multiply the interval by. RFC 4271 recommends (0.75, 1.0) for - /// ConnectRetryTimer and related timers. + /// The jitter_range parameter specifies factors to multiply the interval by. + /// RFC 4271 recommends min: 0.75, max: 1.0 for ConnectRetryTimer and related timers. pub fn new_with_jitter( interval: Duration, - jitter_range: (f64, f64), + jitter_range: JitterRange, ) -> Self { Self { interval, @@ -151,10 +150,10 @@ impl Timer { /// The jitter is recalculated on every reset. pub fn reset(&self) { let interval = match self.jitter_range { - Some((min, max)) => { + Some(jitter) => { use rand::Rng; let mut rng = rand::thread_rng(); - let factor = rng.gen_range(min..=max); + let factor = rng.gen_range(jitter.min..=jitter.max); self.interval.mul_f64(factor) } None => self.interval, @@ -176,7 +175,7 @@ impl Timer { /// Update the jitter range for this timer. The new jitter will be applied /// on the next restart() call. - pub fn set_jitter_range(&mut self, jitter_range: Option<(f64, f64)>) { + pub fn set_jitter_range(&mut self, jitter_range: Option) { self.jitter_range = jitter_range; } } @@ -210,8 +209,8 @@ impl SessionClock { resolution: Duration, connect_retry_interval: Duration, idle_hold_interval: Duration, - connect_retry_jitter: Option<(f64, f64)>, - idle_hold_jitter: Option<(f64, f64)>, + connect_retry_jitter: Option, + idle_hold_jitter: Option, event_tx: Sender>, log: Logger, ) -> Self { diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index 152a53d6..a1e0f208 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -1550,7 +1550,7 @@ impl Display for UpdateMessage { write!( f, - "Update[ treat-as-withdraw: ({}) path_attributes: ({p_str}) withdrawn({}) nlri({}) ]", + "Update[ treat-as-withdraw: ({}) withdrawn({}) path_attributes: ({p_str}) nlri({}) ]", self.treat_as_withdraw, if !w_str.is_empty() { &w_str } else { "empty" }, if !n_str.is_empty() { &n_str } else { "empty" } diff --git a/bgp/src/params.rs b/bgp/src/params.rs index 3040fe54..c79b58db 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -39,9 +39,42 @@ pub enum NeighborResetOp { SoftOutbound, } +/// Jitter range with minimum and maximum multiplier values. +/// When applied to a timer, the timer duration is multiplied by a random value +/// within [min, max] to help break synchronization patterns. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)] +pub struct JitterRange { + /// Minimum jitter multiplier (typically 0.75 or similar) + pub min: f64, + /// Maximum jitter multiplier (typically 1.0 or similar) + pub max: f64, +} + +impl std::str::FromStr for JitterRange { + type Err = String; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 2 { + return Err( + "jitter range must be in format 'min,max' (e.g., '0.75,1.0')" + .to_string(), + ); + } + let min = parts[0].trim().parse::().map_err(|_| { + format!("min value '{}' is not a valid float", parts[0].trim()) + })?; + let max = parts[1].trim().parse::().map_err(|_| { + format!("max value '{}' is not a valid float", parts[1].trim()) + })?; + Ok(JitterRange { min, max }) + } +} + /// Per-address-family configuration for IPv4 Unicast #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] pub struct Ipv4UnicastConfig { + pub nexthop: Option, pub import_policy: ImportExportPolicy4, pub export_policy: ImportExportPolicy4, } @@ -49,6 +82,7 @@ pub struct Ipv4UnicastConfig { /// Per-address-family configuration for IPv6 Unicast #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] pub struct Ipv6UnicastConfig { + pub nexthop: Option, pub import_policy: ImportExportPolicy6, pub export_policy: ImportExportPolicy6, } @@ -79,16 +113,62 @@ pub struct Neighbor { /// IPv6 Unicast address family configuration (None = disabled) pub ipv6_unicast: Option, pub vlan_id: Option, + pub connect_retry_jitter: Option, + pub idle_hold_jitter: Option, + pub deterministic_collision_resolution: bool, } impl Neighbor { /// Validate that at least one address family is enabled - pub fn validate(&self) -> Result<(), String> { + pub fn validate_address_families(&self) -> Result<(), String> { if self.ipv4_unicast.is_none() && self.ipv6_unicast.is_none() { return Err("at least one address family must be enabled".into()); } Ok(()) } + + /// Validate nexthop address family matches configured address families. + /// Initially strict: IPv4 nexthop requires IPv4, IPv6 requires IPv6. + /// Can be relaxed in future for Extended Next-Hop (RFC 5549). + /// + /// Additionally validates cross-AF scenarios: + /// - IPv4 Unicast enabled for IPv6 peer requires configured IPv4 nexthop + /// - IPv6 Unicast enabled for IPv4 peer requires configured IPv6 nexthop + pub fn validate_nexthop(&self) -> Result<(), String> { + if let Some(cfg) = &self.ipv4_unicast { + if let Some(nh) = cfg.nexthop { + if !nh.is_ipv4() { + return Err(format!( + "IPv4 unicast nexthop must be IPv4 address, got {}", + nh + )); + } + } else if self.host.is_ipv6() { + return Err( + "IPv4 Unicast enabled for IPv6 peer requires configured IPv4 nexthop" + .into(), + ); + } + } + + if let Some(cfg) = &self.ipv6_unicast { + if let Some(nh) = cfg.nexthop { + if !nh.is_ipv6() { + return Err(format!( + "IPv6 unicast nexthop must be IPv6 address, got {}", + nh + )); + } + } else if !self.host.is_ipv6() { + return Err( + "IPv6 Unicast enabled for IPv4 peer requires configured IPv6 nexthop" + .into(), + ); + } + } + + Ok(()) + } } /// Legacy neighbor configuration (v1/v2 API compatibility) @@ -222,24 +302,6 @@ impl Neighbor { group: String, rq: BgpPeerConfig, ) -> Self { - let ipv4_unicast = if rq.ipv4_enabled { - Some(Ipv4UnicastConfig { - import_policy: rq.allow_import4, - export_policy: rq.allow_export4, - }) - } else { - None - }; - - let ipv6_unicast = if rq.ipv6_enabled { - Some(Ipv6UnicastConfig { - import_policy: rq.allow_import6, - export_policy: rq.allow_export6, - }) - } else { - None - }; - Self { asn, remote_asn: rq.remote_asn, @@ -259,9 +321,13 @@ impl Neighbor { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - ipv4_unicast, - ipv6_unicast, + ipv4_unicast: rq.ipv4_unicast, + ipv6_unicast: rq.ipv6_unicast, vlan_id: rq.vlan_id, + connect_retry_jitter: rq.connect_retry_jitter, + idle_hold_jitter: rq.idle_hold_jitter, + deterministic_collision_resolution: rq + .deterministic_collision_resolution, } } @@ -269,6 +335,7 @@ impl Neighbor { // Use explicit enablement flags from the database let ipv4_unicast = if rq.ipv4_enabled { Some(Ipv4UnicastConfig { + nexthop: rq.nexthop4, import_policy: rq.allow_import4.clone(), export_policy: rq.allow_export4.clone(), }) @@ -278,6 +345,7 @@ impl Neighbor { let ipv6_unicast = if rq.ipv6_enabled { Some(Ipv6UnicastConfig { + nexthop: rq.nexthop6, import_policy: rq.allow_import6.clone(), export_policy: rq.allow_export6.clone(), }) @@ -307,6 +375,12 @@ impl Neighbor { ipv4_unicast, ipv6_unicast, vlan_id: rq.vlan_id, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + idle_hold_jitter: None, + deterministic_collision_resolution: false, } } } @@ -490,23 +564,24 @@ pub struct BgpPeerConfig { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - /// Whether IPv4 unicast is enabled for this peer. - pub ipv4_enabled: bool, - /// Whether IPv6 unicast is enabled for this peer. - pub ipv6_enabled: bool, - /// Per-address-family import policy for IPv4 routes (only used if ipv4_enabled). - #[serde(default)] - pub allow_import4: ImportExportPolicy4, - /// Per-address-family export policy for IPv4 routes (only used if ipv4_enabled). - #[serde(default)] - pub allow_export4: ImportExportPolicy4, - /// Per-address-family import policy for IPv6 routes (only used if ipv6_enabled). - #[serde(default)] - pub allow_import6: ImportExportPolicy6, - /// Per-address-family export policy for IPv6 routes (only used if ipv6_enabled). - #[serde(default)] - pub allow_export6: ImportExportPolicy6, + /// IPv4 Unicast address family configuration (None = disabled) + pub ipv4_unicast: Option, + /// IPv6 Unicast address family configuration (None = disabled) + pub ipv6_unicast: Option, pub vlan_id: Option, + /// Jitter range for connect_retry timer. When used, the connect_retry timer + /// is multiplied by a random value within the (min, max) range supplied. + /// Useful to help break repeated synchronization of connection collisions. + pub connect_retry_jitter: Option, + /// Jitter range for idle hold timer. When used, the idle hold timer is + /// multiplied by a random value within the (min, max) range supplied. + /// Useful to help break repeated synchronization of connection collisions. + pub idle_hold_jitter: Option, + /// Enable deterministic collision resolution in Established state. + /// When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision + /// resolution even when one connection is already in Established state. + /// When false, Established connection always wins (timing-based resolution). + pub deterministic_collision_resolution: bool, } impl From for BgpPeerConfig { @@ -529,13 +604,19 @@ impl From for BgpPeerConfig { communities: cfg.communities, local_pref: cfg.local_pref, enforce_first_as: cfg.enforce_first_as, - ipv4_enabled: true, - ipv6_enabled: false, - allow_import4: cfg.allow_import.as_ipv4_policy(), - allow_export4: cfg.allow_export.as_ipv4_policy(), - allow_import6: ImportExportPolicy6::NoFiltering, - allow_export6: ImportExportPolicy6::NoFiltering, + ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, + import_policy: cfg.allow_import.as_ipv4_policy(), + export_policy: cfg.allow_export.as_ipv4_policy(), + }), + ipv6_unicast: None, vlan_id: cfg.vlan_id, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + idle_hold_jitter: None, + deterministic_collision_resolution: false, } } } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 971a3b6f..d6612c96 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -18,7 +18,7 @@ use crate::{ OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, UpdateMessage, }, - params::{Ipv4UnicastConfig, Ipv6UnicastConfig}, + params::{Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange}, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, router::Router, @@ -210,6 +210,50 @@ pub fn collision_resolution( } } +/// Pure function to select the next-hop to be used for NLRI of this AFI/SAFI +fn select_nexthop( + nlri_afi: Afi, + local_ip: IpAddr, + configured_nexthop: Option, +) -> Result { + // Canonicalize the local_ip to handle IPv4-mapped IPv6 addresses + let local_ip = local_ip.to_canonical(); + + // If next-hop is configured, use it (with validation) + if let Some(nexthop) = configured_nexthop { + return match (nlri_afi, nexthop) { + (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), + (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), + // XXX: Extended Next-Hop + (Afi::Ipv4, IpAddr::V6(_)) => Err(Error::InvalidAddress( + "IPv4 routes require IPv4 next-hop (configured mismatch)" + .into(), + )), + // XXX: Extended Next-Hop + (Afi::Ipv6, IpAddr::V4(_)) => Err(Error::InvalidAddress( + "IPv6 routes require IPv6 next-hop (configured mismatch)" + .into(), + )), + }; + } + + // Otherwise use the local IP of the TCP connection as the next-hop + match (nlri_afi, local_ip) { + (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), + (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), + (Afi::Ipv4, IpAddr::V6(_)) => { + Err(Error::InvalidAddress( + "IPv4 routes require IPv4 next-hop (Extended Next-Hop not negotiated)".into() + )) + } + (Afi::Ipv6, IpAddr::V4(_)) => { + Err(Error::InvalidAddress( + "IPv6 routes require IPv6 next-hop".into() + )) + } + } +} + /// The states a BGP finite state machine may be at any given time. Many /// of these states carry a connection by value. This is the same connection /// that moves from state to state as transitions are made. Transitions from @@ -807,12 +851,12 @@ pub struct SessionInfo { /// Timer resolution for clocks (how often timers tick) pub resolution: Duration, /// Jitter range for connect_retry timer - /// (None = disabled, Some((min, max)) = enabled) + /// (None = disabled) /// RFC 4271 recommends 0.75-1.0 range to prevent synchronized behavior - pub connect_retry_jitter: Option<(f64, f64)>, + pub connect_retry_jitter: Option, /// Jitter range for idle_hold timer - /// (None = disabled, Some((min, max)) = enabled) - pub idle_hold_jitter: Option<(f64, f64)>, + /// (None = disabled) + pub idle_hold_jitter: Option, /// Enable deterministic collision resolution in Established state. /// When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision /// resolution even when one connection is already in Established state. @@ -836,6 +880,7 @@ impl SessionInfo { local_pref: None, enforce_first_as: false, ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, import_policy: ImportExportPolicy4::default(), export_policy: ImportExportPolicy4::default(), }), @@ -847,9 +892,11 @@ impl SessionInfo { idle_hold_time: Duration::from_secs(peer_config.idle_hold_time), delay_open_time: Duration::from_secs(peer_config.delay_open), resolution: Duration::from_millis(peer_config.resolution), - idle_hold_jitter: Some((0.75, 1.0)), + idle_hold_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), connect_retry_jitter: None, - // XXX: plumb this through to the neighbor API endpoint deterministic_collision_resolution: false, } } @@ -7306,36 +7353,26 @@ impl SessionRunner { Ok(former.difference(¤t)) } - /// Derive peer-specific next-hop based on AFI and negotiated capabilities. + /// Derive the next-hop used in Updates carrying NLRI of this AFI/SAFI. + /// This is a wrapper of select_nexthop which collects all the relevant + /// state and config needed for the actual selection to occur. fn derive_nexthop( &self, - afi: Afi, + nlri_afi: Afi, pc: &PeerConnection, ) -> Result { - let local_ip = pc.conn.local().ip().to_canonical(); - - // Future: Check for Extended Next-Hop capability here - // if afi == Afi::Ipv4 && pc.caps.contains(&Capability::ExtendedNextHop) { - // if let IpAddr::V6(ipv6) = local_ip { - // return Ok(BgpNexthop::Ipv6Single(ipv6)); - // } - // } + let configured_nexthop = match nlri_afi { + Afi::Ipv4 => lock!(self.session) + .ipv4_unicast + .as_ref() + .and_then(|cfg| cfg.nexthop), + Afi::Ipv6 => lock!(self.session) + .ipv6_unicast + .as_ref() + .and_then(|cfg| cfg.nexthop), + }; - // Standard behavior: use local IP as next-hop - match (afi, local_ip) { - (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), - (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), - (Afi::Ipv4, IpAddr::V6(_)) => { - Err(Error::InvalidAddress( - "IPv4 routes require IPv4 next-hop (Extended Next-Hop not negotiated)".into() - )) - } - (Afi::Ipv6, IpAddr::V4(_)) => { - Err(Error::InvalidAddress( - "IPv6 routes require IPv6 next-hop".into() - )) - } - } + select_nexthop(nlri_afi, pc.conn.local().ip(), configured_nexthop) } /// Add peer-specific path attributes to an UPDATE message. @@ -8794,6 +8831,8 @@ impl From for MessageHistoryV1 { #[cfg(test)] mod tests { use super::*; + use mg_common::*; + use std::net::{Ipv4Addr, Ipv6Addr}; #[test] fn test_resolve_collision_decision() { @@ -8970,7 +9009,7 @@ mod tests { fn route_update_is_announcement_and_withdrawal() { let v4_announce = RouteUpdate::V4(RouteUpdate4::Announce(vec![Prefix4::new( - std::net::Ipv4Addr::new(10, 0, 0, 0), + ip!("10.0.0.0"), 8, )])); assert!(v4_announce.is_announcement()); @@ -8978,7 +9017,7 @@ mod tests { let v4_withdraw = RouteUpdate::V4(RouteUpdate4::Withdraw(vec![Prefix4::new( - std::net::Ipv4Addr::new(10, 0, 0, 0), + ip!("10.0.0.0"), 8, )])); assert!(!v4_withdraw.is_announcement()); @@ -8986,9 +9025,7 @@ mod tests { let v6_announce = RouteUpdate::V6(RouteUpdate6::Announce(vec![Prefix6::new( - std::net::Ipv6Addr::from([ - 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]), + ip!("2001:db8::"), 32, )])); assert!(v6_announce.is_announcement()); @@ -8996,12 +9033,136 @@ mod tests { let v6_withdraw = RouteUpdate::V6(RouteUpdate6::Withdraw(vec![Prefix6::new( - std::net::Ipv6Addr::from([ - 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]), + ip!("2001:db8::"), 32, )])); assert!(!v6_withdraw.is_announcement()); assert!(v6_withdraw.is_withdrawal()); } + + #[test] + fn test_select_nexthop_ipv4_configured() { + // When IPv4 nexthop is configured, it should be used (takes precedence) + let configured_nh = ip!("10.0.0.1"); + let local_ip = ip!("10.0.0.2"); + + let result = select_nexthop(Afi::Ipv4, local_ip, Some(configured_nh)); + assert!(result.is_ok()); + match result.unwrap() { + BgpNexthop::Ipv4(addr) => { + let expected: Ipv4Addr = ip!("10.0.0.1"); + assert_eq!(addr, expected); + } + _ => panic!("Expected IPv4 nexthop"), + } + } + + #[test] + fn test_select_nexthop_ipv6_configured() { + // When IPv6 nexthop is configured, it should be used (takes precedence) + let configured_nh = ip!("2001:db8::1"); + let local_ip = ip!("2001:db8::2"); + + let result = select_nexthop(Afi::Ipv6, local_ip, Some(configured_nh)); + assert!(result.is_ok()); + match result.unwrap() { + BgpNexthop::Ipv6Single(addr) => { + let expected: Ipv6Addr = ip!("2001:db8::1"); + assert_eq!(addr, expected); + } + _ => panic!("Expected IPv6 nexthop"), + } + } + + #[test] + fn test_select_nexthop_ipv4_fallback_pure_ipv4() { + // No nexthop configured, pure IPv4 local_ip should be used for IPv4 routes + let local_ip = ip!("10.0.0.1"); + + let result = select_nexthop(Afi::Ipv4, local_ip, None); + assert!(result.is_ok()); + match result.unwrap() { + BgpNexthop::Ipv4(addr) => { + let expected: Ipv4Addr = ip!("10.0.0.1"); + assert_eq!(addr, expected); + } + _ => panic!("Expected IPv4 nexthop"), + } + } + + #[test] + fn test_select_nexthop_ipv4_fallback_mapped_ipv4() { + // No nexthop configured, IPv4-mapped IPv6 local_ip should be canonicalized + // internally by select_nexthop. This is the common case when listening on + // [::]:179 with v6_only=false. + let mapped = ip!("::ffff:10.0.0.1"); + + let result = select_nexthop(Afi::Ipv4, mapped, None); + assert!(result.is_ok()); + match result.unwrap() { + BgpNexthop::Ipv4(addr) => { + let expected: Ipv4Addr = ip!("10.0.0.1"); + assert_eq!(addr, expected); + } + _ => panic!("Expected IPv4 nexthop from canonicalized address"), + } + } + + #[test] + fn test_select_nexthop_ipv6_fallback_pure_ipv6() { + // No nexthop configured, pure IPv6 local_ip should be used for IPv6 routes + let local_ip = ip!("2001:db8::1"); + + let result = select_nexthop(Afi::Ipv6, local_ip, None); + assert!(result.is_ok()); + match result.unwrap() { + BgpNexthop::Ipv6Single(addr) => { + let expected: Ipv6Addr = ip!("2001:db8::1"); + assert_eq!(addr, expected); + } + _ => panic!("Expected IPv6 nexthop"), + } + } + + #[test] + fn test_select_nexthop_wrong_af_ipv4_route_ipv6_nexthop() { + // IPv4 route with IPv6 nexthop configured is a mismatch (error) + let nexthop = ip!("2001:db8::1"); + let local_ip = ip!("10.0.0.1"); + + let result = select_nexthop(Afi::Ipv4, local_ip, Some(nexthop)); + // Should error because IPv4 route needs IPv4 nexthop + assert!(result.is_err()); + } + + #[test] + fn test_select_nexthop_wrong_af_ipv6_route_ipv4_nexthop() { + // IPv6 route with IPv4 nexthop configured is a mismatch (error) + let nexthop = ip!("10.0.0.1"); + let local_ip = ip!("2001:db8::1"); + + let result = select_nexthop(Afi::Ipv6, local_ip, Some(nexthop)); + // Should error because IPv6 route needs IPv6 nexthop + assert!(result.is_err()); + } + + #[test] + fn test_select_nexthop_cross_af_ipv4_routes_ipv6_local_ip_error() { + // IPv4 route with pure IPv6 local_ip and no configured nexthop = error + let local_ip = ip!("2001:db8::1"); + + let result = select_nexthop(Afi::Ipv4, local_ip, None); + // Should error because cannot derive IPv4 nexthop from IPv6 connection + assert!(result.is_err()); + } + + #[test] + fn test_select_nexthop_cross_af_ipv6_routes_ipv4_local_ip_error() { + // IPv6 route with pure IPv4 local_ip and no configured nexthop = error + let local_ip = ip!("10.0.0.1"); + + let result = select_nexthop(Afi::Ipv6, local_ip, None); + // Should error because cannot derive IPv6 nexthop from IPv4 connection + assert!(result.is_err()); + } } diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 543844fd..9323fe7e 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -8,7 +8,7 @@ use crate::{ connection_channel::{BgpConnectionChannel, BgpListenerChannel}, connection_tcp::{BgpConnectionTcp, BgpListenerTcp}, dispatcher::Dispatcher, - params::Ipv6UnicastConfig, + params::{Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange}, router::Router, session::{ AdminEvent, ConnectionKind, FsmEvent, FsmStateKind, SessionEndpoint, @@ -19,11 +19,12 @@ use lazy_static::lazy_static; use mg_common::log::init_file_logger; use mg_common::test::{IpAllocation, LoopbackIpManager}; use mg_common::*; -use rdb::{Asn, ImportExportPolicy6, Prefix, Prefix4}; +use rdb::{Asn, ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4}; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, net::{IpAddr, SocketAddr}, sync::{Arc, Mutex, mpsc::channel}, + time::Duration, }; // Use non-standard port outside the privileged range to avoid needing privs @@ -97,18 +98,141 @@ impl TestRouter { } } +/// Test-specific enum describing which route address families are exchanged +/// in a BGP session. This is independent of the TCP/IP connection address. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RouteExchange { + Ipv4 { + nexthop: Option, + }, + Ipv6 { + nexthop: Option, + }, + DualStack { + ipv4_nexthop: Option, + ipv6_nexthop: Option, + }, +} + struct LogicalRouter { name: String, asn: Asn, id: u32, listen_addr: SocketAddr, bind_addr: Option, - neighbors: Vec, + neighbors: Vec, } -struct Neighbor { - peer_config: PeerConfig, - session_info: Option, +struct NeighborConfig { + peer_name: String, + remote_host: SocketAddr, + session_info: SessionInfo, +} + +/// Create SessionInfo for tests with fixed timer values and route exchange configuration. +/// This constructs SessionInfo directly without using PeerConfig. +/// +/// # Arguments +/// * `route_exchange` - Which route address families to exchange +/// * `local_addr` - Local bind address for this session +/// * `remote_addr` - Remote peer address (for nexthop defaults) +/// * `passive` - Whether to use passive TCP establishment +fn create_test_session_info( + route_exchange: RouteExchange, + local_addr: SocketAddr, + remote_addr: SocketAddr, + passive: bool, +) -> SessionInfo { + // Derive AF configuration from route_exchange + // Use remote_addr for nexthop defaults (what this router advertises) + let (ipv4_unicast, ipv6_unicast) = match route_exchange { + RouteExchange::Ipv4 { nexthop } => { + let ipv4_cfg = Ipv4UnicastConfig { + nexthop: nexthop.or_else(|| { + if remote_addr.is_ipv4() { + Some(remote_addr.ip()) + } else { + None + } + }), + import_policy: ImportExportPolicy4::default(), + export_policy: ImportExportPolicy4::default(), + }; + (Some(ipv4_cfg), None) + } + RouteExchange::Ipv6 { nexthop } => { + let ipv6_cfg = Ipv6UnicastConfig { + nexthop: nexthop.or_else(|| { + if remote_addr.is_ipv6() { + Some(remote_addr.ip()) + } else { + None + } + }), + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }; + (None, Some(ipv6_cfg)) + } + RouteExchange::DualStack { + ipv4_nexthop, + ipv6_nexthop, + } => { + let ipv4_cfg = Ipv4UnicastConfig { + nexthop: ipv4_nexthop.or_else(|| { + if remote_addr.is_ipv4() { + Some(remote_addr.ip()) + } else { + None + } + }), + import_policy: ImportExportPolicy4::default(), + export_policy: ImportExportPolicy4::default(), + }; + let ipv6_cfg = Ipv6UnicastConfig { + nexthop: ipv6_nexthop.or_else(|| { + if remote_addr.is_ipv6() { + Some(remote_addr.ip()) + } else { + None + } + }), + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }; + (Some(ipv4_cfg), Some(ipv6_cfg)) + } + }; + + // Construct SessionInfo directly with fixed test values + SessionInfo { + passive_tcp_establishment: passive, + remote_asn: None, + remote_id: None, + bind_addr: Some(local_addr), + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: BTreeSet::new(), + local_pref: None, + enforce_first_as: false, + ipv4_unicast, + ipv6_unicast, + vlan_id: None, + // Fixed test timer values + connect_retry_time: Duration::from_secs(1), + keepalive_time: Duration::from_secs(3), + hold_time: Duration::from_secs(6), + idle_hold_time: Duration::from_secs(0), + delay_open_time: Duration::from_secs(0), + resolution: Duration::from_millis(100), + connect_retry_jitter: None, + idle_hold_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + deterministic_collision_resolution: false, + } } fn test_setup( @@ -189,17 +313,25 @@ where for neighbor in &logical_router.neighbors { // Each session gets its own channel pair for FsmEvents let (event_tx, event_rx) = channel(); - let peer_config = neighbor.peer_config.clone(); + + // Create PeerConfig from neighbor's configuration for compatibility with new_session + let peer_config = PeerConfig { + name: neighbor.peer_name.clone(), + host: neighbor.remote_host, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; // Use bind_addr from LogicalRouter if specified, otherwise use listen_addr let bind_addr = logical_router .bind_addr .unwrap_or(logical_router.listen_addr); - let session_info = neighbor - .session_info - .clone() - .unwrap_or_else(|| SessionInfo::from_peer_config(&peer_config)); + let session_info = neighbor.session_info.clone(); let session_runner = router .new_session( @@ -257,6 +389,7 @@ fn basic_peering_helper< Listener: BgpListener + 'static, >( passive: bool, + route_exchange: RouteExchange, r1_addr: SocketAddr, r2_addr: SocketAddr, ) { @@ -273,48 +406,6 @@ fn basic_peering_helper< (false, false, false) => "basic_peering_active", }; - // Helper to create session_info with appropriate AF config based on address family - let create_session_info = |peer_config: &PeerConfig, passive: bool| { - let mut info = SessionInfo::from_peer_config(peer_config); - info.passive_tcp_establishment = passive; - match peer_config.host.ip() { - IpAddr::V4(_) => { - // IPv4: keep default (IPv4 enabled, IPv6 disabled) - } - IpAddr::V6(_) => { - // IPv6-only: disable IPv4, enable IPv6 - info.ipv4_unicast = None; - info.ipv6_unicast = Some(Ipv6UnicastConfig { - import_policy: ImportExportPolicy6::NoFiltering, - export_policy: ImportExportPolicy6::NoFiltering, - }); - } - } - info - }; - - let peer_config_r1 = PeerConfig { - name: "r2".into(), - host: r2_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }; - - let peer_config_r2 = PeerConfig { - name: "r1".into(), - host: r1_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }; - let routers = vec![ LogicalRouter { name: "r1".to_string(), @@ -322,12 +413,15 @@ fn basic_peering_helper< id: 1, listen_addr: r1_addr, bind_addr: Some(r1_addr), - neighbors: vec![Neighbor { - peer_config: peer_config_r1.clone(), - session_info: Some(create_session_info( - &peer_config_r1, + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: create_test_session_info( + route_exchange, + r1_addr, + r2_addr, passive, - )), + ), }], }, LogicalRouter { @@ -336,12 +430,15 @@ fn basic_peering_helper< id: 2, listen_addr: r2_addr, bind_addr: Some(r2_addr), - neighbors: vec![Neighbor { - peer_config: peer_config_r2.clone(), - session_info: Some(create_session_info( - &peer_config_r2, + neighbors: vec![NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: create_test_session_info( + route_exchange, + r2_addr, + r1_addr, !passive, - )), + ), }], }, ]; @@ -465,17 +562,18 @@ fn basic_peering_helper< // This test does the following: // 1. Sets up a basic pair of routers -// 2. Configures r1 to originate an IPv4 Unicast prefix +// 2. Configures r1 to originate prefix(es) based on route_exchange // 3. Brings up a BGP session between r1 and r2 // 4. Ensures the BGP FSM moves into Established on both r1 and r2 -// 5. Ensures r2 has succesfully received and installed the prefix +// 5. Ensures r2 has succesfully received and installed the prefix(es) // 6. Shuts down r1 // 7. Ensures the BGP FSM moves out of Established on both r1 and r2 -// 8. Ensures r2 has successfully uninstalled the implicitly withdrawn prefix +// 8. Ensures r2 has successfully uninstalled the implicitly withdrawn prefix(es) fn basic_update_helper< Cnx: BgpConnection + 'static, Listener: BgpListener + 'static, >( + route_exchange: RouteExchange, r1_addr: SocketAddr, r2_addr: SocketAddr, ) { @@ -488,47 +586,6 @@ fn basic_update_helper< (false, false) => "basic_update", }; - // Helper to create session_info with appropriate AF config based on address family - let create_session_info = |peer_config: &PeerConfig| { - let mut info = SessionInfo::from_peer_config(peer_config); - match peer_config.host.ip() { - IpAddr::V4(_) => { - // IPv4: keep default (IPv4 enabled, IPv6 disabled) - } - IpAddr::V6(_) => { - // IPv6-only: disable IPv4, enable IPv6 - info.ipv4_unicast = None; - info.ipv6_unicast = Some(Ipv6UnicastConfig { - import_policy: ImportExportPolicy6::NoFiltering, - export_policy: ImportExportPolicy6::NoFiltering, - }); - } - } - info - }; - - let peer_config_r1 = PeerConfig { - name: "r2".into(), - host: r2_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }; - - let peer_config_r2 = PeerConfig { - name: "r1".into(), - host: r1_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }; - let routers = vec![ LogicalRouter { name: "r1".to_string(), @@ -536,9 +593,15 @@ fn basic_update_helper< id: 1, listen_addr: r1_addr, bind_addr: Some(r1_addr), - neighbors: vec![Neighbor { - peer_config: peer_config_r1.clone(), - session_info: Some(create_session_info(&peer_config_r1)), + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: create_test_session_info( + route_exchange, + r1_addr, + r2_addr, + false, + ), }], }, LogicalRouter { @@ -547,9 +610,15 @@ fn basic_update_helper< id: 2, listen_addr: r2_addr, bind_addr: Some(r2_addr), - neighbors: vec![Neighbor { - peer_config: peer_config_r2.clone(), - session_info: Some(create_session_info(&peer_config_r2)), + neighbors: vec![NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: create_test_session_info( + route_exchange, + r2_addr, + r1_addr, + false, + ), }], }, ]; @@ -572,38 +641,203 @@ fn basic_update_helper< wait_for_eq!(r1_session.state(), FsmStateKind::Established); wait_for_eq!(r2_session.state(), FsmStateKind::Established); - // originate a prefix (IPv4 for IPv4 tests, IPv6 for IPv6 tests) - let prefix = if is_ipv6 { - r1.router - .create_origin6(vec![ip!("3fff:db8::/32")]) - .expect("originate"); - Prefix::V6(cidr!("3fff:db8::/32")) - } else { - r1.router - .create_origin4(vec![ip!("1.2.3.0/24")]) - .expect("originate"); - Prefix::V4(cidr!("1.2.3.0/24")) + // Originate and verify routes based on route_exchange variant + match route_exchange { + RouteExchange::Ipv4 { .. } => { + // IPv4-only: originate and verify IPv4 prefix + r1.router + .create_origin4(vec![cidr!("1.2.3.0/24")]) + .expect("originate IPv4"); + + let prefix_rdb = Prefix::V4(cidr!("1.2.3.0/24")); + wait_for!(!r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + + // Shut down r1 and verify withdrawal + r1.shutdown(); + wait_for_neq!(r1_session.state(), FsmStateKind::Established); + wait_for_neq!(r2_session.state(), FsmStateKind::Established); + wait_for!(r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + } + RouteExchange::Ipv6 { .. } => { + // IPv6-only: originate and verify IPv6 prefix + r1.router + .create_origin6(vec![cidr!("3fff:db8::/32")]) + .expect("originate IPv6"); + + let prefix_rdb = Prefix::V6(cidr!("3fff:db8::/32")); + wait_for!(!r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + + // Shut down r1 and verify withdrawal + r1.shutdown(); + wait_for_neq!(r1_session.state(), FsmStateKind::Established); + wait_for_neq!(r2_session.state(), FsmStateKind::Established); + wait_for!(r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + } + RouteExchange::DualStack { .. } => { + // Dual-stack: originate and verify both IPv4 and IPv6 prefixes + r1.router + .create_origin4(vec![cidr!("1.2.3.0/24")]) + .expect("originate IPv4"); + r1.router + .create_origin6(vec![cidr!("3fff:db8::/32")]) + .expect("originate IPv6"); + + let prefix4_rdb = Prefix::V4(cidr!("1.2.3.0/24")); + let prefix6_rdb = Prefix::V6(cidr!("3fff:db8::/32")); + + wait_for!(!r2.router.db.get_prefix_paths(&prefix4_rdb).is_empty()); + wait_for!(!r2.router.db.get_prefix_paths(&prefix6_rdb).is_empty()); + + // Shut down r1 and verify withdrawal of both + r1.shutdown(); + wait_for_neq!(r1_session.state(), FsmStateKind::Established); + wait_for_neq!(r2_session.state(), FsmStateKind::Established); + wait_for!(r2.router.db.get_prefix_paths(&prefix4_rdb).is_empty()); + wait_for!(r2.router.db.get_prefix_paths(&prefix6_rdb).is_empty()); + } + } + + // Clean up properly + r2.shutdown(); +} + +/// Helper for testing 3-router chain topology: r1 <-> r2 <-> r3 +/// This validates that the BgpListener can handle multiple connections. +fn three_router_chain_helper< + Cnx: BgpConnection + 'static, + Listener: BgpListener + 'static, +>( + r1_addr: SocketAddr, + r2_addr: SocketAddr, + r3_addr: SocketAddr, +) { + let is_tcp = std::any::type_name::().contains("Tcp"); + let is_ipv6 = r1_addr.ip().is_ipv6(); + let test_str = match (is_tcp, is_ipv6) { + (true, true) => "three_router_chain_tcp_ipv6", + (true, false) => "three_router_chain_tcp", + (false, true) => "three_router_chain_ipv6", + (false, false) => "three_router_chain", }; - wait_for!(!r2.router.db.get_prefix_paths(&prefix).is_empty()); + // Set up 3 routers in a chain topology: r1 <-> r2 <-> r3 + let routers = vec![ + LogicalRouter { + name: "r1".to_string(), + asn: Asn::FourOctet(4200000001), + id: 1, + listen_addr: r1_addr, + bind_addr: Some(r1_addr), + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: SessionInfo::from_peer_config(&PeerConfig { + name: "r2".into(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }), + }], + }, + LogicalRouter { + name: "r2".to_string(), + asn: Asn::FourOctet(4200000002), + id: 2, + listen_addr: r2_addr, + bind_addr: Some(r2_addr), + neighbors: vec![ + NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: SessionInfo::from_peer_config(&PeerConfig { + name: "r1".into(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }), + }, + NeighborConfig { + peer_name: "r3".to_string(), + remote_host: r3_addr, + session_info: SessionInfo::from_peer_config(&PeerConfig { + name: "r3".into(), + host: r3_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }), + }, + ], + }, + LogicalRouter { + name: "r3".to_string(), + asn: Asn::FourOctet(4200000003), + id: 3, + listen_addr: r3_addr, + bind_addr: Some(r3_addr), + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: SessionInfo::from_peer_config(&PeerConfig { + name: "r2".into(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }), + }], + }, + ]; + + let (test_routers, _ip_guard) = + test_setup::(test_str, &routers); - // shut down r1 and ensure that the prefixes are withdrawn from r2 on - // session timeout. - r1.shutdown(); - wait_for_neq!( - r1_session.state(), - FsmStateKind::Established, - "r1 state should NOT be established after being shutdown" - ); - wait_for_neq!( - r2_session.state(), - FsmStateKind::Established, - "r2 state should NOT be established after shutdown of r1" - ); - wait_for!(r2.router.db.get_prefix_paths(&prefix).is_empty()); + let r1 = &test_routers[0]; + let r2 = &test_routers[1]; + let r3 = &test_routers[2]; - // Clean up properly - r2.shutdown(); + // Get sessions from each router + let r1_r2_session = r1 + .router + .get_session(r2_addr.ip()) + .expect("get r1->r2 session"); + let r2_r1_session = r2 + .router + .get_session(r1_addr.ip()) + .expect("get r2->r1 session"); + let r2_r3_session = r2 + .router + .get_session(r3_addr.ip()) + .expect("get r2->r3 session"); + let r3_r2_session = r3 + .router + .get_session(r2_addr.ip()) + .expect("get r3->r2 session"); + + // Verify all sessions reach Established state + wait_for_eq!(r1_r2_session.state(), FsmStateKind::Established); + wait_for_eq!(r2_r1_session.state(), FsmStateKind::Established); + wait_for_eq!(r2_r3_session.state(), FsmStateKind::Established); + wait_for_eq!(r3_r2_session.state(), FsmStateKind::Established); + + // Clean up + for router in test_routers.iter() { + router.shutdown(); + } } // Channels vs TCP: @@ -642,6 +876,7 @@ fn basic_update_helper< #[test] fn test_basic_update() { basic_update_helper::( + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("10.0.0.1:{TEST_BGP_PORT}")), sockaddr!(&format!("10.0.0.2:{TEST_BGP_PORT}")), ) @@ -651,6 +886,7 @@ fn test_basic_update() { fn test_basic_peering_passive() { basic_peering_helper::( true, + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("11.0.0.1:{TEST_BGP_PORT}")), sockaddr!(&format!("11.0.0.2:{TEST_BGP_PORT}")), ) @@ -660,6 +896,7 @@ fn test_basic_peering_passive() { fn test_basic_peering_active() { basic_peering_helper::( false, + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("12.0.0.1:{TEST_BGP_PORT}")), sockaddr!(&format!("12.0.0.2:{TEST_BGP_PORT}")), ) @@ -672,6 +909,7 @@ fn test_basic_peering_active() { fn test_basic_peering_passive_tcp() { basic_peering_helper::( true, + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("127.0.0.1:{TEST_BGP_PORT}")), sockaddr!(&format!("127.0.0.2:{TEST_BGP_PORT}")), ) @@ -681,6 +919,7 @@ fn test_basic_peering_passive_tcp() { fn test_basic_peering_active_tcp() { basic_peering_helper::( false, + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("127.0.0.3:{TEST_BGP_PORT}")), sockaddr!(&format!("127.0.0.4:{TEST_BGP_PORT}")), ) @@ -689,6 +928,7 @@ fn test_basic_peering_active_tcp() { #[test] fn test_basic_update_tcp() { basic_update_helper::( + RouteExchange::Ipv4 { nexthop: None }, sockaddr!(&format!("127.0.0.5:{TEST_BGP_PORT}")), sockaddr!(&format!("127.0.0.6:{TEST_BGP_PORT}")), ) @@ -696,129 +936,28 @@ fn test_basic_update_tcp() { #[test] fn test_three_router_chain_tcp() { - let r1_addr = "127.0.0.7"; - let r2_addr = "127.0.0.8"; - let r3_addr = "127.0.0.9"; + let r1_addr: SocketAddr = sockaddr!(&format!("127.0.0.7:{TEST_BGP_PORT}")); + let r2_addr: SocketAddr = sockaddr!(&format!("127.0.0.8:{TEST_BGP_PORT}")); + let r3_addr: SocketAddr = sockaddr!(&format!("127.0.0.9:{TEST_BGP_PORT}")); - // Ensure additional loopback IPs are available for this test + // Ensure loopback IPs are available for this test let _ip_guard = - ensure_loop_ips(&[ip!(r1_addr), ip!(r2_addr), ip!(r3_addr)]); - - // Set up 3 routers in a chain topology: r1 <-> r2 <-> r3 - // This validates that the BgpListener can handle multiple connections - let routers = vec![ - LogicalRouter { - name: "r1".to_string(), - asn: Asn::FourOctet(4200000001), - id: 1, - listen_addr: sockaddr!(&format!("{r1_addr}:{TEST_BGP_PORT}")), - bind_addr: Some(sockaddr!(&format!("{r1_addr}:{TEST_BGP_PORT}"))), - neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r2".into(), - host: sockaddr!(&format!("{r2_addr}:{TEST_BGP_PORT}")), - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, - }], - }, - LogicalRouter { - name: "r2".to_string(), - asn: Asn::FourOctet(4200000002), - id: 2, - listen_addr: sockaddr!(&format!("{r2_addr}:{TEST_BGP_PORT}")), - bind_addr: Some(sockaddr!(&format!("{r2_addr}:{TEST_BGP_PORT}"))), - neighbors: vec![ - Neighbor { - peer_config: PeerConfig { - name: "r1".into(), - host: sockaddr!(&format!("{r1_addr}:{TEST_BGP_PORT}")), - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, - }, - Neighbor { - peer_config: PeerConfig { - name: "r3".into(), - host: sockaddr!(&format!("{r3_addr}:{TEST_BGP_PORT}")), - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, - }, - ], - }, - LogicalRouter { - name: "r3".to_string(), - asn: Asn::FourOctet(4200000003), - id: 3, - listen_addr: sockaddr!(&format!("{r3_addr}:{TEST_BGP_PORT}")), - bind_addr: Some(sockaddr!(&format!("{r3_addr}:{TEST_BGP_PORT}"))), - neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r2".into(), - host: sockaddr!(&format!("{r2_addr}:{TEST_BGP_PORT}")), - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, - }], - }, - ]; + ensure_loop_ips(&[r1_addr.ip(), r2_addr.ip(), r3_addr.ip()]); - let (test_routers, _ip_guard2) = test_setup::< - BgpConnectionTcp, - BgpListenerTcp, - >("three_router_chain_tcp", &routers); - - // Verify BGP sessions reach Established state - // This test validates that the BgpListener can handle multiple connections - - // Get sessions from each router - let r1_r2_session = test_routers[0] - .router - .get_session(ip!(r2_addr)) - .expect("get r1->r2 session"); - let r2_r1_session = test_routers[1] - .router - .get_session(ip!(r1_addr)) - .expect("get r2->r1 session"); - let r2_r3_session = test_routers[1] - .router - .get_session(ip!(r3_addr)) - .expect("get r2->r3 session"); - let r3_r2_session = test_routers[2] - .router - .get_session(ip!(r2_addr)) - .expect("get r3->r2 session"); + three_router_chain_helper::( + r1_addr, r2_addr, r3_addr, + ) +} - wait_for_eq!(r1_r2_session.state(), FsmStateKind::Established); - wait_for_eq!(r2_r1_session.state(), FsmStateKind::Established); - wait_for_eq!(r2_r3_session.state(), FsmStateKind::Established); - wait_for_eq!(r3_r2_session.state(), FsmStateKind::Established); +#[test] +fn test_three_router_chain_tcp_ipv6() { + let r1_addr: SocketAddr = sockaddr!(&format!("[3fff::c]:{TEST_BGP_PORT}")); + let r2_addr: SocketAddr = sockaddr!(&format!("[3fff::d]:{TEST_BGP_PORT}")); + let r3_addr: SocketAddr = sockaddr!(&format!("[3fff::e]:{TEST_BGP_PORT}")); - // Clean up - for router in test_routers.iter() { - router.shutdown(); - } + three_router_chain_helper::( + r1_addr, r2_addr, r3_addr, + ) } /// Test that threads are properly cleaned up throughout the neighbor lifecycle. @@ -852,6 +991,28 @@ fn test_neighbor_thread_lifecycle_no_leaks() { let baseline = 0; eprintln!("=== Baseline BGP thread count: {baseline} ==="); + let r1_peer_config = PeerConfig { + name: "r2".into(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let r2_peer_config = PeerConfig { + name: "r1".into(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let routers = vec![ LogicalRouter { name: "r1".to_string(), @@ -859,18 +1020,10 @@ fn test_neighbor_thread_lifecycle_no_leaks() { id: 1, listen_addr: r1_addr, bind_addr: Some(r1_addr), - neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r2".into(), - host: r2_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: SessionInfo::from_peer_config(&r1_peer_config), }], }, LogicalRouter { @@ -879,18 +1032,10 @@ fn test_neighbor_thread_lifecycle_no_leaks() { id: 2, listen_addr: r2_addr, bind_addr: Some(r2_addr), - neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r1".into(), - host: r1_addr, - hold_time: 6, - idle_hold_time: 0, - delay_open: 0, - connect_retry: 1, - keepalive: 3, - resolution: 100, - }, - session_info: None, + neighbors: vec![NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: SessionInfo::from_peer_config(&r2_peer_config), }], }, ]; @@ -1067,9 +1212,10 @@ fn test_import_export_policy_filtering() { id: 1, listen_addr: r1_addr, bind_addr: Some(r1_addr), - neighbors: vec![Neighbor { - peer_config: r1_peer_config.clone(), - session_info: Some(r1_session_info), + neighbors: vec![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: r1_session_info, }], }, LogicalRouter { @@ -1078,9 +1224,10 @@ fn test_import_export_policy_filtering() { id: 2, listen_addr: r2_addr, bind_addr: Some(r2_addr), - neighbors: vec![Neighbor { - peer_config: r2_peer_config.clone(), - session_info: Some(r2_session_info), + neighbors: vec![NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: r2_session_info, }], }, ]; @@ -1264,6 +1411,7 @@ fn test_import_export_policy_filtering() { #[test] fn test_basic_update_ipv6() { basic_update_helper::( + RouteExchange::Ipv6 { nexthop: None }, sockaddr!(&format!("[3fff::]:{TEST_BGP_PORT}")), sockaddr!(&format!("[3fff::1]:{TEST_BGP_PORT}")), ) @@ -1272,8 +1420,9 @@ fn test_basic_update_ipv6() { #[test] fn test_basic_update_ipv6_tcp() { basic_update_helper::( - sockaddr!(&format!("[3fff::2]:{TEST_BGP_PORT}")), - sockaddr!(&format!("[3fff::3]:{TEST_BGP_PORT}")), + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::a]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::b]:{TEST_BGP_PORT}")), ) } @@ -1281,8 +1430,9 @@ fn test_basic_update_ipv6_tcp() { fn test_ipv6_basic_peering_passive() { basic_peering_helper::( true, - sockaddr!(&format!("[3fff::4]:{TEST_BGP_PORT}")), - sockaddr!(&format!("[3fff::5]:{TEST_BGP_PORT}")), + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::2]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::3]:{TEST_BGP_PORT}")), ) } @@ -1290,8 +1440,9 @@ fn test_ipv6_basic_peering_passive() { fn test_ipv6_basic_peering_active() { basic_peering_helper::( false, - sockaddr!(&format!("[3fff::6]:{TEST_BGP_PORT}")), - sockaddr!(&format!("[3fff::7]:{TEST_BGP_PORT}")), + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::4]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::5]:{TEST_BGP_PORT}")), ) } @@ -1299,8 +1450,9 @@ fn test_ipv6_basic_peering_active() { fn test_ipv6_basic_peering_passive_tcp() { basic_peering_helper::( true, - sockaddr!(&format!("[3fff::8]:{TEST_BGP_PORT}")), - sockaddr!(&format!("[3fff::9]:{TEST_BGP_PORT}")), + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::6]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::7]:{TEST_BGP_PORT}")), ) } @@ -1308,7 +1460,64 @@ fn test_ipv6_basic_peering_passive_tcp() { fn test_ipv6_basic_peering_active_tcp() { basic_peering_helper::( false, - sockaddr!(&format!("[3fff::a]:{TEST_BGP_PORT}")), - sockaddr!(&format!("[3fff::b]:{TEST_BGP_PORT}")), + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::8]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::9]:{TEST_BGP_PORT}")), + ) +} + +// ========================================================================= +// Cross-Address-Family Nexthop Tests +// ========================================================================= +// These tests verify that derive_nexthop() correctly handles configured +// nexthops for cross-AF scenarios (e.g., IPv4 routes over IPv6 connections). + +#[test] +fn test_dual_stack_routes_ipv4_peer_success() { + // IPv4 connection with dual-stack routes + basic_update_helper::( + RouteExchange::DualStack { + ipv4_nexthop: Some(ip!("10.0.1.1")), + ipv6_nexthop: Some(ip!("3fff:db8:1::1")), + }, + sockaddr!(&format!("10.0.1.1:{TEST_BGP_PORT}")), + sockaddr!(&format!("10.0.1.2:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_dual_stack_routes_ipv6_peer_success() { + // IPv6 connection with dual-stack routes + basic_update_helper::( + RouteExchange::DualStack { + ipv4_nexthop: Some(ip!("10.0.2.1")), + ipv6_nexthop: Some(ip!("3fff:db8:2::1")), + }, + sockaddr!(&format!("[3fff::f]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::10]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv4_routes_ipv6_peer_success() { + // IPv6 connection with IPv4-only routes + basic_update_helper::( + RouteExchange::Ipv4 { + nexthop: Some(ip!("10.0.3.1")), + }, + sockaddr!(&format!("[3fff::11]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::12]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_routes_ipv4_peer_success() { + // IPv4 connection with IPv6-only routes + basic_update_helper::( + RouteExchange::Ipv6 { + nexthop: Some(ip!("3fff:db8:4::1")), + }, + sockaddr!(&format!("10.0.4.1:{TEST_BGP_PORT}")), + sockaddr!(&format!("10.0.4.2:{TEST_BGP_PORT}")), ) } diff --git a/mg-admin-client/Cargo.toml b/mg-admin-client/Cargo.toml index 26a2838f..adcde6f4 100644 --- a/mg-admin-client/Cargo.toml +++ b/mg-admin-client/Cargo.toml @@ -16,5 +16,6 @@ schemars.workspace = true chrono.workspace = true uuid.workspace = true rdb-types.workspace = true +bgp.workspace = true tabwriter.workspace = true colored.workspace = true diff --git a/mg-admin-client/src/lib.rs b/mg-admin-client/src/lib.rs index 4095ad5c..c2e8843b 100644 --- a/mg-admin-client/src/lib.rs +++ b/mg-admin-client/src/lib.rs @@ -22,6 +22,7 @@ progenitor::generate_api!( Prefix = rdb_types::Prefix, AddressFamily = rdb_types::AddressFamily, ProtocolFilter = rdb_types::ProtocolFilter, + JitterRange = bgp::params::JitterRange, } ); diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index eb1f10e1..7a578706 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use anyhow::Result; +use bgp::params::JitterRange; use clap::{Args, Subcommand, ValueEnum}; use colored::*; use mg_admin_client::{ @@ -492,10 +493,18 @@ pub struct Neighbor { #[arg(long, default_value_t = 0)] idle_hold_time: u64, + /// Jitter range for idle hold timer (min,max format, e.g., "0.75,1.0"). + #[arg(long)] + pub idle_hold_jitter: Option, + /// How long to wait between connection retries (s). #[arg(long, default_value_t = 5)] connect_retry_time: u64, + /// Jitter range for connect_retry timer (min,max format, e.g., "0.75,1.0"). + #[arg(long)] + pub connect_retry_jitter: Option, + /// Interval for sending keepalive messages (s). #[arg(long, default_value_t = 2)] keepalive_time: u64, @@ -504,9 +513,13 @@ pub struct Neighbor { #[arg(long, default_value_t = 0)] delay_open_time: u64, - /// Blocking interval for message loops (ms). + /// Enable deterministic collision resolution in Established state. + #[arg(long, default_value_t = false)] + pub deterministic_collision_resolution: bool, + + /// Timer granularity in ms (how often do timers tick). #[arg(long, default_value_t = 100)] - resolution: u64, + clock_resolution: u64, /// Do not initiate connections, only accept them. #[arg(long, default_value_t = false)] @@ -548,26 +561,34 @@ pub struct Neighbor { #[arg(long)] pub enable_ipv4: bool, - /// Enable IPv6 unicast address family. - #[arg(long)] - pub enable_ipv6: bool, - /// IPv4 prefixes to allow importing (requires --enable-ipv4). - #[arg(long)] + #[arg(long, requires = "enable_ipv4")] pub allow_import4: Option>, /// IPv4 prefixes to allow exporting (requires --enable-ipv4). - #[arg(long)] + #[arg(long, requires = "enable_ipv4")] pub allow_export4: Option>, - /// IPv6 prefixes to allow importing (requires --enable-ipv6). + /// IPv4 nexthop override for this neighbor (requires --enable-ipv4). + #[arg(long, requires = "enable_ipv4")] + pub nexthop4: Option, + + /// Enable IPv6 unicast address family. #[arg(long)] + pub enable_ipv6: bool, + + /// IPv6 prefixes to allow importing (requires --enable-ipv6). + #[arg(long, requires = "enable_ipv6")] pub allow_import6: Option>, /// IPv6 prefixes to allow exporting (requires --enable-ipv6). - #[arg(long)] + #[arg(long, requires = "enable_ipv6")] pub allow_export6: Option>, + /// IPv6 nexthop override for this neighbor (requires --enable-ipv6). + #[arg(long, requires = "enable_ipv6")] + pub nexthop6: Option, + /// Autonomous system number for the router to add the neighbor to. #[clap(env)] pub asn: u32, @@ -590,6 +611,7 @@ impl From for types::Neighbor { None => ImportExportPolicy4::NoFiltering, }; Some(Ipv4UnicastConfig { + nexthop: n.nexthop4, import_policy, export_policy, }) @@ -612,6 +634,7 @@ impl From for types::Neighbor { None => ImportExportPolicy6::NoFiltering, }; Some(Ipv6UnicastConfig { + nexthop: n.nexthop6, import_policy, export_policy, }) @@ -630,7 +653,7 @@ impl From for types::Neighbor { connect_retry: n.connect_retry_time, keepalive: n.keepalive_time, delay_open: n.delay_open_time, - resolution: n.resolution, + resolution: n.clock_resolution, group: n.group, passive: n.passive_connection, md5_auth_key: n.md5_auth_key.clone(), @@ -641,6 +664,10 @@ impl From for types::Neighbor { ipv4_unicast, ipv6_unicast, vlan_id: n.vlan_id, + connect_retry_jitter: n.connect_retry_jitter, + idle_hold_jitter: n.idle_hold_jitter, + deterministic_collision_resolution: n + .deterministic_collision_resolution, } } } diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index c6acdcbb..22f2da0b 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -1140,6 +1140,7 @@ pub(crate) mod helpers { enforce_first_as: rq.enforce_first_as, // V1 API is IPv4-only; IPv6 support didn't exist in legacy API ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, import_policy: allow_import4.clone(), export_policy: allow_export4.clone(), }), @@ -1153,7 +1154,10 @@ pub(crate) mod helpers { idle_hold_time: Duration::from_secs(rq.idle_hold_time), delay_open_time: Duration::from_secs(rq.delay_open), resolution: Duration::from_millis(rq.resolution), - idle_hold_jitter: Some((0.75, 1.0)), + idle_hold_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), connect_retry_jitter: None, deterministic_collision_resolution: false, }; @@ -1199,14 +1203,17 @@ pub(crate) mod helpers { communities: rq.communities, local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, - // V1 API is IPv4-only; IPv6 support didn't exist in legacy API - ipv4_enabled: true, - ipv6_enabled: false, allow_import4, allow_export4, + vlan_id: rq.vlan_id, + + // V1 API is IPv4-only and doesn't support nexthop override + ipv4_enabled: true, + ipv6_enabled: false, allow_import6: ImportExportPolicy6::NoFiltering, allow_export6: ImportExportPolicy6::NoFiltering, - vlan_id: rq.vlan_id, + nexthop4: None, + nexthop6: None, })?; if start_session { @@ -1227,7 +1234,11 @@ pub(crate) mod helpers { ); // Validate that at least one AF is enabled - rq.validate().map_err(Error::Conflict)?; + rq.validate_address_families() + .map_err(Error::InvalidRequest)?; + + // Validate nexthop address families + rq.validate_nexthop().map_err(Error::InvalidRequest)?; let (event_tx, event_rx) = channel(); @@ -1252,7 +1263,10 @@ pub(crate) mod helpers { idle_hold_time: Duration::from_secs(rq.idle_hold_time), delay_open_time: Duration::from_secs(rq.delay_open), resolution: Duration::from_millis(rq.resolution), - idle_hold_jitter: Some((0.75, 1.0)), + idle_hold_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), connect_retry_jitter: None, deterministic_collision_resolution: false, }; @@ -1279,20 +1293,30 @@ pub(crate) mod helpers { true }; - // Extract per-AF policies for database storage - let (allow_import4, allow_export4) = match &rq.ipv4_unicast { - Some(cfg) => (cfg.import_policy.clone(), cfg.export_policy.clone()), + // Extract per-AF policies and nexthop for database storage + let (allow_import4, allow_export4, nexthop4) = match &rq.ipv4_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), None => ( ImportExportPolicy4::NoFiltering, ImportExportPolicy4::NoFiltering, + None, ), }; - let (allow_import6, allow_export6) = match &rq.ipv6_unicast { - Some(cfg) => (cfg.import_policy.clone(), cfg.export_policy.clone()), + let (allow_import6, allow_export6, nexthop6) = match &rq.ipv6_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), None => ( ImportExportPolicy6::NoFiltering, ImportExportPolicy6::NoFiltering, + None, ), }; @@ -1322,6 +1346,8 @@ pub(crate) mod helpers { allow_export4, allow_import6, allow_export6, + nexthop4, + nexthop6, vlan_id: rq.vlan_id, })?; diff --git a/mgd/src/error.rs b/mgd/src/error.rs index c413fcde..4637b57b 100644 --- a/mgd/src/error.rs +++ b/mgd/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("internal communication error: {0}")] InternalCommunication(String), + + #[error("invalid request: {0}")] + InvalidRequest(String), } impl From for HttpError { @@ -43,6 +46,9 @@ impl From for HttpError { Error::InternalCommunication(_) => { Self::for_internal_error(value.to_string()) } + Error::InvalidRequest(_) => { + Self::for_bad_request(None, value.to_string()) + } } } } diff --git a/openapi/mg-admin/mg-admin-3.0.0-61ffa9.json b/openapi/mg-admin/mg-admin-3.0.0-61ffa9.json new file mode 100644 index 00000000..d7fd4901 --- /dev/null +++ b/openapi/mg-admin/mg-admin-3.0.0-61ffa9.json @@ -0,0 +1,4376 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Maghemite Admin", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "4.0.0" + }, + "paths": { + "/bfd/peers": { + "get": { + "summary": "Get all the peers and their associated BFD state. Peers are identified by IP", + "description": "address.", + "operationId": "get_bfd_peers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_BfdPeerInfo", + "type": "array", + "items": { + "$ref": "#/components/schemas/BfdPeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Add a new peer to the daemon. A session for the specified peer will start", + "description": "immediately.", + "operationId": "add_bfd_peer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BfdPeerConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bfd/peers/{addr}": { + "delete": { + "summary": "Remove the specified peer from the daemon. The associated peer session will", + "description": "be stopped immediately.", + "operationId": "remove_bfd_peer", + "parameters": [ + { + "in": "path", + "name": "addr", + "description": "Address of the peer to remove.", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/clear/neighbor": { + "post": { + "operationId": "clear_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborResetRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/checker": { + "get": { + "operationId": "read_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbor": { + "get": { + "operationId": "read_neighbor_v2", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_neighbor_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_neighbor_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_neighbor_v2", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbors": { + "get": { + "operationId": "read_neighbors_v2", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Neighbor", + "type": "array", + "items": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin4": { + "get": { + "operationId": "read_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin6": { + "get": { + "operationId": "read_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/router": { + "get": { + "operationId": "read_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/routers": { + "get": { + "operationId": "read_routers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Router", + "type": "array", + "items": { + "$ref": "#/components/schemas/Router" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/shaper": { + "get": { + "operationId": "read_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/fsm": { + "get": { + "operationId": "fsm_history", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/message": { + "get": { + "operationId": "message_history_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/omicron/apply": { + "post": { + "operationId": "bgp_apply_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/exported": { + "get": { + "operationId": "get_exported", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsnSelector" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Array_of_Prefix", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + } + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/neighbors": { + "get": { + "operationId": "get_neighbors_v2", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_PeerInfo", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/config/bestpath/fanout": { + "get": { + "operationId": "read_rib_bestpath_fanout", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_rib_bestpath_fanout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/imported": { + "get": { + "operationId": "get_rib_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/selected": { + "get": { + "operationId": "get_rib_selected", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route4": { + "get": { + "operationId": "static_list_v4_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route6": { + "get": { + "operationId": "static_list_v6_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/switch/identifiers": { + "get": { + "operationId": "switch_identifiers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchIdentifiers" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AddPathElement": { + "description": "The add path element comes as a BGP capability extension as described in RFC 7911.", + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier. ", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier. There are a large pile of these ", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "send_receive": { + "description": "This field indicates whether the sender is (a) able to receive multiple paths from its peer (value 1), (b) able to send multiple paths to its peer (value 2), or (c) both (value 3) for the .", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi", + "send_receive" + ] + }, + "AddStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "AddStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Aggregator": { + "description": "AGGREGATOR path attribute (RFC 4271 §5.1.8)\n\nThe AGGREGATOR attribute is an optional transitive attribute that contains the AS number and IP address of the last BGP speaker that formed the aggregate route.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (2-octet)", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "ApplyRequest": { + "description": "Apply changes to an ASN (current version with per-AF policies).", + "type": "object", + "properties": { + "asn": { + "description": "ASN to apply changes to.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "checker": { + "nullable": true, + "description": "Checker rhai code to apply to ingress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/CheckerSource" + } + ] + }, + "originate": { + "description": "Complete set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "peers": { + "description": "Lists of peers indexed by peer group.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerConfig" + } + } + }, + "shaper": { + "nullable": true, + "description": "Checker rhai code to apply to egress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/ShaperSource" + } + ] + } + }, + "required": [ + "asn", + "originate", + "peers" + ] + }, + "As4Aggregator": { + "description": "AS4_AGGREGATOR path attribute (RFC 6793)\n\nThe AS4_AGGREGATOR attribute is an optional transitive attribute with the same semantics as AGGREGATOR, but carries a 4-octet AS number instead of 2-octet.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (4-octet)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "As4PathSegment": { + "type": "object", + "properties": { + "typ": { + "$ref": "#/components/schemas/AsPathType" + }, + "value": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + }, + "required": [ + "typ", + "value" + ] + }, + "AsPathType": { + "description": "Enumeration describes possible AS path types", + "oneOf": [ + { + "description": "The path is to be interpreted as a set", + "type": "string", + "enum": [ + "as_set" + ] + }, + { + "description": "The path is to be interpreted as a sequence", + "type": "string", + "enum": [ + "as_sequence" + ] + } + ] + }, + "AsnSelector": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to get imported prefixes from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + }, + "BestpathFanoutRequest": { + "type": "object", + "properties": { + "fanout": { + "description": "Maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BestpathFanoutResponse": { + "type": "object", + "properties": { + "fanout": { + "description": "Current maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BfdPeerConfig": { + "type": "object", + "properties": { + "detection_threshold": { + "description": "Detection threshold for connectivity as a multipler to required_rx", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "listen": { + "description": "Address to listen on for control messages from the peer.", + "type": "string", + "format": "ip" + }, + "mode": { + "description": "Mode is single-hop (RFC 5881) or multi-hop (RFC 5883).", + "allOf": [ + { + "$ref": "#/components/schemas/SessionMode" + } + ] + }, + "peer": { + "description": "Address of the peer to add.", + "type": "string", + "format": "ip" + }, + "required_rx": { + "description": "Acceptable time between control messages in microseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "detection_threshold", + "listen", + "mode", + "peer", + "required_rx" + ] + }, + "BfdPeerInfo": { + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/BfdPeerConfig" + }, + "state": { + "$ref": "#/components/schemas/BfdPeerState" + } + }, + "required": [ + "config", + "state" + ] + }, + "BfdPeerState": { + "description": "The possible peer states. See the `State` trait implementations `Down`, `Init`, and `Up` for detailed semantics. Data representation is u8 as this enum is used as a part of the BFD wire protocol.", + "oneOf": [ + { + "description": "A stable down state. Non-responsive to incoming messages.", + "type": "string", + "enum": [ + "AdminDown" + ] + }, + { + "description": "The initial state.", + "type": "string", + "enum": [ + "Down" + ] + }, + { + "description": "The peer has detected a remote peer in the down state.", + "type": "string", + "enum": [ + "Init" + ] + }, + { + "description": "The peer has detected a remote peer in the up or init state while in the init state.", + "type": "string", + "enum": [ + "Up" + ] + } + ] + }, + "BgpNexthop": { + "description": "BGP next-hops can come in multiple forms, defined in several different RFCs. This enum represents the forms supported by this implementation.\n\nIn the case of IPv6, RFC 2545 defined the use of either: 1) A single non-link-local next-hop (length=16) 2) A non-link-local plus a link-local next-hop (length=32)\n\nThis does not account for only a link-local address as the sole next-hop. As such, many different implementations decided they would encode this in a variety of ways (since there was no canonical source of truth): a) Single-address encoding just the link-local (length=16) b) Double-address encoding the link-local in both positions (length=32) c) Double-address encoding the link-local in its normal position, but 0's in the non-link-local position (length=32) etc. This led to `draft-ietf-idr-linklocal-capability` which specifies more detailed encoding and error handling standards, signaled via a new Link-Local Next Hop Capability.\n\nIn addition to this, RFC 8950 (formerly RFC 5549) specified the advertisement of IPv4 NLRI via an IPv6 next-hop, enabled via the Extended Next Hop capability. This excerpt contains the encoding logic from RFC 8950: ```text Specifically, this document allows advertising the MP_REACH_NLRI attribute [RFC4760] with this content:\n\n* AFI = 1\n\n* SAFI = 1, 2, or 4\n\n* Length of Next Hop Address = 16 or 32\n\n* Next Hop Address = IPv6 address of a next hop (potentially followed by the link-local IPv6 address of the next hop). This field is to be constructed as per Section 3 of [RFC2545].\n\n* NLRI = NLRI as per the AFI/SAFI definition\n\n[..]\n\nThis is in addition to the existing mode of operation allowing advertisement of NLRI for of <1/1>, <1/2>, and <1/4> with a next-hop address of an IPv4 type and advertisement of NLRI for an of <1/128> and <1/129> with a next-hop address of a VPN- IPv4 type.\n\nThe BGP speaker receiving the advertisement MUST use the Length of Next Hop Address field to determine which network-layer protocol the next-hop address belongs to.\n\n* When the AFI/SAFI is <1/1>, <1/2>, or <1/4> and when the Length of Next Hop Address field is equal to 16 or 32, the next-hop address is of type IPv6.\n\n* When the AFI/SAFI is <1/128> or <1/129> and when the Length of Next Hop Address field is equal to 24 or 48, the next-hop address is of type VPN-IPv6. ```\n\nRFC 8950 also goes on to state that Extended Next Hop is not specified for any AFI/SAFI other than IPv4 {Unicast, Multicast, Labeled Unicast, Unicast VPN, Multicast VPN}, because IPv4 next-hops can already be signaled within IPv6 or VPN-IPv6 encoding (via IPv4-mapped IPv6 addresses).\n\nSo for our purposes, IPv4 Unicast NLRI may have Next-hops in the form of: a) IPv4 nexthop b) IPv6 single GUA (w/ Extended Next-Hop) c) IPv6 single LL (w/ Extended Next-Hop + Link-Local Next Hop) d) IPv6 double (w/ Extended Next-Hop)\n\nand IPv6 Unicast NLRI may have Next-hops in the form of: a) IPv6 single (IPv4-mapped) b) IPv6 single GUA c) IPv6 single LL (w/ Link-Local Next Hop) d) IPv6 double", + "oneOf": [ + { + "type": "object", + "properties": { + "Ipv4": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "Ipv4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Single": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "Ipv6Single" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Double": { + "$ref": "#/components/schemas/Ipv6DoubleNexthop" + } + }, + "required": [ + "Ipv6Double" + ], + "additionalProperties": false + } + ] + }, + "BgpPathProperties": { + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "med": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "origin_as": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "peer": { + "type": "string", + "format": "ip" + }, + "stale": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "as_path", + "id", + "origin_as", + "peer" + ] + }, + "BgpPeerConfig": { + "description": "BGP peer configuration (current version with per-address-family policies).", + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "Capability": { + "description": "Optional capabilities supported by a BGP implementation.", + "oneOf": [ + { + "description": "Multiprotocol extensions as defined in RFC 2858", + "type": "object", + "properties": { + "multiprotocol_extensions": { + "type": "object", + "properties": { + "afi": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + } + }, + "required": [ + "multiprotocol_extensions" + ], + "additionalProperties": false + }, + { + "description": "Route refresh capability as defined in RFC 2918.", + "type": "object", + "properties": { + "route_refresh": { + "type": "object" + } + }, + "required": [ + "route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Outbound filtering capability as defined in RFC 5291. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Multiple routes to destination capability as defined in RFC 8277 (deprecated). Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_routes_to_destination": { + "type": "object" + } + }, + "required": [ + "multiple_routes_to_destination" + ], + "additionalProperties": false + }, + { + "description": "Multiple nexthop encoding capability as defined in RFC 8950. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "extended_next_hop_encoding": { + "type": "object" + } + }, + "required": [ + "extended_next_hop_encoding" + ], + "additionalProperties": false + }, + { + "description": "Extended message capability as defined in RFC 8654. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "b_g_p_extended_message": { + "type": "object" + } + }, + "required": [ + "b_g_p_extended_message" + ], + "additionalProperties": false + }, + { + "description": "BGPSec as defined in RFC 8205. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_sec": { + "type": "object" + } + }, + "required": [ + "bgp_sec" + ], + "additionalProperties": false + }, + { + "description": "Multiple label support as defined in RFC 8277. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_labels": { + "type": "object" + } + }, + "required": [ + "multiple_labels" + ], + "additionalProperties": false + }, + { + "description": "BGP role capability as defined in RFC 9234. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_role": { + "type": "object" + } + }, + "required": [ + "bgp_role" + ], + "additionalProperties": false + }, + { + "description": "Graceful restart as defined in RFC 4724. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "graceful_restart": { + "type": "object" + } + }, + "required": [ + "graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Four octet AS numbers as defined in RFC 6793.", + "type": "object", + "properties": { + "four_octet_as": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + } + }, + "required": [ + "four_octet_as" + ], + "additionalProperties": false + }, + { + "description": "Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "dynamic_capability": { + "type": "object" + } + }, + "required": [ + "dynamic_capability" + ], + "additionalProperties": false + }, + { + "description": "Multi session support as defined in draft-ietf-idr-bgp-multisession. Note this capability is not yet supported.", + "type": "object", + "properties": { + "multisession_bgp": { + "type": "object" + } + }, + "required": [ + "multisession_bgp" + ], + "additionalProperties": false + }, + { + "description": "Add path capability as defined in RFC 7911.", + "type": "object", + "properties": { + "add_path": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddPathElement" + }, + "uniqueItems": true + } + }, + "required": [ + "elements" + ] + } + }, + "required": [ + "add_path" + ], + "additionalProperties": false + }, + { + "description": "Enhanced route refresh as defined in RFC 7313. Note this capability is not yet supported.", + "type": "object", + "properties": { + "enhanced_route_refresh": { + "type": "object" + } + }, + "required": [ + "enhanced_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Long-lived graceful restart as defined in draft-uttaro-idr-bgp-persistence. Note this capability is not yet supported.", + "type": "object", + "properties": { + "long_lived_graceful_restart": { + "type": "object" + } + }, + "required": [ + "long_lived_graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note this capability is not yet supported.", + "type": "object", + "properties": { + "routing_policy_distribution": { + "type": "object" + } + }, + "required": [ + "routing_policy_distribution" + ], + "additionalProperties": false + }, + { + "description": "Fully qualified domain names as defined intdraft-walton-bgp-hostname-capability. Note this capability is not yet supported.", + "type": "object", + "properties": { + "fqdn": { + "type": "object" + } + }, + "required": [ + "fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard route refresh as defined in RFC 8810 (deprecated). Note this capability is not yet supported.", + "type": "object", + "properties": { + "prestandard_route_refresh": { + "type": "object" + } + }, + "required": [ + "prestandard_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard prefix-based outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_orf_and_pd": { + "type": "object" + } + }, + "required": [ + "prestandard_orf_and_pd" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "prestandard_outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard multisession as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_multisession": { + "type": "object" + } + }, + "required": [ + "prestandard_multisession" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard fully qualified domain names as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_fqdn": { + "type": "object" + } + }, + "required": [ + "prestandard_fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard operational messages as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_operational_message": { + "type": "object" + } + }, + "required": [ + "prestandard_operational_message" + ], + "additionalProperties": false + }, + { + "description": "Experimental capability as defined in RFC 8810.", + "type": "object", + "properties": { + "experimental": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "experimental" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "unassigned": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "unassigned" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reserved": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "reserved" + ], + "additionalProperties": false + } + ] + }, + "CeaseErrorSubcode": { + "description": "Cease error subcode types from RFC 4486", + "type": "string", + "enum": [ + "unspecific", + "maximum_numberof_prefixes_reached", + "administrative_shutdown", + "peer_deconfigured", + "administrative_reset", + "connection_rejected", + "other_configuration_change", + "connection_collision_resolution", + "out_of_resources" + ] + }, + "CheckerSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "Community": { + "description": "BGP community value", + "oneOf": [ + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised outside a BGP confederation boundary (a stand-alone autonomous system that is not part of a confederation should be considered a confederation itself)", + "type": "string", + "enum": [ + "no_export" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to other BGP peers.", + "type": "string", + "enum": [ + "no_advertise" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to external BGP peers (this includes peers in other members autonomous systems inside a BGP confederation).", + "type": "string", + "enum": [ + "no_export_sub_confed" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value must set the local preference for the received routes to a low value, preferably zero.", + "type": "string", + "enum": [ + "graceful_shutdown" + ] + }, + { + "description": "A user defined community", + "type": "object", + "properties": { + "user_defined": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "user_defined" + ], + "additionalProperties": false + } + ] + }, + "ConnectionId": { + "description": "Unique identifier for a BGP connection instance", + "type": "object", + "properties": { + "local": { + "description": "Local socket address for this connection", + "type": "string" + }, + "remote": { + "description": "Remote socket address for this connection", + "type": "string" + }, + "uuid": { + "description": "Unique identifier for this connection instance", + "type": "string", + "format": "uuid" + } + }, + "required": [ + "local", + "remote", + "uuid" + ] + }, + "DeleteStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "DeleteStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, + "DynamicTimerInfo": { + "type": "object", + "properties": { + "configured": { + "$ref": "#/components/schemas/Duration" + }, + "negotiated": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "negotiated" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "ErrorCode": { + "description": "This enumeration contains possible notification error codes.", + "type": "string", + "enum": [ + "header", + "open", + "update", + "hold_timer_expired", + "fsm", + "cease" + ] + }, + "ErrorSubcode": { + "description": "This enumeration contains possible notification error subcodes.", + "oneOf": [ + { + "type": "object", + "properties": { + "header": { + "$ref": "#/components/schemas/HeaderErrorSubcode" + } + }, + "required": [ + "header" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "open": { + "$ref": "#/components/schemas/OpenErrorSubcode" + } + }, + "required": [ + "open" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "update": { + "$ref": "#/components/schemas/UpdateErrorSubcode" + } + }, + "required": [ + "update" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hold_time": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "hold_time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "fsm": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "fsm" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "cease": { + "$ref": "#/components/schemas/CeaseErrorSubcode" + } + }, + "required": [ + "cease" + ], + "additionalProperties": false + } + ] + }, + "FsmEventBuffer": { + "oneOf": [ + { + "description": "All FSM events (high frequency, includes all timers)", + "type": "string", + "enum": [ + "all" + ] + }, + { + "description": "Major events only (state transitions, admin, new connections)", + "type": "string", + "enum": [ + "major" + ] + } + ] + }, + "FsmEventCategory": { + "description": "Category of FSM event for filtering and display purposes", + "type": "string", + "enum": [ + "Admin", + "Connection", + "Session", + "StateTransition" + ] + }, + "FsmEventRecord": { + "description": "Serializable record of an FSM event with full context", + "type": "object", + "properties": { + "connection_id": { + "nullable": true, + "description": "Connection ID if event is connection-specific", + "allOf": [ + { + "$ref": "#/components/schemas/ConnectionId" + } + ] + }, + "current_state": { + "description": "FSM state at time of event", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "details": { + "nullable": true, + "description": "Additional event details (e.g., \"Received OPEN\", \"Admin command\")", + "type": "string" + }, + "event_category": { + "description": "High-level event category", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventCategory" + } + ] + }, + "event_type": { + "description": "Specific event type as string (e.g., \"ManualStart\", \"HoldTimerExpires\")", + "type": "string" + }, + "previous_state": { + "nullable": true, + "description": "Previous state if this caused a transition", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "timestamp": { + "description": "UTC timestamp when event occurred", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "current_state", + "event_category", + "event_type", + "timestamp" + ] + }, + "FsmHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "buffer": { + "nullable": true, + "description": "Which buffer to retrieve - if None, returns major buffer", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventBuffer" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "FsmHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "description": "Events organized by peer address Each peer's value contains only the events from the requested buffer", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsmEventRecord" + } + } + } + }, + "required": [ + "by_peer" + ] + }, + "FsmStateKind": { + "description": "Simplified representation of a BGP state without having to carry a connection.", + "oneOf": [ + { + "description": "Initial state. Refuse all incomming BGP connections. No resources allocated to peer.", + "type": "string", + "enum": [ + "Idle" + ] + }, + { + "description": "Waiting for the TCP connection to be completed.", + "type": "string", + "enum": [ + "Connect" + ] + }, + { + "description": "Trying to acquire peer by listening for and accepting a TCP connection.", + "type": "string", + "enum": [ + "Active" + ] + }, + { + "description": "Waiting for open message from peer.", + "type": "string", + "enum": [ + "OpenSent" + ] + }, + { + "description": "Waiting for keepalive or notification from peer.", + "type": "string", + "enum": [ + "OpenConfirm" + ] + }, + { + "description": "Handler for Connection Collisions (RFC 4271 6.8)", + "type": "string", + "enum": [ + "ConnectionCollision" + ] + }, + { + "description": "Sync up with peers.", + "type": "string", + "enum": [ + "SessionSetup" + ] + }, + { + "description": "Able to exchange update, notification and keepliave messages with peers.", + "type": "string", + "enum": [ + "Established" + ] + } + ] + }, + "HeaderErrorSubcode": { + "description": "Header error subcode types", + "type": "string", + "enum": [ + "unspecific", + "connection_not_synchronized", + "bad_message_length", + "bad_message_type" + ] + }, + "ImportExportPolicy4": { + "description": "Import/Export policy for IPv4 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "ImportExportPolicy6": { + "description": "Import/Export policy for IPv6 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "Ipv4UnicastConfig": { + "description": "Per-address-family configuration for IPv4 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "Ipv6DoubleNexthop": { + "description": "IPv6 double nexthop: global unicast address + link-local address. Per RFC 2545, when advertising IPv6 routes, both addresses may be present.", + "type": "object", + "properties": { + "global": { + "description": "Global unicast address", + "type": "string", + "format": "ipv6" + }, + "link_local": { + "description": "Link-local address", + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "global", + "link_local" + ] + }, + "Ipv6UnicastConfig": { + "description": "Per-address-family configuration for IPv6 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "JitterRange": { + "description": "Jitter range with minimum and maximum multiplier values. When applied to a timer, the timer duration is multiplied by a random value within [min, max] to help break synchronization patterns.", + "type": "object", + "properties": { + "max": { + "description": "Maximum jitter multiplier (typically 1.0 or similar)", + "type": "number", + "format": "double" + }, + "min": { + "description": "Minimum jitter multiplier (typically 0.75 or similar)", + "type": "number", + "format": "double" + } + }, + "required": [ + "max", + "min" + ] + }, + "Message": { + "description": "Holds a BGP message. May be an Open, Update, Notification or Keep Alive message.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "open" + ] + }, + "value": { + "$ref": "#/components/schemas/OpenMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "value": { + "$ref": "#/components/schemas/UpdateMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "notification" + ] + }, + "value": { + "$ref": "#/components/schemas/NotificationMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "keep_alive" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "route_refresh" + ] + }, + "value": { + "$ref": "#/components/schemas/RouteRefreshMessage" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "MessageDirection": { + "type": "string", + "enum": [ + "sent", + "received" + ] + }, + "MessageHistory": { + "description": "Message history for a BGP session", + "type": "object", + "properties": { + "received": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + }, + "sent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + } + }, + "required": [ + "received", + "sent" + ] + }, + "MessageHistoryEntry": { + "description": "A message history entry is a BGP message with an associated timestamp and connection ID", + "type": "object", + "properties": { + "connection_id": { + "$ref": "#/components/schemas/ConnectionId" + }, + "message": { + "$ref": "#/components/schemas/Message" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "connection_id", + "message", + "timestamp" + ] + }, + "MessageHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "direction": { + "nullable": true, + "description": "Optional direction filter - if None, returns both sent and received", + "allOf": [ + { + "$ref": "#/components/schemas/MessageDirection" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "MessageHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MessageHistory" + } + } + }, + "required": [ + "by_peer" + ] + }, + "MpReachNlri": { + "description": "MP_REACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being announced.\n\n```text 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14):\n\nThis is an optional non-transitive attribute that can be used for the following purposes:\n\n(a) to advertise a feasible route to a peer\n\n(b) to permit a router to advertise the Network Layer address of the router that should be used as the next hop to the destinations listed in the Network Layer Reachability Information field of the MP_NLRI attribute.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv4 routes.\n\nCurrently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops when extended next-hop capability (RFC 8950) is implemented.", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv4 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + }, + { + "description": "IPv6 Unicast routes (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv6 routes.\n\nCan be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` (32 bytes with link-local address).", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv6 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + } + ] + }, + "MpUnreachNlri": { + "description": "MP_UNREACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being withdrawn.\n\n```text 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15):\n\nThis is an optional non-transitive attribute that can be used for the purpose of withdrawing multiple unfeasible routes from service.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ | Subsequent Address Family Identifier (1 octet) | +---------------------------------------------------------+ | Withdrawn Routes (variable) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes being withdrawn (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + }, + { + "description": "IPv6 Unicast routes being withdrawn (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + } + ] + }, + "Neighbor": { + "description": "Neighbor configuration with explicit per-address-family enablement (v3 API)", + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "asn", + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "group", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "NeighborResetOp": { + "type": "string", + "enum": [ + "Hard", + "SoftInbound", + "SoftOutbound" + ] + }, + "NeighborResetRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "format": "ip" + }, + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "op": { + "$ref": "#/components/schemas/NeighborResetOp" + } + }, + "required": [ + "addr", + "asn", + "op" + ] + }, + "NotificationMessage": { + "description": "Notification messages are exchanged between BGP peers when an exceptional event has occurred.", + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "error_code": { + "description": "Error code associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorCode" + } + ] + }, + "error_subcode": { + "description": "Error subcode associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorSubcode" + } + ] + } + }, + "required": [ + "data", + "error_code", + "error_subcode" + ] + }, + "OpenErrorSubcode": { + "description": "Open message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "unsupported_version_number", + "bad_peer_a_s", + "bad_bgp_identifier", + "unsupported_optional_parameter", + "deprecated", + "unacceptable_hold_time", + "unsupported_capability" + ] + }, + "OpenMessage": { + "description": "The first message sent by each side once a TCP connection is established.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Version | My Autonomous System | Hold Time : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | BGP Identifier : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | Opt Parm Len | Optional Parameters : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Optional Parameters (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.2", + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number of the sender. When 4-byte ASNs are in use this value is set to AS_TRANS which has a value of 23456.\n\nRef: RFC 4893 §7", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "hold_time": { + "description": "Number of seconds the sender proposes for the hold timer.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "id": { + "description": "BGP identifier of the sender", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "parameters": { + "description": "A list of optional parameters.", + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionalParameter" + } + }, + "version": { + "description": "BGP protocol version.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "asn", + "hold_time", + "id", + "parameters", + "version" + ] + }, + "OptionalParameter": { + "description": "The IANA/IETF currently defines the following optional parameter types.", + "oneOf": [ + { + "description": "Code 0", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reserved" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 1: RFC 4217, RFC 5492 (deprecated)", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "authentication" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 2: RFC 5492", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "capabilities" + ] + }, + "value": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + }, + "uniqueItems": true + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Unassigned", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unassigned" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 255: RFC 9072", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "extended_length" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "Origin4": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Origin6": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Path": { + "type": "object", + "properties": { + "bgp": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/BgpPathProperties" + } + ] + }, + "nexthop": { + "type": "string", + "format": "ip" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "shutdown": { + "type": "boolean" + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "rib_priority", + "shutdown" + ] + }, + "PathAttribute": { + "description": "A self-describing BGP path attribute", + "type": "object", + "properties": { + "typ": { + "description": "Type encoding for the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeType" + } + ] + }, + "value": { + "description": "Value of the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeValue" + } + ] + } + }, + "required": [ + "typ", + "value" + ] + }, + "PathAttributeType": { + "description": "Type encoding for a path attribute.", + "type": "object", + "properties": { + "flags": { + "description": "Flags may include, Optional, Transitive, Partial and Extended Length.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "type_code": { + "description": "Type code for the path attribute.", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeTypeCode" + } + ] + } + }, + "required": [ + "flags", + "type_code" + ] + }, + "PathAttributeTypeCode": { + "description": "An enumeration describing available path attribute type codes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "as_path", + "next_hop", + "multi_exit_disc", + "local_pref", + "atomic_aggregate", + "aggregator", + "communities", + "mp_unreach_nlri", + "as4_aggregator" + ] + }, + { + "description": "RFC 4271", + "type": "string", + "enum": [ + "origin" + ] + }, + { + "description": "RFC 4760", + "type": "string", + "enum": [ + "mp_reach_nlri" + ] + }, + { + "description": "RFC 6793", + "type": "string", + "enum": [ + "as4_path" + ] + } + ] + }, + "PathAttributeValue": { + "description": "The value encoding of a path attribute.", + "oneOf": [ + { + "description": "The type of origin associated with a path", + "type": "object", + "properties": { + "origin": { + "$ref": "#/components/schemas/PathOrigin" + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + }, + { + "description": "The AS set associated with a path", + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as_path" + ], + "additionalProperties": false + }, + { + "description": "The nexthop associated with a path (IPv4 only for traditional BGP)", + "type": "object", + "properties": { + "next_hop": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "next_hop" + ], + "additionalProperties": false + }, + { + "description": "A metric used for external (inter-AS) links to discriminate among multiple entry or exit points.", + "type": "object", + "properties": { + "multi_exit_disc": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "multi_exit_disc" + ], + "additionalProperties": false + }, + { + "description": "Local pref is included in update messages sent to internal peers and indicates a degree of preference.", + "type": "object", + "properties": { + "local_pref": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "local_pref" + ], + "additionalProperties": false + }, + { + "description": "AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (2-octet ASN)", + "type": "object", + "properties": { + "aggregator": { + "$ref": "#/components/schemas/Aggregator" + } + }, + "required": [ + "aggregator" + ], + "additionalProperties": false + }, + { + "description": "Indicates communities associated with a path.", + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Community" + } + } + }, + "required": [ + "communities" + ], + "additionalProperties": false + }, + { + "description": "Indicates this route was formed via aggregation (RFC 4271 §5.1.7)", + "type": "string", + "enum": [ + "atomic_aggregate" + ] + }, + { + "description": "The 4-byte encoded AS set associated with a path", + "type": "object", + "properties": { + "as4_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as4_path" + ], + "additionalProperties": false + }, + { + "description": "AS4_AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (4-octet ASN)", + "type": "object", + "properties": { + "as4_aggregator": { + "$ref": "#/components/schemas/As4Aggregator" + } + }, + "required": [ + "as4_aggregator" + ], + "additionalProperties": false + }, + { + "description": "Carries reachable MP-BGP NLRI and Next-hop (advertisement).", + "type": "object", + "properties": { + "mp_reach_nlri": { + "$ref": "#/components/schemas/MpReachNlri" + } + }, + "required": [ + "mp_reach_nlri" + ], + "additionalProperties": false + }, + { + "description": "Carries unreachable MP-BGP NLRI (withdrawal).", + "type": "object", + "properties": { + "mp_unreach_nlri": { + "$ref": "#/components/schemas/MpUnreachNlri" + } + }, + "required": [ + "mp_unreach_nlri" + ], + "additionalProperties": false + } + ] + }, + "PathOrigin": { + "description": "An enumeration indicating the origin type of a path.", + "oneOf": [ + { + "description": "Interior gateway protocol", + "type": "string", + "enum": [ + "igp" + ] + }, + { + "description": "Exterior gateway protocol", + "type": "string", + "enum": [ + "egp" + ] + }, + { + "description": "Incomplete path origin", + "type": "string", + "enum": [ + "incomplete" + ] + } + ] + }, + "PeerInfo": { + "type": "object", + "properties": { + "asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "duration_millis": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "timers": { + "$ref": "#/components/schemas/PeerTimers" + } + }, + "required": [ + "duration_millis", + "state", + "timers" + ] + }, + "PeerTimers": { + "type": "object", + "properties": { + "hold": { + "$ref": "#/components/schemas/DynamicTimerInfo" + }, + "keepalive": { + "$ref": "#/components/schemas/DynamicTimerInfo" + } + }, + "required": [ + "hold", + "keepalive" + ] + }, + "Prefix": { + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "Prefix4": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "length", + "value" + ] + }, + "Prefix6": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "length", + "value" + ] + }, + "Rib": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + }, + "RouteRefreshMessage": { + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + }, + "Router": { + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "graceful_shutdown": { + "description": "Gracefully shut this router down.", + "type": "boolean" + }, + "id": { + "description": "Id for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "listen": { + "description": "Listening address :", + "type": "string" + } + }, + "required": [ + "asn", + "graceful_shutdown", + "id", + "listen" + ] + }, + "SessionMode": { + "type": "string", + "enum": [ + "SingleHop", + "MultiHop" + ] + }, + "ShaperSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "StaticRoute4": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv4" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix4" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute4List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute4" + } + } + }, + "required": [ + "list" + ] + }, + "StaticRoute6": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv6" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix6" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute6List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute6" + } + } + }, + "required": [ + "list" + ] + }, + "SwitchIdentifiers": { + "description": "Identifiers for a switch.", + "type": "object", + "properties": { + "slot": { + "nullable": true, + "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + } + }, + "UpdateErrorSubcode": { + "description": "Update message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "malformed_attribute_list", + "unrecognized_well_known_attribute", + "missing_well_known_attribute", + "attribute_flags", + "attribute_length", + "invalid_origin_attribute", + "deprecated", + "invalid_nexthop_attribute", + "optional_attribute", + "invalid_network_field", + "malformed_as_path" + ] + }, + "UpdateMessage": { + "description": "An update message is used to advertise feasible routes that share common path attributes to a peer, or to withdraw multiple unfeasible routes from service.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Witdrawn Length | Withdrawn Routes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Withdrawn Routes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Path Attribute Length | Path Attributes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Path Attributes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Network Layer Reachability Information (variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.3", + "type": "object", + "properties": { + "nlri": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "path_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathAttribute" + } + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "nlri", + "path_attributes", + "withdrawn" + ] + }, + "AddressFamily": { + "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n``` use rdb_types::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", + "oneOf": [ + { + "description": "Internet Protocol Version 4 (IPv4)", + "type": "string", + "enum": [ + "Ipv4" + ] + }, + { + "description": "Internet Protocol Version 6 (IPv6)", + "type": "string", + "enum": [ + "Ipv6" + ] + } + ] + }, + "ProtocolFilter": { + "oneOf": [ + { + "description": "BGP routes only", + "type": "string", + "enum": [ + "Bgp" + ] + }, + { + "description": "Static routes only", + "type": "string", + "enum": [ + "Static" + ] + } + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/mg-admin/mg-admin-4.0.0-016211.json b/openapi/mg-admin/mg-admin-4.0.0-016211.json index bd953d0d..d7fd4901 100644 --- a/openapi/mg-admin/mg-admin-4.0.0-016211.json +++ b/openapi/mg-admin/mg-admin-4.0.0-016211.json @@ -1767,42 +1767,6 @@ "description": "BGP peer configuration (current version with per-address-family policies).", "type": "object", "properties": { - "allow_export4": { - "description": "Per-address-family export policy for IPv4 routes (only used if ipv4_enabled).", - "default": "NoFiltering", - "allOf": [ - { - "$ref": "#/components/schemas/ImportExportPolicy4" - } - ] - }, - "allow_export6": { - "description": "Per-address-family export policy for IPv6 routes (only used if ipv6_enabled).", - "default": "NoFiltering", - "allOf": [ - { - "$ref": "#/components/schemas/ImportExportPolicy6" - } - ] - }, - "allow_import4": { - "description": "Per-address-family import policy for IPv4 routes (only used if ipv4_enabled).", - "default": "NoFiltering", - "allOf": [ - { - "$ref": "#/components/schemas/ImportExportPolicy4" - } - ] - }, - "allow_import6": { - "description": "Per-address-family import policy for IPv6 routes (only used if ipv6_enabled).", - "default": "NoFiltering", - "allOf": [ - { - "$ref": "#/components/schemas/ImportExportPolicy6" - } - ] - }, "communities": { "type": "array", "items": { @@ -1816,11 +1780,24 @@ "format": "uint64", "minimum": 0 }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, "delay_open": { "type": "integer", "format": "uint64", "minimum": 0 }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, "enforce_first_as": { "type": "boolean" }, @@ -1832,18 +1809,37 @@ "host": { "type": "string" }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, "idle_hold_time": { "type": "integer", "format": "uint64", "minimum": 0 }, - "ipv4_enabled": { - "description": "Whether IPv4 unicast is enabled for this peer.", - "type": "boolean" + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] }, - "ipv6_enabled": { - "description": "Whether IPv6 unicast is enabled for this peer.", - "type": "boolean" + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] }, "keepalive": { "type": "integer", @@ -1900,12 +1896,11 @@ "communities", "connect_retry", "delay_open", + "deterministic_collision_resolution", "enforce_first_as", "hold_time", "host", "idle_hold_time", - "ipv4_enabled", - "ipv6_enabled", "keepalive", "name", "passive", @@ -2875,6 +2870,11 @@ }, "import_policy": { "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" } }, "required": [ @@ -2911,6 +2911,11 @@ }, "import_policy": { "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" } }, "required": [ @@ -2918,6 +2923,26 @@ "import_policy" ] }, + "JitterRange": { + "description": "Jitter range with minimum and maximum multiplier values. When applied to a timer, the timer duration is multiplied by a random value within [min, max] to help break synchronization patterns.", + "type": "object", + "properties": { + "max": { + "description": "Maximum jitter multiplier (typically 1.0 or similar)", + "type": "number", + "format": "double" + }, + "min": { + "description": "Minimum jitter multiplier (typically 0.75 or similar)", + "type": "number", + "format": "double" + } + }, + "required": [ + "max", + "min" + ] + }, "Message": { "description": "Holds a BGP message. May be an Open, Update, Notification or Keep Alive message.", "oneOf": [ @@ -3256,11 +3281,22 @@ "format": "uint64", "minimum": 0 }, + "connect_retry_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, "delay_open": { "type": "integer", "format": "uint64", "minimum": 0 }, + "deterministic_collision_resolution": { + "type": "boolean" + }, "enforce_first_as": { "type": "boolean" }, @@ -3275,6 +3311,14 @@ "host": { "type": "string" }, + "idle_hold_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, "idle_hold_time": { "type": "integer", "format": "uint64", @@ -3354,6 +3398,7 @@ "communities", "connect_retry", "delay_open", + "deterministic_collision_resolution", "enforce_first_as", "group", "hold_time", diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 858693aa..c9ee02c7 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -8,9 +8,12 @@ //! correctness and consistency of prefix operations (excluding wire format //! tests, which are in bgp/src/proptest.rs since they test BgpWireFormat). -use crate::types::{Prefix, Prefix4, Prefix6, StaticRouteKey}; -use proptest::prelude::*; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use crate::types::{ + BgpNeighborInfo, ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, + Prefix6, StaticRouteKey, +}; +use proptest::{prelude::*, strategy::Just}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; // Strategy for generating valid IPv4 prefixes fn ipv4_prefix_strategy() -> impl Strategy { @@ -76,6 +79,124 @@ fn static_route_key_strategy() -> impl Strategy { }) } +// Strategy for generating valid SocketAddr for BGP neighbor configuration +fn socket_addr_strategy() -> impl Strategy { + prop_oneof![ + (any::(), any::()).prop_map(|(addr_bits, port)| { + SocketAddr::new(IpAddr::V4(Ipv4Addr::from(addr_bits)), port) + }), + (any::(), any::()).prop_map(|(addr_bits, port)| { + SocketAddr::new(IpAddr::V6(Ipv6Addr::from(addr_bits)), port) + }), + ] +} + +// Strategy for generating IPv4 import/export policies +fn ipv4_policy_strategy() -> impl Strategy { + prop_oneof![ + Just(ImportExportPolicy4::NoFiltering), + // Empty Allow set (tests serialization of empty BTreeSet) + Just(ImportExportPolicy4::Allow(std::collections::BTreeSet::new())), + // Allow with one IPv4 prefix + ipv4_prefix_strategy().prop_map(|prefix| { + let mut set = std::collections::BTreeSet::new(); + set.insert(prefix); + ImportExportPolicy4::Allow(set) + }), + // Allow with multiple IPv4 prefixes + prop::collection::vec(ipv4_prefix_strategy(), 1..5).prop_map( + |prefixes| { + let set: std::collections::BTreeSet<_> = + prefixes.into_iter().collect(); + ImportExportPolicy4::Allow(set) + } + ), + ] +} + +// Strategy for generating IPv6 import/export policies +fn ipv6_policy_strategy() -> impl Strategy { + prop_oneof![ + Just(ImportExportPolicy6::NoFiltering), + // Empty Allow set (tests serialization of empty BTreeSet) + Just(ImportExportPolicy6::Allow(std::collections::BTreeSet::new())), + // Allow with one IPv6 prefix + ipv6_prefix_strategy().prop_map(|prefix| { + let mut set = std::collections::BTreeSet::new(); + set.insert(prefix); + ImportExportPolicy6::Allow(set) + }), + // Allow with multiple IPv6 prefixes + prop::collection::vec(ipv6_prefix_strategy(), 1..5).prop_map( + |prefixes| { + let set: std::collections::BTreeSet<_> = + prefixes.into_iter().collect(); + ImportExportPolicy6::Allow(set) + } + ), + ] +} + +// Strategy for generating valid BgpNeighborInfo +// Focuses on testing critical fields: nexthop4, nexthop6, and policy variants. +// Uses sensible defaults for non-critical fields (primitives already well-tested by serde). +fn bgp_neighbor_info_strategy() -> impl Strategy { + ( + any::(), // asn (random) + any::(), // name (random - tests any JSON-serializable string) + socket_addr_strategy(), // host (random) + any::>(), // nexthop4 (random - critical field) + any::>(), // nexthop6 (random - critical field) + ipv4_policy_strategy(), // allow_import4 (NoFiltering/Allow variants) + ipv4_policy_strategy(), // allow_export4 (NoFiltering/Allow variants) + ipv6_policy_strategy(), // allow_import6 (NoFiltering/Allow variants) + ipv6_policy_strategy(), // allow_export6 (NoFiltering/Allow variants) + ) + .prop_map( + |( + asn, + name, + host, + nexthop4, + nexthop6, + allow_import4, + allow_export4, + allow_import6, + allow_export6, + )| { + BgpNeighborInfo { + asn, + name, + host, + hold_time: 90, + idle_hold_time: 60, + delay_open: 0, + connect_retry: 30, + keepalive: 30, + resolution: 1000, + group: "test".into(), + passive: false, + remote_asn: Some(65001), + min_ttl: Some(1), + md5_auth_key: Some("password".to_string()), + multi_exit_discriminator: Some(100), + communities: vec![], + local_pref: Some(100), + enforce_first_as: false, + ipv4_enabled: true, + ipv6_enabled: true, + allow_import4, + allow_export4, + allow_import6, + allow_export6, + nexthop4, + nexthop6, + vlan_id: Some(1), + } + }, + ) +} + proptest! { /// Property: IPv4 host bits are always unset after construction #[test] @@ -321,4 +442,76 @@ proptest! { prop_assert_ne!(norm1, norm2, "Greater-than routes should not be equal"); } } + + /// Property: BgpNeighborInfo survives JSON serialization/deserialization round-trip + /// This ensures that the nexthop4 and nexthop6 fields (and all other fields) are + /// correctly preserved when encoding to database and retrieving back. + #[test] + fn prop_bgp_neighbor_info_serialization_roundtrip(neighbor in bgp_neighbor_info_strategy()) { + // Serialize to JSON (simulating database storage) + let json = serde_json::to_string(&neighbor) + .expect("Failed to serialize BgpNeighborInfo to JSON"); + + // Deserialize from JSON (simulating database retrieval) + let deserialized: BgpNeighborInfo = serde_json::from_str(&json) + .expect("Failed to deserialize BgpNeighborInfo from JSON"); + + // All fields should match after round-trip + prop_assert_eq!( + deserialized.asn, neighbor.asn, + "ASN should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.name, neighbor.name, + "Name should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.host, neighbor.host, + "Host should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.nexthop4, neighbor.nexthop4, + "IPv4 nexthop should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.nexthop6, neighbor.nexthop6, + "IPv6 nexthop should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.ipv4_enabled, neighbor.ipv4_enabled, + "IPv4 enabled flag should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.ipv6_enabled, neighbor.ipv6_enabled, + "IPv6 enabled flag should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.multi_exit_discriminator, neighbor.multi_exit_discriminator, + "MED should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.local_pref, neighbor.local_pref, + "Local preference should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.remote_asn, neighbor.remote_asn, + "Remote ASN should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.allow_import4, neighbor.allow_import4, + "IPv4 import policy should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.allow_export4, neighbor.allow_export4, + "IPv4 export policy should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.allow_import6, neighbor.allow_import6, + "IPv6 import policy should survive serialization round-trip" + ); + prop_assert_eq!( + deserialized.allow_export6, neighbor.allow_export6, + "IPv6 export policy should survive serialization round-trip" + ); + } } diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 5048c189..9884b6f5 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -539,6 +539,14 @@ pub struct BgpNeighborInfo { /// Per-address-family export policy for IPv6 routes. #[serde(default)] pub allow_export6: ImportExportPolicy6, + /// Optional next-hop address for IPv4 unicast announcements. + /// If None, derives from TCP connection's local IP. + #[serde(default)] + pub nexthop4: Option, + /// Optional next-hop address for IPv6 unicast announcements. + /// If None, derives from TCP connection's local IP. + #[serde(default)] + pub nexthop6: Option, pub vlan_id: Option, } From dc3e7609d72e0f6654ddc7d8ef5235c992d7c697 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 1 Jan 2026 21:55:04 -0700 Subject: [PATCH 10/20] bgp: add comprehensive peer info to neighbor status Expose detailed peer session state, configuration, and metrics through a new v3.0.0 API endpoint. Includes FSM state, timers with remaining duration, connection counters, and advertised capabilities. Changes: - Add PeerCounters and BgpCapability types for API responses - Ossify DynamicTimerInfo as DynamicTimerInfoV1 for backwards compat - Add new DynamicTimerInfo with remaining field for v3 API - Add PeerTimersV1 for backward compatibility with v1/v2 - Extend PeerInfo with timers, counters, config (v3 only) - Add get_timers() and get_peer_info() to SessionRunner - Plumb peer_group through PeerConfig and NeighborInfo - Implement get_neighbors_v3 endpoint returning PeerInfo - Constrain get_neighbors_v2 to VERSION_IPV6_BASIC..VERSION_MP_BGP - Add Capability::code() method for OpenAPI serialization - Limit BgpNexthop doc comment in OpenAPI schema Closes: #387 --- bgp/src/config.rs | 1 + bgp/src/messages.rs | 8 + bgp/src/params.rs | 292 ++++++++++- bgp/src/router.rs | 1 + bgp/src/session.rs | 181 ++++++- bgp/src/test.rs | 9 + mg-api/src/lib.rs | 10 +- mgadm/src/bgp.rs | 6 +- mgd/src/admin.rs | 9 +- mgd/src/bgp_admin.rs | 53 +- ...61ffa9.json => mg-admin-3.0.0-aca2d9.json} | 461 +++++++++++++++++- 11 files changed, 986 insertions(+), 45 deletions(-) rename openapi/mg-admin/{mg-admin-3.0.0-61ffa9.json => mg-admin-3.0.0-aca2d9.json} (90%) diff --git a/bgp/src/config.rs b/bgp/src/config.rs index a9b9560b..88e38d2f 100644 --- a/bgp/src/config.rs +++ b/bgp/src/config.rs @@ -10,6 +10,7 @@ use std::net::SocketAddr; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct PeerConfig { pub name: String, + pub group: String, pub host: SocketAddr, pub hold_time: u64, pub idle_hold_time: u64, diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index a1e0f208..7d8ff520 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -2611,6 +2611,9 @@ pub struct Ipv6DoubleNexthop { #[derive( Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, )] +#[schemars( + description = "A BGP next-hop address in one of three formats: IPv4, IPv6 single, or IPv6 double." +)] pub enum BgpNexthop { Ipv4(Ipv4Addr), Ipv6Single(Ipv6Addr), @@ -4190,6 +4193,11 @@ impl Capability { } } + /// Get the CapabilityCode for this capability variant + pub fn code(&self) -> CapabilityCode { + self.clone().into() + } + pub fn from_wire(input: &[u8]) -> Result<(&[u8], Capability), Error> { let (input, code) = parse_u8(input)?; let (input, len) = parse_u8(input)?; diff --git a/bgp/src/params.rs b/bgp/src/params.rs index c79b58db..91ef930f 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -2,19 +2,22 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::config::PeerConfig; -use crate::session::FsmStateKind; +use crate::{ + config::PeerConfig, + messages::{AddPathElement, Capability}, + session::{FsmStateKind, SessionCounters}, +}; use rdb::{ ImportExportPolicy, ImportExportPolicy4, ImportExportPolicy6, PolicyAction, Prefix4, Prefix6, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::time::Duration; use std::{ - collections::BTreeMap, + collections::{BTreeMap, HashMap}, net::{IpAddr, SocketAddr}, + sync::atomic::Ordering, + time::Duration, }; #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -201,6 +204,7 @@ impl From for PeerConfig { fn from(rq: Neighbor) -> Self { Self { name: rq.name.clone(), + group: rq.group.clone(), host: rq.host, hold_time: rq.hold_time, idle_hold_time: rq.idle_hold_time, @@ -216,6 +220,7 @@ impl From for PeerConfig { fn from(rq: NeighborV1) -> Self { Self { name: rq.name.clone(), + group: rq.group.clone(), host: rq.host, hold_time: rq.hold_time, idle_hold_time: rq.idle_hold_time, @@ -455,28 +460,275 @@ pub struct GetRouersResponse { #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct RouterInfo { pub asn: u32, - pub peers: BTreeMap, + pub peers: BTreeMap, pub graceful_shutdown: bool, } +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "DynamicTimerInfo")] +pub struct DynamicTimerInfoV1 { + pub configured: Duration, + pub negotiated: Duration, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct DynamicTimerInfo { pub configured: Duration, pub negotiated: Duration, + pub remaining: Duration, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct PeerTimers { pub hold: DynamicTimerInfo, pub keepalive: DynamicTimerInfo, + pub connect_retry: Duration, + pub connect_retry_jitter: Option, + pub idle_hold: Duration, + pub idle_hold_jitter: Option, + pub delay_open: Duration, +} + +/// Session-level counters that persist across connection changes +/// These serve as aggregate counters across all connections for the session +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct PeerCounters { + // FSM Counters + pub connect_retry_counter: u64, + pub connection_retries: u64, + pub active_connections_accepted: u64, + pub active_connections_declined: u64, + pub passive_connections_accepted: u64, + pub passive_connections_declined: u64, + pub transitions_to_idle: u64, + pub transitions_to_connect: u64, + pub transitions_to_active: u64, + pub transitions_to_open_sent: u64, + pub transitions_to_open_confirm: u64, + pub transitions_to_connection_collision: u64, + pub transitions_to_session_setup: u64, + pub transitions_to_established: u64, + pub hold_timer_expirations: u64, + pub idle_hold_timer_expirations: u64, + + // NLRI counters + pub prefixes_advertised: u64, + pub prefixes_imported: u64, + + // Message counters + pub keepalives_sent: u64, + pub keepalives_received: u64, + pub route_refresh_sent: u64, + pub route_refresh_received: u64, + pub opens_sent: u64, + pub opens_received: u64, + pub notifications_sent: u64, + pub notifications_received: u64, + pub updates_sent: u64, + pub updates_received: u64, + + // Message error counters + pub unexpected_update_message: u64, + pub unexpected_keepalive_message: u64, + pub unexpected_open_message: u64, + pub unexpected_route_refresh_message: u64, + pub unexpected_notification_message: u64, + pub update_nexhop_missing: u64, + pub open_handle_failures: u64, + + // Send failure counters + pub notification_send_failure: u64, + pub open_send_failure: u64, + pub keepalive_send_failure: u64, + pub route_refresh_send_failure: u64, + pub update_send_failure: u64, + + // Connection failure counters + pub tcp_connection_failure: u64, + pub md5_auth_failures: u64, + pub connector_panics: u64, +} + +impl From<&SessionCounters> for PeerCounters { + fn from(value: &SessionCounters) -> Self { + Self { + connect_retry_counter: value + .connect_retry_counter + .load(Ordering::Relaxed), + connection_retries: value + .connection_retries + .load(Ordering::Relaxed), + active_connections_accepted: value + .active_connections_accepted + .load(Ordering::Relaxed), + active_connections_declined: value + .active_connections_declined + .load(Ordering::Relaxed), + passive_connections_accepted: value + .passive_connections_accepted + .load(Ordering::Relaxed), + passive_connections_declined: value + .passive_connections_declined + .load(Ordering::Relaxed), + transitions_to_idle: value + .transitions_to_idle + .load(Ordering::Relaxed), + transitions_to_connect: value + .transitions_to_connect + .load(Ordering::Relaxed), + transitions_to_active: value + .transitions_to_active + .load(Ordering::Relaxed), + transitions_to_open_sent: value + .transitions_to_open_sent + .load(Ordering::Relaxed), + transitions_to_open_confirm: value + .transitions_to_open_confirm + .load(Ordering::Relaxed), + transitions_to_connection_collision: value + .transitions_to_connection_collision + .load(Ordering::Relaxed), + transitions_to_session_setup: value + .transitions_to_session_setup + .load(Ordering::Relaxed), + transitions_to_established: value + .transitions_to_established + .load(Ordering::Relaxed), + hold_timer_expirations: value + .hold_timer_expirations + .load(Ordering::Relaxed), + idle_hold_timer_expirations: value + .idle_hold_timer_expirations + .load(Ordering::Relaxed), + prefixes_advertised: value + .prefixes_advertised + .load(Ordering::Relaxed), + prefixes_imported: value.prefixes_imported.load(Ordering::Relaxed), + keepalives_sent: value.keepalives_sent.load(Ordering::Relaxed), + keepalives_received: value + .keepalives_received + .load(Ordering::Relaxed), + route_refresh_sent: value + .route_refresh_sent + .load(Ordering::Relaxed), + route_refresh_received: value + .route_refresh_received + .load(Ordering::Relaxed), + opens_sent: value.opens_sent.load(Ordering::Relaxed), + opens_received: value.opens_received.load(Ordering::Relaxed), + notifications_sent: value + .notifications_sent + .load(Ordering::Relaxed), + notifications_received: value + .notifications_received + .load(Ordering::Relaxed), + updates_sent: value.updates_sent.load(Ordering::Relaxed), + updates_received: value.updates_received.load(Ordering::Relaxed), + unexpected_update_message: value + .unexpected_update_message + .load(Ordering::Relaxed), + unexpected_keepalive_message: value + .unexpected_keepalive_message + .load(Ordering::Relaxed), + unexpected_open_message: value + .unexpected_open_message + .load(Ordering::Relaxed), + unexpected_route_refresh_message: value + .unexpected_route_refresh_message + .load(Ordering::Relaxed), + unexpected_notification_message: value + .unexpected_notification_message + .load(Ordering::Relaxed), + update_nexhop_missing: value + .update_nexhop_missing + .load(Ordering::Relaxed), + open_handle_failures: value + .open_handle_failures + .load(Ordering::Relaxed), + notification_send_failure: value + .notification_send_failure + .load(Ordering::Relaxed), + open_send_failure: value.open_send_failure.load(Ordering::Relaxed), + keepalive_send_failure: value + .keepalive_send_failure + .load(Ordering::Relaxed), + route_refresh_send_failure: value + .route_refresh_send_failure + .load(Ordering::Relaxed), + update_send_failure: value + .update_send_failure + .load(Ordering::Relaxed), + tcp_connection_failure: value + .tcp_connection_failure + .load(Ordering::Relaxed), + md5_auth_failures: value.md5_auth_failures.load(Ordering::Relaxed), + connector_panics: value.connector_panics.load(Ordering::Relaxed), + } + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct AfiSafi { + afi: u16, + safi: u8, +} + +impl From<&AddPathElement> for AfiSafi { + fn from(value: &AddPathElement) -> Self { + Self { + afi: value.afi, + safi: value.safi, + } + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub enum BgpCapability { + MultiprotocolExtensions(AfiSafi), + RouteRefresh, + FourOctetAsn(u32), + AddPath { elements: Vec }, + Unknown(u8), +} + +impl From<&Capability> for BgpCapability { + fn from(value: &Capability) -> Self { + match value { + Capability::MultiprotocolExtensions { afi, safi } => { + BgpCapability::MultiprotocolExtensions(AfiSafi { + afi: *afi, + safi: *safi, + }) + } + Capability::RouteRefresh {} => BgpCapability::RouteRefresh, + Capability::FourOctetAs { asn } => { + BgpCapability::FourOctetAsn(*asn) + } + Capability::AddPath { elements } => BgpCapability::AddPath { + elements: elements.iter().map(AfiSafi::from).collect(), + }, + c => BgpCapability::Unknown(c.code() as u8), + } + } } #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct PeerInfo { - pub state: FsmStateKind, + pub name: String, + pub peer_group: String, + pub fsm_state: FsmStateKind, + pub fsm_state_duration: u64, pub asn: Option, - pub duration_millis: u64, + pub id: Option, + pub local_ip: IpAddr, + pub remote_ip: IpAddr, + pub local_tcp_port: u16, + pub remote_tcp_port: u16, + pub received_capabilities: Vec, pub timers: PeerTimers, + pub counters: PeerCounters, + pub ipv4_unicast: Ipv4UnicastConfig, + pub ipv6_unicast: Ipv6UnicastConfig, } #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] @@ -648,6 +900,7 @@ pub enum PolicyKind { #[derive( Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, JsonSchema, )] +#[schemars(rename = "FsmStateKind")] pub enum FsmStateKindV1 { /// Initial state. Refuse all incomming BGP connections. No resources /// allocated to peer. @@ -693,15 +946,16 @@ impl From for FsmStateKindV1 { } #[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "PeerInfo")] pub struct PeerInfoV1 { pub state: FsmStateKindV1, pub asn: Option, pub duration_millis: u64, - pub timers: PeerTimers, + pub timers: PeerTimersV1, } -impl From for PeerInfoV1 { - fn from(info: PeerInfo) -> Self { +impl From for PeerInfoV1 { + fn from(info: PeerInfoV2) -> Self { Self { state: FsmStateKindV1::from(info.state), asn: info.asn, @@ -711,6 +965,22 @@ impl From for PeerInfoV1 { } } +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "PeerInfo")] +pub struct PeerInfoV2 { + pub state: FsmStateKind, + pub asn: Option, + pub duration_millis: u64, + pub timers: PeerTimersV1, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[schemars(rename = "PeerTimers")] +pub struct PeerTimersV1 { + pub hold: DynamicTimerInfoV1, + pub keepalive: DynamicTimerInfoV1, +} + // ============================================================================ // API Types for VERSION_MP_BGP / v3.0.0 // ============================================================================ diff --git a/bgp/src/router.rs b/bgp/src/router.rs index f9094edc..6dd1ee2a 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -295,6 +295,7 @@ impl Router { let neighbor = NeighborInfo { name: Arc::new(Mutex::new(peer.name.clone())), + peer_group: peer.group.clone(), host: peer.host, }; diff --git a/bgp/src/session.rs b/bgp/src/session.rs index d6612c96..bf4a8851 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -18,7 +18,10 @@ use crate::{ OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, UpdateMessage, }, - params::{Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange}, + params::{ + BgpCapability, DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, + JitterRange, PeerCounters, PeerInfo, PeerTimers, + }, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, router::Router, @@ -35,7 +38,7 @@ use slog::Logger; use std::{ collections::{BTreeSet, VecDeque}, fmt::{self, Display, Formatter}, - net::{IpAddr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, sync::{ Arc, Mutex, RwLock, atomic::{AtomicBool, AtomicU64, Ordering}, @@ -906,6 +909,7 @@ impl SessionInfo { #[derive(Debug, Clone)] pub struct NeighborInfo { pub name: Arc>, + pub peer_group: String, pub host: SocketAddr, } @@ -1504,7 +1508,7 @@ pub struct SessionRunner { id: u32, /// Capabilities to send to the peer - caps_tx: Arc>>, + pub caps_tx: Arc>>, shutdown: AtomicBool, running: AtomicBool, @@ -8548,6 +8552,17 @@ impl SessionRunner { None } + /// Return the learned remote BGP-ID of the peer (if any). + pub fn remote_id(&self) -> Option { + // Query the registry's primary connection + if let Some(ConnectionKind::Full(pc)) = + lock!(self.connection_registry).primary() + { + return Some(pc.id); + } + None + } + /// Return how long the BGP peer state machine has been in the current /// state. pub fn current_state_duration(&self) -> Duration { @@ -8777,6 +8792,166 @@ impl SessionRunner { pub fn connection_count(&self) -> u8 { lock!(self.connection_registry).count() } + + fn get_counters(&self) -> PeerCounters { + PeerCounters::from(self.counters.as_ref()) + } + + fn get_timers(&self) -> PeerTimers { + let connect_retry = lock!(self.clock.timers.connect_retry).remaining(); + let idle_hold = lock!(self.clock.timers.idle_hold).remaining(); + let session_conf = lock!(self.session); + let connect_retry_jitter = session_conf.connect_retry_jitter; + let idle_hold_jitter = session_conf.idle_hold_jitter; + + match self.primary_connection() { + Some(primary) => { + let clock = primary.connection().clock(); + PeerTimers { + hold: DynamicTimerInfo { + configured: clock.timers.config_hold_time, + negotiated: lock!(clock.timers.hold).interval, + remaining: lock!(clock.timers.hold).remaining(), + }, + keepalive: DynamicTimerInfo { + configured: clock.timers.config_keepalive_time, + negotiated: lock!(clock.timers.keepalive).interval, + remaining: lock!(clock.timers.keepalive).remaining(), + }, + connect_retry, + connect_retry_jitter, + idle_hold, + idle_hold_jitter, + delay_open: lock!(clock.timers.delay_open).remaining(), + } + } + None => PeerTimers { + hold: DynamicTimerInfo { + configured: session_conf.hold_time, + negotiated: session_conf.hold_time, + remaining: session_conf.hold_time, + }, + keepalive: DynamicTimerInfo { + configured: session_conf.keepalive_time, + negotiated: session_conf.keepalive_time, + remaining: session_conf.keepalive_time, + }, + connect_retry, + connect_retry_jitter, + idle_hold, + idle_hold_jitter, + delay_open: session_conf.delay_open_time, + }, + } + } + + pub fn get_peer_info(&self) -> PeerInfo { + let fsm_state = self.state(); + let dur = self.current_state_duration().as_millis() % u64::MAX as u128; + let fsm_state_duration = dur as u64; + let counters = self.get_counters(); + let name = lock!(self.neighbor.name).clone(); + let peer_group = self.neighbor.peer_group.clone(); + let session_conf = lock!(self.session); + let ipv4_unicast = + session_conf + .ipv4_unicast + .clone() + .unwrap_or(Ipv4UnicastConfig { + nexthop: None, + import_policy: Default::default(), + export_policy: Default::default(), + }); + let ipv6_unicast = + session_conf + .ipv6_unicast + .clone() + .unwrap_or(Ipv6UnicastConfig { + nexthop: None, + import_policy: Default::default(), + export_policy: Default::default(), + }); + + match self.primary_connection() { + Some(pconn) => match pconn { + ConnectionKind::Partial(conn) => { + let local = conn.local(); + let remote = conn.peer(); + PeerInfo { + name, + peer_group: peer_group.clone(), + fsm_state, + fsm_state_duration, + asn: None, + id: None, + local_ip: local.ip(), + remote_ip: remote.ip(), + local_tcp_port: local.port(), + remote_tcp_port: remote.port(), + received_capabilities: vec![], + timers: self.get_timers(), + counters, + ipv4_unicast, + ipv6_unicast, + } + } + ConnectionKind::Full(pc) => { + let local = pc.conn.local(); + let remote = pc.conn.peer(); + let received_capabilities = + pc.caps.iter().map(BgpCapability::from).collect(); + PeerInfo { + name, + peer_group: peer_group.clone(), + fsm_state, + fsm_state_duration, + asn: Some(pc.asn), + id: Some(pc.id), + local_ip: local.ip(), + remote_ip: remote.ip(), + local_tcp_port: local.port(), + remote_tcp_port: remote.port(), + received_capabilities, + timers: self.get_timers(), + counters, + ipv4_unicast, + ipv6_unicast, + } + } + }, + None => { + let remote_ip = self.neighbor.host.ip(); + // We don't have an active connection, so just display the + // configured next-hop if it's set or use the unspec addr if not + let local_ip = match remote_ip { + IpAddr::V4(_) => ipv4_unicast + .nexthop + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + IpAddr::V6(_) => ipv6_unicast + .nexthop + .unwrap_or(IpAddr::V6(Ipv6Addr::UNSPECIFIED)), + }; + + PeerInfo { + name, + peer_group: peer_group.clone(), + fsm_state, + fsm_state_duration, + asn: None, + id: None, + local_ip, + remote_ip, + local_tcp_port: 0u16, + remote_tcp_port: self.neighbor.host.port(), + received_capabilities: vec![], + timers: self.get_timers(), + counters, + ipv4_unicast, + ipv6_unicast, + } + } + } + } } // ============================================================================ diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 9323fe7e..79897b02 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -317,6 +317,7 @@ where // Create PeerConfig from neighbor's configuration for compatibility with new_session let peer_config = PeerConfig { name: neighbor.peer_name.clone(), + group: String::new(), host: neighbor.remote_host, hold_time: 6, idle_hold_time: 0, @@ -733,6 +734,7 @@ fn three_router_chain_helper< remote_host: r2_addr, session_info: SessionInfo::from_peer_config(&PeerConfig { name: "r2".into(), + group: String::new(), host: r2_addr, hold_time: 6, idle_hold_time: 0, @@ -755,6 +757,7 @@ fn three_router_chain_helper< remote_host: r1_addr, session_info: SessionInfo::from_peer_config(&PeerConfig { name: "r1".into(), + group: String::new(), host: r1_addr, hold_time: 6, idle_hold_time: 0, @@ -769,6 +772,7 @@ fn three_router_chain_helper< remote_host: r3_addr, session_info: SessionInfo::from_peer_config(&PeerConfig { name: "r3".into(), + group: String::new(), host: r3_addr, hold_time: 6, idle_hold_time: 0, @@ -791,6 +795,7 @@ fn three_router_chain_helper< remote_host: r2_addr, session_info: SessionInfo::from_peer_config(&PeerConfig { name: "r2".into(), + group: String::new(), host: r2_addr, hold_time: 6, idle_hold_time: 0, @@ -993,6 +998,7 @@ fn test_neighbor_thread_lifecycle_no_leaks() { let r1_peer_config = PeerConfig { name: "r2".into(), + group: String::new(), host: r2_addr, hold_time: 6, idle_hold_time: 0, @@ -1004,6 +1010,7 @@ fn test_neighbor_thread_lifecycle_no_leaks() { let r2_peer_config = PeerConfig { name: "r1".into(), + group: String::new(), host: r1_addr, hold_time: 6, idle_hold_time: 0, @@ -1168,6 +1175,7 @@ fn test_import_export_policy_filtering() { // Configure r1 with export policy let r1_peer_config = PeerConfig { name: "r2".into(), + group: String::new(), host: r2_addr, hold_time: 6, idle_hold_time: 0, @@ -1188,6 +1196,7 @@ fn test_import_export_policy_filtering() { // Configure r2 with import policy let r2_peer_config = PeerConfig { name: "r1".into(), + group: String::new(), host: r1_addr, hold_time: 6, idle_hold_time: 0, diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 7a7cdbac..42524fab 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -12,7 +12,7 @@ use bfd::BfdPeerState; use bgp::{ params::{ ApplyRequest, ApplyRequestV1, CheckerSource, Neighbor, NeighborResetOp, - NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, Router, + NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, PeerInfoV2, Router, ShaperSource, }, session::{FsmEventRecord, MessageHistory, MessageHistoryV1}, @@ -272,10 +272,16 @@ pub trait MgAdminApi { request: Query, ) -> Result>, HttpError>; - #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_IPV6_BASIC.. }] + #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_IPV6_BASIC..VERSION_MP_BGP }] async fn get_neighbors_v2( rqctx: RequestContext, request: Query, + ) -> Result>, HttpError>; + + #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_MP_BGP.. }] + async fn get_neighbors_v3( + rqctx: RequestContext, + request: Query, ) -> Result>, HttpError>; // V1/V2 API - ApplyRequestV1 with combined import/export policies diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index 7a578706..bf17ae2b 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -834,7 +834,7 @@ async fn delete_router(asn: u32, c: Client) -> Result<()> { } async fn get_neighbors(c: Client, asn: u32) -> Result<()> { - let result = c.get_neighbors_v2(asn).await?; + let result = c.get_neighbors_v3(asn).await?; let mut sorted: Vec<_> = result.iter().collect(); sorted.sort_by_key(|(ip, _)| ip.parse::().ok()); @@ -857,9 +857,9 @@ async fn get_neighbors(c: Client, asn: u32) -> Result<()> { "{}\t{:?}\t{:?}\t{:}\t{}/{}\t{}/{}", addr, info.asn, - info.state, + info.fsm_state, humantime::Duration::from(Duration::from_millis( - info.duration_millis + info.fsm_state_duration ),), humantime::Duration::from(Duration::from_secs( info.timers.hold.configured.secs diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 79dd1e75..cba7140f 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -320,10 +320,17 @@ impl MgAdminApi for MgAdminApiImpl { async fn get_neighbors_v2( ctx: RequestContext, request: Query, - ) -> Result>, HttpError> { + ) -> Result>, HttpError> { bgp_admin::get_neighbors_v2(ctx, request).await } + async fn get_neighbors_v3( + ctx: RequestContext, + request: Query, + ) -> Result>, HttpError> { + bgp_admin::get_neighbors_v3(ctx, request).await + } + async fn bgp_apply( ctx: RequestContext, request: TypedBody, diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index 22f2da0b..0c04f34d 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -571,16 +571,16 @@ pub async fn get_neighbors( ) }; - let pi = PeerInfo { + let pi = PeerInfoV2 { state: s.state(), asn: s.remote_asn(), duration_millis: dur as u64, - timers: PeerTimers { - hold: DynamicTimerInfo { + timers: PeerTimersV1 { + hold: DynamicTimerInfoV1 { configured: conf_holdtime, negotiated: neg_holdtime, }, - keepalive: DynamicTimerInfo { + keepalive: DynamicTimerInfoV1 { configured: conf_keepalive, negotiated: neg_keepalive, }, @@ -596,7 +596,7 @@ pub async fn get_neighbors( pub async fn get_neighbors_v2( ctx: RequestContext>, request: Query, -) -> Result>, HttpError> { +) -> Result>, HttpError> { let rq = request.into_inner(); let ctx = ctx.context(); @@ -630,16 +630,16 @@ pub async fn get_neighbors_v2( peers.insert( s.neighbor.host.ip(), - PeerInfo { + PeerInfoV2 { state: s.state(), asn: s.remote_asn(), duration_millis: dur as u64, - timers: PeerTimers { - hold: DynamicTimerInfo { + timers: PeerTimersV1 { + hold: DynamicTimerInfoV1 { configured: conf_holdtime, negotiated: neg_holdtime, }, - keepalive: DynamicTimerInfo { + keepalive: DynamicTimerInfoV1 { configured: conf_keepalive, negotiated: neg_keepalive, }, @@ -651,6 +651,26 @@ pub async fn get_neighbors_v2( Ok(HttpResponseOk(peers)) } +pub async fn get_neighbors_v3( + ctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + let mut peers = HashMap::new(); + let routers = lock!(ctx.bgp.router); + let r = routers + .get(&rq.asn) + .ok_or(HttpError::for_not_found(None, "ASN not found".to_string()))?; + + for s in lock!(r.sessions).values() { + peers.insert(s.neighbor.host.ip(), s.get_peer_info()); + } + + Ok(HttpResponseOk(peers)) +} + pub async fn bgp_apply( ctx: RequestContext>, request: TypedBody, @@ -1154,11 +1174,12 @@ pub(crate) mod helpers { idle_hold_time: Duration::from_secs(rq.idle_hold_time), delay_open_time: Duration::from_secs(rq.delay_open), resolution: Duration::from_millis(rq.resolution), - idle_hold_jitter: Some(JitterRange { + // insert default values for fields not present in the v1 API + idle_hold_jitter: None, + connect_retry_jitter: Some(JitterRange { min: 0.75, max: 1.0, }), - connect_retry_jitter: None, deterministic_collision_resolution: false, }; @@ -1263,12 +1284,10 @@ pub(crate) mod helpers { idle_hold_time: Duration::from_secs(rq.idle_hold_time), delay_open_time: Duration::from_secs(rq.delay_open), resolution: Duration::from_millis(rq.resolution), - idle_hold_jitter: Some(JitterRange { - min: 0.75, - max: 1.0, - }), - connect_retry_jitter: None, - deterministic_collision_resolution: false, + idle_hold_jitter: rq.idle_hold_jitter, + connect_retry_jitter: rq.connect_retry_jitter, + deterministic_collision_resolution: rq + .deterministic_collision_resolution, }; let start_session = if ensure { diff --git a/openapi/mg-admin/mg-admin-3.0.0-61ffa9.json b/openapi/mg-admin/mg-admin-3.0.0-aca2d9.json similarity index 90% rename from openapi/mg-admin/mg-admin-3.0.0-61ffa9.json rename to openapi/mg-admin/mg-admin-3.0.0-aca2d9.json index d7fd4901..f863f85e 100644 --- a/openapi/mg-admin/mg-admin-3.0.0-61ffa9.json +++ b/openapi/mg-admin/mg-admin-3.0.0-aca2d9.json @@ -1003,7 +1003,7 @@ }, "/bgp/status/neighbors": { "get": { - "operationId": "get_neighbors_v2", + "operationId": "get_neighbors_v3", "parameters": [ { "in": "query", @@ -1405,6 +1405,25 @@ "routes" ] }, + "AfiSafi": { + "type": "object", + "properties": { + "afi": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + }, "Aggregator": { "description": "AGGREGATOR path attribute (RFC 4271 §5.1.8)\n\nThe AGGREGATOR attribute is an optional transitive attribute that contains the AS number and IP address of the last BGP speaker that formed the aggregate route.", "type": "object", @@ -1670,8 +1689,81 @@ } ] }, + "BgpCapability": { + "oneOf": [ + { + "type": "string", + "enum": [ + "RouteRefresh" + ] + }, + { + "type": "object", + "properties": { + "MultiprotocolExtensions": { + "$ref": "#/components/schemas/AfiSafi" + } + }, + "required": [ + "MultiprotocolExtensions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "FourOctetAsn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "FourOctetAsn" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "AddPath": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AfiSafi" + } + } + }, + "required": [ + "elements" + ] + } + }, + "required": [ + "AddPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Unknown": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "Unknown" + ], + "additionalProperties": false + } + ] + }, "BgpNexthop": { - "description": "BGP next-hops can come in multiple forms, defined in several different RFCs. This enum represents the forms supported by this implementation.\n\nIn the case of IPv6, RFC 2545 defined the use of either: 1) A single non-link-local next-hop (length=16) 2) A non-link-local plus a link-local next-hop (length=32)\n\nThis does not account for only a link-local address as the sole next-hop. As such, many different implementations decided they would encode this in a variety of ways (since there was no canonical source of truth): a) Single-address encoding just the link-local (length=16) b) Double-address encoding the link-local in both positions (length=32) c) Double-address encoding the link-local in its normal position, but 0's in the non-link-local position (length=32) etc. This led to `draft-ietf-idr-linklocal-capability` which specifies more detailed encoding and error handling standards, signaled via a new Link-Local Next Hop Capability.\n\nIn addition to this, RFC 8950 (formerly RFC 5549) specified the advertisement of IPv4 NLRI via an IPv6 next-hop, enabled via the Extended Next Hop capability. This excerpt contains the encoding logic from RFC 8950: ```text Specifically, this document allows advertising the MP_REACH_NLRI attribute [RFC4760] with this content:\n\n* AFI = 1\n\n* SAFI = 1, 2, or 4\n\n* Length of Next Hop Address = 16 or 32\n\n* Next Hop Address = IPv6 address of a next hop (potentially followed by the link-local IPv6 address of the next hop). This field is to be constructed as per Section 3 of [RFC2545].\n\n* NLRI = NLRI as per the AFI/SAFI definition\n\n[..]\n\nThis is in addition to the existing mode of operation allowing advertisement of NLRI for of <1/1>, <1/2>, and <1/4> with a next-hop address of an IPv4 type and advertisement of NLRI for an of <1/128> and <1/129> with a next-hop address of a VPN- IPv4 type.\n\nThe BGP speaker receiving the advertisement MUST use the Length of Next Hop Address field to determine which network-layer protocol the next-hop address belongs to.\n\n* When the AFI/SAFI is <1/1>, <1/2>, or <1/4> and when the Length of Next Hop Address field is equal to 16 or 32, the next-hop address is of type IPv6.\n\n* When the AFI/SAFI is <1/128> or <1/129> and when the Length of Next Hop Address field is equal to 24 or 48, the next-hop address is of type VPN-IPv6. ```\n\nRFC 8950 also goes on to state that Extended Next Hop is not specified for any AFI/SAFI other than IPv4 {Unicast, Multicast, Labeled Unicast, Unicast VPN, Multicast VPN}, because IPv4 next-hops can already be signaled within IPv6 or VPN-IPv6 encoding (via IPv4-mapped IPv6 addresses).\n\nSo for our purposes, IPv4 Unicast NLRI may have Next-hops in the form of: a) IPv4 nexthop b) IPv6 single GUA (w/ Extended Next-Hop) c) IPv6 single LL (w/ Extended Next-Hop + Link-Local Next Hop) d) IPv6 double (w/ Extended Next-Hop)\n\nand IPv6 Unicast NLRI may have Next-hops in the form of: a) IPv6 single (IPv4-mapped) b) IPv6 single GUA c) IPv6 single LL (w/ Link-Local Next Hop) d) IPv6 double", + "description": "A BGP next-hop address in one of three formats: IPv4, IPv6 single, or IPv6 double.", "oneOf": [ { "type": "object", @@ -2482,11 +2574,15 @@ }, "negotiated": { "$ref": "#/components/schemas/Duration" + }, + "remaining": { + "$ref": "#/components/schemas/Duration" } }, "required": [ "configured", - "negotiated" + "negotiated", + "remaining" ] }, "Error": { @@ -3986,6 +4082,272 @@ } ] }, + "PeerCounters": { + "description": "Session-level counters that persist across connection changes These serve as aggregate counters across all connections for the session", + "type": "object", + "properties": { + "active_connections_accepted": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "active_connections_declined": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_counter": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connection_retries": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connector_panics": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "hold_timer_expirations": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_timer_expirations": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalives_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalives_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "md5_auth_failures": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notification_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notifications_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notifications_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "open_handle_failures": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "open_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "opens_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "opens_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "passive_connections_accepted": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "passive_connections_declined": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "prefixes_advertised": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "prefixes_imported": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "tcp_connection_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_active": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_connect": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_connection_collision": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_established": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_idle": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_open_confirm": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_open_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_session_setup": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_keepalive_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_notification_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_open_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_route_refresh_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_update_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "update_nexhop_missing": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "update_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "updates_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "updates_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "active_connections_accepted", + "active_connections_declined", + "connect_retry_counter", + "connection_retries", + "connector_panics", + "hold_timer_expirations", + "idle_hold_timer_expirations", + "keepalive_send_failure", + "keepalives_received", + "keepalives_sent", + "md5_auth_failures", + "notification_send_failure", + "notifications_received", + "notifications_sent", + "open_handle_failures", + "open_send_failure", + "opens_received", + "opens_sent", + "passive_connections_accepted", + "passive_connections_declined", + "prefixes_advertised", + "prefixes_imported", + "route_refresh_received", + "route_refresh_send_failure", + "route_refresh_sent", + "tcp_connection_failure", + "transitions_to_active", + "transitions_to_connect", + "transitions_to_connection_collision", + "transitions_to_established", + "transitions_to_idle", + "transitions_to_open_confirm", + "transitions_to_open_sent", + "transitions_to_session_setup", + "unexpected_keepalive_message", + "unexpected_notification_message", + "unexpected_open_message", + "unexpected_route_refresh_message", + "unexpected_update_message", + "update_nexhop_missing", + "update_send_failure", + "updates_received", + "updates_sent" + ] + }, "PeerInfo": { "type": "object", "properties": { @@ -3995,36 +4357,119 @@ "format": "uint32", "minimum": 0 }, - "duration_millis": { + "counters": { + "$ref": "#/components/schemas/PeerCounters" + }, + "fsm_state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "fsm_state_duration": { "type": "integer", "format": "uint64", "minimum": 0 }, - "state": { - "$ref": "#/components/schemas/FsmStateKind" + "id": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ipv4_unicast": { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + }, + "ipv6_unicast": { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + }, + "local_ip": { + "type": "string", + "format": "ip" + }, + "local_tcp_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "peer_group": { + "type": "string" + }, + "received_capabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpCapability" + } + }, + "remote_ip": { + "type": "string", + "format": "ip" + }, + "remote_tcp_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 }, "timers": { "$ref": "#/components/schemas/PeerTimers" } }, "required": [ - "duration_millis", - "state", + "counters", + "fsm_state", + "fsm_state_duration", + "ipv4_unicast", + "ipv6_unicast", + "local_ip", + "local_tcp_port", + "name", + "peer_group", + "received_capabilities", + "remote_ip", + "remote_tcp_port", "timers" ] }, "PeerTimers": { "type": "object", "properties": { + "connect_retry": { + "$ref": "#/components/schemas/Duration" + }, + "connect_retry_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "$ref": "#/components/schemas/Duration" + }, "hold": { "$ref": "#/components/schemas/DynamicTimerInfo" }, + "idle_hold": { + "$ref": "#/components/schemas/Duration" + }, + "idle_hold_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, "keepalive": { "$ref": "#/components/schemas/DynamicTimerInfo" } }, "required": [ + "connect_retry", + "delay_open", "hold", + "idle_hold", "keepalive" ] }, From 7e318e860da7a3fe72f3c5ef4bc69ab309d1cd56 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Fri, 2 Jan 2026 17:52:18 -0700 Subject: [PATCH 11/20] bgp: add nexthop override update handler - Adds handler for nexthop override updates - Removes PathAttributesChanged FSM event This was functionally equivalent to ReadvertiseRoutes except it didn't have an address-family discriminator. When a change occurs that affects both address-families, two ReadvertiseRoute events can be sent in place of one PathAttributesChanged event. --- bgp/src/session.rs | 76 +++++++++---------- bgp/src/test.rs | 180 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 43 deletions(-) diff --git a/bgp/src/session.rs b/bgp/src/session.rs index bf4a8851..3626a0bd 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -526,9 +526,6 @@ pub enum AdminEvent { /// Fires when we need to re-send our routes to the peer. ReAdvertiseRoutes(Afi), - - /// Fires when path attributes have changed. - PathAttributesChanged, } impl AdminEvent { @@ -552,7 +549,6 @@ impl AdminEvent { Afi::Ipv4 => "re-advertise routes (ipv4 unicast)", Afi::Ipv6 => "re-advertise routes (ipv6 unicast)", }, - AdminEvent::PathAttributesChanged => "path attributes changed", } } } @@ -2154,8 +2150,7 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -2400,8 +2395,7 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -2763,8 +2757,7 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -3138,8 +3131,7 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log!( self, @@ -3719,8 +3711,7 @@ impl SessionRunner { | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log!( self, @@ -4245,8 +4236,7 @@ impl SessionRunner { | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); collision_log!( self, @@ -5101,8 +5091,7 @@ impl SessionRunner { | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart | AdminEvent::SendRouteRefresh(_) - | AdminEvent::ReAdvertiseRoutes(_) - | AdminEvent::PathAttributesChanged => { + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); collision_log!( self, @@ -6310,23 +6299,6 @@ impl SessionRunner { FsmState::Established(pc) } - AdminEvent::PathAttributesChanged => { - match self.originate_update(&pc, ShaperApplication::Current) - { - Err(e) => { - session_log!( - self, - error, - pc.conn, - "failed to originate update, fsm transition to idle"; - "error" => format!("{e}") - ); - self.exit_established(pc) - } - Ok(()) => FsmState::Established(pc), - } - } - AdminEvent::ManualStart => { let title = admin_event.title(); session_log_lite!( @@ -8628,7 +8600,8 @@ impl SessionRunner { info: SessionInfo, ) -> Result { let mut reset_needed = false; - let mut path_attributes_changed = false; + let mut readvertise_needed4 = false; + let mut readvertise_needed6 = false; let mut refresh_needed4 = false; let mut refresh_needed6 = false; let mut current = lock!(self.session); @@ -8657,12 +8630,14 @@ impl SessionRunner { if current.multi_exit_discriminator != info.multi_exit_discriminator { current.multi_exit_discriminator = info.multi_exit_discriminator; - path_attributes_changed = true; + readvertise_needed4 = true; + readvertise_needed6 = true; } if current.communities != info.communities { current.communities.clone_from(&info.communities); - path_attributes_changed = true; + readvertise_needed4 = true; + readvertise_needed6 = true; } if current.local_pref != info.local_pref { @@ -8694,6 +8669,21 @@ impl SessionRunner { refresh_needed6 = true; } + // Handle per-AF nexthop override changes (trigger re-advertisement) + if current.ipv4_unicast.as_ref().map(|c| c.nexthop) + != info.ipv4_unicast.as_ref().map(|c| c.nexthop) + { + current.ipv4_unicast = info.ipv4_unicast.clone(); + readvertise_needed4 = true; + } + + if current.ipv6_unicast.as_ref().map(|c| c.nexthop) + != info.ipv6_unicast.as_ref().map(|c| c.nexthop) + { + current.ipv6_unicast = info.ipv6_unicast.clone(); + readvertise_needed6 = true; + } + if current.vlan_id != info.vlan_id { current.vlan_id = info.vlan_id; reset_needed = true; @@ -8753,9 +8743,15 @@ impl SessionRunner { drop(current); - if path_attributes_changed { + if readvertise_needed4 { + self.event_tx + .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes(Afi::Ipv4))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } + + if readvertise_needed6 { self.event_tx - .send(FsmEvent::Admin(AdminEvent::PathAttributesChanged)) + .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes(Afi::Ipv6))) .map_err(|e| Error::EventSend(e.to_string()))?; } diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 79897b02..e2a78231 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -644,7 +644,9 @@ fn basic_update_helper< // Originate and verify routes based on route_exchange variant match route_exchange { - RouteExchange::Ipv4 { .. } => { + RouteExchange::Ipv4 { + nexthop: initial_nexthop, + } => { // IPv4-only: originate and verify IPv4 prefix r1.router .create_origin4(vec![cidr!("1.2.3.0/24")]) @@ -653,13 +655,61 @@ fn basic_update_helper< let prefix_rdb = Prefix::V4(cidr!("1.2.3.0/24")); wait_for!(!r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + // Verify initial nexthop if one was configured and test override change + let paths = r2.router.db.get_prefix_paths(&prefix_rdb); + assert_eq!(paths.len(), 1); + if let Some(initial_nh) = initial_nexthop { + assert_eq!(paths[0].nexthop, initial_nh); + + // Test nexthop override change + let new_nexthop = match initial_nh { + IpAddr::V4(_) => ip!("10.255.255.254"), + IpAddr::V6(_) => unreachable!(), + }; + + let peer_config = PeerConfig { + name: "r2".into(), + group: String::new(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let mut session_info = create_test_session_info( + route_exchange, + r1_addr, + r2_addr, + false, + ); + session_info.ipv4_unicast.as_mut().unwrap().nexthop = + Some(new_nexthop); + + r1.router + .update_session(peer_config, session_info) + .expect("update nexthop"); + + // Verify nexthop change is reflected in re-advertised route + wait_for!( + { + let paths = r2.router.db.get_prefix_paths(&prefix_rdb); + !paths.is_empty() && paths[0].nexthop == new_nexthop + }, + "nexthop should be updated" + ); + } + // Shut down r1 and verify withdrawal r1.shutdown(); wait_for_neq!(r1_session.state(), FsmStateKind::Established); wait_for_neq!(r2_session.state(), FsmStateKind::Established); wait_for!(r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); } - RouteExchange::Ipv6 { .. } => { + RouteExchange::Ipv6 { + nexthop: initial_nexthop, + } => { // IPv6-only: originate and verify IPv6 prefix r1.router .create_origin6(vec![cidr!("3fff:db8::/32")]) @@ -668,13 +718,62 @@ fn basic_update_helper< let prefix_rdb = Prefix::V6(cidr!("3fff:db8::/32")); wait_for!(!r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); + // Verify initial nexthop if one was configured and test override change + let paths = r2.router.db.get_prefix_paths(&prefix_rdb); + assert_eq!(paths.len(), 1); + if let Some(initial_nh) = initial_nexthop { + assert_eq!(paths[0].nexthop, initial_nh); + + // Test nexthop override change + let new_nexthop = match initial_nh { + IpAddr::V6(_) => ip!("3fff:ffff:ffff:ffff::ffff:fffe"), + IpAddr::V4(_) => unreachable!(), + }; + + let peer_config = PeerConfig { + name: "r2".into(), + group: String::new(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let mut session_info = create_test_session_info( + route_exchange, + r1_addr, + r2_addr, + false, + ); + session_info.ipv6_unicast.as_mut().unwrap().nexthop = + Some(new_nexthop); + + r1.router + .update_session(peer_config, session_info) + .expect("update nexthop"); + + // Verify nexthop change is reflected in re-advertised route + wait_for!( + { + let paths = r2.router.db.get_prefix_paths(&prefix_rdb); + !paths.is_empty() && paths[0].nexthop == new_nexthop + }, + "nexthop should be updated" + ); + } + // Shut down r1 and verify withdrawal r1.shutdown(); wait_for_neq!(r1_session.state(), FsmStateKind::Established); wait_for_neq!(r2_session.state(), FsmStateKind::Established); wait_for!(r2.router.db.get_prefix_paths(&prefix_rdb).is_empty()); } - RouteExchange::DualStack { .. } => { + RouteExchange::DualStack { + ipv4_nexthop, + ipv6_nexthop, + } => { // Dual-stack: originate and verify both IPv4 and IPv6 prefixes r1.router .create_origin4(vec![cidr!("1.2.3.0/24")]) @@ -689,6 +788,81 @@ fn basic_update_helper< wait_for!(!r2.router.db.get_prefix_paths(&prefix4_rdb).is_empty()); wait_for!(!r2.router.db.get_prefix_paths(&prefix6_rdb).is_empty()); + // Verify initial nexthops if configured + let paths4 = r2.router.db.get_prefix_paths(&prefix4_rdb); + assert_eq!(paths4.len(), 1); + if let Some(expected_nexthop) = ipv4_nexthop { + assert_eq!(paths4[0].nexthop, expected_nexthop); + } + + let paths6 = r2.router.db.get_prefix_paths(&prefix6_rdb); + assert_eq!(paths6.len(), 1); + if let Some(expected_nexthop) = ipv6_nexthop { + assert_eq!(paths6[0].nexthop, expected_nexthop); + } + + // Test nexthop override changes if any were configured + if ipv4_nexthop.is_some() || ipv6_nexthop.is_some() { + let new_ipv4_nexthop = + ipv4_nexthop.map(|_| ip!("10.255.255.254")); + let new_ipv6_nexthop = + ipv6_nexthop.map(|_| ip!("3fff:ffff:ffff:ffff::ffff:fffe")); + + let peer_config = PeerConfig { + name: "r2".into(), + group: String::new(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let mut session_info = create_test_session_info( + route_exchange, + r1_addr, + r2_addr, + false, + ); + if let Some(nexthop) = new_ipv4_nexthop { + session_info.ipv4_unicast.as_mut().unwrap().nexthop = + Some(nexthop); + } + if let Some(nexthop) = new_ipv6_nexthop { + session_info.ipv6_unicast.as_mut().unwrap().nexthop = + Some(nexthop); + } + + r1.router + .update_session(peer_config, session_info) + .expect("update nexthop"); + + // Verify IPv4 nexthop change if applicable + if let Some(new_nh) = new_ipv4_nexthop { + wait_for!( + { + let paths = + r2.router.db.get_prefix_paths(&prefix4_rdb); + !paths.is_empty() && paths[0].nexthop == new_nh + }, + "IPv4 nexthop should be updated" + ); + } + + // Verify IPv6 nexthop change if applicable + if let Some(new_nh) = new_ipv6_nexthop { + wait_for!( + { + let paths = + r2.router.db.get_prefix_paths(&prefix6_rdb); + !paths.is_empty() && paths[0].nexthop == new_nh + }, + "IPv6 nexthop should be updated" + ); + } + } + // Shut down r1 and verify withdrawal of both r1.shutdown(); wait_for_neq!(r1_session.state(), FsmStateKind::Established); From 5ea560d0fe36858e81ced9d360d5100168a8deff Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Fri, 2 Jan 2026 18:46:43 -0700 Subject: [PATCH 12/20] rdb: use HashMap keying for path identity management Replace BTreeSet with HashMap in the RIB to explicitly track path identity via PathKey. Paths from the same source (peer ID for BGP, nexthop+vlan_id for static routes) now properly replace each other rather than coexisting. This ensures correct bestpath selection when routes are updated. Adds new unit tests for bestpaths and rib insertion/removal to ensure path identity properties are upheld. --- mg-api/src/lib.rs | 44 ++-- mg-lower/src/lib.rs | 2 +- mg-lower/src/test.rs | 4 + mgd/src/rib_admin.rs | 4 +- rdb/src/bestpath.rs | 542 +++++++++++++++++++++++++++++-------------- rdb/src/db.rs | 455 +++++++++++++++++++++++++++--------- rdb/src/types.rs | 29 +++ 7 files changed, 775 insertions(+), 305 deletions(-) diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 42524fab..817e6eeb 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -4,6 +4,7 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, + iter::FromIterator, net::{IpAddr, Ipv4Addr, Ipv6Addr}, num::NonZeroU8, }; @@ -640,34 +641,47 @@ pub struct Rib(BTreeMap>); impl From for Rib { fn from(value: rdb::db::Rib) -> Self { - Rib(value.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) + Rib(value + .into_iter() + .map(|(k, v)| (k.to_string(), v.into_values().collect())) + .collect()) + } +} + +impl FromIterator<(rdb::Prefix, BTreeSet)> for Rib { + fn from_iter)>>( + iter: T, + ) -> Self { + Rib(iter.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) } } pub fn filter_rib_by_protocol( - rib: BTreeMap>, + rib: BTreeMap>, protocol_filter: Option, -) -> BTreeMap> { +) -> Rib { match protocol_filter { - None => rib, - Some(filter) => { - let mut filtered = BTreeMap::new(); - - for (prefix, paths) in rib { + None => rib + .into_iter() + .map(|(prefix, paths)| (prefix, paths.into_values().collect())) + .collect(), + Some(filter) => rib + .into_iter() + .filter_map(|(prefix, paths)| { let filtered_paths: BTreeSet = paths - .into_iter() + .into_values() .filter(|path| match filter { ProtocolFilter::Bgp => path.bgp.is_some(), ProtocolFilter::Static => path.bgp.is_none(), }) .collect(); - if !filtered_paths.is_empty() { - filtered.insert(prefix, filtered_paths); + if filtered_paths.is_empty() { + None + } else { + Some((prefix, filtered_paths)) } - } - - filtered - } + }) + .collect(), } } diff --git a/mg-lower/src/lib.rs b/mg-lower/src/lib.rs index 3b374b0b..9203063b 100644 --- a/mg-lower/src/lib.rs +++ b/mg-lower/src/lib.rs @@ -224,7 +224,7 @@ pub(crate) fn sync_prefix( // The best routes in the RIB let mut best: HashSet = HashSet::new(); if let Some(paths) = rib_loc.get(prefix) { - for path in paths { + for path in paths.values() { best.insert(RouteHash::for_prefix_path(sw, *prefix, path.clone())?); } } diff --git a/mg-lower/src/test.rs b/mg-lower/src/test.rs index 7ccf6f16..ae264363 100644 --- a/mg-lower/src/test.rs +++ b/mg-lower/src/test.rs @@ -38,6 +38,7 @@ async fn sync_prefix_test() { vlan_id: None, }] .into_iter() + .map(|p| (p.key(), p)) .collect(), ); @@ -241,6 +242,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() + .map(|p| (p.key(), p)) .collect(), ); rib.insert( @@ -253,6 +255,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() + .map(|p| (p.key(), p)) .collect(), ); rib.insert( @@ -265,6 +268,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() + .map(|p| (p.key(), p)) .collect(), ); } diff --git a/mgd/src/rib_admin.rs b/mgd/src/rib_admin.rs index 3abedc09..2ab479fd 100644 --- a/mgd/src/rib_admin.rs +++ b/mgd/src/rib_admin.rs @@ -21,7 +21,7 @@ pub async fn get_rib_imported( let query = query.into_inner(); let imported = ctx.db.full_rib(query.address_family); let filtered = filter_rib_by_protocol(imported, query.protocol); - Ok(HttpResponseOk(filtered.into())) + Ok(HttpResponseOk(filtered)) } pub async fn get_rib_selected( @@ -32,7 +32,7 @@ pub async fn get_rib_selected( let query = query.into_inner(); let selected = ctx.db.loc_rib(query.address_family); let filtered = filter_rib_by_protocol(selected, query.protocol); - Ok(HttpResponseOk(filtered.into())) + Ok(HttpResponseOk(filtered)) } pub async fn read_rib_bestpath_fanout( diff --git a/rdb/src/bestpath.rs b/rdb/src/bestpath.rs index e6099d4d..98ff5f77 100644 --- a/rdb/src/bestpath.rs +++ b/rdb/src/bestpath.rs @@ -2,9 +2,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::collections::BTreeSet; +use std::collections::HashMap; -use crate::types::Path; +use crate::types::{Path, PathKey}; use itertools::Itertools; /// The bestpath algorithm chooses the best set of up to `max` paths for a @@ -26,39 +26,55 @@ use itertools::Itertools; /// multi-exit discriminator (MED) on a per-AS basis. /// /// Upon completion of these filtering operations, if the selection group -/// is larger than `max`, return the first `max` entries. This is a set, -/// so "first" has no semantic meaning, consider it to be random. If the +/// is larger than `max`, return the first `max` entries. If the /// selection group is smaller than `max`, the entire group is returned. -pub fn bestpaths(paths: &BTreeSet, max: usize) -> Option> { +pub fn bestpaths( + paths: &HashMap, + max: usize, +) -> Option> { + // Short-circuit: if there are no candidates, there is no best path + if paths.is_empty() { + return None; + } + // Short-circuit: if there's only 1 candidate, then it is the best if paths.len() == 1 { return Some(paths.clone()); } + // Extract references to path values from the HashMap + let path_refs: Vec<&Path> = paths.values().collect(); + // Partition the choice space on whether routes are shutdown or not. If we // only have shutdown routes then use those. Otherwise use active routes - let (active, shutdown): (BTreeSet<&Path>, BTreeSet<&Path>) = - paths.iter().partition(|x| x.shutdown); + let (active, shutdown): (Vec<&Path>, Vec<&Path>) = + path_refs.into_iter().partition(|x| x.shutdown); let candidates = if active.is_empty() { shutdown } else { active }; // Filter down to paths with the best (lowest) RIB priority. This is a // coarse filter to roughly separate RIB paths by protocol (e.g. BGP vs Static), // similar to Administrative Distance on Cisco-like platforms. - let candidates = candidates + let candidates: Vec<&Path> = candidates .into_iter() - .min_set_by_key(|path| path.rib_priority); + .min_set_by_key(|path| path.rib_priority) + .into_iter() + .collect(); // In the case where paths come from multiple protocols but have the same // RIB priority, follow the principle of least surprise. e.g. If a user has // configured a static route with the same RIB priority as BGP is using, // prefer the Static route. // TODO: update this if new upper layer protocols are added - let (b, s): (BTreeSet<&Path>, BTreeSet<&Path>) = + let (b, s): (Vec<&Path>, Vec<&Path>) = candidates.into_iter().partition(|path| path.bgp.is_some()); // Some paths are static, return up to `max` paths from static routes if !s.is_empty() { - return Some(s.into_iter().take(max).cloned().collect()); + let mut result = HashMap::new(); + for path in s.into_iter().take(max) { + result.insert(path.key(), path.clone()); + } + return Some(result); } // None of the remaining paths are static. @@ -69,225 +85,403 @@ pub fn bestpaths(paths: &BTreeSet, max: usize) -> Option> { /// The BGP-specific portion of the bestpath algorithm. This evaluates BGP path /// attributes in order to determine up to `max` suitable paths. pub fn bgp_bestpaths( - candidates: BTreeSet<&Path>, + candidates: Vec<&Path>, max: usize, -) -> BTreeSet { +) -> HashMap { // Filter down to paths that are not stale (Graceful Restart). // The `min_set_by_key` method allows us to assign "not stale" paths to the // `0` set, and "stale" paths to the `1` set. The method will then return // the `0` set if any "not stale" paths exist. - let candidates = - candidates - .into_iter() - .min_set_by_key(|path| match path.bgp { - Some(ref bgp) => match bgp.stale { - Some(_) => 1, - None => 0, - }, + let candidates: Vec<&Path> = candidates + .into_iter() + .min_set_by_key(|path| match path.bgp { + Some(ref bgp) => match bgp.stale { + Some(_) => 1, None => 0, - }); + }, + None => 0, + }) + .into_iter() + .collect(); // Filter down to paths with the highest local preference - let candidates = - candidates - .into_iter() - .max_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.local_pref.unwrap_or(0), - None => 0, - }); + let candidates: Vec<&Path> = candidates + .into_iter() + .max_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.local_pref.unwrap_or(0), + None => 0, + }) + .into_iter() + .collect(); // Filter down to paths with the shortest AS-Path length - let candidates = - candidates - .into_iter() - .min_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.as_path.len(), - None => 0, - }); + let candidates: Vec<&Path> = candidates + .into_iter() + .min_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.as_path.len(), + None => 0, + }) + .into_iter() + .collect(); - // Group candidates by AS for MED selection - let as_groups = candidates.into_iter().chunk_by(|path| match path.bgp { - Some(ref bgp) => bgp.origin_as, - None => 0, - }); + // Group candidates by AS for MED selection using HashMap + let mut as_groups: HashMap> = HashMap::new(); + for path in candidates { + let origin_as = match path.bgp { + Some(ref bgp) => bgp.origin_as, + None => 0, + }; + as_groups.entry(origin_as).or_default().push(path); + } // Filter AS groups to paths with lowest MED - let candidates = as_groups.into_iter().flat_map(|(_asn, paths)| { - paths.min_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.med.unwrap_or(0), - None => 0, + let mut candidates: Vec<&Path> = as_groups + .into_iter() + .flat_map(|(_asn, paths)| { + paths + .into_iter() + .min_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.med.unwrap_or(0), + None => 0, + }) + .into_iter() + .collect::>() }) - }); + .collect(); - // Return up to max elements - candidates.take(max).cloned().collect() + // Return up to max elements, in deterministic order (sorted by nexthop for consistency) + candidates.sort_by_key(|p| p.nexthop); + candidates.truncate(max); + candidates + .into_iter() + .cloned() + .map(|p| (p.key(), p)) + .collect() } #[cfg(test)] mod test { - use std::collections::BTreeSet; + use std::collections::HashMap; use std::net::IpAddr; use std::str::FromStr; use super::bestpaths; use crate::{ BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, - DEFAULT_RIB_PRIORITY_STATIC, Path, + DEFAULT_RIB_PRIORITY_STATIC, Path, PathKey, }; - // Bestpaths is purely a function of the path info itself, so we don't - // need a Rib or Prefix, just a set of candidate paths and a set of - // expected paths. - #[test] - fn test_bestpath() { - let mut max: usize = 2; - let remote_ip1 = IpAddr::from_str("203.0.113.1").unwrap(); - let remote_ip2 = IpAddr::from_str("203.0.113.2").unwrap(); - let remote_ip3 = IpAddr::from_str("203.0.113.3").unwrap(); - let remote_ip4 = IpAddr::from_str("203.0.113.4").unwrap(); - - // Add one path and make sure we get it back - let path1 = Path { - nexthop: remote_ip1, + // Helper function to create a BGP path with common attributes + fn make_bgp_path( + nexthop: IpAddr, + peer: IpAddr, + origin_as: u32, + local_pref: u32, + med: u32, + as_path: Vec, + ) -> Path { + Path { + nexthop, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { - origin_as: 470, - peer: remote_ip1, - id: 47, - med: Some(75), - local_pref: Some(100), - as_path: vec![470, 64501, 64502], + origin_as, + peer, + id: origin_as, + med: Some(med), + local_pref: Some(local_pref), + as_path, stale: None, }), vlan_id: None, - }; + } + } - let mut candidates = BTreeSet::::new(); - candidates.insert(path1.clone()); + // Helper function to create a static path + fn make_static_path( + nexthop: IpAddr, + vlan_id: Option, + priority: u8, + ) -> Path { + Path { + nexthop, + rib_priority: priority, + shutdown: false, + bgp: None, + vlan_id, + } + } + + // Helper function to assert a path exists in the result + fn assert_path_in_result(result: &HashMap, expected: &Path) { + assert!( + result.values().any(|p| p == expected), + "Expected path with nexthop {:?} not found in result", + expected.nexthop + ); + } + + // Bestpaths is purely a function of the path info itself, so we don't + // need a Rib or Prefix, just a set of candidate paths and a set of + // expected paths. + #[test] + fn test_bestpath() { + let peer1 = IpAddr::from_str("203.0.113.1").unwrap(); + let peer2 = IpAddr::from_str("203.0.113.2").unwrap(); + let peer3 = IpAddr::from_str("203.0.113.3").unwrap(); + let peer4 = IpAddr::from_str("203.0.113.4").unwrap(); + let mut max = 2; + + // Add one path and make sure we get it back + let path1 = + make_bgp_path(peer1, peer1, 470, 100, 75, vec![470, 64501, 64502]); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result, BTreeSet::from([path1.clone()])); - - // Add path2: - let mut path2 = Path { - nexthop: remote_ip2, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as: 480, - peer: remote_ip2, - id: 48, - med: Some(75), - local_pref: Some(100), - as_path: vec![480, 64501, 64502], - stale: None, - }), - vlan_id: None, - }; + assert_path_in_result(&result, &path1); - candidates.insert(path2.clone()); + // Add path2 with same local_pref, as_path, and med + let path2 = + make_bgp_path(peer2, peer2, 480, 100, 75, vec![480, 64501, 64502]); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); let result = bestpaths(&candidates, max).unwrap(); - // we expect both paths to be selected because path1 and path2 have: - // - matching local-pref - // - matching as-path-len - // - matching med + // we expect both paths to be selected (ECMP) assert_eq!(result.len(), 2); - assert_eq!(result, BTreeSet::from([path1.clone(), path2.clone()])); - - // Add path3 with: - // - matching local-pref - // - matching as-path-len - // - worse (higher) med - let mut path3 = Path { - nexthop: remote_ip3, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as: 490, - peer: remote_ip3, - id: 49, - med: Some(100), - local_pref: Some(100), - as_path: vec![490, 64501, 64502], - stale: None, - }), - vlan_id: None, - }; - let mut candidates = result.clone(); - candidates.insert(path3.clone()); + assert_path_in_result(&result, &path1); + assert_path_in_result(&result, &path2); + + // Add path3 with worse (higher) MED + let path3 = + make_bgp_path(peer3, peer3, 490, 100, 100, vec![490, 64501, 64502]); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 2); - // paths 1 and 2 should always be selected since they have the lowest MED - assert_eq!(result, BTreeSet::from([path1.clone(), path2.clone()])); - - // increase max paths to 3 - max = 3; + // paths 1 and 2 should be selected (lowest MED) + assert_path_in_result(&result, &path1); + assert_path_in_result(&result, &path2); - // set the med to 75 (matching path1/path2) and re-run bestpath w/ - // max paths set to 3. path3 should now be part of the ecmp group returned. - let mut candidates = result.clone(); - candidates.remove(&path3); + // Improve path3's MED to match path1 and path2 + let mut path3 = path3; path3.bgp.as_mut().unwrap().med = Some(75); - candidates.insert(path3.clone()); + max = 3; + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 3); - assert_eq!( - result, - BTreeSet::from([path1.clone(), path2.clone(), path3.clone()]) - ); + assert_path_in_result(&result, &path1); + assert_path_in_result(&result, &path2); + assert_path_in_result(&result, &path3); - // bump the local_pref on path2, this should make it the singular - // best path regardless of max paths - let mut candidates = result.clone(); - candidates.remove(&path2); + // Boost path2's local_pref - it should become the only best path + let mut path2 = path2; path2.bgp.as_mut().unwrap().local_pref = Some(125); - candidates.insert(path2.clone()); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result, BTreeSet::from([path2.clone()])); - - // Add a fourth path (which is static) and make sure that: - // - path 4 loses to BGP paths with higher RIB priority - // - path 4 wins over BGP paths with lower RIB priority - // - path 4 wins over BGP paths with equal RIB priority - // > static is preferred over bgp when RIB priority matches - let mut path4 = Path { - nexthop: remote_ip4, - rib_priority: u8::MAX, - shutdown: false, - bgp: None, - vlan_id: None, - }; - let mut candidates = result.clone(); - candidates.insert(path4.clone()); + assert_path_in_result(&result, &path2); + + // Test static route vs BGP with different RIB priorities + // path4 with poor (high) priority should lose to BGP + let path4_low_priority = make_static_path(peer4, None, u8::MAX); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); + candidates.insert(path4_low_priority.key(), path4_low_priority.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // path4 (static) has worse rib priority, path2 should win because it - // has the best (highest) local-pref among bgp paths (paths 1-3) - assert_eq!(result, BTreeSet::from([path2.clone()])); - - // Lower the RIB Priority (better) - let mut candidates = result.clone(); - candidates.remove(&path4); - path4.rib_priority = DEFAULT_RIB_PRIORITY_STATIC; - candidates.insert(path4.clone()); + // BGP path2 wins (priority 20 < 255) + assert_path_in_result(&result, &path2); + + // Lower path4's priority to static default - now it should win + let path4_static_priority = + make_static_path(peer4, None, DEFAULT_RIB_PRIORITY_STATIC); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); + candidates + .insert(path4_static_priority.key(), path4_static_priority.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // path4 (static) has the best (lower) rib priority - assert_eq!(result, BTreeSet::from([path4.clone()])); - - // Raise the RIB Priority equal to BGP (paths 1-3) - let mut candidates = result.clone(); - candidates.remove(&path4); - path4.rib_priority = DEFAULT_RIB_PRIORITY_BGP; - candidates.insert(path4.clone()); + // Static path4 wins (priority 1 < 20) + assert_path_in_result(&result, &path4_static_priority); + + // Set path4's priority equal to BGP - static should win due to protocol preference + let path4_bgp_priority = + make_static_path(peer4, None, DEFAULT_RIB_PRIORITY_BGP); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path3.key(), path3.clone()); + candidates.insert(path4_bgp_priority.key(), path4_bgp_priority.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // path4 (static) wins due to protocol preference - // i.e. static > bgp when rib priority matches - assert_eq!(result, BTreeSet::from([path4.clone()])); + // Static path4 wins over BGP (same priority, but static preferred) + assert_path_in_result(&result, &path4_bgp_priority); + } + + #[test] + fn test_pathkey_replacement_same_key() { + // Test that when two paths have the same PathKey, the second insertion + // replaces the first one in the HashMap. + let max = 1; + let nexthop = IpAddr::from_str("10.0.0.1").unwrap(); + + // Create path1 with priority 1 + let path1 = make_static_path(nexthop, None, 1); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + assert_eq!(candidates.len(), 1); + + // Create path2 with same nexthop and vlan_id but priority 10 + // This has the same PathKey as path1, so it should replace it + let path2 = make_static_path(nexthop, None, 10); + candidates.insert(path2.key(), path2.clone()); + assert_eq!( + candidates.len(), + 1, + "Expected replacement, but HashMap has 2 entries" + ); + + // Verify that path2 is the only one in the HashMap + assert_eq!(candidates.get(&path2.key()), Some(&path2)); + assert_eq!(candidates.get(&path1.key()), Some(&path2)); + + // Run bestpath and verify it uses path2's priority (10, which is worse than expected) + let result = bestpaths(&candidates, max).unwrap(); + assert_path_in_result(&result, &path2); + } + + #[test] + fn test_bestpath_max_parameter_limits() { + // Test that bestpaths respects the max parameter + let mut max = 5; + let nexthop1 = IpAddr::from_str("10.0.0.1").unwrap(); + let nexthop2 = IpAddr::from_str("10.0.0.2").unwrap(); + let peer1 = IpAddr::from_str("192.0.2.1").unwrap(); + let peer2 = IpAddr::from_str("192.0.2.2").unwrap(); + let peer3 = IpAddr::from_str("192.0.2.3").unwrap(); + let peer4 = IpAddr::from_str("192.0.2.4").unwrap(); + let peer5 = IpAddr::from_str("192.0.2.5").unwrap(); + + // Test case 1: 2 candidates, max=5 should return 2 + let path1 = make_bgp_path(nexthop1, peer1, 100, 100, 75, vec![100]); + let path2 = make_bgp_path(nexthop2, peer2, 100, 100, 75, vec![100]); + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1.clone()); + candidates.insert(path2.key(), path2.clone()); + let result = bestpaths(&candidates, max).unwrap(); + assert_eq!( + result.len(), + 2, + "With 2 candidates and max=5, expected 2 results" + ); + + // Test case 2: 5 ECMP candidates, max=2 should return 2 + let path3 = make_bgp_path( + IpAddr::from_str("10.0.0.3").unwrap(), + peer3, + 100, + 100, + 75, + vec![100], + ); + let path4 = make_bgp_path( + IpAddr::from_str("10.0.0.4").unwrap(), + peer4, + 100, + 100, + 75, + vec![100], + ); + let path5 = make_bgp_path( + IpAddr::from_str("10.0.0.5").unwrap(), + peer5, + 100, + 100, + 75, + vec![100], + ); + max = 2; + let mut candidates = HashMap::new(); + candidates.insert(path1.key(), path1); + candidates.insert(path2.key(), path2); + candidates.insert(path3.key(), path3); + candidates.insert(path4.key(), path4); + candidates.insert(path5.key(), path5); + let result = bestpaths(&candidates, max).unwrap(); + assert_eq!( + result.len(), + 2, + "With 5 candidates and max=2, expected 2 results" + ); + + // Test case 3: empty HashMap should return None + max = 10; + let empty: HashMap = HashMap::new(); + let result = bestpaths(&empty, max); + assert_eq!(result, None, "Empty candidates should return None"); + } + + #[test] + fn test_bestpath_deterministic_ordering() { + // Test that bestpath returns results in deterministic order (sorted by nexthop). + // This is important for consistent behavior across runs. + let max = 3; + let nh3 = IpAddr::from_str("10.0.0.3").unwrap(); + let nh1 = IpAddr::from_str("10.0.0.1").unwrap(); + let nh2 = IpAddr::from_str("10.0.0.2").unwrap(); + let peer1 = IpAddr::from_str("192.0.2.1").unwrap(); + let peer2 = IpAddr::from_str("192.0.2.2").unwrap(); + let peer3 = IpAddr::from_str("192.0.2.3").unwrap(); + + // Create 3 ECMP paths with identical attributes but different nexthops + // They have the same local_pref, med, as_path, so they're all equally good + let path3 = make_bgp_path(nh3, peer3, 100, 100, 75, vec![100]); + let path1 = make_bgp_path(nh1, peer1, 100, 100, 75, vec![100]); + let path2 = make_bgp_path(nh2, peer2, 100, 100, 75, vec![100]); + + // Add in reverse order (3, 2, 1) to ensure sorting doesn't just rely on insertion order + let mut candidates = HashMap::new(); + candidates.insert(path3.key(), path3.clone()); + candidates.insert(path2.key(), path2.clone()); + candidates.insert(path1.key(), path1.clone()); + + // Get results + let result = bestpaths(&candidates, max).unwrap(); + assert_eq!(result.len(), 3); + + // Convert to sorted vector to check order + let mut result_vec: Vec<_> = result.values().collect(); + result_vec.sort_by_key(|p| p.nexthop); + + // Verify they're sorted by nexthop + assert_eq!(result_vec[0].nexthop, nh1); + assert_eq!(result_vec[1].nexthop, nh2); + assert_eq!(result_vec[2].nexthop, nh3); + + // Run multiple times to ensure consistency (deterministic) + for _ in 0..5 { + let result = bestpaths(&candidates, max).unwrap(); + let mut result_vec: Vec<_> = result.values().collect(); + result_vec.sort_by_key(|p| p.nexthop); + assert_eq!(result_vec[0].nexthop, nh1); + assert_eq!(result_vec[1].nexthop, nh2); + assert_eq!(result_vec[2].nexthop, nh3); + } } } diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 9ba4fc7c..32bb0ec7 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -18,7 +18,7 @@ use mg_common::{lock, read_lock, write_lock}; use sled::Tree; use slog::{Logger, error}; use std::cmp::Ordering as CmpOrdering; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::net::{IpAddr, Ipv6Addr}; use std::num::NonZeroU8; use std::sync::atomic::{AtomicU64, Ordering}; @@ -68,9 +68,9 @@ const BESTPATH_FANOUT: &str = "bestpath_fanout"; /// Default bestpath fanout value. Maximum number of ECMP paths in RIB. const DEFAULT_BESTPATH_FANOUT: u8 = 1; -pub type Rib = BTreeMap>; -pub type Rib4 = BTreeMap>; -pub type Rib6 = BTreeMap>; +pub type Rib = BTreeMap>; +pub type Rib4 = BTreeMap>; +pub type Rib6 = BTreeMap>; /// The central routing information base. Both persistent an volatile route /// information is managed through this structure. @@ -548,14 +548,24 @@ impl Db { let rib = lock!(self.rib4_in); match rib.get(p4) { None => Vec::new(), - Some(p) => p.iter().cloned().collect(), + Some(paths) => { + let mut result: Vec = + paths.values().cloned().collect(); + result.sort(); + result + } } } Prefix::V6(p6) => { let rib = lock!(self.rib6_in); match rib.get(p6) { None => Vec::new(), - Some(p) => p.iter().cloned().collect(), + Some(paths) => { + let mut result: Vec = + paths.values().cloned().collect(); + result.sort(); + result + } } } } @@ -567,14 +577,14 @@ impl Db { let rib = lock!(self.rib4_loc); match rib.get(p4) { None => Vec::new(), - Some(p) => p.iter().cloned().collect(), + Some(paths) => paths.values().cloned().collect(), } } Prefix::V6(p6) => { let rib = lock!(self.rib6_loc); match rib.get(p6) { None => Vec::new(), - Some(p) => p.iter().cloned().collect(), + Some(paths) => paths.values().cloned().collect(), } } } @@ -601,8 +611,8 @@ impl Db { Some(paths) => { match bestpaths(paths, fanout.get() as usize) { // bestpath found at least 1 path for loc-rib - Some(bp) => { - rib_loc.insert(*prefix, bp.clone()); + Some(bp_paths) => { + rib_loc.insert(*prefix, bp_paths); } // bestpath found no suitable paths None => { @@ -638,8 +648,8 @@ impl Db { Some(paths) => { match bestpaths(paths, fanout.get() as usize) { // bestpath found at least 1 path for loc-rib - Some(bp) => { - rib_loc.insert(*prefix, bp.clone()); + Some(bp_paths) => { + rib_loc.insert(*prefix, bp_paths); } // bestpath found no suitable paths None => { @@ -659,7 +669,7 @@ impl Db { // bestpath is run against via the bestpath_needed closure pub fn trigger_bestpath_when(&self, bestpath_needed: F) where - F: Fn(&Prefix, &BTreeSet) -> bool, + F: Fn(&Prefix, &HashMap) -> bool, { { // only grab the lock once, release it once the loop ends @@ -691,12 +701,17 @@ impl Db { rib_in: &mut Rib4, rib_loc: &mut Rib4, ) { + let key = path.key(); match rib_in.get_mut(p4) { Some(paths) => { - paths.replace(path.clone()); + // HashMap::insert() will replace any existing path with the same key, + // ensuring only one path per source (peer for BGP, nexthop+vlan for static). + paths.insert(key, path.clone()); } None => { - rib_in.insert(*p4, BTreeSet::from([path.clone()])); + let mut paths = HashMap::new(); + paths.insert(key, path.clone()); + rib_in.insert(*p4, paths); } } self.update_rib4_loc(rib_in, rib_loc, p4); @@ -709,12 +724,17 @@ impl Db { rib_in: &mut Rib6, rib_loc: &mut Rib6, ) { + let key = path.key(); match rib_in.get_mut(p6) { Some(paths) => { - paths.replace(path.clone()); + // HashMap::insert() will replace any existing path with the same key, + // ensuring only one path per source (peer for BGP, nexthop+vlan for static). + paths.insert(key, path.clone()); } None => { - rib_in.insert(*p6, BTreeSet::from([path.clone()])); + let mut paths = HashMap::new(); + paths.insert(key, path.clone()); + rib_in.insert(*p6, paths); } } self.update_rib6_loc(rib_in, rib_loc, p6); @@ -901,11 +921,11 @@ impl Db { let mut rib4_in = lock!(self.rib4_in); let mut rib4_loc = lock!(self.rib4_loc); for (prefix, paths) in rib4_in.iter_mut() { - for p in paths.clone().into_iter() { - if p.nexthop == nexthop && p.shutdown != shutdown { - let mut replacement = p.clone(); + for (key, path) in paths.clone().into_iter() { + if path.nexthop == nexthop && path.shutdown != shutdown { + let mut replacement = path.clone(); replacement.shutdown = shutdown; - paths.insert(replacement); + paths.insert(key, replacement); pcn.changed.insert(Prefix::from(*prefix)); } } @@ -921,11 +941,11 @@ impl Db { let mut rib6_in = lock!(self.rib6_in); let mut rib6_loc = lock!(self.rib6_loc); for (prefix, paths) in rib6_in.iter_mut() { - for p in paths.clone().into_iter() { - if p.nexthop == nexthop && p.shutdown != shutdown { - let mut replacement = p.clone(); + for (key, path) in paths.clone().into_iter() { + if path.nexthop == nexthop && path.shutdown != shutdown { + let mut replacement = path.clone(); replacement.shutdown = shutdown; - paths.insert(replacement); + paths.insert(key, replacement); pcn6.changed.insert(Prefix::from(*prefix)); } } @@ -951,7 +971,7 @@ impl Db { F: Fn(&Path) -> bool, { if let Some(paths) = rib_in.get_mut(prefix) { - paths.retain(|p| !prefix_cmp(p)); + paths.retain(|_key, path| !prefix_cmp(path)); if paths.is_empty() { rib_in.remove(prefix); } @@ -970,7 +990,7 @@ impl Db { F: Fn(&Path) -> bool, { if let Some(paths) = rib_in.get_mut(prefix) { - paths.retain(|p| !prefix_cmp(p)); + paths.retain(|_key, path| !prefix_cmp(path)); if paths.is_empty() { rib_in.remove(prefix); } @@ -1230,44 +1250,26 @@ impl Db { pub fn mark_bgp_peer_stale4(&self, peer: IpAddr) { let mut rib = lock!(self.rib4_loc); - rib.iter_mut().for_each(|(_prefix, path)| { - let targets: Vec = path - .iter() - .filter_map(|p| { - if let Some(bgp) = p.bgp.as_ref() - && bgp.peer == peer - { - let mut marked = p.clone(); - marked.bgp = Some(bgp.as_stale()); - return Some(marked); - } - None - }) - .collect(); - for t in targets.into_iter() { - path.replace(t); + rib.iter_mut().for_each(|(_prefix, paths)| { + for (_key, path) in paths.iter_mut() { + if let Some(bgp) = path.bgp.as_mut() + && bgp.peer == peer + { + bgp.stale = Some(Utc::now()); + } } }); } pub fn mark_bgp_peer_stale6(&self, peer: IpAddr) { let mut rib = lock!(self.rib6_loc); - rib.iter_mut().for_each(|(_prefix, path)| { - let targets: Vec = path - .iter() - .filter_map(|p| { - if let Some(bgp) = p.bgp.as_ref() - && bgp.peer == peer - { - let mut marked = p.clone(); - marked.bgp = Some(bgp.as_stale()); - return Some(marked); - } - None - }) - .collect(); - for t in targets.into_iter() { - path.replace(t); + rib.iter_mut().for_each(|(_prefix, paths)| { + for (_key, path) in paths.iter_mut() { + if let Some(bgp) = path.bgp.as_mut() + && bgp.peer == peer + { + bgp.stale = Some(Utc::now()); + } } }); } @@ -1328,7 +1330,7 @@ impl Reaper { .unwrap() .iter_mut() .for_each(|(_prefix, paths)| { - paths.retain(|p| { + paths.retain(|_key, p| { p.bgp .as_ref() .map(|b| { @@ -1348,8 +1350,9 @@ impl Reaper { #[cfg(test)] mod test { use crate::{ - AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, - Prefix6, StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, + AddressFamily, BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, + DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, Prefix6, + StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, }; use mg_common::log::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -1382,13 +1385,23 @@ mod test { true } + // Helper function to create a static route key with explicit priority + fn make_static_route( + prefix: Prefix, + nexthop: IpAddr, + vlan_id: Option, + priority: u8, + ) -> StaticRouteKey { + StaticRouteKey { + prefix, + nexthop, + vlan_id, + rib_priority: priority, + } + } + #[test] fn test_rib() { - use crate::StaticRouteKey; - use crate::{ - BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, - DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, db::Db, - }; // init test vars let p0 = Prefix::from("192.168.0.0/24".parse::().unwrap()); let p1 = Prefix::from("192.168.1.0/24".parse::().unwrap()); @@ -1447,19 +1460,19 @@ mod test { }), vlan_id: None, }; - let static_key0 = StaticRouteKey { - prefix: p0, - nexthop: remote_ip0, - vlan_id: None, - rib_priority: DEFAULT_RIB_PRIORITY_STATIC, - }; + let static_key0 = make_static_route( + p0, + remote_ip0, + None, + DEFAULT_RIB_PRIORITY_STATIC, + ); let static_path0 = Path::from(static_key0); - let static_key1 = StaticRouteKey { - prefix: p0, - nexthop: remote_ip0, - vlan_id: None, - rib_priority: DEFAULT_RIB_PRIORITY_STATIC + 10, - }; + let static_key1 = make_static_route( + p0, + remote_ip0, + None, + DEFAULT_RIB_PRIORITY_STATIC + 10, + ); let static_path1 = Path::from(static_key1); // setup @@ -1475,29 +1488,35 @@ mod test { assert!(db.full_rib(None).is_empty()); assert!(db.loc_rib(None).is_empty()); - // both paths have the same next-hop, but not all fields - // from StaticRouteKey match (rib_priority is different). - db.add_static_routes(&[static_key0, static_key1]).expect( - "add_static_routes failed for {static_key0} and {static_key1}", - ); + // add static route with DEFAULT_RIB_PRIORITY_STATIC + db.add_static_routes(&[static_key0]) + .expect("add_static_routes failed for {static_key0}"); // expected current state // rib_in: - // - p0 via static_path0, static_path1 + // - p0 via static_path0 // loc_rib: - // - p0 via static_path0 (win by rib_priority) - let rib_in_paths = vec![static_path0.clone(), static_path1.clone()]; + // - p0 via static_path0 + let rib_in_paths = vec![static_path0.clone()]; let loc_rib_paths = vec![static_path0.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); - // rib_priority differs, so removal of static_key0 - // should not affect path from static_key1 - db.remove_static_routes(&[static_key0]) - .expect("remove_static_routes_failed for {static_key0}"); + // add a second static route with higher rib_priority (worse). + // Since it has the same (nexthop, vlan_id), it replaces static_key0. + // Only static_path1 (priority 11) remains in the RIB after replacement. + db.add_static_routes(&[static_key1]) + .expect("add_static_routes failed for {static_key1}"); let rib_in_paths = vec![static_path1.clone()]; let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); + // remove static_key1 + db.remove_static_routes(&[static_key1]) + .expect("remove_static_routes failed for {static_key1}"); + let rib_in_paths = Vec::new(); + let loc_rib_paths = Vec::new(); + assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); + // install bgp routes db.add_bgp_prefixes(&[p0, p1], bgp_path0.clone()); db.add_bgp_prefixes(&[p1, p2], bgp_path1.clone()); @@ -1505,15 +1524,15 @@ mod test { // expected current state // rib_in: - // - p0 via static_path1, bgp_path0 + // - p0 via bgp_path0 // - p1 via bgp_path{0,1,2} // - p2 via bgp_path{1,2} // loc_rib: - // - p0 via static_path1 (win by rib_priority/protocol) + // - p0 via bgp_path0 (only path available) // - p1 via bgp_path2 (win by local pref) // - p2 via bgp_path2 (win by local pref) - let rib_in_paths = vec![static_path1.clone(), bgp_path0.clone()]; - let loc_rib_paths = vec![static_path1.clone()]; + let rib_in_paths = vec![bgp_path0.clone()]; + let loc_rib_paths = vec![bgp_path0.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path0.clone(), bgp_path1.clone(), bgp_path2.clone()]; @@ -1527,15 +1546,15 @@ mod test { db.remove_bgp_prefixes(&[p2], &bgp_path1.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 via static_path1, bgp_path0 + // - p0 via bgp_path0 // - p1 via bgp_path{0,1,2} // - p2 via bgp_path2 // loc_rib: - // - p0 via static_path1 (win by rib_priority/protocol) + // - p0 via bgp_path0 (only path available) // - p1 via bgp_path2 (win by local pref) // - p2 via bgp_path2 (win by local pref) - let rib_in_paths = vec![static_path1.clone(), bgp_path0.clone()]; - let loc_rib_paths = vec![static_path1.clone()]; + let rib_in_paths = vec![bgp_path0.clone()]; + let loc_rib_paths = vec![bgp_path0.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path0.clone(), bgp_path1.clone(), bgp_path2.clone()]; @@ -1549,15 +1568,15 @@ mod test { db.remove_bgp_prefixes_from_peer(&bgp_path0.bgp.unwrap().peer); // expected current state // rib_in: - // - p0 via static_path1 + // - p0 is empty (bgp_path0 removed) // - p1 via bgp_path{1,2} // - p2 via bgp_path2 // loc_rib: - // - p0 via static_path1 (only path) + // - p0 is empty // - p1 via bgp_path2 (local pref) // - p2 via bgp_path2 (only path) - let rib_in_paths = vec![static_path1.clone()]; - let loc_rib_paths = vec![static_path1.clone()]; + let rib_in_paths = vec![]; + let loc_rib_paths = vec![]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path1.clone(), bgp_path2.clone()]; let loc_rib_paths = vec![bgp_path2.clone()]; @@ -1567,17 +1586,17 @@ mod test { assert!(check_prefix_path(&db, &p2, rib_in_paths, loc_rib_paths)); // yank all routes from bgp_path2, simulating peer shutdown - // bgp_path2 should be unaffected, despite also having the same RID + // bgp_path1 should be unaffected, despite also having the same RID db.remove_bgp_prefixes_from_peer(&bgp_path2.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 via static_path1 + // - p0 is empty // - p1 via bgp_path1 // loc_rib: - // - p0 via static_path1 (only path) + // - p0 is empty // - p1 via bgp_path1 (only path) - let rib_in_paths = vec![static_path1.clone()]; - let loc_rib_paths = vec![static_path1.clone()]; + let rib_in_paths = vec![]; + let loc_rib_paths = vec![]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path1.clone()]; let loc_rib_paths = vec![bgp_path1.clone()]; @@ -1587,15 +1606,15 @@ mod test { assert!(check_prefix_path(&db, &p2, rib_in_paths, loc_rib_paths)); // yank all routes from bgp_path1, simulating peer shutdown - // p0 should be unaffected, still retaining the static path + // p0 should already be empty from earlier removal db.remove_bgp_prefixes_from_peer(&bgp_path1.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 via static_path1 + // - p0 is empty // loc_rib: - // - p0 via static_path1 (only path) - let rib_in_paths = vec![static_path1.clone()]; - let loc_rib_paths = vec![static_path1.clone()]; + // - p0 is empty + let rib_in_paths = vec![]; + let loc_rib_paths = vec![]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![]; let loc_rib_paths = vec![]; @@ -1626,6 +1645,216 @@ mod test { assert!(db.loc_rib(None).is_empty()); } + #[test] + fn test_static_pathkey_coexistence() { + let db = get_test_db(); + let prefix = Prefix::from("192.168.1.0/24".parse::().unwrap()); + let nexthop = IpAddr::V4(Ipv4Addr::from_str("10.0.0.1").unwrap()); + + // Create two static routes with same prefix and nexthop but different vlan_id + // These should have different PathKeys and coexist in the RIB + let route_no_vlan = make_static_route( + prefix, + nexthop, + None, + DEFAULT_RIB_PRIORITY_STATIC, + ); + let route_with_vlan = make_static_route( + prefix, + nexthop, + Some(100), + DEFAULT_RIB_PRIORITY_STATIC, + ); + + // Add both routes + db.add_static_routes(&[route_no_vlan, route_with_vlan]) + .expect("Failed to add static routes"); + + // Verify both routes exist in rib_in (2 paths with different PathKeys) + let rib_in_paths = db.get_prefix_paths(&prefix); + assert_eq!( + rib_in_paths.len(), + 2, + "Expected 2 paths in rib_in (different vlan_id should not replace)" + ); + + // Verify both routes exist in loc_rib (with default max=1, only one should be selected) + let loc_rib_paths = db.get_selected_prefix_paths(&prefix); + assert_eq!( + loc_rib_paths.len(), + 1, + "With default bestpath max=1, only 1 should be selected" + ); + + // Now test with fanout=2 to allow both to be selected + db.set_bestpath_fanout(std::num::NonZeroU8::new(2).unwrap()) + .expect("Failed to set bestpath fanout"); + + let loc_rib_paths = db.get_selected_prefix_paths(&prefix); + assert_eq!( + loc_rib_paths.len(), + 2, + "With fanout=2, both paths should be in loc_rib" + ); + } + + #[test] + fn test_static_priority_update_affects_selection() { + let db = get_test_db(); + let prefix = Prefix::from("10.0.0.0/8".parse::().unwrap()); + let static_nexthop = + IpAddr::V4(Ipv4Addr::from_str("192.168.1.1").unwrap()); + let bgp_peer = IpAddr::V4(Ipv4Addr::from_str("203.0.113.1").unwrap()); + + // Add a static route with priority 1 (better than BGP default 20) + let static_route_priority_1 = + make_static_route(prefix, static_nexthop, None, 1); + db.add_static_routes(&[static_route_priority_1]) + .expect("Failed to add static route"); + + // Add a BGP route with priority 20 + let bgp_path = Path { + nexthop: bgp_peer, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 1111, + peer: bgp_peer, + id: 1111, + med: Some(0), + local_pref: Some(100), + as_path: vec![1111], + stale: None, + }), + vlan_id: None, + }; + db.add_bgp_prefixes(&[prefix], bgp_path.clone()); + + // Verify static route is selected (priority 1 < 20) + let loc_rib_paths = db.get_selected_prefix_paths(&prefix); + assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); + assert_eq!( + loc_rib_paths[0].nexthop, static_nexthop, + "Static route (priority 1) should be selected" + ); + + // Now update the static route with priority 30 (worse than BGP priority 20) + let static_route_priority_30 = + make_static_route(prefix, static_nexthop, None, 30); + db.add_static_routes(&[static_route_priority_30]) + .expect("Failed to update static route priority"); + + // Verify BGP route is now selected (priority 20 < 30) + let loc_rib_paths = db.get_selected_prefix_paths(&prefix); + assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); + assert_eq!( + loc_rib_paths[0].nexthop, bgp_peer, + "BGP route (priority 20) should now be selected" + ); + + // Update static route back to priority 1 + let static_route_priority_1_again = + make_static_route(prefix, static_nexthop, None, 1); + db.add_static_routes(&[static_route_priority_1_again]) + .expect("Failed to update static route priority back"); + + // Verify static route is selected again + let loc_rib_paths = db.get_selected_prefix_paths(&prefix); + assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); + assert_eq!( + loc_rib_paths[0].nexthop, static_nexthop, + "Static route (priority 1) should be selected again" + ); + } + + #[test] + fn test_pathkey_identity_matrix() { + let db = get_test_db(); + let prefix = Prefix::from("10.0.0.0/8".parse::().unwrap()); + + // Create paths with all different PathKey combinations: + let peer1 = IpAddr::V4(Ipv4Addr::from_str("203.0.113.1").unwrap()); + let peer2 = IpAddr::V4(Ipv4Addr::from_str("203.0.113.2").unwrap()); + let nh1 = IpAddr::V4(Ipv4Addr::from_str("192.168.1.1").unwrap()); + let nh2 = IpAddr::V4(Ipv4Addr::from_str("192.168.1.2").unwrap()); + + // BGP path 1: peer1 + let bgp_path1 = Path { + nexthop: peer1, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 1111, + peer: peer1, + id: 1111, + med: Some(100), + local_pref: Some(100), + as_path: vec![1111], + stale: None, + }), + vlan_id: None, + }; + + // BGP path 2: peer2 (different PathKey than path1) + let bgp_path2 = Path { + nexthop: peer2, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 2222, + peer: peer2, + id: 2222, + med: Some(100), + local_pref: Some(100), + as_path: vec![2222], + stale: None, + }), + vlan_id: None, + }; + + // Add BGP paths + db.add_bgp_prefixes(&[prefix], bgp_path1.clone()); + db.add_bgp_prefixes(&[prefix], bgp_path2.clone()); + + // Add static routes + let static_key1 = + make_static_route(prefix, nh1, None, DEFAULT_RIB_PRIORITY_STATIC); + let static_key2 = make_static_route( + prefix, + nh1, + Some(100), + DEFAULT_RIB_PRIORITY_STATIC, + ); + let static_key3 = + make_static_route(prefix, nh2, None, DEFAULT_RIB_PRIORITY_STATIC); + db.add_static_routes(&[static_key1, static_key2, static_key3]) + .expect("Failed to add static routes"); + + // Verify all 5 paths exist in rib_in (all different PathKeys) + let rib_in_paths = db.get_prefix_paths(&prefix); + assert_eq!( + rib_in_paths.len(), + 5, + "Expected all 5 paths with different PathKeys to coexist" + ); + + // Verify the paths have the expected nexthops + let nexthops: Vec = + rib_in_paths.iter().map(|p| p.nexthop).collect(); + assert!(nexthops.contains(&peer1)); + assert!(nexthops.contains(&peer2)); + assert!(nexthops.contains(&nh1)); + assert!(nexthops.contains(&nh2)); + + // Verify static routes are in storage + let stored_routes = db.get_static(Some(AddressFamily::Ipv4)).unwrap(); + assert_eq!( + stored_routes.len(), + 3, + "All 3 static routes should be stored" + ); + } + #[test] fn test_static_routing_ipv4_basic() { let db = get_test_db(); diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 9884b6f5..d66c2f24 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -44,6 +44,22 @@ pub struct Ipv4Marker; #[derive(Clone, Copy, Debug)] pub struct Ipv6Marker; +/// Uniquely identifies a path for deduplication. +/// Two paths with the same PathKey represent the same logical +/// path and should replace each other in the RIB. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub enum PathKey { + /// BGP path identified by peer IP. + /// All paths from the same peer replace each other. + // XXX: Include AddPathId when AddPath is fully supported + Bgp(IpAddr), + /// Static route identified by (nexthop, vlan_id) tuple. + Static { + nexthop: IpAddr, + vlan_id: Option, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] pub struct Path { pub nexthop: IpAddr, @@ -53,6 +69,19 @@ pub struct Path { pub vlan_id: Option, } +impl Path { + /// Returns the identity key for this path. + pub fn key(&self) -> PathKey { + match &self.bgp { + Some(bgp) => PathKey::Bgp(bgp.peer), + None => PathKey::Static { + nexthop: self.nexthop, + vlan_id: self.vlan_id, + }, + } + } +} + // Define a basic ordering on paths so bestpath selection is deterministic impl PartialOrd for Path { fn partial_cmp(&self, other: &Self) -> Option { From 890b2d5f3bba3b028a59cefa00fccdd23f90e458 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Fri, 2 Jan 2026 22:34:11 -0700 Subject: [PATCH 13/20] Fix RIB deadlock and optimize bestpath fanout reads Fix deadlock in trigger_bestpath_when() caused by re-acquiring already-held Mutex locks. Iterate over the acquired lock reference instead of calling full_rib*() which tries to re-acquire the same mutex. Optimize fanout reads by caching it once per operation instead of reading from persistent storage for each prefix. Apply optimization to both bestpath triggering and nexthop shutdown operations. Fix test database conflicts by using thread names to ensure each test function gets a unique database path. --- bgp/src/test.rs | 18 ++++++++++-- rdb/src/db.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/bgp/src/test.rs b/bgp/src/test.rs index e2a78231..10b61cc8 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -259,15 +259,27 @@ where None }; + // Extract the actual test function name from the thread name. + // The Rust test harness names threads like "test::test_function_name". + // This ensures each test function gets its own database, even if helpers + // are called with identical parameters by different tests. + let thread_name = std::thread::current(); + let actual_test_name = thread_name + .name() + .and_then(|name| name.split("::").last()) + .map(|s| s.to_string()) + .unwrap_or_else(|| test_name.to_string()); + // Create all routers first for logical_router in routers.iter() { let log = init_file_logger(&format!( - "{}.{test_name}.log", + "{}.{actual_test_name}.log", logical_router.name )); - // Create database - let db_path = format!("/tmp/{}.{test_name}.db", logical_router.name); + // Create database with unique path per test function + let db_path = + format!("/tmp/{}.{actual_test_name}.db", logical_router.name); let _ = std::fs::remove_dir_all(&db_path); let db = rdb::Db::new(&db_path, log.clone()).expect("create db"); diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 32bb0ec7..db8e8427 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -605,7 +605,16 @@ impl Db { ); NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() }); + self.do_update_rib4_loc(rib_in, rib_loc, prefix, fanout); + } + fn do_update_rib4_loc( + &self, + rib_in: &Rib4, + rib_loc: &mut Rib4, + prefix: &Prefix4, + fanout: NonZeroU8, + ) { match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { @@ -642,7 +651,16 @@ impl Db { ); NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() }); + self.do_update_rib6_loc(rib_in, rib_loc, prefix, fanout); + } + fn do_update_rib6_loc( + &self, + rib_in: &Rib6, + rib_loc: &mut Rib6, + prefix: &Prefix6, + fanout: NonZeroU8, + ) { match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { @@ -671,13 +689,29 @@ impl Db { where F: Fn(&Prefix, &HashMap) -> bool, { + // Read fanout once to avoid repeated disk I/O during the loop + let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { + rdb_log!( + self, + error, + "failed to get bestpath fanout: {e}"; + "unit" => UNIT_PERSISTENT + ); + NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() + }); + { // only grab the lock once, release it once the loop ends let rib4_in = lock!(self.rib4_in); let mut rib4_loc = lock!(self.rib4_loc); - for (prefix, paths) in self.full_rib4().iter() { + for (prefix, paths) in rib4_in.iter() { if bestpath_needed(&Prefix::from(*prefix), paths) { - self.update_rib4_loc(&rib4_in, &mut rib4_loc, prefix); + self.do_update_rib4_loc( + &rib4_in, + &mut rib4_loc, + prefix, + fanout, + ); } } } @@ -686,9 +720,14 @@ impl Db { // only grab the lock once, release it once the loop ends let rib6_in = lock!(self.rib6_in); let mut rib6_loc = lock!(self.rib6_loc); - for (prefix, paths) in self.full_rib6().iter() { + for (prefix, paths) in rib6_in.iter() { if bestpath_needed(&Prefix::from(*prefix), paths) { - self.update_rib6_loc(&rib6_in, &mut rib6_loc, prefix); + self.do_update_rib6_loc( + &rib6_in, + &mut rib6_loc, + prefix, + fanout, + ); } } } @@ -917,6 +956,18 @@ impl Db { pub fn set_nexthop_shutdown(&self, nexthop: IpAddr, shutdown: bool) { let mut pcn = PrefixChangeNotification::default(); let mut pcn6 = PrefixChangeNotification::default(); + + // Read fanout once to avoid repeated disk I/O during the loops + let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { + rdb_log!( + self, + error, + "failed to get bestpath fanout: {e}"; + "unit" => UNIT_PERSISTENT + ); + NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() + }); + { let mut rib4_in = lock!(self.rib4_in); let mut rib4_loc = lock!(self.rib4_loc); @@ -932,7 +983,12 @@ impl Db { } for prefix in pcn.changed.iter() { if let Prefix::V4(p4) = prefix { - self.update_rib4_loc(&rib4_in, &mut rib4_loc, p4); + self.do_update_rib4_loc( + &rib4_in, + &mut rib4_loc, + p4, + fanout, + ); } } } @@ -952,7 +1008,12 @@ impl Db { } for prefix in pcn6.changed.iter() { if let Prefix::V6(p6) = prefix { - self.update_rib6_loc(&rib6_in, &mut rib6_loc, p6); + self.do_update_rib6_loc( + &rib6_in, + &mut rib6_loc, + p6, + fanout, + ); } } } From cd55c15f49d3f9d3a956407c8ea04a0f54f7eae3 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 5 Jan 2026 17:14:25 +0000 Subject: [PATCH 14/20] Improve loopback-manager logging Open file-backed logger in append mode so old entries aren't lost. Use the thread name in the filename to avoid multiple processes stomping on each other. With a per-process instance of slog::Logger and no central authority across processes to coordinate writes, it's possible for two processes to corrupt the log file contents by writing at the same time. Signed-off-by: Trey Aspelund --- bgp/src/test.rs | 12 +++++++++++- mg-common/src/log.rs | 7 ++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bgp/src/test.rs b/bgp/src/test.rs index 10b61cc8..ed565a7a 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -50,7 +50,17 @@ lazy_static! { panic!("unsupported platform"); }; - let log = init_file_logger("loopback-manager.log"); + // Extract test name from thread name for per-test log files. + // With cargo nextest, each test runs in its own process, so this + // will be unique per test process. + let thread_name = std::thread::current(); + let test_name = thread_name + .name() + .and_then(|name| name.split("::").last()) + .unwrap_or("unknown"); + let log_filename = format!("loopback-manager.{}.log", test_name); + + let log = init_file_logger(&log_filename); Arc::new(Mutex::new(LoopbackIpManager::new(ifname, log))) }; diff --git a/mg-common/src/log.rs b/mg-common/src/log.rs index 33292e03..ad55ab05 100644 --- a/mg-common/src/log.rs +++ b/mg-common/src/log.rs @@ -11,7 +11,12 @@ pub fn init_logger() -> Logger { } pub fn init_file_logger(filename: &str) -> Logger { - build_logger(File::create(filename).expect("build logger")) + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(filename) + .expect("build logger"); + build_logger(file) } pub fn build_logger(w: W) -> Logger { From 267158594ee1badb88bee7fdf94805c310738019 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 5 Jan 2026 17:45:33 +0000 Subject: [PATCH 15/20] mg-common: fix addrobj format for loopbackmanager Signed-off-by: Trey Aspelund --- mg-common/src/test.rs | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index b2e5e177..8e1e568c 100644 --- a/mg-common/src/test.rs +++ b/mg-common/src/test.rs @@ -326,19 +326,24 @@ impl LoopbackIpManager { #[cfg(target_os = "illumos")] let output = { - let ip_descr = format!("{}", ip.address).replace('.', "dot"); - let addr_obj = format!("{}/test{}", ifname, ip_descr); - Command::new("pfexec") - .args([ - "ipadm", - "create-addr", - "-T", - "static", - "-a", - &addr_str, - &addr_obj, - ]) - .output()? + let v = match ip.address { + IpAddr::V4(_) => "v4", + IpAddr::V6(_) => "v6", + }; + let mut ip_descr = format!("{v}{}", ip.address); + ip_descr.retain(|c| c.is_alphanumeric()); + let addr_obj = format!("{}/{}", ifname, ip_descr); + let cmd = [ + "ipadm", + "create-addr", + "-T", + "static", + "-a", + &addr_str, + &addr_obj, + ]; + info!(log, "running cmd '{cmd:?}'"); + Command::new("pfexec").args(cmd).output()? }; #[cfg(target_os = "linux")] @@ -469,7 +474,12 @@ impl LoopbackIpManager { ) { #[cfg(target_os = "illumos")] let output = { - let ip_descr = format!("{}", ip.address).replace('.', "dot"); + let v = match ip.address { + IpAddr::V4(_) => "v4", + IpAddr::V6(_) => "v6", + }; + let mut ip_descr = format!("{v}{}", ip.address); + ip_descr.retain(|c| c.is_alphanumeric()); let addr_obj = format!("{}/test{}", ifname, ip_descr); Command::new("pfexec") .args(["ipadm", "delete-addr", &addr_obj]) From e77a1901e1d0a422299f3d01ba81ef1c16f3fab3 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Mon, 5 Jan 2026 18:06:47 +0000 Subject: [PATCH 16/20] remove unused import Signed-off-by: Trey Aspelund --- mg-common/src/log.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/mg-common/src/log.rs b/mg-common/src/log.rs index ad55ab05..ed8dfdb8 100644 --- a/mg-common/src/log.rs +++ b/mg-common/src/log.rs @@ -3,7 +3,6 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use slog::{Drain, Logger}; -use std::fs::File; use std::io::Write; pub fn init_logger() -> Logger { From f32f3f80cfb06dd347251b322ad916c1664d52d2 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Wed, 7 Jan 2026 00:55:05 +0000 Subject: [PATCH 17/20] Fixup bgp neighbor status Refactor codepath to collect peer information for bgp neighbor status. Fixes a deadlock in this codepath (locking the SessionInfo while it's already locked). Introduces StaticTimerInfo type to describe timers that don't get negotiated. Updates `mgadm bgp status neighbors` to take a --mode argument to allow user to optionally display all the neighbor details via `--mode detail`. Signed-off-by: Trey Aspelund --- bgp/src/clock.rs | 56 +++- bgp/src/params.rs | 47 +++- bgp/src/session.rs | 144 +++++----- mg-admin-client/src/lib.rs | 2 + mgadm/src/bgp.rs | 245 ++++++++++++++++-- mgd/src/bgp_admin.rs | 19 +- ...aca2d9.json => mg-admin-3.0.0-ea8abe.json} | 24 +- 7 files changed, 441 insertions(+), 96 deletions(-) rename openapi/mg-admin/{mg-admin-3.0.0-aca2d9.json => mg-admin-3.0.0-ea8abe.json} (99%) diff --git a/bgp/src/clock.rs b/bgp/src/clock.rs index a1e867e9..b2997f99 100644 --- a/bgp/src/clock.rs +++ b/bgp/src/clock.rs @@ -3,7 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use crate::connection::{BgpConnection, ConnectionId}; -use crate::params::JitterRange; +use crate::params::{DynamicTimerInfo, JitterRange}; use crate::session::{ConnectionEvent, FsmEvent, SessionEvent}; use mg_common::lock; use mg_common::thread::ManagedThread; @@ -178,6 +178,11 @@ impl Timer { pub fn set_jitter_range(&mut self, jitter_range: Option) { self.jitter_range = jitter_range; } + + /// Get the jitter range for this timer. + pub fn jitter_range(&self) -> Option { + self.jitter_range + } } impl Display for Timer { @@ -303,6 +308,27 @@ impl SessionClock { lock!(timers.connect_retry).stop(); lock!(timers.idle_hold).stop(); } + + /// Get a snapshot of session-level timer state + pub fn get_timer_snapshot(&self) -> SessionTimerSnapshot { + let connect_retry = lock!(self.timers.connect_retry); + let idle_hold = lock!(self.timers.idle_hold); + SessionTimerSnapshot { + connect_retry_remaining: connect_retry.remaining(), + connect_retry_jitter: connect_retry.jitter_range(), + idle_hold_remaining: idle_hold.remaining(), + idle_hold_jitter: idle_hold.jitter_range(), + } + } +} + +/// Snapshot of session-level timer state +#[derive(Debug, Clone)] +pub struct SessionTimerSnapshot { + pub connect_retry_remaining: Duration, + pub connect_retry_jitter: Option, + pub idle_hold_remaining: Duration, + pub idle_hold_jitter: Option, } impl Display for SessionClock { @@ -445,6 +471,34 @@ impl ConnectionClock { lock!(timers.hold).disable(); lock!(timers.delay_open).disable(); } + + /// Get a snapshot of connection-level timer state + pub fn get_timer_snapshot(&self) -> ConnectionTimerSnapshot { + let hold = lock!(self.timers.hold); + let keepalive = lock!(self.timers.keepalive); + let delay_open = lock!(self.timers.delay_open); + ConnectionTimerSnapshot { + hold: DynamicTimerInfo { + configured: self.timers.config_hold_time, + negotiated: hold.interval, + remaining: hold.remaining(), + }, + keepalive: DynamicTimerInfo { + configured: self.timers.config_keepalive_time, + negotiated: keepalive.interval, + remaining: keepalive.remaining(), + }, + delay_open_remaining: delay_open.remaining(), + } + } +} + +/// Snapshot of connection-level timer state +#[derive(Debug, Clone)] +pub struct ConnectionTimerSnapshot { + pub hold: DynamicTimerInfo, + pub keepalive: DynamicTimerInfo, + pub delay_open_remaining: Duration, } impl Display for ConnectionClock { diff --git a/bgp/src/params.rs b/bgp/src/params.rs index 91ef930f..92cfb806 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -5,7 +5,7 @@ use crate::{ config::PeerConfig, messages::{AddPathElement, Capability}, - session::{FsmStateKind, SessionCounters}, + session::{FsmStateKind, SessionCounters, SessionInfo}, }; use rdb::{ ImportExportPolicy, ImportExportPolicy4, ImportExportPolicy6, PolicyAction, @@ -74,6 +74,36 @@ impl std::str::FromStr for JitterRange { } } +/// Timer configuration extracted from SessionInfo. +/// This is a lightweight value type that can be cloned and passed without locks. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct TimerConfig { + pub connect_retry_time: Duration, + pub keepalive_time: Duration, + pub hold_time: Duration, + pub idle_hold_time: Duration, + pub delay_open_time: Duration, + pub resolution: Duration, + pub connect_retry_jitter: Option, + pub idle_hold_jitter: Option, +} + +impl TimerConfig { + /// Extract timer config from SessionInfo without holding lock + pub fn from_session_info(session: &SessionInfo) -> Self { + Self { + connect_retry_time: session.connect_retry_time, + keepalive_time: session.keepalive_time, + hold_time: session.hold_time, + idle_hold_time: session.idle_hold_time, + delay_open_time: session.delay_open_time, + resolution: session.resolution, + connect_retry_jitter: session.connect_retry_jitter, + idle_hold_jitter: session.idle_hold_jitter, + } + } +} + /// Per-address-family configuration for IPv4 Unicast #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] pub struct Ipv4UnicastConfig { @@ -471,22 +501,29 @@ pub struct DynamicTimerInfoV1 { pub negotiated: Duration, } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct DynamicTimerInfo { pub configured: Duration, pub negotiated: Duration, pub remaining: Duration, } +/// Timer information for static (non-negotiated) timers +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct StaticTimerInfo { + pub configured: Duration, + pub remaining: Duration, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct PeerTimers { pub hold: DynamicTimerInfo, pub keepalive: DynamicTimerInfo, - pub connect_retry: Duration, + pub connect_retry: StaticTimerInfo, pub connect_retry_jitter: Option, - pub idle_hold: Duration, + pub idle_hold: StaticTimerInfo, pub idle_hold_jitter: Option, - pub delay_open: Duration, + pub delay_open: StaticTimerInfo, } /// Session-level counters that persist across connection changes diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 3626a0bd..f0a1e62a 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -20,7 +20,8 @@ use crate::{ }, params::{ BgpCapability, DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, - JitterRange, PeerCounters, PeerInfo, PeerTimers, + JitterRange, PeerCounters, PeerInfo, PeerTimers, StaticTimerInfo, + TimerConfig, }, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, @@ -8793,54 +8794,6 @@ impl SessionRunner { PeerCounters::from(self.counters.as_ref()) } - fn get_timers(&self) -> PeerTimers { - let connect_retry = lock!(self.clock.timers.connect_retry).remaining(); - let idle_hold = lock!(self.clock.timers.idle_hold).remaining(); - let session_conf = lock!(self.session); - let connect_retry_jitter = session_conf.connect_retry_jitter; - let idle_hold_jitter = session_conf.idle_hold_jitter; - - match self.primary_connection() { - Some(primary) => { - let clock = primary.connection().clock(); - PeerTimers { - hold: DynamicTimerInfo { - configured: clock.timers.config_hold_time, - negotiated: lock!(clock.timers.hold).interval, - remaining: lock!(clock.timers.hold).remaining(), - }, - keepalive: DynamicTimerInfo { - configured: clock.timers.config_keepalive_time, - negotiated: lock!(clock.timers.keepalive).interval, - remaining: lock!(clock.timers.keepalive).remaining(), - }, - connect_retry, - connect_retry_jitter, - idle_hold, - idle_hold_jitter, - delay_open: lock!(clock.timers.delay_open).remaining(), - } - } - None => PeerTimers { - hold: DynamicTimerInfo { - configured: session_conf.hold_time, - negotiated: session_conf.hold_time, - remaining: session_conf.hold_time, - }, - keepalive: DynamicTimerInfo { - configured: session_conf.keepalive_time, - negotiated: session_conf.keepalive_time, - remaining: session_conf.keepalive_time, - }, - connect_retry, - connect_retry_jitter, - idle_hold, - idle_hold_jitter, - delay_open: session_conf.delay_open_time, - }, - } - } - pub fn get_peer_info(&self) -> PeerInfo { let fsm_state = self.state(); let dur = self.current_state_duration().as_millis() % u64::MAX as u128; @@ -8848,26 +8801,87 @@ impl SessionRunner { let counters = self.get_counters(); let name = lock!(self.neighbor.name).clone(); let peer_group = self.neighbor.peer_group.clone(); - let session_conf = lock!(self.session); - let ipv4_unicast = - session_conf - .ipv4_unicast - .clone() - .unwrap_or(Ipv4UnicastConfig { + + // Extract config and runtime state WITHOUT holding any locks long-term + let (ipv4_unicast, ipv6_unicast, timer_config) = { + let session_conf = lock!(self.session); + let ipv4 = session_conf.ipv4_unicast.clone().unwrap_or( + Ipv4UnicastConfig { nexthop: None, import_policy: Default::default(), export_policy: Default::default(), - }); - let ipv6_unicast = - session_conf - .ipv6_unicast - .clone() - .unwrap_or(Ipv6UnicastConfig { + }, + ); + let ipv6 = session_conf.ipv6_unicast.clone().unwrap_or( + Ipv6UnicastConfig { nexthop: None, import_policy: Default::default(), export_policy: Default::default(), - }); + }, + ); + let timers = TimerConfig::from_session_info(&session_conf); + (ipv4, ipv6, timers) + }; // Lock dropped here! + + // Get timer runtime state from clocks (no SessionInfo lock needed) + let session_timers = self.clock.get_timer_snapshot(); + + // Build PeerTimers from snapshots + let timers = match self.primary_connection() { + Some(primary) => { + let conn_timers = + primary.connection().clock().get_timer_snapshot(); + PeerTimers { + hold: conn_timers.hold, + keepalive: conn_timers.keepalive, + connect_retry: StaticTimerInfo { + configured: timer_config.connect_retry_time, + remaining: session_timers.connect_retry_remaining, + }, + connect_retry_jitter: session_timers.connect_retry_jitter, + idle_hold: StaticTimerInfo { + configured: timer_config.idle_hold_time, + remaining: session_timers.idle_hold_remaining, + }, + idle_hold_jitter: session_timers.idle_hold_jitter, + delay_open: StaticTimerInfo { + configured: timer_config.delay_open_time, + remaining: conn_timers.delay_open_remaining, + }, + } + } + None => { + // No connection - use configured values + PeerTimers { + hold: DynamicTimerInfo { + configured: timer_config.hold_time, + negotiated: timer_config.hold_time, + remaining: timer_config.hold_time, + }, + keepalive: DynamicTimerInfo { + configured: timer_config.keepalive_time, + negotiated: timer_config.keepalive_time, + remaining: timer_config.keepalive_time, + }, + connect_retry: StaticTimerInfo { + configured: timer_config.connect_retry_time, + remaining: session_timers.connect_retry_remaining, + }, + connect_retry_jitter: session_timers.connect_retry_jitter, + idle_hold: StaticTimerInfo { + configured: timer_config.idle_hold_time, + remaining: session_timers.idle_hold_remaining, + }, + idle_hold_jitter: session_timers.idle_hold_jitter, + delay_open: StaticTimerInfo { + configured: timer_config.delay_open_time, + remaining: timer_config.delay_open_time, + }, + } + } + }; + // Build and return PeerInfo match self.primary_connection() { Some(pconn) => match pconn { ConnectionKind::Partial(conn) => { @@ -8885,7 +8899,7 @@ impl SessionRunner { local_tcp_port: local.port(), remote_tcp_port: remote.port(), received_capabilities: vec![], - timers: self.get_timers(), + timers, counters, ipv4_unicast, ipv6_unicast, @@ -8908,7 +8922,7 @@ impl SessionRunner { local_tcp_port: local.port(), remote_tcp_port: remote.port(), received_capabilities, - timers: self.get_timers(), + timers, counters, ipv4_unicast, ipv6_unicast, @@ -8940,7 +8954,7 @@ impl SessionRunner { local_tcp_port: 0u16, remote_tcp_port: self.neighbor.host.port(), received_capabilities: vec![], - timers: self.get_timers(), + timers, counters, ipv4_unicast, ipv6_unicast, diff --git a/mg-admin-client/src/lib.rs b/mg-admin-client/src/lib.rs index c2e8843b..73f08faa 100644 --- a/mg-admin-client/src/lib.rs +++ b/mg-admin-client/src/lib.rs @@ -23,6 +23,8 @@ progenitor::generate_api!( AddressFamily = rdb_types::AddressFamily, ProtocolFilter = rdb_types::ProtocolFilter, JitterRange = bgp::params::JitterRange, + PeerInfo = bgp::params::PeerInfo, + Duration = std::time::Duration, } ); diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index bf17ae2b..6374ef80 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -17,7 +17,7 @@ use mg_admin_client::{ use rdb::types::{PolicyAction, Prefix4, Prefix6}; use std::fs::read_to_string; use std::io::{Write, stdout}; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use tabwriter::TabWriter; @@ -60,6 +60,15 @@ pub enum ConfigCmd { Policy(PolicySubcommand), } +#[derive(Clone, Debug, ValueEnum)] +#[allow(non_camel_case_types)] +pub enum NeighborDisplayMode { + /// Display summary information (default). + summary, + /// Display detailed information. + detail, +} + #[derive(Debug, Args)] pub struct StatusSubcommand { #[command(subcommand)] @@ -72,6 +81,10 @@ pub enum StatusCmd { Neighbors { #[clap(env)] asn: u32, + + /// Display mode: summary (default) or detail. + #[clap(long, value_enum, default_value = "summary")] + mode: NeighborDisplayMode, }, /// Get the prefixes exported by a BGP router. @@ -747,7 +760,9 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { }, Commands::Status(cmd) => match cmd.command { - StatusCmd::Neighbors { asn } => get_neighbors(c, asn).await?, + StatusCmd::Neighbors { asn, mode } => { + get_neighbors(c, asn, mode).await? + } StatusCmd::Exported { asn } => get_exported(c, asn).await?, }, @@ -833,11 +848,37 @@ async fn delete_router(asn: u32, c: Client) -> Result<()> { Ok(()) } -async fn get_neighbors(c: Client, asn: u32) -> Result<()> { +async fn get_neighbors( + c: Client, + asn: u32, + mode: NeighborDisplayMode, +) -> Result<()> { let result = c.get_neighbors_v3(asn).await?; let mut sorted: Vec<_> = result.iter().collect(); sorted.sort_by_key(|(ip, _)| ip.parse::().ok()); + match mode { + NeighborDisplayMode::summary => { + display_neighbors_summary(&sorted)?; + } + NeighborDisplayMode::detail => { + display_neighbors_detail(&sorted)?; + } + } + + Ok(()) +} + +/// Format a Duration as decimal seconds (e.g., "4.300s", "0.100s", "10.000s") +fn format_duration_decimal(d: Duration) -> String { + let secs = d.as_secs(); + let millis = d.subsec_millis(); + format!("{}.{:03}s", secs, millis) +} + +fn display_neighbors_summary( + neighbors: &[(&String, &bgp::params::PeerInfo)], +) -> Result<()> { let mut tw = TabWriter::new(stdout()); writeln!( &mut tw, @@ -851,7 +892,7 @@ async fn get_neighbors(c: Client, asn: u32) -> Result<()> { ) .unwrap(); - for (addr, info) in sorted.iter() { + for (addr, info) in neighbors.iter() { writeln!( &mut tw, "{}\t{:?}\t{:?}\t{:}\t{}/{}\t{}/{}", @@ -861,18 +902,10 @@ async fn get_neighbors(c: Client, asn: u32) -> Result<()> { humantime::Duration::from(Duration::from_millis( info.fsm_state_duration ),), - humantime::Duration::from(Duration::from_secs( - info.timers.hold.configured.secs - )), - humantime::Duration::from(Duration::from_secs( - info.timers.hold.negotiated.secs - )), - humantime::Duration::from(Duration::from_secs( - info.timers.keepalive.configured.secs, - )), - humantime::Duration::from(Duration::from_secs( - info.timers.keepalive.negotiated.secs, - )), + humantime::Duration::from(info.timers.hold.configured), + humantime::Duration::from(info.timers.hold.negotiated), + humantime::Duration::from(info.timers.keepalive.configured), + humantime::Duration::from(info.timers.keepalive.negotiated), ) .unwrap(); } @@ -880,6 +913,186 @@ async fn get_neighbors(c: Client, asn: u32) -> Result<()> { Ok(()) } +fn display_neighbors_detail( + neighbors: &[(&String, &bgp::params::PeerInfo)], +) -> Result<()> { + for (i, (addr, info)) in neighbors.iter().enumerate() { + if i > 0 { + println!(); + } + + println!("{}", "=".repeat(80)); + println!("{}", format!("Neighbor: {}", addr).bold()); + println!("{}", "=".repeat(80)); + + println!("\n{}", "Basic Information:".bold()); + println!(" Name: {}", info.name); + println!(" Peer Group: {}", info.peer_group); + println!(" FSM State: {:?}", info.fsm_state); + println!( + " FSM State Duration: {}", + humantime::Duration::from(Duration::from_millis( + info.fsm_state_duration + )) + ); + if let Some(asn) = info.asn { + println!(" Peer ASN: {}", asn); + } + if let Some(id) = info.id { + println!(" Peer Router ID: {}", Ipv4Addr::from(id)); + } + + println!("\n{}", "Connection:".bold()); + println!(" Local: {}:{}", info.local_ip, info.local_tcp_port); + println!(" Remote: {}:{}", info.remote_ip, info.remote_tcp_port); + + println!("\n{}", "Address Families:".bold()); + println!(" IPv4 Unicast:"); + println!(" Import Policy: {:?}", info.ipv4_unicast.import_policy); + println!(" Export Policy: {:?}", info.ipv4_unicast.export_policy); + if let Some(nh) = info.ipv4_unicast.nexthop { + println!(" Nexthop: {}", nh); + } + + println!(" IPv6 Unicast:"); + println!(" Import Policy: {:?}", info.ipv6_unicast.import_policy); + println!(" Export Policy: {:?}", info.ipv6_unicast.export_policy); + if let Some(nh) = info.ipv6_unicast.nexthop { + println!(" Nexthop: {}", nh); + } + + println!("\n{}", "Timers:".bold()); + println!( + " Hold Time: configured={}, negotiated={}, remaining={}", + format_duration_decimal(info.timers.hold.configured), + format_duration_decimal(info.timers.hold.negotiated), + format_duration_decimal(info.timers.hold.remaining), + ); + println!( + " Keepalive: configured={}, negotiated={}, remaining={}", + format_duration_decimal(info.timers.keepalive.configured), + format_duration_decimal(info.timers.keepalive.negotiated), + format_duration_decimal(info.timers.keepalive.remaining), + ); + println!( + " Connect Retry: configured={}, remaining={}", + format_duration_decimal(info.timers.connect_retry.configured), + format_duration_decimal(info.timers.connect_retry.remaining), + ); + match info.timers.connect_retry_jitter { + Some(jitter) => { + println!(" Jitter: {}-{}", jitter.min, jitter.max) + } + None => println!(" Jitter: none"), + } + println!( + " Idle Hold: configured={}, remaining={}", + format_duration_decimal(info.timers.idle_hold.configured), + format_duration_decimal(info.timers.idle_hold.remaining), + ); + match info.timers.idle_hold_jitter { + Some(jitter) => { + println!(" Jitter: {}-{}", jitter.min, jitter.max) + } + None => println!(" Jitter: none"), + } + println!( + " Delay Open: configured={}, remaining={}", + format_duration_decimal(info.timers.delay_open.configured), + format_duration_decimal(info.timers.delay_open.remaining), + ); + + if !info.received_capabilities.is_empty() { + println!("\n{}", "Received Capabilities:".bold()); + for cap in &info.received_capabilities { + println!(" {:?}", cap); + } + } + + println!("\n{}", "Counters:".bold()); + println!(" Prefixes:"); + println!(" Advertised: {}", info.counters.prefixes_advertised); + println!(" Imported: {}", info.counters.prefixes_imported); + + println!(" Messages Sent:"); + println!(" Opens: {}", info.counters.opens_sent); + println!(" Updates: {}", info.counters.updates_sent); + println!(" Keepalives: {}", info.counters.keepalives_sent); + println!(" Route Refresh: {}", info.counters.route_refresh_sent); + println!(" Notifications: {}", info.counters.notifications_sent); + + println!(" Messages Received:"); + println!(" Opens: {}", info.counters.opens_received); + println!(" Updates: {}", info.counters.updates_received); + println!(" Keepalives: {}", info.counters.keepalives_received); + println!( + " Route Refresh: {}", + info.counters.route_refresh_received + ); + println!( + " Notifications: {}", + info.counters.notifications_received + ); + + println!(" FSM Transitions:"); + println!( + " To Established: {}", + info.counters.transitions_to_established + ); + println!(" To Idle: {}", info.counters.transitions_to_idle); + println!(" To Connect: {}", info.counters.transitions_to_connect); + + println!(" Connections:"); + println!( + " Active Accepted: {}", + info.counters.active_connections_accepted + ); + println!( + " Active Declined: {}", + info.counters.active_connections_declined + ); + println!( + " Passive Accepted: {}", + info.counters.passive_connections_accepted + ); + println!( + " Passive Declined: {}", + info.counters.passive_connections_declined + ); + println!( + " Connection Retries: {}", + info.counters.connection_retries + ); + + // Error Counters + println!("\n{}", "Error Counters:".bold()); + println!( + " TCP Connection Failures: {}", + info.counters.tcp_connection_failure + ); + println!(" MD5 Auth Failures: {}", info.counters.md5_auth_failures); + println!( + " Hold Timer Expirations: {}", + info.counters.hold_timer_expirations + ); + println!( + " Update Nexthop Missing: {}", + info.counters.update_nexhop_missing + ); + println!( + " Open Handle Failures: {}", + info.counters.open_handle_failures + ); + println!( + " Notification Send Failures: {}", + info.counters.notification_send_failure + ); + println!(" Connector Panics: {}", info.counters.connector_panics); + } + + Ok(()) +} + async fn get_exported(c: Client, asn: u32) -> Result<()> { let exported = c .get_exported(&types::AsnSelector { asn }) diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index 0c04f34d..09f43e71 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -659,13 +659,20 @@ pub async fn get_neighbors_v3( let ctx = ctx.context(); let mut peers = HashMap::new(); - let routers = lock!(ctx.bgp.router); - let r = routers - .get(&rq.asn) - .ok_or(HttpError::for_not_found(None, "ASN not found".to_string()))?; - for s in lock!(r.sessions).values() { - peers.insert(s.neighbor.host.ip(), s.get_peer_info()); + // Clone sessions while holding locks, then release them + let sessions: Vec<_> = { + let routers = lock!(ctx.bgp.router); + let r = routers.get(&rq.asn).ok_or(HttpError::for_not_found( + None, + "ASN not found".to_string(), + ))?; + lock!(r.sessions).values().cloned().collect() + }; + + for s in sessions.iter() { + let peer_ip = s.neighbor.host.ip(); + peers.insert(peer_ip, s.get_peer_info()); } Ok(HttpResponseOk(peers)) diff --git a/openapi/mg-admin/mg-admin-3.0.0-aca2d9.json b/openapi/mg-admin/mg-admin-3.0.0-ea8abe.json similarity index 99% rename from openapi/mg-admin/mg-admin-3.0.0-aca2d9.json rename to openapi/mg-admin/mg-admin-3.0.0-ea8abe.json index f863f85e..501c566a 100644 --- a/openapi/mg-admin/mg-admin-3.0.0-aca2d9.json +++ b/openapi/mg-admin/mg-admin-3.0.0-ea8abe.json @@ -4434,7 +4434,7 @@ "type": "object", "properties": { "connect_retry": { - "$ref": "#/components/schemas/Duration" + "$ref": "#/components/schemas/StaticTimerInfo" }, "connect_retry_jitter": { "nullable": true, @@ -4445,13 +4445,13 @@ ] }, "delay_open": { - "$ref": "#/components/schemas/Duration" + "$ref": "#/components/schemas/StaticTimerInfo" }, "hold": { "$ref": "#/components/schemas/DynamicTimerInfo" }, "idle_hold": { - "$ref": "#/components/schemas/Duration" + "$ref": "#/components/schemas/StaticTimerInfo" }, "idle_hold_jitter": { "nullable": true, @@ -4707,6 +4707,7 @@ "list" ] }, +<<<<<<< HEAD:openapi/mg-admin/mg-admin-3.0.0-aca2d9.json "SwitchIdentifiers": { "description": "Identifiers for a switch.", "type": "object", @@ -4719,6 +4720,23 @@ "minimum": 0 } } +======= + "StaticTimerInfo": { + "description": "Timer information for static (non-negotiated) timers", + "type": "object", + "properties": { + "configured": { + "$ref": "#/components/schemas/Duration" + }, + "remaining": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "remaining" + ] +>>>>>>> 4ce8064 (Fixup bgp neighbor status):openapi/mg-admin/mg-admin-3.0.0-ea8abe.json }, "UpdateErrorSubcode": { "description": "Update message error subcode types", From 45a75bd77e85d2ced4204949c94f4cd6e5322ffa Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 8 Jan 2026 01:01:51 +0000 Subject: [PATCH 18/20] Add per AFI/SAFI support to BGP neighbor reset Adds support for the neighbor reset API to handle soft reset requests targeting one or both supported AFI/SAFIs (IPv4/IPv6 Unicast). Updates the API to handle old/new request versions. Cleans up the mgadm CLI for neighbor reset and adds per AF support. Updates the FSM event handler return early if the AFI/SAFI wasn't negotiated with the BGP peer. Signed-off-by: Trey Aspelund --- Cargo.lock | 1 + bgp/Cargo.toml | 1 + bgp/src/messages.rs | 1 + bgp/src/params.rs | 33 ++++- bgp/src/session.rs | 8 ++ mg-admin-client/src/lib.rs | 2 + mg-api/src/lib.rs | 43 ++++++- mgadm/Cargo.toml | 2 +- mgadm/src/bgp.rs | 106 +++++++++------ mgd/src/admin.rs | 9 +- mgd/src/bgp_admin.rs | 121 +++++++++++++----- ...ea8abe.json => mg-admin-3.0.0-0dc074.json} | 71 +++++++++- 12 files changed, 313 insertions(+), 85 deletions(-) rename openapi/mg-admin/{mg-admin-3.0.0-ea8abe.json => mg-admin-3.0.0-0dc074.json} (98%) diff --git a/Cargo.lock b/Cargo.lock index a8acac45..8c33ec5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "clap", "itertools 0.14.0", "lazy_static", "libc", diff --git a/bgp/Cargo.toml b/bgp/Cargo.toml index 1e81f022..2a92c126 100644 --- a/bgp/Cargo.toml +++ b/bgp/Cargo.toml @@ -21,6 +21,7 @@ itertools.workspace = true oxnet.workspace = true uuid.workspace = true rand.workspace = true +clap = { workspace = true, optional = true } [target.'cfg(target_os = "illumos")'.dependencies] libnet = { workspace = true, optional = true } diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index 7d8ff520..aa2729e0 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -4789,6 +4789,7 @@ impl From for CapabilityCode { TryFromPrimitive, JsonSchema, )] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[repr(u16)] pub enum Afi { /// Internet protocol version 4 diff --git a/bgp/src/params.rs b/bgp/src/params.rs index 92cfb806..7a708ae2 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -4,7 +4,7 @@ use crate::{ config::PeerConfig, - messages::{AddPathElement, Capability}, + messages::{AddPathElement, Afi, Capability}, session::{FsmStateKind, SessionCounters, SessionInfo}, }; use rdb::{ @@ -35,13 +35,42 @@ pub struct Router { pub graceful_shutdown: bool, } +/// V1 API neighbor reset operations (backwards compatibility) #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] -pub enum NeighborResetOp { +#[schemars(rename = "NeighborResetOp")] +pub enum NeighborResetOpV1 { Hard, SoftInbound, SoftOutbound, } +/// V2 API neighbor reset operations with per-AF support +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub enum NeighborResetOp { + /// Hard reset - closes TCP connection and restarts session + Hard, + /// Soft inbound reset - sends route refresh for specified AF(s) + /// None means all negotiated AFs + SoftInbound(Option), + /// Soft outbound reset - re-advertises routes for specified AF(s) + /// None means all negotiated AFs + SoftOutbound(Option), +} + +impl From for NeighborResetOp { + fn from(op: NeighborResetOpV1) -> Self { + match op { + NeighborResetOpV1::Hard => NeighborResetOp::Hard, + NeighborResetOpV1::SoftInbound => { + NeighborResetOp::SoftInbound(Some(Afi::Ipv4)) + } + NeighborResetOpV1::SoftOutbound => { + NeighborResetOp::SoftOutbound(Some(Afi::Ipv4)) + } + } + } +} + /// Jitter range with minimum and maximum multiplier values. /// When applied to a timer, the timer duration is multiplied by a random value /// within [min, max] to help break synchronization patterns. diff --git a/bgp/src/session.rs b/bgp/src/session.rs index f0a1e62a..d110b287 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -8070,6 +8070,10 @@ impl SessionRunner { &self, pc: &PeerConnection, ) -> Result<(), Error> { + if !pc.ipv4_unicast.negotiated() { + return Ok(()); + } + let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -8099,6 +8103,10 @@ impl SessionRunner { &self, pc: &PeerConnection, ) -> Result<(), Error> { + if !pc.ipv6_unicast.negotiated() { + return Ok(()); + } + let originated = match self.db.get_origin6() { Ok(value) => value, Err(e) => { diff --git a/mg-admin-client/src/lib.rs b/mg-admin-client/src/lib.rs index 73f08faa..baea9681 100644 --- a/mg-admin-client/src/lib.rs +++ b/mg-admin-client/src/lib.rs @@ -25,6 +25,8 @@ progenitor::generate_api!( JitterRange = bgp::params::JitterRange, PeerInfo = bgp::params::PeerInfo, Duration = std::time::Duration, + Afi = bgp::messages::Afi, + NeighborResetOp = bgp::params::NeighborResetOp, } ); diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 817e6eeb..20353eff 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -13,8 +13,8 @@ use bfd::BfdPeerState; use bgp::{ params::{ ApplyRequest, ApplyRequestV1, CheckerSource, Neighbor, NeighborResetOp, - NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, PeerInfoV2, Router, - ShaperSource, + NeighborResetOpV1, NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, + PeerInfoV2, Router, ShaperSource, }, session::{FsmEventRecord, MessageHistory, MessageHistoryV1}, }; @@ -178,9 +178,16 @@ pub trait MgAdminApi { request: Query, ) -> Result; - // Common operations (not versioned - works with both APIs) - #[endpoint { method = POST, path = "/bgp/clear/neighbor" }] + // V1/V2 API clear neighbor (backwards compatibility w/ IPv4 only support) + #[endpoint { method = POST, path = "/bgp/clear/neighbor", versions = ..VERSION_MP_BGP }] async fn clear_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + // V3 API clear neighbor with per-AF support + #[endpoint { method = POST, path = "/bgp/clear/neighbor", versions = VERSION_MP_BGP.. }] + async fn clear_neighbor_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; @@ -469,6 +476,14 @@ pub struct NeighborSelector { pub addr: IpAddr, } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[schemars(rename = "NeighborResetRequest")] +pub struct NeighborResetRequestV1 { + pub asn: u32, + pub addr: IpAddr, + pub op: NeighborResetOpV1, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] pub struct NeighborResetRequest { pub asn: u32, @@ -476,6 +491,26 @@ pub struct NeighborResetRequest { pub op: NeighborResetOp, } +impl std::fmt::Display for NeighborResetRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "neighbor {} asn {} op {:?}", + self.addr, self.asn, self.op + ) + } +} + +impl From for NeighborResetRequest { + fn from(req: NeighborResetRequestV1) -> Self { + Self { + asn: req.asn, + addr: req.addr, + op: req.op.into(), + } + } +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct DeleteNeighborRequest { pub asn: u32, diff --git a/mgadm/Cargo.toml b/mgadm/Cargo.toml index 191316bb..7e50fd7f 100644 --- a/mgadm/Cargo.toml +++ b/mgadm/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -bgp = { path = "../bgp" } +bgp = { path = "../bgp", features = ["clap"] } mg-common = { path = "../mg-common" } mg-admin-client = { path = "../mg-admin-client" } clap.workspace = true diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index 6374ef80..0c399218 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -3,22 +3,26 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use anyhow::Result; -use bgp::params::JitterRange; +use bgp::{ + messages::Afi, + params::{JitterRange, NeighborResetOp}, +}; use clap::{Args, Subcommand, ValueEnum}; use colored::*; use mg_admin_client::{ Client, types::{ self, ImportExportPolicy4, ImportExportPolicy6, Ipv4UnicastConfig, - Ipv6UnicastConfig, NeighborResetOp as MgdNeighborResetOp, - NeighborResetRequest, + Ipv6UnicastConfig, NeighborResetRequest, }, }; use rdb::types::{PolicyAction, Prefix4, Prefix6}; -use std::fs::read_to_string; -use std::io::{Write, stdout}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::time::Duration; +use std::{ + fs::read_to_string, + io::{Write, stdout}, + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Duration, +}; use tabwriter::TabWriter; #[derive(Subcommand, Debug)] @@ -147,27 +151,6 @@ pub enum HistoryCmd { }, } -#[derive(Clone, Debug, ValueEnum)] -#[allow(non_camel_case_types)] -pub enum NeighborResetOp { - /// Perform a hard reset of the neighbor. This resets the TCP connection. - hard, - /// Send a route refresh to the neighbor. Does not reset the TCP connection. - soft_inbound, - /// Re-send all originated routes to the neighbor. Does not reset the TCP connection. - soft_outbound, -} - -impl From for MgdNeighborResetOp { - fn from(op: NeighborResetOp) -> MgdNeighborResetOp { - match op { - NeighborResetOp::hard => MgdNeighborResetOp::Hard, - NeighborResetOp::soft_inbound => MgdNeighborResetOp::SoftInbound, - NeighborResetOp::soft_outbound => MgdNeighborResetOp::SoftOutbound, - } - } -} - #[derive(Debug, Args)] pub struct ClearSubcommand { #[command(subcommand)] @@ -181,14 +164,65 @@ pub enum ClearCmd { Neighbor { /// IP address of the neighbor you want to clear the state of. addr: IpAddr, - #[clap(value_enum)] - clear_type: NeighborResetOp, - /// BGP Autonomous System number. Can be a 16-bit or 32-bit unsigned value. + + /// BGP Autonomous System number. Can be a 16-bit or 32-bit unsigned value. #[clap(env)] asn: u32, + + #[command(subcommand)] + operation: NeighborOperation, }, } +#[derive(Subcommand, Debug, Clone)] +pub enum NeighborOperation { + /// Perform a hard reset of the neighbor. This resets the TCP connection. + Hard, + + /// Perform a soft reset (does not reset TCP connection) + Soft { + #[command(subcommand)] + direction: SoftDirection, + }, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum SoftDirection { + /// Send a route refresh to the neighbor + Inbound { + /// Address family to refresh (if not specified, refreshes all negotiated AFs) + #[clap(value_enum)] + afi: Option, + }, + + /// Re-send all originated routes to the neighbor + Outbound { + /// Address family to re-advertise (if not specified, re-advertises all negotiated AFs) + #[clap(value_enum)] + afi: Option, + }, +} + +impl From for NeighborResetOp { + fn from(op: NeighborOperation) -> Self { + match op { + NeighborOperation::Hard => NeighborResetOp::Hard, + NeighborOperation::Soft { direction } => direction.into(), + } + } +} + +impl From for NeighborResetOp { + fn from(direction: SoftDirection) -> Self { + match direction { + SoftDirection::Inbound { afi } => NeighborResetOp::SoftInbound(afi), + SoftDirection::Outbound { afi } => { + NeighborResetOp::SoftOutbound(afi) + } + } + } +} + #[derive(Debug, Args)] pub struct OmicronSubcommand { #[command(subcommand)] @@ -798,8 +832,8 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { ClearCmd::Neighbor { asn, addr, - clear_type, - } => clear_nbr(asn, addr, clear_type, c).await?, + operation, + } => clear_nbr(asn, addr, operation, c).await?, }, Commands::Omicron(cmd) => match cmd.command { @@ -1133,13 +1167,13 @@ async fn delete_nbr(asn: u32, addr: IpAddr, c: Client) -> Result<()> { async fn clear_nbr( asn: u32, addr: IpAddr, - op: NeighborResetOp, + operation: NeighborOperation, c: Client, ) -> Result<()> { - c.clear_neighbor(&NeighborResetRequest { + c.clear_neighbor_v2(&NeighborResetRequest { asn, addr, - op: op.into(), + op: operation.into(), }) .await?; Ok(()) diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index cba7140f..3320076d 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -214,11 +214,18 @@ impl MgAdminApi for MgAdminApiImpl { async fn clear_neighbor( ctx: RequestContext, - request: TypedBody, + request: TypedBody, ) -> Result { bgp_admin::clear_neighbor(ctx, request).await } + async fn clear_neighbor_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::clear_neighbor_v2(ctx, request).await + } + async fn create_origin4( ctx: RequestContext, request: TypedBody, diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index 09f43e71..c494d691 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -26,7 +26,8 @@ use mg_api::{ AsnSelector, BestpathFanoutRequest, BestpathFanoutResponse, FsmEventBuffer, FsmHistoryRequest, FsmHistoryResponse, MessageDirection, MessageHistoryRequest, MessageHistoryRequestV1, MessageHistoryResponse, - MessageHistoryResponseV1, NeighborResetRequest, NeighborSelector, Rib, + MessageHistoryResponseV1, NeighborResetRequest, NeighborResetRequestV1, + NeighborSelector, Rib, }; use mg_common::lock; use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicy, Prefix}; @@ -235,13 +236,25 @@ pub async fn delete_neighbor( Ok(helpers::remove_neighbor(ctx.clone(), rq.asn, rq.addr).await?) } +// Legacy API handler - hardcoded to IPv4 for backwards compatibility pub async fn clear_neighbor( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = ctx.context(); + + Ok(helpers::reset_neighbor(ctx.clone(), rq.into()).await?) +} + +// V2 API handler - supports per-AF operations +pub async fn clear_neighbor_v2( ctx: RequestContext>, request: TypedBody, ) -> Result { let rq = request.into_inner(); let ctx = ctx.context(); - Ok(helpers::reset_neighbor(ctx.clone(), rq.asn, rq.addr, rq.op).await?) + Ok(helpers::reset_neighbor(ctx.clone(), rq).await?) } // V3 API handlers (new Neighbor type with optional per-AF configs) @@ -1386,49 +1399,87 @@ pub(crate) mod helpers { pub(crate) async fn reset_neighbor( ctx: Arc, - asn: u32, - addr: IpAddr, - op: NeighborResetOp, + rq: NeighborResetRequest, ) -> Result { - bgp_log!(ctx.log, info, "clear neighbor {addr}, asn {asn}"; - "op" => format!("{op:?}") - ); + bgp_log!(ctx.log, info, "clear {rq}"); - let session = get_router!(ctx, asn)? - .get_session(addr) + let session = get_router!(ctx, rq.asn)? + .get_session(rq.addr) .ok_or(Error::NotFound("session for bgp peer not found".into()))?; - // XXX: Add IPv6 support -- needs API update - match op { - NeighborResetOp::Hard => session - .event_tx - .send(FsmEvent::Admin(AdminEvent::Reset)) - .map_err(|e| { - Error::InternalCommunication(format!( - "failed to reset bgp session {e}", - )) - })?, - NeighborResetOp::SoftInbound => { - // XXX: check if neighbor has negotiated route refresh cap + match rq.op { + NeighborResetOp::Hard => { session .event_tx - .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh( - Afi::Ipv4, - ))) + .send(FsmEvent::Admin(AdminEvent::Reset)) .map_err(|e| { Error::InternalCommunication(format!( - "failed to generate route refresh {e}" + "failed to reset bgp session {e}", )) - })? + })?; + } + NeighborResetOp::SoftInbound(afi) => { + // Send the request to the FSM; it will handle checking capabilities + // and which AFs are negotiated. None means all negotiated AFs. + match afi { + Some(af) => { + session + .event_tx + .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh(af))) + .map_err(|e| { + Error::InternalCommunication(format!( + "failed to generate route refresh for {af}: {e}" + )) + })?; + } + None => { + // Send for both AFs; FSM will handle which are actually negotiated + session + .event_tx + .send(FsmEvent::Admin( + AdminEvent::SendRouteRefresh(Afi::Ipv4), + )) + .ok(); + session + .event_tx + .send(FsmEvent::Admin( + AdminEvent::SendRouteRefresh(Afi::Ipv6), + )) + .ok(); + } + } + } + NeighborResetOp::SoftOutbound(afi) => { + // Send the request to the FSM; it will handle which AFs are negotiated. + // None means all negotiated AFs. + match afi { + Some(af) => { + session + .event_tx + .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes(af))) + .map_err(|e| { + Error::InternalCommunication(format!( + "failed to trigger outbound update for {af}: {e}" + )) + })?; + } + None => { + // Send for both AFs; FSM will handle which are actually negotiated + session + .event_tx + .send(FsmEvent::Admin( + AdminEvent::ReAdvertiseRoutes(Afi::Ipv4), + )) + .ok(); + session + .event_tx + .send(FsmEvent::Admin( + AdminEvent::ReAdvertiseRoutes(Afi::Ipv6), + )) + .ok(); + } + } } - NeighborResetOp::SoftOutbound => session - .event_tx - .send(FsmEvent::Admin(AdminEvent::ReAdvertiseRoutes(Afi::Ipv4))) - .map_err(|e| { - Error::InternalCommunication(format!( - "failed to trigger outbound update {e}" - )) - })?, } Ok(HttpResponseUpdatedNoContent()) diff --git a/openapi/mg-admin/mg-admin-3.0.0-ea8abe.json b/openapi/mg-admin/mg-admin-3.0.0-0dc074.json similarity index 98% rename from openapi/mg-admin/mg-admin-3.0.0-ea8abe.json rename to openapi/mg-admin/mg-admin-3.0.0-0dc074.json index 501c566a..83a533ff 100644 --- a/openapi/mg-admin/mg-admin-3.0.0-ea8abe.json +++ b/openapi/mg-admin/mg-admin-3.0.0-0dc074.json @@ -96,7 +96,7 @@ }, "/bgp/clear/neighbor": { "post": { - "operationId": "clear_neighbor", + "operationId": "clear_neighbor_v2", "requestBody": { "content": { "application/json": { @@ -1405,6 +1405,25 @@ "routes" ] }, + "Afi": { + "description": "Address families supported by Maghemite BGP.", + "oneOf": [ + { + "description": "Internet protocol version 4", + "type": "string", + "enum": [ + "Ipv4" + ] + }, + { + "description": "Internet protocol version 6", + "type": "string", + "enum": [ + "Ipv6" + ] + } + ] + }, "AfiSafi": { "type": "object", "properties": { @@ -3507,11 +3526,51 @@ ] }, "NeighborResetOp": { - "type": "string", - "enum": [ - "Hard", - "SoftInbound", - "SoftOutbound" + "description": "V2 API neighbor reset operations with per-AF support", + "oneOf": [ + { + "description": "Hard reset - closes TCP connection and restarts session", + "type": "string", + "enum": [ + "Hard" + ] + }, + { + "description": "Soft inbound reset - sends route refresh for specified AF(s) None means all negotiated AFs", + "type": "object", + "properties": { + "SoftInbound": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Afi" + } + ] + } + }, + "required": [ + "SoftInbound" + ], + "additionalProperties": false + }, + { + "description": "Soft outbound reset - re-advertises routes for specified AF(s) None means all negotiated AFs", + "type": "object", + "properties": { + "SoftOutbound": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Afi" + } + ] + } + }, + "required": [ + "SoftOutbound" + ], + "additionalProperties": false + } ] }, "NeighborResetRequest": { From f13f1870139acc18a0491901d2cc7d26fbf3c9de Mon Sep 17 00:00:00 2001 From: Ryan Goodfellow Date: Sat, 10 Jan 2026 01:21:33 +0000 Subject: [PATCH 19/20] rebase api version fixup --- openapi/mg-admin/mg-admin-4.0.0-016211.json | 4376 ----------------- ...0dc074.json => mg-admin-4.0.0-bf8364.json} | 28 +- openapi/mg-admin/mg-admin-latest.json | 2 +- 3 files changed, 14 insertions(+), 4392 deletions(-) delete mode 100644 openapi/mg-admin/mg-admin-4.0.0-016211.json rename openapi/mg-admin/{mg-admin-3.0.0-0dc074.json => mg-admin-4.0.0-bf8364.json} (99%) diff --git a/openapi/mg-admin/mg-admin-4.0.0-016211.json b/openapi/mg-admin/mg-admin-4.0.0-016211.json deleted file mode 100644 index d7fd4901..00000000 --- a/openapi/mg-admin/mg-admin-4.0.0-016211.json +++ /dev/null @@ -1,4376 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Maghemite Admin", - "contact": { - "url": "https://oxide.computer", - "email": "api@oxide.computer" - }, - "version": "4.0.0" - }, - "paths": { - "/bfd/peers": { - "get": { - "summary": "Get all the peers and their associated BFD state. Peers are identified by IP", - "description": "address.", - "operationId": "get_bfd_peers", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Array_of_BfdPeerInfo", - "type": "array", - "items": { - "$ref": "#/components/schemas/BfdPeerInfo" - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "summary": "Add a new peer to the daemon. A session for the specified peer will start", - "description": "immediately.", - "operationId": "add_bfd_peer", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BfdPeerConfig" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bfd/peers/{addr}": { - "delete": { - "summary": "Remove the specified peer from the daemon. The associated peer session will", - "description": "be stopped immediately.", - "operationId": "remove_bfd_peer", - "parameters": [ - { - "in": "path", - "name": "addr", - "description": "Address of the peer to remove.", - "required": true, - "schema": { - "type": "string", - "format": "ip" - } - } - ], - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/clear/neighbor": { - "post": { - "operationId": "clear_neighbor", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NeighborResetRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/checker": { - "get": { - "operationId": "read_checker", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CheckerSource" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_checker", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CheckerSource" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_checker", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CheckerSource" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_checker", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/neighbor": { - "get": { - "operationId": "read_neighbor_v2", - "parameters": [ - { - "in": "query", - "name": "addr", - "required": true, - "schema": { - "type": "string", - "format": "ip" - } - }, - { - "in": "query", - "name": "asn", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Neighbor" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_neighbor_v2", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Neighbor" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_neighbor_v2", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Neighbor" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_neighbor_v2", - "parameters": [ - { - "in": "query", - "name": "addr", - "required": true, - "schema": { - "type": "string", - "format": "ip" - } - }, - { - "in": "query", - "name": "asn", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/neighbors": { - "get": { - "operationId": "read_neighbors_v2", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Array_of_Neighbor", - "type": "array", - "items": { - "$ref": "#/components/schemas/Neighbor" - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/origin4": { - "get": { - "operationId": "read_origin4", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin4" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_origin4", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin4" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_origin4", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin4" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_origin4", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/origin6": { - "get": { - "operationId": "read_origin6", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin6" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_origin6", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin6" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_origin6", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Origin6" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_origin6", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/router": { - "get": { - "operationId": "read_router", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Router" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_router", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Router" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_router", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Router" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_router", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/routers": { - "get": { - "operationId": "read_routers", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Array_of_Router", - "type": "array", - "items": { - "$ref": "#/components/schemas/Router" - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/config/shaper": { - "get": { - "operationId": "read_shaper", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShaperSource" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "create_shaper", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShaperSource" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_shaper", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ShaperSource" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "delete_shaper", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/history/fsm": { - "get": { - "operationId": "fsm_history", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FsmHistoryRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FsmHistoryResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/history/message": { - "get": { - "operationId": "message_history_v2", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MessageHistoryRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MessageHistoryResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/omicron/apply": { - "post": { - "operationId": "bgp_apply_v2", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApplyRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/status/exported": { - "get": { - "operationId": "get_exported", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AsnSelector" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Map_of_Array_of_Prefix", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix" - } - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/bgp/status/neighbors": { - "get": { - "operationId": "get_neighbors_v2", - "parameters": [ - { - "in": "query", - "name": "asn", - "description": "ASN of the router to get imported prefixes from.", - "required": true, - "schema": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Map_of_PeerInfo", - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/PeerInfo" - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/rib/config/bestpath/fanout": { - "get": { - "operationId": "read_rib_bestpath_fanout", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BestpathFanoutResponse" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "post": { - "operationId": "update_rib_bestpath_fanout", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BestpathFanoutRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/rib/status/imported": { - "get": { - "operationId": "get_rib_imported", - "parameters": [ - { - "in": "query", - "name": "address_family", - "description": "Filter by address family (None means all families)", - "schema": { - "$ref": "#/components/schemas/AddressFamily" - } - }, - { - "in": "query", - "name": "protocol", - "description": "Filter by protocol (optional)", - "schema": { - "$ref": "#/components/schemas/ProtocolFilter" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Rib" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/rib/status/selected": { - "get": { - "operationId": "get_rib_selected", - "parameters": [ - { - "in": "query", - "name": "address_family", - "description": "Filter by address family (None means all families)", - "schema": { - "$ref": "#/components/schemas/AddressFamily" - } - }, - { - "in": "query", - "name": "protocol", - "description": "Filter by protocol (optional)", - "schema": { - "$ref": "#/components/schemas/ProtocolFilter" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Rib" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/static/route4": { - "get": { - "operationId": "static_list_v4_routes", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Map_of_Set_of_Path", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Path" - }, - "uniqueItems": true - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "static_add_v4_route", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddStaticRoute4Request" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "static_remove_v4_route", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteStaticRoute4Request" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/static/route6": { - "get": { - "operationId": "static_list_v6_routes", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "title": "Map_of_Set_of_Path", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Path" - }, - "uniqueItems": true - } - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "put": { - "operationId": "static_add_v6_route", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AddStaticRoute6Request" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "resource updated" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - }, - "delete": { - "operationId": "static_remove_v6_route", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteStaticRoute6Request" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "successful deletion" - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - }, - "/switch/identifiers": { - "get": { - "operationId": "switch_identifiers", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SwitchIdentifiers" - } - } - } - }, - "4XX": { - "$ref": "#/components/responses/Error" - }, - "5XX": { - "$ref": "#/components/responses/Error" - } - } - } - } - }, - "components": { - "schemas": { - "AddPathElement": { - "description": "The add path element comes as a BGP capability extension as described in RFC 7911.", - "type": "object", - "properties": { - "afi": { - "description": "Address family identifier. ", - "type": "integer", - "format": "uint16", - "minimum": 0 - }, - "safi": { - "description": "Subsequent address family identifier. There are a large pile of these ", - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "send_receive": { - "description": "This field indicates whether the sender is (a) able to receive multiple paths from its peer (value 1), (b) able to send multiple paths to its peer (value 2), or (c) both (value 3) for the .", - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "afi", - "safi", - "send_receive" - ] - }, - "AddStaticRoute4Request": { - "type": "object", - "properties": { - "routes": { - "$ref": "#/components/schemas/StaticRoute4List" - } - }, - "required": [ - "routes" - ] - }, - "AddStaticRoute6Request": { - "type": "object", - "properties": { - "routes": { - "$ref": "#/components/schemas/StaticRoute6List" - } - }, - "required": [ - "routes" - ] - }, - "Aggregator": { - "description": "AGGREGATOR path attribute (RFC 4271 §5.1.8)\n\nThe AGGREGATOR attribute is an optional transitive attribute that contains the AS number and IP address of the last BGP speaker that formed the aggregate route.", - "type": "object", - "properties": { - "address": { - "description": "IP address of the BGP speaker that formed the aggregate", - "type": "string", - "format": "ipv4" - }, - "asn": { - "description": "Autonomous System Number that formed the aggregate (2-octet)", - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "address", - "asn" - ] - }, - "ApplyRequest": { - "description": "Apply changes to an ASN (current version with per-AF policies).", - "type": "object", - "properties": { - "asn": { - "description": "ASN to apply changes to.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "checker": { - "nullable": true, - "description": "Checker rhai code to apply to ingress open and update messages.", - "allOf": [ - { - "$ref": "#/components/schemas/CheckerSource" - } - ] - }, - "originate": { - "description": "Complete set of prefixes to originate.", - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - }, - "peers": { - "description": "Lists of peers indexed by peer group.", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BgpPeerConfig" - } - } - }, - "shaper": { - "nullable": true, - "description": "Checker rhai code to apply to egress open and update messages.", - "allOf": [ - { - "$ref": "#/components/schemas/ShaperSource" - } - ] - } - }, - "required": [ - "asn", - "originate", - "peers" - ] - }, - "As4Aggregator": { - "description": "AS4_AGGREGATOR path attribute (RFC 6793)\n\nThe AS4_AGGREGATOR attribute is an optional transitive attribute with the same semantics as AGGREGATOR, but carries a 4-octet AS number instead of 2-octet.", - "type": "object", - "properties": { - "address": { - "description": "IP address of the BGP speaker that formed the aggregate", - "type": "string", - "format": "ipv4" - }, - "asn": { - "description": "Autonomous System Number that formed the aggregate (4-octet)", - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "address", - "asn" - ] - }, - "As4PathSegment": { - "type": "object", - "properties": { - "typ": { - "$ref": "#/components/schemas/AsPathType" - }, - "value": { - "type": "array", - "items": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - } - }, - "required": [ - "typ", - "value" - ] - }, - "AsPathType": { - "description": "Enumeration describes possible AS path types", - "oneOf": [ - { - "description": "The path is to be interpreted as a set", - "type": "string", - "enum": [ - "as_set" - ] - }, - { - "description": "The path is to be interpreted as a sequence", - "type": "string", - "enum": [ - "as_sequence" - ] - } - ] - }, - "AsnSelector": { - "type": "object", - "properties": { - "asn": { - "description": "ASN of the router to get imported prefixes from.", - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "asn" - ] - }, - "BestpathFanoutRequest": { - "type": "object", - "properties": { - "fanout": { - "description": "Maximum number of equal-cost paths for ECMP forwarding", - "type": "integer", - "format": "uint8", - "minimum": 1 - } - }, - "required": [ - "fanout" - ] - }, - "BestpathFanoutResponse": { - "type": "object", - "properties": { - "fanout": { - "description": "Current maximum number of equal-cost paths for ECMP forwarding", - "type": "integer", - "format": "uint8", - "minimum": 1 - } - }, - "required": [ - "fanout" - ] - }, - "BfdPeerConfig": { - "type": "object", - "properties": { - "detection_threshold": { - "description": "Detection threshold for connectivity as a multipler to required_rx", - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "listen": { - "description": "Address to listen on for control messages from the peer.", - "type": "string", - "format": "ip" - }, - "mode": { - "description": "Mode is single-hop (RFC 5881) or multi-hop (RFC 5883).", - "allOf": [ - { - "$ref": "#/components/schemas/SessionMode" - } - ] - }, - "peer": { - "description": "Address of the peer to add.", - "type": "string", - "format": "ip" - }, - "required_rx": { - "description": "Acceptable time between control messages in microseconds.", - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "detection_threshold", - "listen", - "mode", - "peer", - "required_rx" - ] - }, - "BfdPeerInfo": { - "type": "object", - "properties": { - "config": { - "$ref": "#/components/schemas/BfdPeerConfig" - }, - "state": { - "$ref": "#/components/schemas/BfdPeerState" - } - }, - "required": [ - "config", - "state" - ] - }, - "BfdPeerState": { - "description": "The possible peer states. See the `State` trait implementations `Down`, `Init`, and `Up` for detailed semantics. Data representation is u8 as this enum is used as a part of the BFD wire protocol.", - "oneOf": [ - { - "description": "A stable down state. Non-responsive to incoming messages.", - "type": "string", - "enum": [ - "AdminDown" - ] - }, - { - "description": "The initial state.", - "type": "string", - "enum": [ - "Down" - ] - }, - { - "description": "The peer has detected a remote peer in the down state.", - "type": "string", - "enum": [ - "Init" - ] - }, - { - "description": "The peer has detected a remote peer in the up or init state while in the init state.", - "type": "string", - "enum": [ - "Up" - ] - } - ] - }, - "BgpNexthop": { - "description": "BGP next-hops can come in multiple forms, defined in several different RFCs. This enum represents the forms supported by this implementation.\n\nIn the case of IPv6, RFC 2545 defined the use of either: 1) A single non-link-local next-hop (length=16) 2) A non-link-local plus a link-local next-hop (length=32)\n\nThis does not account for only a link-local address as the sole next-hop. As such, many different implementations decided they would encode this in a variety of ways (since there was no canonical source of truth): a) Single-address encoding just the link-local (length=16) b) Double-address encoding the link-local in both positions (length=32) c) Double-address encoding the link-local in its normal position, but 0's in the non-link-local position (length=32) etc. This led to `draft-ietf-idr-linklocal-capability` which specifies more detailed encoding and error handling standards, signaled via a new Link-Local Next Hop Capability.\n\nIn addition to this, RFC 8950 (formerly RFC 5549) specified the advertisement of IPv4 NLRI via an IPv6 next-hop, enabled via the Extended Next Hop capability. This excerpt contains the encoding logic from RFC 8950: ```text Specifically, this document allows advertising the MP_REACH_NLRI attribute [RFC4760] with this content:\n\n* AFI = 1\n\n* SAFI = 1, 2, or 4\n\n* Length of Next Hop Address = 16 or 32\n\n* Next Hop Address = IPv6 address of a next hop (potentially followed by the link-local IPv6 address of the next hop). This field is to be constructed as per Section 3 of [RFC2545].\n\n* NLRI = NLRI as per the AFI/SAFI definition\n\n[..]\n\nThis is in addition to the existing mode of operation allowing advertisement of NLRI for of <1/1>, <1/2>, and <1/4> with a next-hop address of an IPv4 type and advertisement of NLRI for an of <1/128> and <1/129> with a next-hop address of a VPN- IPv4 type.\n\nThe BGP speaker receiving the advertisement MUST use the Length of Next Hop Address field to determine which network-layer protocol the next-hop address belongs to.\n\n* When the AFI/SAFI is <1/1>, <1/2>, or <1/4> and when the Length of Next Hop Address field is equal to 16 or 32, the next-hop address is of type IPv6.\n\n* When the AFI/SAFI is <1/128> or <1/129> and when the Length of Next Hop Address field is equal to 24 or 48, the next-hop address is of type VPN-IPv6. ```\n\nRFC 8950 also goes on to state that Extended Next Hop is not specified for any AFI/SAFI other than IPv4 {Unicast, Multicast, Labeled Unicast, Unicast VPN, Multicast VPN}, because IPv4 next-hops can already be signaled within IPv6 or VPN-IPv6 encoding (via IPv4-mapped IPv6 addresses).\n\nSo for our purposes, IPv4 Unicast NLRI may have Next-hops in the form of: a) IPv4 nexthop b) IPv6 single GUA (w/ Extended Next-Hop) c) IPv6 single LL (w/ Extended Next-Hop + Link-Local Next Hop) d) IPv6 double (w/ Extended Next-Hop)\n\nand IPv6 Unicast NLRI may have Next-hops in the form of: a) IPv6 single (IPv4-mapped) b) IPv6 single GUA c) IPv6 single LL (w/ Link-Local Next Hop) d) IPv6 double", - "oneOf": [ - { - "type": "object", - "properties": { - "Ipv4": { - "type": "string", - "format": "ipv4" - } - }, - "required": [ - "Ipv4" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "Ipv6Single": { - "type": "string", - "format": "ipv6" - } - }, - "required": [ - "Ipv6Single" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "Ipv6Double": { - "$ref": "#/components/schemas/Ipv6DoubleNexthop" - } - }, - "required": [ - "Ipv6Double" - ], - "additionalProperties": false - } - ] - }, - "BgpPathProperties": { - "type": "object", - "properties": { - "as_path": { - "type": "array", - "items": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "id": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "local_pref": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "med": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "origin_as": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "peer": { - "type": "string", - "format": "ip" - }, - "stale": { - "nullable": true, - "type": "string", - "format": "date-time" - } - }, - "required": [ - "as_path", - "id", - "origin_as", - "peer" - ] - }, - "BgpPeerConfig": { - "description": "BGP peer configuration (current version with per-address-family policies).", - "type": "object", - "properties": { - "communities": { - "type": "array", - "items": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "connect_retry": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "connect_retry_jitter": { - "nullable": true, - "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", - "allOf": [ - { - "$ref": "#/components/schemas/JitterRange" - } - ] - }, - "delay_open": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "deterministic_collision_resolution": { - "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", - "type": "boolean" - }, - "enforce_first_as": { - "type": "boolean" - }, - "hold_time": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "host": { - "type": "string" - }, - "idle_hold_jitter": { - "nullable": true, - "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", - "allOf": [ - { - "$ref": "#/components/schemas/JitterRange" - } - ] - }, - "idle_hold_time": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "ipv4_unicast": { - "nullable": true, - "description": "IPv4 Unicast address family configuration (None = disabled)", - "allOf": [ - { - "$ref": "#/components/schemas/Ipv4UnicastConfig" - } - ] - }, - "ipv6_unicast": { - "nullable": true, - "description": "IPv6 Unicast address family configuration (None = disabled)", - "allOf": [ - { - "$ref": "#/components/schemas/Ipv6UnicastConfig" - } - ] - }, - "keepalive": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "local_pref": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "md5_auth_key": { - "nullable": true, - "type": "string" - }, - "min_ttl": { - "nullable": true, - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "multi_exit_discriminator": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "name": { - "type": "string" - }, - "passive": { - "type": "boolean" - }, - "remote_asn": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "resolution": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "vlan_id": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "communities", - "connect_retry", - "delay_open", - "deterministic_collision_resolution", - "enforce_first_as", - "hold_time", - "host", - "idle_hold_time", - "keepalive", - "name", - "passive", - "resolution" - ] - }, - "Capability": { - "description": "Optional capabilities supported by a BGP implementation.", - "oneOf": [ - { - "description": "Multiprotocol extensions as defined in RFC 2858", - "type": "object", - "properties": { - "multiprotocol_extensions": { - "type": "object", - "properties": { - "afi": { - "type": "integer", - "format": "uint16", - "minimum": 0 - }, - "safi": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "afi", - "safi" - ] - } - }, - "required": [ - "multiprotocol_extensions" - ], - "additionalProperties": false - }, - { - "description": "Route refresh capability as defined in RFC 2918.", - "type": "object", - "properties": { - "route_refresh": { - "type": "object" - } - }, - "required": [ - "route_refresh" - ], - "additionalProperties": false - }, - { - "description": "Outbound filtering capability as defined in RFC 5291. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "outbound_route_filtering": { - "type": "object" - } - }, - "required": [ - "outbound_route_filtering" - ], - "additionalProperties": false - }, - { - "description": "Multiple routes to destination capability as defined in RFC 8277 (deprecated). Note this capability is not yet implemented.", - "type": "object", - "properties": { - "multiple_routes_to_destination": { - "type": "object" - } - }, - "required": [ - "multiple_routes_to_destination" - ], - "additionalProperties": false - }, - { - "description": "Multiple nexthop encoding capability as defined in RFC 8950. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "extended_next_hop_encoding": { - "type": "object" - } - }, - "required": [ - "extended_next_hop_encoding" - ], - "additionalProperties": false - }, - { - "description": "Extended message capability as defined in RFC 8654. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "b_g_p_extended_message": { - "type": "object" - } - }, - "required": [ - "b_g_p_extended_message" - ], - "additionalProperties": false - }, - { - "description": "BGPSec as defined in RFC 8205. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "bgp_sec": { - "type": "object" - } - }, - "required": [ - "bgp_sec" - ], - "additionalProperties": false - }, - { - "description": "Multiple label support as defined in RFC 8277. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "multiple_labels": { - "type": "object" - } - }, - "required": [ - "multiple_labels" - ], - "additionalProperties": false - }, - { - "description": "BGP role capability as defined in RFC 9234. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "bgp_role": { - "type": "object" - } - }, - "required": [ - "bgp_role" - ], - "additionalProperties": false - }, - { - "description": "Graceful restart as defined in RFC 4724. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "graceful_restart": { - "type": "object" - } - }, - "required": [ - "graceful_restart" - ], - "additionalProperties": false - }, - { - "description": "Four octet AS numbers as defined in RFC 6793.", - "type": "object", - "properties": { - "four_octet_as": { - "type": "object", - "properties": { - "asn": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "asn" - ] - } - }, - "required": [ - "four_octet_as" - ], - "additionalProperties": false - }, - { - "description": "Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note this capability is not yet implemented.", - "type": "object", - "properties": { - "dynamic_capability": { - "type": "object" - } - }, - "required": [ - "dynamic_capability" - ], - "additionalProperties": false - }, - { - "description": "Multi session support as defined in draft-ietf-idr-bgp-multisession. Note this capability is not yet supported.", - "type": "object", - "properties": { - "multisession_bgp": { - "type": "object" - } - }, - "required": [ - "multisession_bgp" - ], - "additionalProperties": false - }, - { - "description": "Add path capability as defined in RFC 7911.", - "type": "object", - "properties": { - "add_path": { - "type": "object", - "properties": { - "elements": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AddPathElement" - }, - "uniqueItems": true - } - }, - "required": [ - "elements" - ] - } - }, - "required": [ - "add_path" - ], - "additionalProperties": false - }, - { - "description": "Enhanced route refresh as defined in RFC 7313. Note this capability is not yet supported.", - "type": "object", - "properties": { - "enhanced_route_refresh": { - "type": "object" - } - }, - "required": [ - "enhanced_route_refresh" - ], - "additionalProperties": false - }, - { - "description": "Long-lived graceful restart as defined in draft-uttaro-idr-bgp-persistence. Note this capability is not yet supported.", - "type": "object", - "properties": { - "long_lived_graceful_restart": { - "type": "object" - } - }, - "required": [ - "long_lived_graceful_restart" - ], - "additionalProperties": false - }, - { - "description": "Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note this capability is not yet supported.", - "type": "object", - "properties": { - "routing_policy_distribution": { - "type": "object" - } - }, - "required": [ - "routing_policy_distribution" - ], - "additionalProperties": false - }, - { - "description": "Fully qualified domain names as defined intdraft-walton-bgp-hostname-capability. Note this capability is not yet supported.", - "type": "object", - "properties": { - "fqdn": { - "type": "object" - } - }, - "required": [ - "fqdn" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard route refresh as defined in RFC 8810 (deprecated). Note this capability is not yet supported.", - "type": "object", - "properties": { - "prestandard_route_refresh": { - "type": "object" - } - }, - "required": [ - "prestandard_route_refresh" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard prefix-based outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", - "type": "object", - "properties": { - "prestandard_orf_and_pd": { - "type": "object" - } - }, - "required": [ - "prestandard_orf_and_pd" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", - "type": "object", - "properties": { - "prestandard_outbound_route_filtering": { - "type": "object" - } - }, - "required": [ - "prestandard_outbound_route_filtering" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard multisession as defined in RFC 8810 (deprecated). Note this is not yet implemented.", - "type": "object", - "properties": { - "prestandard_multisession": { - "type": "object" - } - }, - "required": [ - "prestandard_multisession" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard fully qualified domain names as defined in RFC 8810 (deprecated). Note this is not yet implemented.", - "type": "object", - "properties": { - "prestandard_fqdn": { - "type": "object" - } - }, - "required": [ - "prestandard_fqdn" - ], - "additionalProperties": false - }, - { - "description": "Pre-standard operational messages as defined in RFC 8810 (deprecated). Note this is not yet implemented.", - "type": "object", - "properties": { - "prestandard_operational_message": { - "type": "object" - } - }, - "required": [ - "prestandard_operational_message" - ], - "additionalProperties": false - }, - { - "description": "Experimental capability as defined in RFC 8810.", - "type": "object", - "properties": { - "experimental": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "code" - ] - } - }, - "required": [ - "experimental" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "unassigned": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "code" - ] - } - }, - "required": [ - "unassigned" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "reserved": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "code" - ] - } - }, - "required": [ - "reserved" - ], - "additionalProperties": false - } - ] - }, - "CeaseErrorSubcode": { - "description": "Cease error subcode types from RFC 4486", - "type": "string", - "enum": [ - "unspecific", - "maximum_numberof_prefixes_reached", - "administrative_shutdown", - "peer_deconfigured", - "administrative_reset", - "connection_rejected", - "other_configuration_change", - "connection_collision_resolution", - "out_of_resources" - ] - }, - "CheckerSource": { - "type": "object", - "properties": { - "asn": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "code": { - "type": "string" - } - }, - "required": [ - "asn", - "code" - ] - }, - "Community": { - "description": "BGP community value", - "oneOf": [ - { - "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised outside a BGP confederation boundary (a stand-alone autonomous system that is not part of a confederation should be considered a confederation itself)", - "type": "string", - "enum": [ - "no_export" - ] - }, - { - "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to other BGP peers.", - "type": "string", - "enum": [ - "no_advertise" - ] - }, - { - "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to external BGP peers (this includes peers in other members autonomous systems inside a BGP confederation).", - "type": "string", - "enum": [ - "no_export_sub_confed" - ] - }, - { - "description": "All routes received carrying a communities attribute containing this value must set the local preference for the received routes to a low value, preferably zero.", - "type": "string", - "enum": [ - "graceful_shutdown" - ] - }, - { - "description": "A user defined community", - "type": "object", - "properties": { - "user_defined": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "user_defined" - ], - "additionalProperties": false - } - ] - }, - "ConnectionId": { - "description": "Unique identifier for a BGP connection instance", - "type": "object", - "properties": { - "local": { - "description": "Local socket address for this connection", - "type": "string" - }, - "remote": { - "description": "Remote socket address for this connection", - "type": "string" - }, - "uuid": { - "description": "Unique identifier for this connection instance", - "type": "string", - "format": "uuid" - } - }, - "required": [ - "local", - "remote", - "uuid" - ] - }, - "DeleteStaticRoute4Request": { - "type": "object", - "properties": { - "routes": { - "$ref": "#/components/schemas/StaticRoute4List" - } - }, - "required": [ - "routes" - ] - }, - "DeleteStaticRoute6Request": { - "type": "object", - "properties": { - "routes": { - "$ref": "#/components/schemas/StaticRoute6List" - } - }, - "required": [ - "routes" - ] - }, - "Duration": { - "type": "object", - "properties": { - "nanos": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "secs": { - "type": "integer", - "format": "uint64", - "minimum": 0 - } - }, - "required": [ - "nanos", - "secs" - ] - }, - "DynamicTimerInfo": { - "type": "object", - "properties": { - "configured": { - "$ref": "#/components/schemas/Duration" - }, - "negotiated": { - "$ref": "#/components/schemas/Duration" - } - }, - "required": [ - "configured", - "negotiated" - ] - }, - "Error": { - "description": "Error information from a response.", - "type": "object", - "properties": { - "error_code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "request_id": { - "type": "string" - } - }, - "required": [ - "message", - "request_id" - ] - }, - "ErrorCode": { - "description": "This enumeration contains possible notification error codes.", - "type": "string", - "enum": [ - "header", - "open", - "update", - "hold_timer_expired", - "fsm", - "cease" - ] - }, - "ErrorSubcode": { - "description": "This enumeration contains possible notification error subcodes.", - "oneOf": [ - { - "type": "object", - "properties": { - "header": { - "$ref": "#/components/schemas/HeaderErrorSubcode" - } - }, - "required": [ - "header" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "open": { - "$ref": "#/components/schemas/OpenErrorSubcode" - } - }, - "required": [ - "open" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "update": { - "$ref": "#/components/schemas/UpdateErrorSubcode" - } - }, - "required": [ - "update" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "hold_time": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "hold_time" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "fsm": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "fsm" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "cease": { - "$ref": "#/components/schemas/CeaseErrorSubcode" - } - }, - "required": [ - "cease" - ], - "additionalProperties": false - } - ] - }, - "FsmEventBuffer": { - "oneOf": [ - { - "description": "All FSM events (high frequency, includes all timers)", - "type": "string", - "enum": [ - "all" - ] - }, - { - "description": "Major events only (state transitions, admin, new connections)", - "type": "string", - "enum": [ - "major" - ] - } - ] - }, - "FsmEventCategory": { - "description": "Category of FSM event for filtering and display purposes", - "type": "string", - "enum": [ - "Admin", - "Connection", - "Session", - "StateTransition" - ] - }, - "FsmEventRecord": { - "description": "Serializable record of an FSM event with full context", - "type": "object", - "properties": { - "connection_id": { - "nullable": true, - "description": "Connection ID if event is connection-specific", - "allOf": [ - { - "$ref": "#/components/schemas/ConnectionId" - } - ] - }, - "current_state": { - "description": "FSM state at time of event", - "allOf": [ - { - "$ref": "#/components/schemas/FsmStateKind" - } - ] - }, - "details": { - "nullable": true, - "description": "Additional event details (e.g., \"Received OPEN\", \"Admin command\")", - "type": "string" - }, - "event_category": { - "description": "High-level event category", - "allOf": [ - { - "$ref": "#/components/schemas/FsmEventCategory" - } - ] - }, - "event_type": { - "description": "Specific event type as string (e.g., \"ManualStart\", \"HoldTimerExpires\")", - "type": "string" - }, - "previous_state": { - "nullable": true, - "description": "Previous state if this caused a transition", - "allOf": [ - { - "$ref": "#/components/schemas/FsmStateKind" - } - ] - }, - "timestamp": { - "description": "UTC timestamp when event occurred", - "type": "string", - "format": "date-time" - } - }, - "required": [ - "current_state", - "event_category", - "event_type", - "timestamp" - ] - }, - "FsmHistoryRequest": { - "type": "object", - "properties": { - "asn": { - "description": "ASN of the BGP router", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "buffer": { - "nullable": true, - "description": "Which buffer to retrieve - if None, returns major buffer", - "allOf": [ - { - "$ref": "#/components/schemas/FsmEventBuffer" - } - ] - }, - "peer": { - "nullable": true, - "description": "Optional peer filter - if None, returns history for all peers", - "type": "string", - "format": "ip" - } - }, - "required": [ - "asn" - ] - }, - "FsmHistoryResponse": { - "type": "object", - "properties": { - "by_peer": { - "description": "Events organized by peer address Each peer's value contains only the events from the requested buffer", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FsmEventRecord" - } - } - } - }, - "required": [ - "by_peer" - ] - }, - "FsmStateKind": { - "description": "Simplified representation of a BGP state without having to carry a connection.", - "oneOf": [ - { - "description": "Initial state. Refuse all incomming BGP connections. No resources allocated to peer.", - "type": "string", - "enum": [ - "Idle" - ] - }, - { - "description": "Waiting for the TCP connection to be completed.", - "type": "string", - "enum": [ - "Connect" - ] - }, - { - "description": "Trying to acquire peer by listening for and accepting a TCP connection.", - "type": "string", - "enum": [ - "Active" - ] - }, - { - "description": "Waiting for open message from peer.", - "type": "string", - "enum": [ - "OpenSent" - ] - }, - { - "description": "Waiting for keepalive or notification from peer.", - "type": "string", - "enum": [ - "OpenConfirm" - ] - }, - { - "description": "Handler for Connection Collisions (RFC 4271 6.8)", - "type": "string", - "enum": [ - "ConnectionCollision" - ] - }, - { - "description": "Sync up with peers.", - "type": "string", - "enum": [ - "SessionSetup" - ] - }, - { - "description": "Able to exchange update, notification and keepliave messages with peers.", - "type": "string", - "enum": [ - "Established" - ] - } - ] - }, - "HeaderErrorSubcode": { - "description": "Header error subcode types", - "type": "string", - "enum": [ - "unspecific", - "connection_not_synchronized", - "bad_message_length", - "bad_message_type" - ] - }, - "ImportExportPolicy4": { - "description": "Import/Export policy for IPv4 prefixes only.", - "oneOf": [ - { - "type": "string", - "enum": [ - "NoFiltering" - ] - }, - { - "type": "object", - "properties": { - "Allow": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - }, - "uniqueItems": true - } - }, - "required": [ - "Allow" - ], - "additionalProperties": false - } - ] - }, - "ImportExportPolicy6": { - "description": "Import/Export policy for IPv6 prefixes only.", - "oneOf": [ - { - "type": "string", - "enum": [ - "NoFiltering" - ] - }, - { - "type": "object", - "properties": { - "Allow": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix6" - }, - "uniqueItems": true - } - }, - "required": [ - "Allow" - ], - "additionalProperties": false - } - ] - }, - "Ipv4UnicastConfig": { - "description": "Per-address-family configuration for IPv4 Unicast", - "type": "object", - "properties": { - "export_policy": { - "$ref": "#/components/schemas/ImportExportPolicy4" - }, - "import_policy": { - "$ref": "#/components/schemas/ImportExportPolicy4" - }, - "nexthop": { - "nullable": true, - "type": "string", - "format": "ip" - } - }, - "required": [ - "export_policy", - "import_policy" - ] - }, - "Ipv6DoubleNexthop": { - "description": "IPv6 double nexthop: global unicast address + link-local address. Per RFC 2545, when advertising IPv6 routes, both addresses may be present.", - "type": "object", - "properties": { - "global": { - "description": "Global unicast address", - "type": "string", - "format": "ipv6" - }, - "link_local": { - "description": "Link-local address", - "type": "string", - "format": "ipv6" - } - }, - "required": [ - "global", - "link_local" - ] - }, - "Ipv6UnicastConfig": { - "description": "Per-address-family configuration for IPv6 Unicast", - "type": "object", - "properties": { - "export_policy": { - "$ref": "#/components/schemas/ImportExportPolicy6" - }, - "import_policy": { - "$ref": "#/components/schemas/ImportExportPolicy6" - }, - "nexthop": { - "nullable": true, - "type": "string", - "format": "ip" - } - }, - "required": [ - "export_policy", - "import_policy" - ] - }, - "JitterRange": { - "description": "Jitter range with minimum and maximum multiplier values. When applied to a timer, the timer duration is multiplied by a random value within [min, max] to help break synchronization patterns.", - "type": "object", - "properties": { - "max": { - "description": "Maximum jitter multiplier (typically 1.0 or similar)", - "type": "number", - "format": "double" - }, - "min": { - "description": "Minimum jitter multiplier (typically 0.75 or similar)", - "type": "number", - "format": "double" - } - }, - "required": [ - "max", - "min" - ] - }, - "Message": { - "description": "Holds a BGP message. May be an Open, Update, Notification or Keep Alive message.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "open" - ] - }, - "value": { - "$ref": "#/components/schemas/OpenMessage" - } - }, - "required": [ - "type", - "value" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "update" - ] - }, - "value": { - "$ref": "#/components/schemas/UpdateMessage" - } - }, - "required": [ - "type", - "value" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "notification" - ] - }, - "value": { - "$ref": "#/components/schemas/NotificationMessage" - } - }, - "required": [ - "type", - "value" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "keep_alive" - ] - } - }, - "required": [ - "type" - ] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "route_refresh" - ] - }, - "value": { - "$ref": "#/components/schemas/RouteRefreshMessage" - } - }, - "required": [ - "type", - "value" - ] - } - ] - }, - "MessageDirection": { - "type": "string", - "enum": [ - "sent", - "received" - ] - }, - "MessageHistory": { - "description": "Message history for a BGP session", - "type": "object", - "properties": { - "received": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessageHistoryEntry" - } - }, - "sent": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessageHistoryEntry" - } - } - }, - "required": [ - "received", - "sent" - ] - }, - "MessageHistoryEntry": { - "description": "A message history entry is a BGP message with an associated timestamp and connection ID", - "type": "object", - "properties": { - "connection_id": { - "$ref": "#/components/schemas/ConnectionId" - }, - "message": { - "$ref": "#/components/schemas/Message" - }, - "timestamp": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "connection_id", - "message", - "timestamp" - ] - }, - "MessageHistoryRequest": { - "type": "object", - "properties": { - "asn": { - "description": "ASN of the BGP router", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "direction": { - "nullable": true, - "description": "Optional direction filter - if None, returns both sent and received", - "allOf": [ - { - "$ref": "#/components/schemas/MessageDirection" - } - ] - }, - "peer": { - "nullable": true, - "description": "Optional peer filter - if None, returns history for all peers", - "type": "string", - "format": "ip" - } - }, - "required": [ - "asn" - ] - }, - "MessageHistoryResponse": { - "type": "object", - "properties": { - "by_peer": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/MessageHistory" - } - } - }, - "required": [ - "by_peer" - ] - }, - "MpReachNlri": { - "description": "MP_REACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being announced.\n\n```text 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14):\n\nThis is an optional non-transitive attribute that can be used for the following purposes:\n\n(a) to advertise a feasible route to a peer\n\n(b) to permit a router to advertise the Network Layer address of the router that should be used as the next hop to the destinations listed in the Network Layer Reachability Information field of the MP_NLRI attribute.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ ```", - "oneOf": [ - { - "description": "IPv4 Unicast routes (AFI=1, SAFI=1)", - "type": "object", - "properties": { - "afi_safi": { - "type": "string", - "enum": [ - "ipv4_unicast" - ] - }, - "nexthop": { - "description": "Next-hop for IPv4 routes.\n\nCurrently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops when extended next-hop capability (RFC 8950) is implemented.", - "allOf": [ - { - "$ref": "#/components/schemas/BgpNexthop" - } - ] - }, - "nlri": { - "description": "IPv4 prefixes being announced", - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - }, - "reserved": { - "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "afi_safi", - "nexthop", - "nlri", - "reserved" - ] - }, - { - "description": "IPv6 Unicast routes (AFI=2, SAFI=1)", - "type": "object", - "properties": { - "afi_safi": { - "type": "string", - "enum": [ - "ipv6_unicast" - ] - }, - "nexthop": { - "description": "Next-hop for IPv6 routes.\n\nCan be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` (32 bytes with link-local address).", - "allOf": [ - { - "$ref": "#/components/schemas/BgpNexthop" - } - ] - }, - "nlri": { - "description": "IPv6 prefixes being announced", - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix6" - } - }, - "reserved": { - "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "afi_safi", - "nexthop", - "nlri", - "reserved" - ] - } - ] - }, - "MpUnreachNlri": { - "description": "MP_UNREACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being withdrawn.\n\n```text 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15):\n\nThis is an optional non-transitive attribute that can be used for the purpose of withdrawing multiple unfeasible routes from service.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ | Subsequent Address Family Identifier (1 octet) | +---------------------------------------------------------+ | Withdrawn Routes (variable) | +---------------------------------------------------------+ ```", - "oneOf": [ - { - "description": "IPv4 Unicast routes being withdrawn (AFI=1, SAFI=1)", - "type": "object", - "properties": { - "afi_safi": { - "type": "string", - "enum": [ - "ipv4_unicast" - ] - }, - "withdrawn": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - } - }, - "required": [ - "afi_safi", - "withdrawn" - ] - }, - { - "description": "IPv6 Unicast routes being withdrawn (AFI=2, SAFI=1)", - "type": "object", - "properties": { - "afi_safi": { - "type": "string", - "enum": [ - "ipv6_unicast" - ] - }, - "withdrawn": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix6" - } - } - }, - "required": [ - "afi_safi", - "withdrawn" - ] - } - ] - }, - "Neighbor": { - "description": "Neighbor configuration with explicit per-address-family enablement (v3 API)", - "type": "object", - "properties": { - "asn": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "communities": { - "type": "array", - "items": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "connect_retry": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "connect_retry_jitter": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/JitterRange" - } - ] - }, - "delay_open": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "deterministic_collision_resolution": { - "type": "boolean" - }, - "enforce_first_as": { - "type": "boolean" - }, - "group": { - "type": "string" - }, - "hold_time": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "host": { - "type": "string" - }, - "idle_hold_jitter": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/JitterRange" - } - ] - }, - "idle_hold_time": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "ipv4_unicast": { - "nullable": true, - "description": "IPv4 Unicast address family configuration (None = disabled)", - "allOf": [ - { - "$ref": "#/components/schemas/Ipv4UnicastConfig" - } - ] - }, - "ipv6_unicast": { - "nullable": true, - "description": "IPv6 Unicast address family configuration (None = disabled)", - "allOf": [ - { - "$ref": "#/components/schemas/Ipv6UnicastConfig" - } - ] - }, - "keepalive": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "local_pref": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "md5_auth_key": { - "nullable": true, - "type": "string" - }, - "min_ttl": { - "nullable": true, - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "multi_exit_discriminator": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "name": { - "type": "string" - }, - "passive": { - "type": "boolean" - }, - "remote_asn": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "resolution": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "vlan_id": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "asn", - "communities", - "connect_retry", - "delay_open", - "deterministic_collision_resolution", - "enforce_first_as", - "group", - "hold_time", - "host", - "idle_hold_time", - "keepalive", - "name", - "passive", - "resolution" - ] - }, - "NeighborResetOp": { - "type": "string", - "enum": [ - "Hard", - "SoftInbound", - "SoftOutbound" - ] - }, - "NeighborResetRequest": { - "type": "object", - "properties": { - "addr": { - "type": "string", - "format": "ip" - }, - "asn": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "op": { - "$ref": "#/components/schemas/NeighborResetOp" - } - }, - "required": [ - "addr", - "asn", - "op" - ] - }, - "NotificationMessage": { - "description": "Notification messages are exchanged between BGP peers when an exceptional event has occurred.", - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "error_code": { - "description": "Error code associated with the notification", - "allOf": [ - { - "$ref": "#/components/schemas/ErrorCode" - } - ] - }, - "error_subcode": { - "description": "Error subcode associated with the notification", - "allOf": [ - { - "$ref": "#/components/schemas/ErrorSubcode" - } - ] - } - }, - "required": [ - "data", - "error_code", - "error_subcode" - ] - }, - "OpenErrorSubcode": { - "description": "Open message error subcode types", - "type": "string", - "enum": [ - "unspecific", - "unsupported_version_number", - "bad_peer_a_s", - "bad_bgp_identifier", - "unsupported_optional_parameter", - "deprecated", - "unacceptable_hold_time", - "unsupported_capability" - ] - }, - "OpenMessage": { - "description": "The first message sent by each side once a TCP connection is established.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Version | My Autonomous System | Hold Time : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | BGP Identifier : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | Opt Parm Len | Optional Parameters : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Optional Parameters (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.2", - "type": "object", - "properties": { - "asn": { - "description": "Autonomous system number of the sender. When 4-byte ASNs are in use this value is set to AS_TRANS which has a value of 23456.\n\nRef: RFC 4893 §7", - "type": "integer", - "format": "uint16", - "minimum": 0 - }, - "hold_time": { - "description": "Number of seconds the sender proposes for the hold timer.", - "type": "integer", - "format": "uint16", - "minimum": 0 - }, - "id": { - "description": "BGP identifier of the sender", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "parameters": { - "description": "A list of optional parameters.", - "type": "array", - "items": { - "$ref": "#/components/schemas/OptionalParameter" - } - }, - "version": { - "description": "BGP protocol version.", - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "asn", - "hold_time", - "id", - "parameters", - "version" - ] - }, - "OptionalParameter": { - "description": "The IANA/IETF currently defines the following optional parameter types.", - "oneOf": [ - { - "description": "Code 0", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "reserved" - ] - } - }, - "required": [ - "type" - ] - }, - { - "description": "Code 1: RFC 4217, RFC 5492 (deprecated)", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "authentication" - ] - } - }, - "required": [ - "type" - ] - }, - { - "description": "Code 2: RFC 5492", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "capabilities" - ] - }, - "value": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Capability" - }, - "uniqueItems": true - } - }, - "required": [ - "type", - "value" - ] - }, - { - "description": "Unassigned", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "unassigned" - ] - } - }, - "required": [ - "type" - ] - }, - { - "description": "Code 255: RFC 9072", - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "extended_length" - ] - } - }, - "required": [ - "type" - ] - } - ] - }, - "Origin4": { - "type": "object", - "properties": { - "asn": { - "description": "ASN of the router to originate from.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "prefixes": { - "description": "Set of prefixes to originate.", - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - } - }, - "required": [ - "asn", - "prefixes" - ] - }, - "Origin6": { - "type": "object", - "properties": { - "asn": { - "description": "ASN of the router to originate from.", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "prefixes": { - "description": "Set of prefixes to originate.", - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix6" - } - } - }, - "required": [ - "asn", - "prefixes" - ] - }, - "Path": { - "type": "object", - "properties": { - "bgp": { - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/BgpPathProperties" - } - ] - }, - "nexthop": { - "type": "string", - "format": "ip" - }, - "rib_priority": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "shutdown": { - "type": "boolean" - }, - "vlan_id": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "nexthop", - "rib_priority", - "shutdown" - ] - }, - "PathAttribute": { - "description": "A self-describing BGP path attribute", - "type": "object", - "properties": { - "typ": { - "description": "Type encoding for the attribute", - "allOf": [ - { - "$ref": "#/components/schemas/PathAttributeType" - } - ] - }, - "value": { - "description": "Value of the attribute", - "allOf": [ - { - "$ref": "#/components/schemas/PathAttributeValue" - } - ] - } - }, - "required": [ - "typ", - "value" - ] - }, - "PathAttributeType": { - "description": "Type encoding for a path attribute.", - "type": "object", - "properties": { - "flags": { - "description": "Flags may include, Optional, Transitive, Partial and Extended Length.", - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "type_code": { - "description": "Type code for the path attribute.", - "allOf": [ - { - "$ref": "#/components/schemas/PathAttributeTypeCode" - } - ] - } - }, - "required": [ - "flags", - "type_code" - ] - }, - "PathAttributeTypeCode": { - "description": "An enumeration describing available path attribute type codes.", - "oneOf": [ - { - "type": "string", - "enum": [ - "as_path", - "next_hop", - "multi_exit_disc", - "local_pref", - "atomic_aggregate", - "aggregator", - "communities", - "mp_unreach_nlri", - "as4_aggregator" - ] - }, - { - "description": "RFC 4271", - "type": "string", - "enum": [ - "origin" - ] - }, - { - "description": "RFC 4760", - "type": "string", - "enum": [ - "mp_reach_nlri" - ] - }, - { - "description": "RFC 6793", - "type": "string", - "enum": [ - "as4_path" - ] - } - ] - }, - "PathAttributeValue": { - "description": "The value encoding of a path attribute.", - "oneOf": [ - { - "description": "The type of origin associated with a path", - "type": "object", - "properties": { - "origin": { - "$ref": "#/components/schemas/PathOrigin" - } - }, - "required": [ - "origin" - ], - "additionalProperties": false - }, - { - "description": "The AS set associated with a path", - "type": "object", - "properties": { - "as_path": { - "type": "array", - "items": { - "$ref": "#/components/schemas/As4PathSegment" - } - } - }, - "required": [ - "as_path" - ], - "additionalProperties": false - }, - { - "description": "The nexthop associated with a path (IPv4 only for traditional BGP)", - "type": "object", - "properties": { - "next_hop": { - "type": "string", - "format": "ipv4" - } - }, - "required": [ - "next_hop" - ], - "additionalProperties": false - }, - { - "description": "A metric used for external (inter-AS) links to discriminate among multiple entry or exit points.", - "type": "object", - "properties": { - "multi_exit_disc": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "multi_exit_disc" - ], - "additionalProperties": false - }, - { - "description": "Local pref is included in update messages sent to internal peers and indicates a degree of preference.", - "type": "object", - "properties": { - "local_pref": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "local_pref" - ], - "additionalProperties": false - }, - { - "description": "AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (2-octet ASN)", - "type": "object", - "properties": { - "aggregator": { - "$ref": "#/components/schemas/Aggregator" - } - }, - "required": [ - "aggregator" - ], - "additionalProperties": false - }, - { - "description": "Indicates communities associated with a path.", - "type": "object", - "properties": { - "communities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Community" - } - } - }, - "required": [ - "communities" - ], - "additionalProperties": false - }, - { - "description": "Indicates this route was formed via aggregation (RFC 4271 §5.1.7)", - "type": "string", - "enum": [ - "atomic_aggregate" - ] - }, - { - "description": "The 4-byte encoded AS set associated with a path", - "type": "object", - "properties": { - "as4_path": { - "type": "array", - "items": { - "$ref": "#/components/schemas/As4PathSegment" - } - } - }, - "required": [ - "as4_path" - ], - "additionalProperties": false - }, - { - "description": "AS4_AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (4-octet ASN)", - "type": "object", - "properties": { - "as4_aggregator": { - "$ref": "#/components/schemas/As4Aggregator" - } - }, - "required": [ - "as4_aggregator" - ], - "additionalProperties": false - }, - { - "description": "Carries reachable MP-BGP NLRI and Next-hop (advertisement).", - "type": "object", - "properties": { - "mp_reach_nlri": { - "$ref": "#/components/schemas/MpReachNlri" - } - }, - "required": [ - "mp_reach_nlri" - ], - "additionalProperties": false - }, - { - "description": "Carries unreachable MP-BGP NLRI (withdrawal).", - "type": "object", - "properties": { - "mp_unreach_nlri": { - "$ref": "#/components/schemas/MpUnreachNlri" - } - }, - "required": [ - "mp_unreach_nlri" - ], - "additionalProperties": false - } - ] - }, - "PathOrigin": { - "description": "An enumeration indicating the origin type of a path.", - "oneOf": [ - { - "description": "Interior gateway protocol", - "type": "string", - "enum": [ - "igp" - ] - }, - { - "description": "Exterior gateway protocol", - "type": "string", - "enum": [ - "egp" - ] - }, - { - "description": "Incomplete path origin", - "type": "string", - "enum": [ - "incomplete" - ] - } - ] - }, - "PeerInfo": { - "type": "object", - "properties": { - "asn": { - "nullable": true, - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "duration_millis": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "state": { - "$ref": "#/components/schemas/FsmStateKind" - }, - "timers": { - "$ref": "#/components/schemas/PeerTimers" - } - }, - "required": [ - "duration_millis", - "state", - "timers" - ] - }, - "PeerTimers": { - "type": "object", - "properties": { - "hold": { - "$ref": "#/components/schemas/DynamicTimerInfo" - }, - "keepalive": { - "$ref": "#/components/schemas/DynamicTimerInfo" - } - }, - "required": [ - "hold", - "keepalive" - ] - }, - "Prefix": { - "oneOf": [ - { - "type": "object", - "properties": { - "V4": { - "$ref": "#/components/schemas/Prefix4" - } - }, - "required": [ - "V4" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "V6": { - "$ref": "#/components/schemas/Prefix6" - } - }, - "required": [ - "V6" - ], - "additionalProperties": false - } - ] - }, - "Prefix4": { - "type": "object", - "properties": { - "length": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "value": { - "type": "string", - "format": "ipv4" - } - }, - "required": [ - "length", - "value" - ] - }, - "Prefix6": { - "type": "object", - "properties": { - "length": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "value": { - "type": "string", - "format": "ipv6" - } - }, - "required": [ - "length", - "value" - ] - }, - "Rib": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Path" - }, - "uniqueItems": true - } - }, - "RouteRefreshMessage": { - "type": "object", - "properties": { - "afi": { - "description": "Address family identifier.", - "type": "integer", - "format": "uint16", - "minimum": 0 - }, - "safi": { - "description": "Subsequent address family identifier.", - "type": "integer", - "format": "uint8", - "minimum": 0 - } - }, - "required": [ - "afi", - "safi" - ] - }, - "Router": { - "type": "object", - "properties": { - "asn": { - "description": "Autonomous system number for this router", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "graceful_shutdown": { - "description": "Gracefully shut this router down.", - "type": "boolean" - }, - "id": { - "description": "Id for this router", - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "listen": { - "description": "Listening address :", - "type": "string" - } - }, - "required": [ - "asn", - "graceful_shutdown", - "id", - "listen" - ] - }, - "SessionMode": { - "type": "string", - "enum": [ - "SingleHop", - "MultiHop" - ] - }, - "ShaperSource": { - "type": "object", - "properties": { - "asn": { - "type": "integer", - "format": "uint32", - "minimum": 0 - }, - "code": { - "type": "string" - } - }, - "required": [ - "asn", - "code" - ] - }, - "StaticRoute4": { - "type": "object", - "properties": { - "nexthop": { - "type": "string", - "format": "ipv4" - }, - "prefix": { - "$ref": "#/components/schemas/Prefix4" - }, - "rib_priority": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "vlan_id": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "nexthop", - "prefix", - "rib_priority" - ] - }, - "StaticRoute4List": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StaticRoute4" - } - } - }, - "required": [ - "list" - ] - }, - "StaticRoute6": { - "type": "object", - "properties": { - "nexthop": { - "type": "string", - "format": "ipv6" - }, - "prefix": { - "$ref": "#/components/schemas/Prefix6" - }, - "rib_priority": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "vlan_id": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, - "required": [ - "nexthop", - "prefix", - "rib_priority" - ] - }, - "StaticRoute6List": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StaticRoute6" - } - } - }, - "required": [ - "list" - ] - }, - "SwitchIdentifiers": { - "description": "Identifiers for a switch.", - "type": "object", - "properties": { - "slot": { - "nullable": true, - "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", - "type": "integer", - "format": "uint16", - "minimum": 0 - } - } - }, - "UpdateErrorSubcode": { - "description": "Update message error subcode types", - "type": "string", - "enum": [ - "unspecific", - "malformed_attribute_list", - "unrecognized_well_known_attribute", - "missing_well_known_attribute", - "attribute_flags", - "attribute_length", - "invalid_origin_attribute", - "deprecated", - "invalid_nexthop_attribute", - "optional_attribute", - "invalid_network_field", - "malformed_as_path" - ] - }, - "UpdateMessage": { - "description": "An update message is used to advertise feasible routes that share common path attributes to a peer, or to withdraw multiple unfeasible routes from service.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Witdrawn Length | Withdrawn Routes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Withdrawn Routes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Path Attribute Length | Path Attributes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Path Attributes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Network Layer Reachability Information (variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.3", - "type": "object", - "properties": { - "nlri": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - }, - "path_attributes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PathAttribute" - } - }, - "withdrawn": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prefix4" - } - } - }, - "required": [ - "nlri", - "path_attributes", - "withdrawn" - ] - }, - "AddressFamily": { - "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n``` use rdb_types::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", - "oneOf": [ - { - "description": "Internet Protocol Version 4 (IPv4)", - "type": "string", - "enum": [ - "Ipv4" - ] - }, - { - "description": "Internet Protocol Version 6 (IPv6)", - "type": "string", - "enum": [ - "Ipv6" - ] - } - ] - }, - "ProtocolFilter": { - "oneOf": [ - { - "description": "BGP routes only", - "type": "string", - "enum": [ - "Bgp" - ] - }, - { - "description": "Static routes only", - "type": "string", - "enum": [ - "Static" - ] - } - ] - } - }, - "responses": { - "Error": { - "description": "Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } -} diff --git a/openapi/mg-admin/mg-admin-3.0.0-0dc074.json b/openapi/mg-admin/mg-admin-4.0.0-bf8364.json similarity index 99% rename from openapi/mg-admin/mg-admin-3.0.0-0dc074.json rename to openapi/mg-admin/mg-admin-4.0.0-bf8364.json index 83a533ff..4c5798da 100644 --- a/openapi/mg-admin/mg-admin-3.0.0-0dc074.json +++ b/openapi/mg-admin/mg-admin-4.0.0-bf8364.json @@ -4766,20 +4766,6 @@ "list" ] }, -<<<<<<< HEAD:openapi/mg-admin/mg-admin-3.0.0-aca2d9.json - "SwitchIdentifiers": { - "description": "Identifiers for a switch.", - "type": "object", - "properties": { - "slot": { - "nullable": true, - "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", - "type": "integer", - "format": "uint16", - "minimum": 0 - } - } -======= "StaticTimerInfo": { "description": "Timer information for static (non-negotiated) timers", "type": "object", @@ -4795,7 +4781,19 @@ "configured", "remaining" ] ->>>>>>> 4ce8064 (Fixup bgp neighbor status):openapi/mg-admin/mg-admin-3.0.0-ea8abe.json + }, + "SwitchIdentifiers": { + "description": "Identifiers for a switch.", + "type": "object", + "properties": { + "slot": { + "nullable": true, + "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + } }, "UpdateErrorSubcode": { "description": "Update message error subcode types", diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 8ebcbd08..704edfc2 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-4.0.0-016211.json \ No newline at end of file +mg-admin-4.0.0-bf8364.json \ No newline at end of file From ac624778747478b197894aa443bdf288d1f01b83 Mon Sep 17 00:00:00 2001 From: Trey Aspelund Date: Thu, 15 Jan 2026 10:07:47 -0700 Subject: [PATCH 20/20] ry's feedback --- Cargo.lock | 1 - bgp/src/connection_tcp.rs | 14 +- bgp/src/error.rs | 15 +- bgp/src/fanout.rs | 20 +- bgp/src/lib.rs | 1 + bgp/src/messages.rs | 881 +++++++++++------- bgp/src/params.rs | 34 +- bgp/src/proptest.rs | 14 - bgp/src/router.rs | 12 +- bgp/src/session.rs | 267 +++--- mg-admin-client/Cargo.toml | 1 - mg-admin-client/src/lib.rs | 4 - mg-api/src/lib.rs | 44 +- mg-lower/src/lib.rs | 2 +- mg-lower/src/test.rs | 4 - mgadm/src/bgp.rs | 53 +- mgd/src/bgp_admin.rs | 26 +- mgd/src/main.rs | 4 +- mgd/src/rib_admin.rs | 4 +- ...bf8364.json => mg-admin-4.0.0-9d15bb.json} | 16 +- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/src/bestpath.rs | 639 ++++++------- rdb/src/db.rs | 575 ++++-------- rdb/src/types.rs | 108 ++- 24 files changed, 1291 insertions(+), 1450 deletions(-) rename openapi/mg-admin/{mg-admin-4.0.0-bf8364.json => mg-admin-4.0.0-9d15bb.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index 8c33ec5a..368d5fd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3312,7 +3312,6 @@ dependencies = [ name = "mg-admin-client" version = "0.1.0" dependencies = [ - "bgp", "chrono", "colored", "progenitor 0.11.2", diff --git a/bgp/src/connection_tcp.rs b/bgp/src/connection_tcp.rs index 788aca0d..c8274893 100644 --- a/bgp/src/connection_tcp.rs +++ b/bgp/src/connection_tcp.rs @@ -761,7 +761,7 @@ impl BgpConnectionTcp { "error" => format!("{e}") ); - let (subcode, reason) = match &e { + let (subcode, reason) = match e { Error::UnsupportedCapability(cap) => ( OpenErrorSubcode::UnsupportedCapability, OpenParseErrorReason::Other { @@ -771,10 +771,14 @@ impl BgpConnectionTcp { ), }, ), - Error::UnsupportedCapabilityCode(code) => ( - OpenErrorSubcode::UnsupportedCapability, - OpenParseErrorReason::UnsupportedCapability { - code: *code as u8, + Error::BadBgpIdentifier(id) => ( + OpenErrorSubcode::BadBgpIdentifier, + OpenParseErrorReason::BadBgpIdentifier { id }, + ), + Error::BadVersion(ver) => ( + OpenErrorSubcode::UnsupportedVersionNumber, + OpenParseErrorReason::InvalidVersion { + version: ver, }, ), _ => ( diff --git a/bgp/src/error.rs b/bgp/src/error.rs index aa029c08..d739ecdc 100644 --- a/bgp/src/error.rs +++ b/bgp/src/error.rs @@ -2,7 +2,10 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::{fmt::Display, net::IpAddr}; +use std::{ + fmt::Display, + net::{IpAddr, Ipv4Addr}, +}; use num_enum::TryFromPrimitiveError; @@ -20,8 +23,11 @@ pub enum Error { #[error("invalid message type")] InvalidMessageType(u8), - #[error("bad version")] - BadVersion, + #[error("bad version: {0}")] + BadVersion(u8), + + #[error("bad bgp identifier: {0}")] + BadBgpIdentifier(Ipv4Addr), #[error("reserved capability")] ReservedCapability, @@ -121,9 +127,6 @@ pub enum Error { #[error("Session for peer already exists")] PeerExists, - #[error("Capability code not supported {0:?}")] - UnsupportedCapabilityCode(crate::messages::CapabilityCode), - #[error("Capability not supported {0:?}")] UnsupportedCapability(crate::messages::Capability), diff --git a/bgp/src/fanout.rs b/bgp/src/fanout.rs index ebce1458..e6a4e68f 100644 --- a/bgp/src/fanout.rs +++ b/bgp/src/fanout.rs @@ -50,10 +50,7 @@ pub struct Egress { // IPv4-specific implementation impl Fanout { /// Announce and/or withdraw IPv4 routes to all peers. - /// - /// Per RFC 7606, announcements and withdrawals are sent as separate - /// UPDATE messages to avoid mixing reachable and unreachable NLRI. - pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { + pub fn send_all(&self, nlri: Vec, withdrawn: Vec) { for egress in self.egress.values() { if !nlri.is_empty() { let announce = @@ -69,10 +66,7 @@ impl Fanout { } /// Announce and/or withdraw IPv4 routes to all peers except the origin. - /// - /// Per RFC 7606, announcements and withdrawals are sent as separate - /// UPDATE messages to avoid mixing reachable and unreachable NLRI. - pub fn announce_except( + pub fn send_except( &self, origin: IpAddr, nlri: Vec, @@ -99,10 +93,7 @@ impl Fanout { // IPv6-specific implementation impl Fanout { /// Announce and/or withdraw IPv6 routes to all peers. - /// - /// Per RFC 7606, announcements and withdrawals are sent as separate - /// UPDATE messages to avoid mixing reachable and unreachable NLRI. - pub fn announce_all(&self, nlri: Vec, withdrawn: Vec) { + pub fn send_all(&self, nlri: Vec, withdrawn: Vec) { for egress in self.egress.values() { if !nlri.is_empty() { let announce = @@ -118,10 +109,7 @@ impl Fanout { } /// Announce and/or withdraw IPv6 routes to all peers except the origin. - /// - /// Per RFC 7606, announcements and withdrawals are sent as separate - /// UPDATE messages to avoid mixing reachable and unreachable NLRI. - pub fn announce_except( + pub fn send_except( &self, origin: IpAddr, nlri: Vec, diff --git a/bgp/src/lib.rs b/bgp/src/lib.rs index 09903a77..d7b59c3f 100644 --- a/bgp/src/lib.rs +++ b/bgp/src/lib.rs @@ -32,6 +32,7 @@ mod test; pub mod connection_channel; pub const BGP_PORT: u16 = 179; +pub const BGP_VERSION: u8 = 4; pub const COMPONENT_BGP: &str = "bgp"; pub const MOD_ROUTER: &str = "router"; pub const MOD_NEIGHBOR: &str = "neighbor"; diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index aa2729e0..c6a6bf23 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -2,13 +2,14 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::error::Error; +use crate::{BGP_VERSION, error::Error}; use nom::{ bytes::complete::{tag, take}, number::complete::{be_u8, be_u16, be_u32, u8 as parse_u8}, }; use num_enum::FromPrimitive; use num_enum::{IntoPrimitive, TryFromPrimitive}; +use path_attribute_flags::*; pub use rdb::types::Prefix; use rdb::types::{AddressFamily, Prefix4, Prefix6}; use schemars::JsonSchema; @@ -40,6 +41,30 @@ pub trait BgpWireFormat: Sized { fn from_wire(input: &[u8]) -> Result<(&[u8], T), Self::Error>; } +/// NLRI section identifier for error context +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NlriSection { + /// Withdrawn routes section + Withdrawn, + /// IPv4 NLRI section (non-MP-BGP) + Nlri, + /// MP_REACH_NLRI attribute + MpReach, + /// MP_UNREACH_NLRI attribute + MpUnreach, +} + +impl Display for NlriSection { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Withdrawn => write!(f, "withdrawn"), + Self::Nlri => write!(f, "nlri"), + Self::MpReach => write!(f, "mp_reach"), + Self::MpUnreach => write!(f, "mp_unreach"), + } + } +} + /// Errors from parsing NLRI prefixes. /// /// These preserve the specific failure reason so callers can convert @@ -56,7 +81,7 @@ pub enum PrefixParseError { impl PrefixParseError { /// Convert to UpdateParseErrorReason with section context. - pub fn into_reason(self, section: &'static str) -> UpdateParseErrorReason { + pub fn into_reason(self, section: NlriSection) -> UpdateParseErrorReason { match self { Self::MissingLength => { UpdateParseErrorReason::NlriMissingLength { section } @@ -84,7 +109,7 @@ impl BgpWireFormat for Prefix4 { fn to_wire(&self) -> Vec { let mut buf = vec![self.length]; - let n = (self.length as usize).div_ceil(8); + let n = usize::from(self.length).div_ceil(8); buf.extend_from_slice(&self.value.octets()[..n]); buf } @@ -104,7 +129,7 @@ impl BgpWireFormat for Prefix4 { }); } - let byte_count = (len as usize).div_ceil(8); + let byte_count = usize::from(len).div_ceil(8); if input.len() < 1 + byte_count { return Err(PrefixParseError::Truncated { needed: 1 + byte_count, @@ -148,7 +173,7 @@ impl BgpWireFormat for Prefix6 { fn to_wire(&self) -> Vec { let mut buf = vec![self.length]; - let n = (self.length as usize).div_ceil(8); + let n = usize::from(self.length).div_ceil(8); buf.extend_from_slice(&self.value.octets()[..n]); buf } @@ -168,7 +193,7 @@ impl BgpWireFormat for Prefix6 { }); } - let byte_count = (len as usize).div_ceil(8); + let byte_count = usize::from(len).div_ceil(8); if input.len() < 1 + byte_count { return Err(PrefixParseError::Truncated { needed: 1 + byte_count, @@ -210,7 +235,9 @@ impl BgpWireFormat for Prefix6 { /// BGP Message types. /// /// Ref: RFC 4271 §4.1 -#[derive(Debug, Eq, PartialEq, TryFromPrimitive, Copy, Clone)] +#[derive( + Clone, Copy, Debug, Eq, IntoPrimitive, PartialEq, TryFromPrimitive, +)] #[repr(u8)] pub enum MessageType { /// The first message sent by each side once a TCP connection is @@ -273,7 +300,7 @@ impl Message { Self::Update(m) => m.to_wire(), Self::Notification(m) => m.to_wire(), Self::KeepAlive => Ok(Vec::new()), - Self::RouteRefresh(m) => m.to_wire(), + Self::RouteRefresh(m) => Ok(m.to_wire()), } } @@ -483,7 +510,7 @@ impl Header { pub fn to_wire(&self) -> Vec { let mut buf = MARKER.to_vec(); buf.extend_from_slice(&self.length.to_be_bytes()); - buf.push(self.typ as u8); + buf.push(self.typ.into()); buf } @@ -601,7 +628,7 @@ impl OpenMessage { } pub fn asn(&self) -> u32 { - let mut remote_asn = self.asn as u32; + let mut remote_asn = u32::from(self.asn); for p in &self.parameters { if let OptionalParameter::Capabilities(caps) = p { for c in caps { @@ -660,11 +687,20 @@ impl OpenMessage { } let (input, version) = parse_u8(input)?; + if version != BGP_VERSION { + return Err(Error::BadVersion(version)); + } + let (input, asn) = be_u16(input)?; let (input, hold_time) = be_u16(input)?; + let (input, id) = be_u32(input)?; + if id == 0 { + return Err(Error::BadBgpIdentifier(Ipv4Addr::from_bits(id))); + } + let (input, param_len) = parse_u8(input)?; - let param_len = param_len as usize; + let param_len = usize::from(param_len); if input.len() < param_len { return Err(Error::TooSmall( @@ -756,22 +792,25 @@ pub struct UpdateMessage { pub path_attributes: Vec, // XXX: use map for O(1) lookups? pub nlri: Vec, - /// True if a TreatAsWithdraw error occurred during from_wire(). - /// When true, session should process all NLRI (v4 + v6) as withdrawals. - /// Not serialized - only used for internal signaling. - #[serde(skip)] - pub treat_as_withdraw: bool, - /// All attribute parse errors encountered during from_wire(). /// Includes both TreatAsWithdraw and Discard errors. /// SessionReset errors cause early return and are not collected here. /// Not serialized - only used for internal signaling. + /// Use the treat_as_withdraw() method to check if any TreatAsWithdraw errors occurred. #[serde(skip)] #[schemars(skip)] pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, } impl UpdateMessage { + /// Returns true if a TreatAsWithdraw error occurred during parsing. + /// When true, all NLRI (v4 + v6) should be processed as withdrawals. + pub fn treat_as_withdraw(&self) -> bool { + self.errors.iter().any(|(_, action)| { + matches!(action, AttributeAction::TreatAsWithdraw) + }) + } + pub fn to_wire(&self) -> Result, Error> { let mut buf = Vec::new(); @@ -942,7 +981,7 @@ impl UpdateMessage { error_subcode: ErrorSubcode::Update( UpdateErrorSubcode::InvalidNetworkField, ), - reason: e.into_reason("withdrawn"), + reason: e.into_reason(NlriSection::Withdrawn), }); } }; @@ -977,7 +1016,6 @@ impl UpdateMessage { let ParsedPathAttrs { attrs: path_attributes, errors: attr_errors, - treat_as_withdraw, } = Self::path_attrs_from_wire(attrs_input)?; // 6. Parse NLRI (remaining bytes) @@ -991,7 +1029,7 @@ impl UpdateMessage { error_subcode: ErrorSubcode::Update( UpdateErrorSubcode::InvalidNetworkField, ), - reason: e.into_reason("nlri"), + reason: e.into_reason(NlriSection::Nlri), }); } }; @@ -1000,7 +1038,6 @@ impl UpdateMessage { // Only required when UPDATE carries reachability information (NLRI). // Missing mandatory attrs = TreatAsWithdraw per RFC 7606. let mut errors = attr_errors; - let mut treat_as_withdraw = treat_as_withdraw; // Check if we have any NLRI (traditional or MP-BGP) let has_traditional_nlri = !nlri.is_empty(); @@ -1014,7 +1051,6 @@ impl UpdateMessage { .iter() .any(|a| matches!(a.value, PathAttributeValue::Origin(_))); if !has_origin { - treat_as_withdraw = true; errors.push(( UpdateParseErrorReason::MissingAttribute { type_code: PathAttributeTypeCode::Origin, @@ -1033,7 +1069,6 @@ impl UpdateMessage { ) }); if !has_as_path { - treat_as_withdraw = true; errors.push(( UpdateParseErrorReason::MissingAttribute { type_code: PathAttributeTypeCode::AsPath, @@ -1050,7 +1085,6 @@ impl UpdateMessage { .iter() .any(|a| matches!(a.value, PathAttributeValue::NextHop(_))); if !has_next_hop { - treat_as_withdraw = true; errors.push(( UpdateParseErrorReason::MissingAttribute { type_code: PathAttributeTypeCode::NextHop, @@ -1065,7 +1099,6 @@ impl UpdateMessage { withdrawn, path_attributes, nlri, - treat_as_withdraw, errors, }) } @@ -1139,9 +1172,24 @@ impl UpdateMessage { fn path_attrs_from_wire( mut buf: &[u8], ) -> Result { + type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; + + fn take_bytes<'a>(buf: &'a [u8], n: usize) -> ParseRes<'a, &'a [u8]> { + take(n)(buf) + } + + fn parse_u8<'a>(input: &'a [u8]) -> ParseRes<'a, u8> { + nom::number::complete::u8(input) + } + + fn parse_u16<'a>(input: &'a [u8]) -> ParseRes<'a, u16> { + nom::number::complete::be_u16(input) + } + let mut result = Vec::new(); let mut errors = Vec::new(); - let mut treat_as_withdraw = false; let mut seen_types: HashSet = HashSet::new(); let mut has_mp_reach = false; let mut has_mp_unreach = false; @@ -1154,33 +1202,10 @@ impl UpdateMessage { // ===== FRAMING: Parse attribute header (type + length) ===== // 1. Parse 2-byte type header (flags + type_code) - let (remaining, type_bytes) = - match take::<_, _, nom::error::Error<&[u8]>>(2usize)(buf) { - Ok((r, t)) => (r, t), - Err(e) => { - // Can't even read type header - fatal framing error - return Err(UpdateParseError { - error_code: ErrorCode::Update, - error_subcode: ErrorSubcode::Update( - UpdateErrorSubcode::MalformedAttributeList, - ), - reason: - UpdateParseErrorReason::AttributeParseError { - type_code: None, - detail: format!( - "failed to read attribute type: {e}" - ), - }, - }); - } - }; - - // 2. Parse PathAttributeType from the 2 bytes - let typ = match PathAttributeType::from_wire(type_bytes) { - Ok(t) => t, + let (remaining, type_bytes) = match take_bytes(buf, 2) { + Ok((r, t)) => (r, t), Err(e) => { - // Unknown/invalid type code - fatal framing error - // (we don't know the length encoding without valid flags) + // Can't even read type header - fatal framing error return Err(UpdateParseError { error_code: ErrorCode::Update, error_subcode: ErrorSubcode::Update( @@ -1188,13 +1213,127 @@ impl UpdateMessage { ), reason: UpdateParseErrorReason::AttributeParseError { type_code: None, - detail: format!("invalid attribute type: {e}"), + detail: format!( + "failed to read attribute type: {e}" + ), }, }); } }; - let type_code_u8 = typ.type_code as u8; + // 2. Parse flags and type_code from the 2 bytes separately + // We need to handle unknown type codes per RFC 4271 Section 4.3 + let flags_byte = type_bytes[0]; + let type_code_u8 = type_bytes[1]; + + // Try to parse as a known type code + let typ = match PathAttributeTypeCode::try_from(type_code_u8) { + Ok(code) => { + // Known type code - construct PathAttributeType + PathAttributeType { + flags: flags_byte, + type_code: code, + } + } + Err(_) => { + // Unknown type code - check Optional flag per RFC 4271 + // Section 4.3. We must parse the length regardless to skip + // the attribute correctly + let optional = (flags_byte & OPTIONAL) != 0; + + // Parse length based on EXTENDED_LENGTH flag + let (remaining, len) = if flags_byte & EXTENDED_LENGTH != 0 + { + match parse_u16(remaining) { + Ok((r, l)) => (r, usize::from(l)), + Err(e) => { + // If we fail to parse the length, we have to + // bail out since we can't skip this attr. + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "failed to read extended length: {e}" + ), + }, + }); + } + } + } else { + match parse_u8(remaining) { + Ok((r, l)) => (r, usize::from(l)), + Err(e) => { + // If we fail to parse the length, we have to + // bail out since we can't skip this attr. + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!("failed to read length: {e}"), + }, + }); + } + } + }; + + // Skip the attribute value + let (remaining, _) = match take_bytes(remaining, len) { + Ok((r, v)) => (r, v), + Err(e) => { + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "attribute truncated: declared {} bytes, {e}", + len + ), + }, + }); + } + }; + + // Update buf to next attribute + buf = remaining; + + // Handle unknown attribute based on Optional flag + if optional { + // Optional unknown attribute - discard per RFC 4271 Section 4.3 + // Record the error and continue to next attribute + errors.push(( + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code_u8), + detail: format!( + "unknown optional attribute type code: {type_code_u8}" + ), + }, + AttributeAction::Discard, + )); + continue; + } else { + // Mandatory unknown attribute - Session Reset per RFC 4271 Section 4.3 + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason: UpdateParseErrorReason::UnrecognizedMandatoryAttribute { + type_code: type_code_u8, + }, + }); + } + } + }; // 3. Validate attribute flags (RFC 7606 Section 3c) // Even if flag validation fails, we need to parse the length to skip @@ -1204,8 +1343,8 @@ impl UpdateMessage { // 4. Parse length (1 or 2 bytes depending on EXTENDED_LENGTH flag) let (remaining, len) = if typ.flags & path_attribute_flags::EXTENDED_LENGTH != 0 { - match be_u16::<_, nom::error::Error<&[u8]>>(remaining) { - Ok((r, l)) => (r, l as usize), + match parse_u16(remaining) { + Ok((r, l)) => (r, usize::from(l)), Err(e) => { // Can't read extended length - fatal framing error return Err(UpdateParseError { @@ -1224,8 +1363,8 @@ impl UpdateMessage { } } } else { - match parse_u8::<_, nom::error::Error<&[u8]>>(remaining) { - Ok((r, l)) => (r, l as usize), + match parse_u8(remaining) { + Ok((r, l)) => (r, usize::from(l)), Err(e) => { // Can't read length - fatal framing error return Err(UpdateParseError { @@ -1246,15 +1385,13 @@ impl UpdateMessage { }; // 5. Extract `len` bytes for the attribute value - let (remaining, value_bytes) = match take::< - _, - _, - nom::error::Error<&[u8]>, - >(len)(remaining) - { + let (remaining, value_bytes) = match take_bytes(remaining, len) { Ok((r, v)) => (r, v), Err(e) => { // Declared length exceeds available bytes - fatal framing error + // RFC 7606 Section 4 says use treat-as-withdraw, but we can't + // reliably locate the next attribute, so this is a structural + // error in the UPDATE message itself (too few bytes overall) return Err(UpdateParseError { error_code: ErrorCode::Update, error_subcode: ErrorSubcode::Update( @@ -1275,6 +1412,29 @@ impl UpdateMessage { // Update buf to point past this attribute for the next iteration buf = remaining; + // ===== RFC 7606 Section 4: Validate zero-length attributes ===== + // Only AS_PATH and ATOMIC_AGGREGATE may have zero length. + // All other attributes with zero length are a syntax error (treat-as-withdraw). + if len == 0 { + match typ.type_code { + PathAttributeTypeCode::AsPath + | PathAttributeTypeCode::AtomicAggregate => { + // These are allowed to have zero length + } + _ => { + // All other attributes must have non-zero length + let reason = + UpdateParseErrorReason::AttributeLengthError { + type_code: typ.type_code, + expected: 1, // At least 1 byte needed for meaningful data + got: 0, + }; + errors.push((reason, AttributeAction::TreatAsWithdraw)); + continue; + } + } + } + // ===== Handle flag validation error ===== // Now that we've advanced buf, we can handle the flag error if let Some((reason, action)) = flag_error { @@ -1289,7 +1449,6 @@ impl UpdateMessage { }); } AttributeAction::TreatAsWithdraw => { - treat_as_withdraw = true; errors.push((reason, action)); continue; // Skip value parsing, move to next attribute } @@ -1345,8 +1504,15 @@ impl UpdateMessage { // non-MP-BGP attributes. seen_types.insert(type_code_u8); result.push(pa); + } else { + // Discard duplicate non-MP-BGP attribute per RFC 7606 3(g) + errors.push(( + UpdateParseErrorReason::DuplicateAttribute { + type_code: type_code_u8, + }, + AttributeAction::Discard, + )); } - // else: discard duplicate non-MP-BGP attribute per RFC 7606 3(g) } Err(reason) => { // Value parsing failed - determine action based on attribute type @@ -1365,7 +1531,6 @@ impl UpdateMessage { } AttributeAction::TreatAsWithdraw => { // Record error, skip this attribute, continue parsing - treat_as_withdraw = true; errors.push((reason, action)); } AttributeAction::Discard => { @@ -1380,7 +1545,6 @@ impl UpdateMessage { Ok(ParsedPathAttrs { attrs: result, errors, - treat_as_withdraw, }) } @@ -1551,7 +1715,7 @@ impl Display for UpdateMessage { write!( f, "Update[ treat-as-withdraw: ({}) withdrawn({}) path_attributes: ({p_str}) nlri({}) ]", - self.treat_as_withdraw, + self.treat_as_withdraw(), if !w_str.is_empty() { &w_str } else { "empty" }, if !n_str.is_empty() { &n_str } else { "empty" } ) @@ -1640,10 +1804,21 @@ impl PathAttribute { fn validate_attribute_flags( typ: &PathAttributeType, ) -> Result<(), (UpdateParseErrorReason, AttributeAction)> { - use path_attribute_flags::*; - let optional = typ.flags & OPTIONAL != 0; let transitive = typ.flags & TRANSITIVE != 0; + let partial = typ.flags & PARTIAL != 0; + + // RFC 4271 Section 4.3: + // The Partial bit (bit 2) is only meaningful when Transitive (bit 1) is + // set. i.e. If Transitive=0, Partial MUST be 0. + if !transitive && partial { + let reason = UpdateParseErrorReason::InvalidAttributeFlags { + type_code: typ.type_code.into(), + flags: typ.flags, + }; + // RFC 7606 Section 3.c: Attribute flag errors must use treat-as-withdraw + return Err((reason, AttributeAction::TreatAsWithdraw)); + } // Define expected flags for each known attribute type // Format: (expected_optional, expected_transitive) @@ -1671,11 +1846,11 @@ fn validate_attribute_flags( if optional != expected_optional || transitive != expected_transitive { let reason = UpdateParseErrorReason::InvalidAttributeFlags { - type_code: typ.type_code as u8, + type_code: typ.type_code.into(), flags: typ.flags, }; - let action = typ.error_action(); - return Err((reason, action)); + // RFC 7606 Section 3.c: Attribute flag errors must use treat-as-withdraw + return Err((reason, AttributeAction::TreatAsWithdraw)); } Ok(()) @@ -1693,7 +1868,7 @@ pub struct PathAttributeType { impl PathAttributeType { pub fn to_wire(&self) -> Vec { - vec![self.flags, self.type_code as u8] + vec![self.flags, self.type_code.into()] } pub fn from_wire(input: &[u8]) -> Result { @@ -1771,15 +1946,16 @@ pub mod path_attribute_flags { /// An enumeration describing available path attribute type codes. #[derive( - Debug, - PartialEq, - Eq, - Copy, Clone, - TryFromPrimitive, - Serialize, + Copy, + Debug, Deserialize, + Eq, + IntoPrimitive, JsonSchema, + PartialEq, + Serialize, + TryFromPrimitive, )] #[repr(u8)] #[serde(rename_all = "snake_case")] @@ -1998,11 +2174,11 @@ pub enum PathAttributeValue { impl PathAttributeValue { pub fn to_wire(&self) -> Result, Error> { match self { - Self::Origin(x) => Ok(vec![*x as u8]), + Self::Origin(x) => Ok(vec![(*x).into()]), Self::AsPath(segments) => { let mut buf = Vec::new(); for s in segments { - buf.push(s.typ as u8); + buf.push(s.typ.into()); buf.push(s.value.len() as u8); for v in &s.value { buf.extend_from_slice(&v.to_be_bytes()); @@ -2030,7 +2206,7 @@ impl PathAttributeValue { Self::Aggregator(agg) => Ok(agg.to_wire()), Self::As4Aggregator(agg) => Ok(agg.to_wire()), Self::AtomicAggregate => Ok(Vec::new()), // Zero-length attribute - Self::MpReachNlri(mp) => mp.to_wire(), + Self::MpReachNlri(mp) => Ok(mp.to_wire()), Self::MpUnreachNlri(mp) => mp.to_wire(), } } @@ -2039,8 +2215,22 @@ impl PathAttributeValue { mut input: &[u8], type_code: PathAttributeTypeCode, ) -> Result { - // Helper for nom type annotation + // Helper type aliases and functions for nom parsers type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; + + fn parse_u8<'a>(input: &'a [u8]) -> ParseRes<'a, u8> { + be_u8(input) + } + + fn parse_u32<'a>(input: &'a [u8]) -> ParseRes<'a, u32> { + be_u32(input) + } + + fn take_bytes<'a>(input: &'a [u8], n: usize) -> ParseRes<'a, &'a [u8]> { + take(n)(input) + } // RFC 7606 §3: Zero-length attributes only valid for AS_PATH and ATOMIC_AGGREGATE if input.is_empty() { @@ -2069,13 +2259,12 @@ impl PathAttributeValue { got: input.len(), }); } - let (_input, origin) = - be_u8::<_, NomErr<'_>>(input).map_err(|e| { - UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), - detail: format!("{e}"), - } - })?; + let (_input, origin) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; PathOrigin::try_from(origin) .map(PathAttributeValue::Origin) .map_err(|_| UpdateParseErrorReason::InvalidOriginValue { @@ -2107,13 +2296,12 @@ impl PathAttributeValue { got: input.len(), }); } - let (_input, b) = take::<_, _, NomErr<'_>>(4usize)(input) - .map_err(|e| { - UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), - detail: format!("{e}"), - } - })?; + let (_input, b) = take_bytes(input, 4).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::NextHop(Ipv4Addr::new( b[0], b[1], b[2], b[3], ))) @@ -2127,13 +2315,12 @@ impl PathAttributeValue { got: input.len(), }); } - let (_input, v) = - be_u32::<_, NomErr<'_>>(input).map_err(|e| { - UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), - detail: format!("{e}"), - } - })?; + let (_input, v) = parse_u32(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::MultiExitDisc(v)) } PathAttributeTypeCode::As4Path => { @@ -2171,7 +2358,7 @@ impl PathAttributeValue { let (out, v) = be_u32::<_, NomErr<'_>>(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), + type_code: Some(type_code.into()), detail: format!("{e}"), } })?; @@ -2189,13 +2376,12 @@ impl PathAttributeValue { got: input.len(), }); } - let (_input, v) = - be_u32::<_, NomErr<'_>>(input).map_err(|e| { - UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), - detail: format!("{e}"), - } - })?; + let (_input, v) = parse_u32(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::LocalPref(v)) } PathAttributeTypeCode::MpReachNlri => { @@ -2230,7 +2416,7 @@ impl PathAttributeValue { } let agg = Aggregator::from_wire(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), + type_code: Some(type_code.into()), detail: e, } })?; @@ -2248,7 +2434,7 @@ impl PathAttributeValue { } let agg = As4Aggregator::from_wire(input).map_err(|e| { UpdateParseErrorReason::AttributeParseError { - type_code: Some(type_code as u8), + type_code: Some(type_code.into()), detail: e, } })?; @@ -2275,18 +2461,6 @@ impl Display for PathAttributeValue { PathAttributeValue::LocalPref(pref) => { write!(f, "local-pref: {pref}") } - /* - * RFC 4271 - * - * g) AGGREGATOR (Type Code 7) - * - * AGGREGATOR is an optional transitive attribute of length 6. - * The attribute contains the last AS number that formed the - * aggregate route (encoded as 2 octets), followed by the IP - * address of the BGP speaker that formed the aggregate route - * (encoded as 4 octets). This SHOULD be the same address as - * the one used for the BGP Identifier of the speaker. - */ PathAttributeValue::Aggregator(agg) => { write!(f, "aggregator: {}", agg) } @@ -2315,14 +2489,6 @@ impl Display for PathAttributeValue { .join(" "); write!(f, "as4-path: [{path}]") } - /* - * RFC 6793 - * - * Similarly, this document defines a new BGP path attribute called - * AS4_AGGREGATOR, which is optional transitive. The AS4_AGGREGATOR - * attribute has the same semantics and the same encoding as the - * AGGREGATOR attribute, except that it carries a four-octet AS number. - */ PathAttributeValue::As4Aggregator(agg) => { write!(f, "as4-aggregator: {}", agg) } @@ -2376,15 +2542,16 @@ pub enum Community { /// An enumeration indicating the origin type of a path. #[derive( - Debug, - PartialEq, - Eq, Clone, Copy, - TryFromPrimitive, - Serialize, + Debug, Deserialize, + Eq, + IntoPrimitive, JsonSchema, + PartialEq, + Serialize, + TryFromPrimitive, )] #[repr(u8)] #[serde(rename_all = "snake_case")] @@ -2430,7 +2597,7 @@ impl As4PathSegment { if self.value.len() > u8::MAX as usize { return Err(Error::TooLarge("AS4 path segment".into())); } - let mut buf = vec![self.typ as u8, self.value.len() as u8]; + let mut buf = vec![self.typ.into(), self.value.len() as u8]; for v in &self.value { buf.extend_from_slice(&v.to_be_bytes()); } @@ -2446,7 +2613,7 @@ impl As4PathSegment { // RFC 4271 §5.1.3: Check for overflow when calculating byte length from segment count // Each AS number is 4 bytes, so byte_len = len_u8 * 4 // Note: This is technically safe (max 255 * 4 = 1020), but we validate for defense-in-depth - let byte_len = (len_u8 as usize).checked_mul(4).ok_or_else(|| { + let byte_len = usize::from(len_u8).checked_mul(4).ok_or_else(|| { Error::TooLarge( "AS path segment length calculation overflow".into(), ) @@ -2502,15 +2669,16 @@ impl Display for As4PathSegment { /// Enumeration describes possible AS path types #[derive( - Debug, - PartialEq, - Eq, - Copy, Clone, - TryFromPrimitive, - Serialize, + Copy, + Debug, Deserialize, + Eq, + IntoPrimitive, JsonSchema, + PartialEq, + Serialize, + TryFromPrimitive, )] #[repr(u8)] #[serde(rename_all = "snake_case")] @@ -2639,7 +2807,7 @@ impl BgpNexthop { nh_len: u8, afi: Afi, ) -> Result { - if nh_bytes.len() != nh_len as usize { + if nh_bytes.len() != usize::from(nh_len) { return Err(Error::InvalidAddress(format!( "next-hop bytes length {} doesn't match nh_len {}", nh_bytes.len(), @@ -2696,12 +2864,12 @@ impl BgpNexthop { match self { BgpNexthop::Ipv4(addr) => addr.octets().to_vec(), BgpNexthop::Ipv6Single(addr) => addr.octets().to_vec(), - BgpNexthop::Ipv6Double(addrs) => { - let mut buf = Vec::new(); - buf.extend_from_slice(&addrs.global.octets()); - buf.extend_from_slice(&addrs.link_local.octets()); - buf - } + BgpNexthop::Ipv6Double(addrs) => addrs + .global + .octets() + .into_iter() + .chain(addrs.link_local.octets()) + .collect(), } } } @@ -2730,15 +2898,6 @@ impl From for BgpNexthop { } } -impl From<(Ipv6Addr, Ipv6Addr)> for BgpNexthop { - fn from(value: (Ipv6Addr, Ipv6Addr)) -> Self { - BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { - global: value.0, - link_local: value.1, - }) - } -} - impl From for BgpNexthop { fn from(value: IpAddr) -> Self { match value { @@ -2904,19 +3063,19 @@ impl MpReachNlri { } /// Serialize to wire format. - pub fn to_wire(&self) -> Result, Error> { + pub fn to_wire(&self) -> Vec { let mut buf = Vec::new(); // AFI (2 bytes) - buf.extend_from_slice(&(self.afi() as u16).to_be_bytes()); + buf.extend_from_slice(&u16::from(self.afi()).to_be_bytes()); // SAFI (1 byte) - buf.push(self.safi() as u8); + buf.push(self.safi().into()); // Next-hop - let nh_bytes = self.nexthop().to_bytes(); - buf.push(nh_bytes.len() as u8); // Next-hop length - buf.extend_from_slice(&nh_bytes); + let nh = self.nexthop(); + buf.push(nh.byte_len()); // Next-hop length + buf.extend_from_slice(&nh.to_bytes()); // Reserved (1 byte from RFC 4760 §3, historically "Number of SNPAs") let reserved = match self { @@ -2939,7 +3098,7 @@ impl MpReachNlri { } } - Ok(buf) + buf } /// Parse from wire format. @@ -2954,12 +3113,25 @@ impl MpReachNlri { pub fn from_wire( input: &[u8], ) -> Result<(&[u8], Self), UpdateParseErrorReason> { + type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; + + fn parse_u16<'a>(input: &'a [u8]) -> ParseRes<'a, u16> { + be_u16(input) + } + + fn parse_u8<'a>(input: &'a [u8]) -> ParseRes<'a, u8> { + be_u8(input) + } + // Parse AFI (2 bytes) - let (input, afi_raw) = be_u16::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + let (input, afi_raw) = parse_u16(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri.into()), detail: format!("failed to parse AFI: {e}"), - })?; + } + })?; let afi = Afi::try_from(afi_raw).map_err(|_| { UpdateParseErrorReason::UnsupportedAfiSafi { afi: afi_raw, @@ -2968,27 +3140,29 @@ impl MpReachNlri { })?; // Parse SAFI (1 byte) - let (input, safi_raw) = be_u8::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + let (input, safi_raw) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri.into()), detail: format!("failed to parse SAFI: {e}"), - })?; - let _safi = Safi::try_from(safi_raw).map_err(|_| { - UpdateParseErrorReason::UnsupportedAfiSafi { - afi: afi_raw, - safi: safi_raw, } })?; + if Safi::try_from(safi_raw).is_err() { + return Err(UpdateParseErrorReason::UnsupportedAfiSafi { + afi: afi_raw, + safi: safi_raw, + }); + } // Parse Next-hop Length (1 byte) - let (input, nh_len) = be_u8::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + let (input, nh_len) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri.into()), detail: format!("failed to parse next-hop length: {e}"), - })?; + } + })?; // Extract next-hop bytes - if input.len() < nh_len as usize { + if input.len() < usize::from(nh_len) { let expected = match afi { Afi::Ipv4 => "4", Afi::Ipv6 => "16 or 32", @@ -2999,8 +3173,8 @@ impl MpReachNlri { got: input.len(), }); } - let nh_bytes = &input[..nh_len as usize]; - let input = &input[nh_len as usize..]; + let nh_bytes = &input[..usize::from(nh_len)]; + let input = &input[usize::from(nh_len)..]; // Parse next-hop let nexthop = @@ -3012,7 +3186,7 @@ impl MpReachNlri { UpdateParseErrorReason::InvalidMpNextHopLength { afi: afi_raw, expected, - got: nh_len as usize, + got: usize::from(nh_len), } })?; @@ -3021,17 +3195,18 @@ impl MpReachNlri { // the sender and MUST be ignored by the receiver." // Historical note: In RFC 2858 (obsoleted by RFC 4760), this was "Number of SNPAs". // Store the value for session layer validation/logging, but don't error here. - let (input, reserved) = be_u8::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpReachNlri as u8), + let (input, reserved) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri.into()), detail: format!("failed to parse reserved byte: {e}"), - })?; + } + })?; // Parse NLRI based on AFI match afi { Afi::Ipv4 => { let nlri = prefixes4_from_wire(input) - .map_err(|e| e.into_reason("mp_reach"))?; + .map_err(|e| e.into_reason(NlriSection::MpReach))?; Ok(( &[], Self::Ipv4Unicast(MpReachIpv4Unicast { @@ -3043,7 +3218,7 @@ impl MpReachNlri { } Afi::Ipv6 => { let nlri = prefixes6_from_wire(input) - .map_err(|e| e.into_reason("mp_reach"))?; + .map_err(|e| e.into_reason(NlriSection::MpReach))?; Ok(( &[], Self::Ipv6Unicast(MpReachIpv6Unicast { @@ -3159,10 +3334,10 @@ impl MpUnreachNlri { let mut buf = Vec::new(); // AFI (2 bytes) - buf.extend_from_slice(&(self.afi() as u16).to_be_bytes()); + buf.extend_from_slice(&u16::from(self.afi()).to_be_bytes()); // SAFI (1 byte) - buf.push(self.safi() as u8); + buf.push(self.safi().into()); // Withdrawn routes match self { @@ -3192,12 +3367,25 @@ impl MpUnreachNlri { pub fn from_wire( input: &[u8], ) -> Result<(&[u8], Self), UpdateParseErrorReason> { + type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; + + fn parse_u16<'a>(input: &'a [u8]) -> ParseRes<'a, u16> { + be_u16(input) + } + + fn parse_u8<'a>(input: &'a [u8]) -> ParseRes<'a, u8> { + be_u8(input) + } + // Parse AFI (2 bytes) - let (input, afi_raw) = be_u16::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpUnreachNlri as u8), + let (input, afi_raw) = parse_u16(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpUnreachNlri.into()), detail: format!("failed to parse AFI: {e}"), - })?; + } + })?; let afi = Afi::try_from(afi_raw).map_err(|_| { UpdateParseErrorReason::UnsupportedAfiSafi { afi: afi_raw, @@ -3206,11 +3394,12 @@ impl MpUnreachNlri { })?; // Parse SAFI (1 byte) - let (input, safi_raw) = be_u8::<_, nom::error::Error<&[u8]>>(input) - .map_err(|e| UpdateParseErrorReason::AttributeParseError { - type_code: Some(PathAttributeTypeCode::MpUnreachNlri as u8), + let (input, safi_raw) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpUnreachNlri.into()), detail: format!("failed to parse SAFI: {e}"), - })?; + } + })?; let _safi = Safi::try_from(safi_raw).map_err(|_| { UpdateParseErrorReason::UnsupportedAfiSafi { afi: afi_raw, @@ -3222,12 +3411,12 @@ impl MpUnreachNlri { match afi { Afi::Ipv4 => { let withdrawn = prefixes4_from_wire(input) - .map_err(|e| e.into_reason("mp_unreach"))?; + .map_err(|e| e.into_reason(NlriSection::MpUnreach))?; Ok((&[], Self::Ipv4Unicast(MpUnreachIpv4Unicast { withdrawn }))) } Afi::Ipv6 => { let withdrawn = prefixes6_from_wire(input) - .map_err(|e| e.into_reason("mp_unreach"))?; + .map_err(|e| e.into_reason(NlriSection::MpUnreach))?; Ok((&[], Self::Ipv6Unicast(MpUnreachIpv6Unicast { withdrawn }))) } } @@ -3348,7 +3537,7 @@ pub struct NotificationMessage { impl NotificationMessage { pub fn to_wire(&self) -> Result, Error> { - let buf = vec![self.error_code as u8, self.error_subcode.as_u8()]; + let buf = vec![self.error_code.into(), self.error_subcode.as_u8()]; //TODO data, see comment above on data field Ok(buf) } @@ -3413,12 +3602,12 @@ pub struct RouteRefreshMessage { } impl RouteRefreshMessage { - pub fn to_wire(&self) -> Result, Error> { + pub fn to_wire(&self) -> Vec { let mut buf = Vec::new(); buf.extend_from_slice(&self.afi.to_be_bytes()); buf.push(0); // reserved buf.push(self.safi); - Ok(buf) + buf } pub fn from_wire(input: &[u8]) -> Result { let (input, afi) = be_u16(input)?; @@ -3445,6 +3634,7 @@ impl Display for RouteRefreshMessage { Eq, Clone, Copy, + IntoPrimitive, TryFromPrimitive, Serialize, Deserialize, @@ -3463,7 +3653,7 @@ pub enum ErrorCode { impl Display for ErrorCode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; + let val: u8 = (*self).into(); match self { ErrorCode::Header => write!(f, "{val} (Header)"), ErrorCode::Open => write!(f, "{val} (Open)"), @@ -3518,12 +3708,12 @@ impl From for ErrorSubcode { impl ErrorSubcode { fn as_u8(&self) -> u8 { match self { - Self::Header(h) => *h as u8, - Self::Open(o) => *o as u8, - Self::Update(u) => *u as u8, + Self::Header(h) => (*h).into(), + Self::Open(o) => (*o).into(), + Self::Update(u) => (*u).into(), Self::HoldTime(x) => *x, Self::Fsm(x) => *x, - Self::Cease(x) => *x as u8, + Self::Cease(x) => (*x).into(), } } } @@ -3556,6 +3746,7 @@ impl Display for ErrorSubcode { Eq, Clone, Copy, + IntoPrimitive, TryFromPrimitive, Serialize, Deserialize, @@ -3572,17 +3763,17 @@ pub enum HeaderErrorSubcode { impl Display for HeaderErrorSubcode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; + let val: u8 = (*self).into(); match self { - HeaderErrorSubcode::Unspecific => write!(f, "{val}(Unspecific)"), + HeaderErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), HeaderErrorSubcode::ConnectionNotSynchronized => { - write!(f, "{val}(Connection Not Synchronized)") + write!(f, "{val} (Connection Not Synchronized)") } HeaderErrorSubcode::BadMessageLength => { - write!(f, "{val}(Bad Message Length)") + write!(f, "{val} (Bad Message Length)") } HeaderErrorSubcode::BadMessageType => { - write!(f, "{val}(Bad Message Type)") + write!(f, "{val} (Bad Message Type)") } } } @@ -3595,6 +3786,7 @@ impl Display for HeaderErrorSubcode { Eq, Clone, Copy, + IntoPrimitive, TryFromPrimitive, Serialize, Deserialize, @@ -3615,7 +3807,7 @@ pub enum OpenErrorSubcode { impl Display for OpenErrorSubcode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; + let val: u8 = (*self).into(); match self { OpenErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), OpenErrorSubcode::UnsupportedVersionNumber => { @@ -3646,6 +3838,7 @@ impl Display for OpenErrorSubcode { Eq, Clone, Copy, + IntoPrimitive, TryFromPrimitive, Serialize, Deserialize, @@ -3670,7 +3863,7 @@ pub enum UpdateErrorSubcode { impl Display for UpdateErrorSubcode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; + let val: u8 = (*self).into(); match self { UpdateErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), UpdateErrorSubcode::MalformedAttributeList => { @@ -3715,6 +3908,7 @@ impl Display for UpdateErrorSubcode { Eq, Clone, Copy, + IntoPrimitive, TryFromPrimitive, Serialize, Deserialize, @@ -3736,7 +3930,7 @@ pub enum CeaseErrorSubcode { impl Display for CeaseErrorSubcode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; + let val: u8 = (*self).into(); match self { CeaseErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), CeaseErrorSubcode::MaximumNumberofPrefixesReached => { @@ -3809,7 +4003,7 @@ impl Display for OptionalParameter { } } -#[derive(Debug, Eq, PartialEq, TryFromPrimitive)] +#[derive(Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)] #[repr(u8)] pub enum OptionalParameterCode { Reserved = 0, @@ -3824,7 +4018,8 @@ impl OptionalParameter { Self::Reserved => Err(Error::ReservedOptionalParameter), Self::Unassigned => Err(Error::Unassigned(0)), Self::Capabilities(cs) => { - let mut buf = vec![OptionalParameterCode::Capabilities as u8]; + let mut buf = + vec![u8::from(OptionalParameterCode::Capabilities)]; let mut csbuf = Vec::new(); for c in cs { let cbuf = c.to_wire()?; @@ -4149,31 +4344,34 @@ impl Capability { match self { Self::MultiprotocolExtensions { afi, safi } => { let mut buf = - vec![CapabilityCode::MultiprotocolExtensions as u8, 4]; + vec![CapabilityCode::MultiprotocolExtensions.into(), 4]; buf.extend_from_slice(&afi.to_be_bytes()); buf.push(0); buf.push(*safi); Ok(buf) } Self::RouteRefresh {} => { - let buf = vec![CapabilityCode::RouteRefresh as u8, 0]; + let buf = vec![CapabilityCode::RouteRefresh.into(), 0]; Ok(buf) } Self::GracefulRestart {} => { //TODO audit - let buf = vec![CapabilityCode::GracefulRestart as u8, 0]; + let buf = vec![CapabilityCode::GracefulRestart.into(), 0]; Ok(buf) } Self::FourOctetAs { asn } => { - let mut buf = vec![CapabilityCode::FourOctetAs as u8, 4]; + let mut buf = vec![CapabilityCode::FourOctetAs.into(), 4]; buf.extend_from_slice(&asn.to_be_bytes()); Ok(buf) } Self::AddPath { elements } => { - let mut buf = vec![ - CapabilityCode::AddPath as u8, - (elements.len() * 4) as u8, - ]; + let len = u8::try_from(elements.len() * 4).map_err(|_| { + Error::TooLarge(format!( + "AddPath capability has too many elements: {} elements", + elements.len() + )) + })?; + let mut buf = vec![CapabilityCode::AddPath.into(), len]; for e in elements { buf.extend_from_slice(&e.afi.to_be_bytes()); buf.push(e.safi); @@ -4183,7 +4381,7 @@ impl Capability { } Self::EnhancedRouteRefresh {} => { //TODO audit - let buf = vec![CapabilityCode::EnhancedRouteRefresh as u8, 0]; + let buf = vec![CapabilityCode::EnhancedRouteRefresh.into(), 0]; Ok(buf) } Self::Experimental { code: _ } => Err(Error::Experimental), @@ -4201,7 +4399,7 @@ impl Capability { pub fn from_wire(input: &[u8]) -> Result<(&[u8], Capability), Error> { let (input, code) = parse_u8(input)?; let (input, len) = parse_u8(input)?; - let len = len as usize; + let len = usize::from(len); if input.len() < len { return Err(Error::Eom); } @@ -4513,22 +4711,24 @@ impl Capability { /// Helper function to generate an IPv4 Unicast MP-BGP capability. pub fn ipv4_unicast() -> Self { Self::MultiprotocolExtensions { - afi: Afi::Ipv4 as u16, - safi: Safi::Unicast as u8, + afi: Afi::Ipv4.into(), + safi: Safi::Unicast.into(), } } /// Helper function to generate an IPv6 Unicast MP-BGP capability. pub fn ipv6_unicast() -> Self { Self::MultiprotocolExtensions { - afi: Afi::Ipv6 as u16, - safi: Safi::Unicast as u8, + afi: Afi::Ipv6.into(), + safi: Safi::Unicast.into(), } } } /// The set of capability codes supported by this BGP implementation -#[derive(Debug, Eq, PartialEq, TryFromPrimitive, Copy, Clone)] +#[derive( + Clone, Copy, Debug, Eq, IntoPrimitive, PartialEq, TryFromPrimitive, +)] #[repr(u8)] pub enum CapabilityCode { /// RFC 5492 @@ -4784,10 +4984,11 @@ impl From for CapabilityCode { Clone, Deserialize, Eq, + IntoPrimitive, + JsonSchema, PartialEq, Serialize, TryFromPrimitive, - JsonSchema, )] #[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[repr(u16)] @@ -4843,10 +5044,11 @@ impl slog::Value for Afi { Clone, Deserialize, Eq, + IntoPrimitive, + JsonSchema, PartialEq, Serialize, TryFromPrimitive, - JsonSchema, )] #[repr(u8)] pub enum Safi { @@ -4920,6 +5122,8 @@ pub enum UpdateParseErrorReason { DuplicateMpReachNlri, /// Duplicate MP_UNREACH_NLRI attribute in UPDATE DuplicateMpUnreachNlri, + /// Duplicate non-MP-BGP attribute (discarded per RFC 7606 3(g)) + DuplicateAttribute { type_code: u8 }, /// AFI/SAFI combination not recognized (raw bytes) UnsupportedAfiSafi { afi: u16, safi: u8 }, /// MP next-hop has invalid length for AFI @@ -4949,18 +5153,18 @@ pub enum UpdateParseErrorReason { // NLRI/prefix parsing errors /// NLRI section is empty when prefix length byte expected NlriMissingLength { - /// Which section: "nlri", "withdrawn", "mp_reach", "mp_unreach" - section: &'static str, + /// Which section the error occurred in + section: NlriSection, }, /// Prefix length exceeds maximum for address family (32 for IPv4, 128 for IPv6) InvalidNlriMask { - section: &'static str, + section: NlriSection, length: u8, max: u8, }, /// Not enough bytes for declared prefix length TruncatedNlri { - section: &'static str, + section: NlriSection, needed: usize, available: usize, }, @@ -5026,6 +5230,9 @@ impl Display for UpdateParseErrorReason { Self::DuplicateMpUnreachNlri => { write!(f, "duplicate MP_UNREACH_NLRI attribute") } + Self::DuplicateAttribute { type_code } => { + write!(f, "duplicate attribute type {}", type_code) + } Self::UnsupportedAfiSafi { afi, safi } => { write!(f, "unsupported AFI/SAFI: {}/{}", afi, safi) } @@ -5101,10 +5308,18 @@ impl Display for UpdateParseErrorReason { pub struct ParsedPathAttrs { /// Successfully parsed attributes pub attrs: Vec, - /// All non-fatal errors collected during parsing + /// All non-fatal errors collected during parsing. + /// Use the treat_as_withdraw() method to check if any TreatAsWithdraw errors occurred. pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, - /// True if any TreatAsWithdraw error occurred - pub treat_as_withdraw: bool, +} + +impl ParsedPathAttrs { + /// Returns true if a TreatAsWithdraw error occurred during parsing. + pub fn treat_as_withdraw(&self) -> bool { + self.errors.iter().any(|(_, action)| { + matches!(action, AttributeAction::TreatAsWithdraw) + }) + } } /// Fatal UPDATE parse error requiring session reset. @@ -5122,7 +5337,7 @@ impl Display for UpdateParseError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{:?}/{:?}: {}", + "{}/{}: {}", self.error_code, self.error_subcode, self.reason ) } @@ -5131,6 +5346,8 @@ impl Display for UpdateParseError { /// All possible reasons for OPEN parse errors. #[derive(Debug, Clone, PartialEq, Eq)] pub enum OpenParseErrorReason { + /// BGP-ID is invalid (must be non-zero) + BadBgpIdentifier { id: Ipv4Addr }, /// Version number not supported InvalidVersion { version: u8 }, /// Hold time is invalid @@ -5146,19 +5363,22 @@ pub enum OpenParseErrorReason { impl Display for OpenParseErrorReason { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { + Self::BadBgpIdentifier { id } => { + write!(f, "bad bgp identifier: {id}") + } Self::InvalidVersion { version } => { - write!(f, "unsupported version: {}", version) + write!(f, "unsupported version: {version}") } Self::InvalidHoldTime { hold_time } => { - write!(f, "invalid hold time: {}", hold_time) + write!(f, "invalid hold time: {hold_time}") } Self::UnsupportedCapability { code } => { - write!(f, "unsupported capability: {}", code) + write!(f, "unsupported capability: {code}") } Self::TooSmall { field } => { - write!(f, "message too small for {}", field) + write!(f, "message too small for {field}") } - Self::Other { detail } => write!(f, "{}", detail), + Self::Other { detail } => write!(f, "{detail}"), } } } @@ -5175,7 +5395,7 @@ impl Display for OpenParseError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{:?}/{:?}: {}", + "{}/{}: {}", self.error_code, self.error_subcode, self.reason ) } @@ -5218,7 +5438,7 @@ impl Display for NotificationParseError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{:?}/{:?}: {}", + "{}/{}: {}", self.error_code, self.error_subcode, self.reason ) } @@ -5259,7 +5479,7 @@ impl Display for RouteRefreshParseError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{:?}/{:?}: {}", + "{}/{}: {}", self.error_code, self.error_subcode, self.reason ) } @@ -5730,7 +5950,6 @@ mod tests { rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 13), 32), rdb::Prefix4::new(std::net::Ipv4Addr::new(0, 23, 1, 14), 32), ], - treat_as_withdraw: false, errors: vec![], }; @@ -5767,22 +5986,22 @@ mod tests { fn route_refresh_round_trip() { // IPv4 Unicast route refresh let rr0 = RouteRefreshMessage { - afi: Afi::Ipv4 as u16, - safi: Safi::Unicast as u8, + afi: Afi::Ipv4.into(), + safi: Safi::Unicast.into(), }; - let buf = rr0.to_wire().expect("route refresh to wire"); + let buf = rr0.to_wire(); let rr1 = RouteRefreshMessage::from_wire(&buf) .expect("route refresh from wire"); assert_eq!(rr0, rr1); // IPv6 Unicast route refresh let rr2 = RouteRefreshMessage { - afi: Afi::Ipv6 as u16, - safi: Safi::Unicast as u8, + afi: Afi::Ipv6.into(), + safi: Safi::Unicast.into(), }; - let buf = rr2.to_wire().expect("route refresh to wire"); + let buf = rr2.to_wire(); let rr3 = RouteRefreshMessage::from_wire(&buf) .expect("route refresh from wire"); assert_eq!(rr2, rr3); @@ -6104,7 +6323,7 @@ mod tests { let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Expected treat_as_withdraw to be true for bad NEXT_HOP length" ); @@ -6354,7 +6573,7 @@ mod tests { )]; let original = MpReachNlri::ipv6_unicast(nh, nlri.clone()); - let wire = original.to_wire().expect("to_wire should succeed"); + let wire = original.to_wire(); let (remaining, parsed) = MpReachNlri::from_wire(&wire).expect("from_wire should succeed"); @@ -6486,7 +6705,6 @@ mod tests { }, ], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -6502,7 +6720,7 @@ mod tests { let first_attr_type_code = wire[path_attrs_start + 1]; assert_eq!( first_attr_type_code, - PathAttributeTypeCode::MpReachNlri as u8, + u8::from(PathAttributeTypeCode::MpReachNlri), "MP_REACH_NLRI should be encoded as the first path attribute" ); } @@ -6530,7 +6748,6 @@ mod tests { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8)], - treat_as_withdraw: false, errors: vec![], }; @@ -6590,7 +6807,6 @@ mod tests { }, ], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -6656,7 +6872,6 @@ mod tests { }, ], nlri: traditional_nlri.clone(), - treat_as_withdraw: false, errors: vec![], }; @@ -6723,7 +6938,6 @@ mod tests { withdrawn: vec![], path_attributes: vec![], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -6750,7 +6964,6 @@ mod tests { value: PathAttributeValue::MpUnreachNlri(mp_eor), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -6787,7 +7000,6 @@ mod tests { value: PathAttributeValue::MpUnreachNlri(mp_eor_v4), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -6828,14 +7040,14 @@ mod tests { let attrs = vec![ // First ORIGIN attribute (IGP = 0) path_attribute_flags::TRANSITIVE, // flags - PathAttributeTypeCode::Origin as u8, // type + u8::from(PathAttributeTypeCode::Origin), // type 1, // length - PathOrigin::Igp as u8, // value + u8::from(PathOrigin::Igp), // value // Second ORIGIN attribute (EGP = 1) - should be discarded path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, - PathOrigin::Egp as u8, + u8::from(PathOrigin::Egp), ]; // Path attributes length @@ -7263,7 +7475,7 @@ mod tests { } => { assert_eq!( type_code, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), "should include the attribute type code" ); assert_eq!( @@ -7308,7 +7520,7 @@ mod tests { fn origin_attr() -> Vec { vec![ path_attribute_flags::TRANSITIVE, // flags (0x40) - PathAttributeTypeCode::Origin as u8, // type 1 + u8::from(PathAttributeTypeCode::Origin), // type 1 1, // length 0, // IGP ] @@ -7318,7 +7530,7 @@ mod tests { fn as_path_attr(asn: u32) -> Vec { let mut attr = vec![ path_attribute_flags::TRANSITIVE, // flags (0x40) - PathAttributeTypeCode::AsPath as u8, // type 2 + u8::from(PathAttributeTypeCode::AsPath), // type 2 6, // length: 1 (segment type) + 1 (count) + 4 (ASN) 2, // AS_SEQUENCE 1, // 1 ASN in sequence @@ -7331,7 +7543,7 @@ mod tests { fn next_hop_attr(ip: [u8; 4]) -> Vec { let mut attr = vec![ path_attribute_flags::TRANSITIVE, // flags (0x40) - PathAttributeTypeCode::NextHop as u8, // type 3 + u8::from(PathAttributeTypeCode::NextHop), // type 3 4, // length ]; attr.extend_from_slice(&ip); @@ -7343,7 +7555,7 @@ mod tests { fn bad_next_hop_attr() -> Vec { vec![ path_attribute_flags::TRANSITIVE, // flags (0x40) - PathAttributeTypeCode::NextHop as u8, // type 3 + u8::from(PathAttributeTypeCode::NextHop), // type 3 16, // length - WRONG, should be 4 0, 0, @@ -7369,7 +7581,7 @@ mod tests { fn bad_med_attr() -> Vec { vec![ path_attribute_flags::OPTIONAL, // flags (0x80) - PathAttributeTypeCode::MultiExitDisc as u8, // type 4 + u8::from(PathAttributeTypeCode::MultiExitDisc), // type 4 2, // length - WRONG, should be 4 0, 100, // only 2 bytes @@ -7382,8 +7594,8 @@ mod tests { vec![ path_attribute_flags::OPTIONAL | path_attribute_flags::TRANSITIVE, // 0xC0 - PathAttributeTypeCode::Aggregator as u8, // type 7 - 3, // length - WRONG + u8::from(PathAttributeTypeCode::Aggregator), // type 7 + 3, // length - WRONG 0, 100, 1, // only 3 bytes @@ -7394,7 +7606,7 @@ mod tests { fn bad_origin_attr() -> Vec { vec![ path_attribute_flags::TRANSITIVE, // flags (0x40) - PathAttributeTypeCode::Origin as u8, // type 1 + u8::from(PathAttributeTypeCode::Origin), // type 1 1, // length 99, // INVALID - must be 0, 1, or 2 ] @@ -7428,7 +7640,10 @@ mod tests { ); let msg = result.unwrap(); - assert!(msg.treat_as_withdraw, "treat_as_withdraw should be true"); + assert!( + msg.treat_as_withdraw(), + "treat_as_withdraw should be true" + ); // 3 parse errors + 2 missing attr errors (ORIGIN, NEXT_HOP) assert_eq!( @@ -7503,7 +7718,7 @@ mod tests { let msg = result.unwrap(); assert!( - !msg.treat_as_withdraw, + !msg.treat_as_withdraw(), "treat_as_withdraw should be false (Discard doesn't set it)" ); @@ -7552,7 +7767,7 @@ mod tests { let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "treat_as_withdraw should be true (TaW errors present)" ); @@ -7612,7 +7827,10 @@ mod tests { assert!(result.is_ok(), "Parsing should succeed"); let msg = result.unwrap(); - assert!(msg.treat_as_withdraw, "treat_as_withdraw should be true"); + assert!( + msg.treat_as_withdraw(), + "treat_as_withdraw should be true" + ); // 1 parse error (InvalidOriginValue) + 1 MissingAttribute (Origin) assert_eq!( msg.errors.len(), @@ -7644,7 +7862,7 @@ mod tests { let msg = result.unwrap(); assert!( - !msg.treat_as_withdraw, + !msg.treat_as_withdraw(), "treat_as_withdraw should be false" ); assert!(msg.errors.is_empty(), "No errors expected"); @@ -7663,7 +7881,7 @@ mod tests { let mut attrs = vec![ path_attribute_flags::OPTIONAL | path_attribute_flags::TRANSITIVE, // 0xC0 - wrong! - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, // length 0, // IGP value ]; @@ -7682,7 +7900,7 @@ mod tests { let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Flag errors on ORIGIN cause TreatAsWithdraw" ); // 1 flag error + 1 MissingAttribute for Origin (skipped due to bad flags) @@ -7746,7 +7964,7 @@ mod tests { fn origin_attr() -> Vec { vec![ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, 0, // IGP ] @@ -7755,7 +7973,7 @@ mod tests { fn as_path_attr(asn: u32) -> Vec { let mut attr = vec![ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::AsPath as u8, + u8::from(PathAttributeTypeCode::AsPath), 6, 2, // AS_SEQUENCE 1, // 1 ASN @@ -7767,7 +7985,7 @@ mod tests { fn next_hop_attr(ip: [u8; 4]) -> Vec { let mut attr = vec![ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::NextHop as u8, + u8::from(PathAttributeTypeCode::NextHop), 4, ]; attr.extend_from_slice(&ip); @@ -7785,12 +8003,11 @@ mod tests { 48, )], ); - let value_bytes = - mp_reach.to_wire().expect("MP_REACH_NLRI encoding"); + let value_bytes = mp_reach.to_wire(); let mut attr = vec![ path_attribute_flags::OPTIONAL, - PathAttributeTypeCode::MpReachNlri as u8, + u8::from(PathAttributeTypeCode::MpReachNlri), ]; // Use extended length if needed if value_bytes.len() > 255 { @@ -7834,7 +8051,10 @@ mod tests { result.err() ); let msg = result.unwrap(); - assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + !msg.treat_as_withdraw(), + "Should not be treat-as-withdraw" + ); assert!( msg.errors.is_empty(), "Should have no errors, got: {:?}", @@ -7861,7 +8081,7 @@ mod tests { ); let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Missing NEXT_HOP with traditional NLRI should treat-as-withdraw" ); assert!( @@ -7890,7 +8110,7 @@ mod tests { let mut attrs = vec![ path_attribute_flags::OPTIONAL, - PathAttributeTypeCode::MpUnreachNlri as u8, + u8::from(PathAttributeTypeCode::MpUnreachNlri), ]; if value_bytes.len() > 255 { attrs[0] |= path_attribute_flags::EXTENDED_LENGTH; @@ -7912,7 +8132,10 @@ mod tests { result.err() ); let msg = result.unwrap(); - assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + !msg.treat_as_withdraw(), + "Should not be treat-as-withdraw" + ); assert!( msg.errors.is_empty(), "Should have no errors for MP_UNREACH-only UPDATE, got: {:?}", @@ -7950,7 +8173,7 @@ mod tests { ); let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Missing NEXT_HOP should trigger treat-as-withdraw" ); assert!( @@ -7982,7 +8205,7 @@ mod tests { ); let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Missing ORIGIN should trigger treat-as-withdraw" ); assert!( @@ -8014,7 +8237,7 @@ mod tests { ); let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Missing AS_PATH should trigger treat-as-withdraw" ); assert!( @@ -8045,7 +8268,7 @@ mod tests { ); let msg = result.unwrap(); assert!( - msg.treat_as_withdraw, + msg.treat_as_withdraw(), "Missing mandatory attrs should trigger treat-as-withdraw" ); assert!( @@ -8089,7 +8312,10 @@ mod tests { result.err() ); let msg = result.unwrap(); - assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + !msg.treat_as_withdraw(), + "Should not be treat-as-withdraw" + ); assert!( msg.errors.is_empty(), "Should have no errors for withdraw-only UPDATE" @@ -8109,7 +8335,10 @@ mod tests { result.err() ); let msg = result.unwrap(); - assert!(!msg.treat_as_withdraw, "Should not be treat-as-withdraw"); + assert!( + !msg.treat_as_withdraw(), + "Should not be treat-as-withdraw" + ); assert!( msg.errors.is_empty(), "Should have no errors for empty UPDATE" @@ -8355,7 +8584,7 @@ mod tests { // ORIGIN attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, 0, // IGP ]); @@ -8363,14 +8592,14 @@ mod tests { // AS_PATH (empty) attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::AsPath as u8, + u8::from(PathAttributeTypeCode::AsPath), 0, // empty ]); // NEXT_HOP attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::NextHop as u8, + u8::from(PathAttributeTypeCode::NextHop), 4, 192, 0, @@ -8382,7 +8611,7 @@ mod tests { attrs.extend([ path_attribute_flags::OPTIONAL | path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Aggregator as u8, + u8::from(PathAttributeTypeCode::Aggregator), 6, 0xFDu8, 0xE8, // ASN 65000 @@ -8424,7 +8653,7 @@ mod tests { // ORIGIN attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, 0, // IGP ]); @@ -8432,14 +8661,14 @@ mod tests { // AS_PATH attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::AsPath as u8, + u8::from(PathAttributeTypeCode::AsPath), 0, ]); // NEXT_HOP attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::NextHop as u8, + u8::from(PathAttributeTypeCode::NextHop), 4, 192, 0, @@ -8450,7 +8679,7 @@ mod tests { // ATOMIC_AGGREGATE (zero-length) attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::AtomicAggregate as u8, + u8::from(PathAttributeTypeCode::AtomicAggregate), 0, // zero-length ]); @@ -8486,7 +8715,7 @@ mod tests { // ORIGIN attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Origin as u8, + u8::from(PathAttributeTypeCode::Origin), 1, 0, ]); @@ -8494,14 +8723,14 @@ mod tests { // AS_PATH attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::AsPath as u8, + u8::from(PathAttributeTypeCode::AsPath), 0, ]); // NEXT_HOP attrs.extend([ path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::NextHop as u8, + u8::from(PathAttributeTypeCode::NextHop), 4, 192, 0, @@ -8513,7 +8742,7 @@ mod tests { attrs.extend([ path_attribute_flags::OPTIONAL | path_attribute_flags::TRANSITIVE, - PathAttributeTypeCode::Aggregator as u8, + u8::from(PathAttributeTypeCode::Aggregator), 5, // WRONG! 0xFDu8, 0xE8, diff --git a/bgp/src/params.rs b/bgp/src/params.rs index 7a708ae2..2a038c43 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -8,8 +8,8 @@ use crate::{ session::{FsmStateKind, SessionCounters, SessionInfo}, }; use rdb::{ - ImportExportPolicy, ImportExportPolicy4, ImportExportPolicy6, PolicyAction, - Prefix4, Prefix6, + ImportExportPolicy4, ImportExportPolicy6, ImportExportPolicyV1, + PolicyAction, Prefix4, Prefix6, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -134,7 +134,9 @@ impl TimerConfig { } /// Per-address-family configuration for IPv4 Unicast -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] +#[derive( + Debug, Default, Clone, Deserialize, Serialize, JsonSchema, PartialEq, +)] pub struct Ipv4UnicastConfig { pub nexthop: Option, pub import_policy: ImportExportPolicy4, @@ -142,7 +144,9 @@ pub struct Ipv4UnicastConfig { } /// Per-address-family configuration for IPv6 Unicast -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)] +#[derive( + Debug, Default, Clone, Deserialize, Serialize, JsonSchema, PartialEq, +)] pub struct Ipv6UnicastConfig { pub nexthop: Option, pub import_policy: ImportExportPolicy6, @@ -254,8 +258,8 @@ pub struct NeighborV1 { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + pub allow_import: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, } @@ -343,11 +347,11 @@ impl NeighborV1 { local_pref: rq.local_pref, enforce_first_as: rq.enforce_first_as, // Combine per-AF policies into legacy format for API compatibility - allow_import: ImportExportPolicy::from_per_af_policies( + allow_import: ImportExportPolicyV1::from_per_af_policies( &rq.allow_import4, &rq.allow_import6, ), - allow_export: ImportExportPolicy::from_per_af_policies( + allow_export: ImportExportPolicyV1::from_per_af_policies( &rq.allow_export4, &rq.allow_export6, ), @@ -560,7 +564,6 @@ pub struct PeerTimers { #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct PeerCounters { // FSM Counters - pub connect_retry_counter: u64, pub connection_retries: u64, pub active_connections_accepted: u64, pub active_connections_declined: u64, @@ -601,6 +604,7 @@ pub struct PeerCounters { pub unexpected_notification_message: u64, pub update_nexhop_missing: u64, pub open_handle_failures: u64, + pub unnegotiated_address_family: u64, // Send failure counters pub notification_send_failure: u64, @@ -618,9 +622,6 @@ pub struct PeerCounters { impl From<&SessionCounters> for PeerCounters { fn from(value: &SessionCounters) -> Self { Self { - connect_retry_counter: value - .connect_retry_counter - .load(Ordering::Relaxed), connection_retries: value .connection_retries .load(Ordering::Relaxed), @@ -711,6 +712,9 @@ impl From<&SessionCounters> for PeerCounters { open_handle_failures: value .open_handle_failures .load(Ordering::Relaxed), + unnegotiated_address_family: value + .unnegotiated_address_family + .load(Ordering::Relaxed), notification_send_failure: value .notification_send_failure .load(Ordering::Relaxed), @@ -783,7 +787,7 @@ pub struct PeerInfo { pub name: String, pub peer_group: String, pub fsm_state: FsmStateKind, - pub fsm_state_duration: u64, + pub fsm_state_duration: Duration, pub asn: Option, pub id: Option, pub local_ip: IpAddr, @@ -858,8 +862,8 @@ pub struct BgpPeerConfigV1 { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + pub allow_import: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, } diff --git a/bgp/src/proptest.rs b/bgp/src/proptest.rs index 32d4f196..5be14edc 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -200,7 +200,6 @@ fn update_traditional_strategy() -> impl Strategy { withdrawn, path_attributes, nlri, - treat_as_withdraw: false, errors: vec![], }) } @@ -220,7 +219,6 @@ fn update_mp_reach_strategy() -> impl Strategy { withdrawn: vec![], path_attributes: attrs, nlri: vec![], - treat_as_withdraw: false, errors: vec![], } }, @@ -239,7 +237,6 @@ fn update_mp_unreach_strategy() -> impl Strategy { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }) } @@ -296,7 +293,6 @@ proptest! { withdrawn: vec![], path_attributes: vec![], nlri: prefixes.clone(), - treat_as_withdraw: false, errors: vec![], }; @@ -313,7 +309,6 @@ proptest! { withdrawn: prefixes.clone(), path_attributes: vec![], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -341,7 +336,6 @@ proptest! { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -376,7 +370,6 @@ proptest! { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -414,7 +407,6 @@ proptest! { value: PathAttributeValue::MpReachNlri(mp_reach), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -449,7 +441,6 @@ proptest! { value: PathAttributeValue::MpUnreachNlri(mp_unreach), }], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -489,7 +480,6 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -517,7 +507,6 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -544,7 +533,6 @@ proptest! { withdrawn: vec![], path_attributes: vec![attr], nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; @@ -737,7 +725,6 @@ proptest! { )), ], nlri: nlri_prefixes.clone(), - treat_as_withdraw: false, errors: vec![], }; @@ -772,7 +759,6 @@ proptest! { withdrawn: vec![], path_attributes: mp_attrs, nlri: vec![], - treat_as_withdraw: false, errors: vec![], }; diff --git a/bgp/src/router.rs b/bgp/src/router.rs index 6dd1ee2a..2bdfa07d 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -427,7 +427,7 @@ impl Router { "count" => prefixes.len(), ); - read_lock!(self.fanout4).announce_all(prefixes, vec![]); + read_lock!(self.fanout4).send_all(prefixes, vec![]); } fn withdraw_origin4(&self, prefixes: Vec) { @@ -449,7 +449,7 @@ impl Router { "count" => prefixes.len(), ); - read_lock!(self.fanout4).announce_all(vec![], prefixes); + read_lock!(self.fanout4).send_all(vec![], prefixes); } pub fn create_origin6(&self, prefixes: Vec) -> Result<(), Error> { @@ -531,7 +531,7 @@ impl Router { "count" => prefixes.len(), ); - read_lock!(self.fanout6).announce_all(prefixes, vec![]); + read_lock!(self.fanout6).send_all(prefixes, vec![]); } fn withdraw_origin6(&self, prefixes: Vec) { @@ -553,7 +553,7 @@ impl Router { "count" => prefixes.len(), ); - read_lock!(self.fanout6).announce_all(vec![], prefixes); + read_lock!(self.fanout6).send_all(vec![], prefixes); } pub fn base_attributes(&self) -> Vec { @@ -631,7 +631,7 @@ impl Router { "count" => originated4.len(), ); - read_lock!(self.fanout4).announce_all(originated4, vec![]); + read_lock!(self.fanout4).send_all(originated4, vec![]); } // Also announce IPv6 originated routes @@ -645,7 +645,7 @@ impl Router { "count" => originated6.len(), ); - read_lock!(self.fanout6).announce_all(originated6, vec![]); + read_lock!(self.fanout6).send_all(originated6, vec![]); } Ok(()) diff --git a/bgp/src/session.rs b/bgp/src/session.rs index d110b287..809213e2 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -15,8 +15,8 @@ use crate::{ AddPathElement, Afi, BgpNexthop, Capability, CeaseErrorSubcode, Community, ErrorCode, ErrorSubcode, Message, MessageKind, MessageParseError, MpReachNlri, MpUnreachNlri, NotificationMessage, - OpenMessage, PathAttributeValue, RouteRefreshMessage, Safi, - UpdateMessage, + OpenErrorSubcode, OpenMessage, PathAttributeValue, RouteRefreshMessage, + Safi, UpdateMessage, }, params::{ BgpCapability, DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, @@ -29,8 +29,8 @@ use crate::{ }; use mg_common::{lock, read_lock, write_lock}; use rdb::{ - AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy4, - ImportExportPolicy6, Prefix, Prefix4, Prefix6, TypedImportExportPolicy, + AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy, + ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, Prefix6, }; pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY}; use schemars::JsonSchema; @@ -383,20 +383,14 @@ impl From<&FsmState> for FsmStateKind { } } -/// IPv4 route update - either an announcement or withdrawal, never both. -/// -/// RFC 7606 requires that UPDATE messages not mix reachable and unreachable -/// NLRI. This type enforces that constraint at compile time. +/// IPv4 route update #[derive(Clone, Debug)] pub enum RouteUpdate4 { Announce(Vec), Withdraw(Vec), } -/// IPv6 route update - either an announcement or withdrawal, never both. -/// -/// RFC 7606 requires that UPDATE messages not mix reachable and unreachable -/// NLRI. This type enforces that constraint at compile time. +/// IPv6 route update #[derive(Clone, Debug)] pub enum RouteUpdate6 { Announce(Vec), @@ -505,7 +499,7 @@ pub enum AdminEvent { /// Fires when an export policy has changed. /// Contains the previous policy for determining routes to re-advertise. - ExportPolicyChanged(TypedImportExportPolicy), + ExportPolicyChanged(ImportExportPolicy), // The checker for the router has changed. Event contains previous checker. // Current checker is available in the router policy object. @@ -536,8 +530,8 @@ impl AdminEvent { AdminEvent::ShaperChanged(_) => "shaper changed", AdminEvent::CheckerChanged(_) => "checker changed", AdminEvent::ExportPolicyChanged(p) => match p { - TypedImportExportPolicy::V4(_) => "ipv4 export policy changed", - TypedImportExportPolicy::V6(_) => "ipv6 export policy changed", + ImportExportPolicy::V4(_) => "ipv4 export policy changed", + ImportExportPolicy::V6(_) => "ipv6 export policy changed", }, AdminEvent::Reset => "reset", AdminEvent::ManualStart => "manual start", @@ -1061,8 +1055,7 @@ impl FsmEventHistory { #[derive(Default)] pub struct SessionCounters { // FSM Counters - pub connect_retry_counter: AtomicU64, // increments/zeroes with FSM per RFC - pub connection_retries: AtomicU64, // total number of retries + pub connection_retries: AtomicU64, // total number of retries pub active_connections_accepted: AtomicU64, pub active_connections_declined: AtomicU64, pub passive_connections_accepted: AtomicU64, @@ -1102,6 +1095,7 @@ pub struct SessionCounters { pub unexpected_notification_message: AtomicU64, pub update_nexhop_missing: AtomicU64, pub open_handle_failures: AtomicU64, + pub unnegotiated_address_family: AtomicU64, // Send failure counters pub notification_send_failure: AtomicU64, @@ -1495,9 +1489,6 @@ pub struct SessionRunner { /// Configuration for this BGP Session pub session: Arc>, - /// Track how many times a connection has been attempted. - pub connect_retry_counter: AtomicU64, - event_rx: Receiver>, state: Arc>, last_state_change: Mutex, @@ -1563,7 +1554,6 @@ impl SessionRunner { let session_info = lock!(session); let runner = SessionRunner { session: session.clone(), - connect_retry_counter: AtomicU64::new(0), event_rx, event_tx: event_tx.clone(), asn: router.config.asn, @@ -2615,8 +2605,6 @@ impl SessionRunner { } session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); return FsmState::Idle; } @@ -2838,8 +2826,6 @@ impl SessionRunner { } session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -3308,8 +3294,6 @@ impl SessionRunner { } session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); session_log!( self, @@ -3524,8 +3508,6 @@ impl SessionRunner { }; session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.stop(Some(&conn), None, stop_reason); return FsmState::Idle; @@ -4624,11 +4606,6 @@ impl SessionRunner { "error" => format!("{e}") ); // notification sent by handle_open(), nothing to do here - self.connect_retry_counter - .fetch_add( - 1, - Ordering::Relaxed, - ); self.counters .connection_retries .fetch_add( @@ -5268,8 +5245,6 @@ impl SessionRunner { ); // notification sent by handle_open(), nothing to do here - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -5351,8 +5326,6 @@ impl SessionRunner { self.stop(Some(&exist), None, StopReason::FsmError); session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); return FsmState::OpenSent(new); } @@ -5379,8 +5352,6 @@ impl SessionRunner { ); // notification sent by handle_open(), nothing to do here - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -5473,8 +5444,6 @@ impl SessionRunner { self.stop(Some(&new), None, StopReason::FsmError); session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); return FsmState::OpenSent(exist); } @@ -5904,7 +5873,7 @@ impl SessionRunner { && let Err(e) = self.send_update( RouteUpdate::V4(RouteUpdate4::Announce(originated4)), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -5922,7 +5891,7 @@ impl SessionRunner { && let Err(e) = self.send_update( RouteUpdate::V6(RouteUpdate6::Announce(originated6)), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -5942,7 +5911,7 @@ impl SessionRunner { fn originate_update( &self, pc: &PeerConnection, - _sa: ShaperApplication, + sa: &ShaperApplication, ) -> anyhow::Result<()> { // Get originated IPv4 routes let originated4 = match self.db.get_origin4() { @@ -5956,7 +5925,7 @@ impl SessionRunner { self.send_update( RouteUpdate::V4(RouteUpdate4::Announce(originated4)), pc, - ShaperApplication::Current, + sa, )?; } @@ -5972,7 +5941,7 @@ impl SessionRunner { self.send_update( RouteUpdate::V6(RouteUpdate6::Announce(originated6)), pc, - ShaperApplication::Current, + sa, )?; } @@ -6027,7 +5996,7 @@ impl SessionRunner { if let Err(e) = self.send_update( route_update, &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( self, @@ -6045,7 +6014,7 @@ impl SessionRunner { AdminEvent::ShaperChanged(previous) => { match self.originate_update( &pc, - ShaperApplication::Difference(previous), + &ShaperApplication::Difference(previous), ) { Err(e) => { session_log!( @@ -6063,7 +6032,7 @@ impl SessionRunner { AdminEvent::ExportPolicyChanged(previous) => { match previous { - TypedImportExportPolicy::V4(previous4) => { + ImportExportPolicy::V4(previous4) => { let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -6132,7 +6101,7 @@ impl SessionRunner { to_announce, )), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -6151,7 +6120,7 @@ impl SessionRunner { to_withdraw, )), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -6166,7 +6135,7 @@ impl SessionRunner { FsmState::Established(pc) } - TypedImportExportPolicy::V6(previous6) => { + ImportExportPolicy::V6(previous6) => { let originated = match self.db.get_origin6() { Ok(value) => value, Err(e) => { @@ -6235,7 +6204,7 @@ impl SessionRunner { to_announce, )), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -6254,7 +6223,7 @@ impl SessionRunner { to_withdraw, )), &pc, - ShaperApplication::Current, + &ShaperApplication::Current, ) { session_log!( @@ -6964,9 +6933,7 @@ impl SessionRunner { self.send_notification( conn, ErrorCode::Open, - ErrorSubcode::Open( - crate::messages::OpenErrorSubcode::BadPeerAS, - ), + ErrorSubcode::Open(OpenErrorSubcode::BadPeerAS), ); self.unregister_conn(conn.id()); return Err(Error::UnexpectedAsn(ExpectationMismatch { @@ -7012,9 +6979,13 @@ impl SessionRunner { let requested = u64::from(om.hold_time); if requested > 0 { if requested < 3 { - self.send_notification(conn, ErrorCode::Open, ErrorSubcode::Open( - crate::messages::OpenErrorSubcode::UnacceptableHoldTime, - )); + self.send_notification( + conn, + ErrorCode::Open, + ErrorSubcode::Open( + OpenErrorSubcode::UnacceptableHoldTime, + ), + ); self.unregister_conn(conn.id()); return Err(Error::HoldTimeTooSmall); } @@ -7070,8 +7041,8 @@ impl SessionRunner { "message" => "route refresh" ); let rr = Message::RouteRefresh(RouteRefreshMessage { - afi: af as u16, - safi: Safi::Unicast as u8, + afi: af.into(), + safi: Safi::Unicast.into(), }); if let Err(e) = conn.send(rr) { session_log!( @@ -7279,12 +7250,12 @@ impl SessionRunner { fn shape_update( &self, update: UpdateMessage, - shaper_application: ShaperApplication, + shaper_application: &ShaperApplication, ) -> Result { match shaper_application { ShaperApplication::Current => self.shape_update_basic(update), ShaperApplication::Difference(previous) => { - self.shape_update_differential(update, previous) + self.shape_update_differential(update, previous.clone()) } } } @@ -7354,11 +7325,7 @@ impl SessionRunner { /// Add peer-specific path attributes to an UPDATE message. /// This adds MED, LOCAL_PREF, and Communities based on session configuration. - fn enrich_update( - &self, - update: &mut UpdateMessage, - _pc: &PeerConnection, - ) -> Result<(), Error> { + fn enrich_update(&self, update: &mut UpdateMessage) -> Result<(), Error> { let session = lock!(self.session); // Add MED if configured @@ -7451,7 +7418,7 @@ impl SessionRunner { &self, route_update: RouteUpdate, pc: &PeerConnection, - shaper_application: ShaperApplication, + shaper_application: &ShaperApplication, ) -> Result<(), Error> { // XXX: Handle more originated routes than can fit in a single Update @@ -7527,7 +7494,7 @@ impl SessionRunner { }; // 3. Add peer-specific enrichments - self.enrich_update(&mut update, pc)?; + self.enrich_update(&mut update)?; // 4. Apply export policy filtering self.apply_export_policy(&mut update)?; @@ -7635,7 +7602,6 @@ impl SessionRunner { conn_timer!(pc.conn, hold).disable(); conn_timer!(pc.conn, keepalive).disable(); session_timer!(self, connect_retry).stop(); - self.connect_retry_counter.fetch_add(1, Ordering::Relaxed); if pc.ipv4_unicast.negotiated() { write_lock!(self.fanout4).remove_egress(self.neighbor.host.ip()); @@ -7733,9 +7699,6 @@ impl SessionRunner { if let Some(c2) = conn2 { self.send_admin_reset_notification(c2); } - self.counters - .connect_retry_counter - .store(0, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -7749,9 +7712,6 @@ impl SessionRunner { if let Some(c2) = conn2 { self.send_admin_shutdown_notification(c2); } - self.counters - .connect_retry_counter - .store(0, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -7765,9 +7725,6 @@ impl SessionRunner { if let Some(c2) = conn2 { self.send_fsm_notification(c2) } - self.counters - .connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -7786,9 +7743,6 @@ impl SessionRunner { self.counters .hold_timer_expirations .fetch_add(1, Ordering::Relaxed); - self.counters - .connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -7824,9 +7778,6 @@ impl SessionRunner { if let Some(c2) = conn2 { self.send_notification(c2, error_code, error_subcode); } - self.counters - .connect_retry_counter - .fetch_add(1, Ordering::Relaxed); self.counters .connection_retries .fetch_add(1, Ordering::Relaxed); @@ -7952,7 +7903,7 @@ impl SessionRunner { /// Filter MP-BGP attributes based on AFI/SAFI negotiation state. /// /// This checks whether the AFI/SAFI in MP_REACH_NLRI and MP_UNREACH_NLRI - /// attributes was negotiated with the peer during capability exchange. + /// attributes were negotiated with the peer during capability exchange. /// Attributes for unnegotiated AFI/SAFIs are silently filtered out /// (logged as warnings but not treated as errors). /// @@ -8018,6 +7969,10 @@ impl SessionRunner { afi, safi; ); + self.counters + .unnegotiated_address_family + .fetch_add(1, Ordering::Relaxed); + // Don't send notification - just filter silently continue; } @@ -8047,6 +8002,10 @@ impl SessionRunner { afi, safi; ); + self.counters + .unnegotiated_address_family + .fetch_add(1, Ordering::Relaxed); + // Don't send notification - just filter silently continue; } @@ -8093,7 +8052,7 @@ impl SessionRunner { self.send_update( RouteUpdate::V4(RouteUpdate4::Announce(originated)), pc, - ShaperApplication::Current, + &ShaperApplication::Current, )?; } Ok(()) @@ -8126,7 +8085,7 @@ impl SessionRunner { self.send_update( RouteUpdate::V6(RouteUpdate6::Announce(originated)), pc, - ShaperApplication::Current, + &ShaperApplication::Current, )?; } Ok(()) @@ -8148,7 +8107,7 @@ impl SessionRunner { msg: RouteRefreshMessage, pc: &PeerConnection, ) -> Result<(), Error> { - if msg.safi != Safi::Unicast as u8 { + if msg.safi != u8::from(Safi::Unicast) { return Err(Error::UnsupportedAddressFamily(msg.afi, msg.safi)); } @@ -8663,36 +8622,6 @@ impl SessionRunner { reset_needed = true; } - // Handle per-AF import policy changes (trigger route refresh) - if current.ipv4_unicast.as_ref().map(|c| &c.import_policy) - != info.ipv4_unicast.as_ref().map(|c| &c.import_policy) - { - current.ipv4_unicast = info.ipv4_unicast.clone(); - refresh_needed4 = true; - } - - if current.ipv6_unicast.as_ref().map(|c| &c.import_policy) - != info.ipv6_unicast.as_ref().map(|c| &c.import_policy) - { - current.ipv6_unicast = info.ipv6_unicast.clone(); - refresh_needed6 = true; - } - - // Handle per-AF nexthop override changes (trigger re-advertisement) - if current.ipv4_unicast.as_ref().map(|c| c.nexthop) - != info.ipv4_unicast.as_ref().map(|c| c.nexthop) - { - current.ipv4_unicast = info.ipv4_unicast.clone(); - readvertise_needed4 = true; - } - - if current.ipv6_unicast.as_ref().map(|c| c.nexthop) - != info.ipv6_unicast.as_ref().map(|c| c.nexthop) - { - current.ipv6_unicast = info.ipv6_unicast.clone(); - readvertise_needed6 = true; - } - if current.vlan_id != info.vlan_id { current.vlan_id = info.vlan_id; reset_needed = true; @@ -8711,43 +8640,72 @@ impl SessionRunner { .set_jitter_range(info.idle_hold_jitter); } - // Handle per-AF export policy changes - if current.ipv4_unicast.as_ref().map(|c| &c.export_policy) - != info.ipv4_unicast.as_ref().map(|c| &c.export_policy) - { - if let Some(previous4) = current - .ipv4_unicast - .as_ref() - .map(|c| c.export_policy.clone()) + // ===== Handle IPv4 Unicast configuration changes ===== + if current.ipv4_unicast != info.ipv4_unicast { + let current_v4 = current.ipv4_unicast.as_ref(); + let info_v4 = info.ipv4_unicast.as_ref(); + + // Import policy changed - trigger route refresh + if current_v4.map(|c| &c.import_policy) + != info_v4.map(|c| &c.import_policy) { - current.ipv4_unicast = info.ipv4_unicast.clone(); + refresh_needed4 = true; + } + + // Nexthop override changed - trigger re-advertisement + if current_v4.map(|c| c.nexthop) != info_v4.map(|c| c.nexthop) { + readvertise_needed4 = true; + } + + // Export policy changed - send FSM notification + if current_v4.map(|c| &c.export_policy) + != info_v4.map(|c| &c.export_policy) + { + let previous4 = current_v4 + .map(|c| c.export_policy.clone()) + .unwrap_or_default(); self.event_tx .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - TypedImportExportPolicy::V4(previous4), + ImportExportPolicy::V4(previous4), ))) .map_err(|e| Error::EventSend(e.to_string()))?; - } else { - current.ipv4_unicast = info.ipv4_unicast.clone(); } + + current.ipv4_unicast = info.ipv4_unicast.clone(); } - if current.ipv6_unicast.as_ref().map(|c| &c.export_policy) - != info.ipv6_unicast.as_ref().map(|c| &c.export_policy) - { - if let Some(previous6) = current - .ipv6_unicast - .as_ref() - .map(|c| c.export_policy.clone()) + // ===== Handle IPv6 Unicast configuration changes ===== + if current.ipv6_unicast != info.ipv6_unicast { + let current_v6 = current.ipv6_unicast.as_ref(); + let info_v6 = info.ipv6_unicast.as_ref(); + + // Import policy changed - trigger route refresh + if current_v6.map(|c| &c.import_policy) + != info_v6.map(|c| &c.import_policy) + { + refresh_needed6 = true; + } + + // Nexthop override changed - trigger re-advertisement + if current_v6.map(|c| c.nexthop) != info_v6.map(|c| c.nexthop) { + readvertise_needed6 = true; + } + + // Export policy changed - send FSM notification + if current_v6.map(|c| &c.export_policy) + != info_v6.map(|c| &c.export_policy) { - current.ipv6_unicast = info.ipv6_unicast.clone(); + let previous6 = current_v6 + .map(|c| c.export_policy.clone()) + .unwrap_or_default(); self.event_tx .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - TypedImportExportPolicy::V6(previous6), + ImportExportPolicy::V6(previous6), ))) .map_err(|e| Error::EventSend(e.to_string()))?; - } else { - current.ipv6_unicast = info.ipv6_unicast.clone(); } + + current.ipv6_unicast = info.ipv6_unicast.clone(); } drop(current); @@ -8804,8 +8762,7 @@ impl SessionRunner { pub fn get_peer_info(&self) -> PeerInfo { let fsm_state = self.state(); - let dur = self.current_state_duration().as_millis() % u64::MAX as u128; - let fsm_state_duration = dur as u64; + let fsm_state_duration = self.current_state_duration(); let counters = self.get_counters(); let name = lock!(self.neighbor.name).clone(); let peer_group = self.neighbor.peer_group.clone(); @@ -8813,20 +8770,8 @@ impl SessionRunner { // Extract config and runtime state WITHOUT holding any locks long-term let (ipv4_unicast, ipv6_unicast, timer_config) = { let session_conf = lock!(self.session); - let ipv4 = session_conf.ipv4_unicast.clone().unwrap_or( - Ipv4UnicastConfig { - nexthop: None, - import_policy: Default::default(), - export_policy: Default::default(), - }, - ); - let ipv6 = session_conf.ipv6_unicast.clone().unwrap_or( - Ipv6UnicastConfig { - nexthop: None, - import_policy: Default::default(), - export_policy: Default::default(), - }, - ); + let ipv4 = session_conf.ipv4_unicast.clone().unwrap_or_default(); + let ipv6 = session_conf.ipv6_unicast.clone().unwrap_or_default(); let timers = TimerConfig::from_session_info(&session_conf); (ipv4, ipv6, timers) }; // Lock dropped here! diff --git a/mg-admin-client/Cargo.toml b/mg-admin-client/Cargo.toml index adcde6f4..26a2838f 100644 --- a/mg-admin-client/Cargo.toml +++ b/mg-admin-client/Cargo.toml @@ -16,6 +16,5 @@ schemars.workspace = true chrono.workspace = true uuid.workspace = true rdb-types.workspace = true -bgp.workspace = true tabwriter.workspace = true colored.workspace = true diff --git a/mg-admin-client/src/lib.rs b/mg-admin-client/src/lib.rs index baea9681..247d6d49 100644 --- a/mg-admin-client/src/lib.rs +++ b/mg-admin-client/src/lib.rs @@ -22,11 +22,7 @@ progenitor::generate_api!( Prefix = rdb_types::Prefix, AddressFamily = rdb_types::AddressFamily, ProtocolFilter = rdb_types::ProtocolFilter, - JitterRange = bgp::params::JitterRange, - PeerInfo = bgp::params::PeerInfo, Duration = std::time::Duration, - Afi = bgp::messages::Afi, - NeighborResetOp = bgp::params::NeighborResetOp, } ); diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 20353eff..1f2e2528 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -4,7 +4,6 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, - iter::FromIterator, net::{IpAddr, Ipv4Addr, Ipv6Addr}, num::NonZeroU8, }; @@ -676,47 +675,34 @@ pub struct Rib(BTreeMap>); impl From for Rib { fn from(value: rdb::db::Rib) -> Self { - Rib(value - .into_iter() - .map(|(k, v)| (k.to_string(), v.into_values().collect())) - .collect()) - } -} - -impl FromIterator<(rdb::Prefix, BTreeSet)> for Rib { - fn from_iter)>>( - iter: T, - ) -> Self { - Rib(iter.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) + Rib(value.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) } } pub fn filter_rib_by_protocol( - rib: BTreeMap>, + rib: BTreeMap>, protocol_filter: Option, -) -> Rib { +) -> BTreeMap> { match protocol_filter { - None => rib - .into_iter() - .map(|(prefix, paths)| (prefix, paths.into_values().collect())) - .collect(), - Some(filter) => rib - .into_iter() - .filter_map(|(prefix, paths)| { + None => rib, + Some(filter) => { + let mut filtered = BTreeMap::new(); + + for (prefix, paths) in rib { let filtered_paths: BTreeSet = paths - .into_values() + .into_iter() .filter(|path| match filter { ProtocolFilter::Bgp => path.bgp.is_some(), ProtocolFilter::Static => path.bgp.is_none(), }) .collect(); - if filtered_paths.is_empty() { - None - } else { - Some((prefix, filtered_paths)) + if !filtered_paths.is_empty() { + filtered.insert(prefix, filtered_paths); } - }) - .collect(), + } + + filtered + } } } diff --git a/mg-lower/src/lib.rs b/mg-lower/src/lib.rs index 9203063b..3b374b0b 100644 --- a/mg-lower/src/lib.rs +++ b/mg-lower/src/lib.rs @@ -224,7 +224,7 @@ pub(crate) fn sync_prefix( // The best routes in the RIB let mut best: HashSet = HashSet::new(); if let Some(paths) = rib_loc.get(prefix) { - for path in paths.values() { + for path in paths { best.insert(RouteHash::for_prefix_path(sw, *prefix, path.clone())?); } } diff --git a/mg-lower/src/test.rs b/mg-lower/src/test.rs index ae264363..7ccf6f16 100644 --- a/mg-lower/src/test.rs +++ b/mg-lower/src/test.rs @@ -38,7 +38,6 @@ async fn sync_prefix_test() { vlan_id: None, }] .into_iter() - .map(|p| (p.key(), p)) .collect(), ); @@ -242,7 +241,6 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() - .map(|p| (p.key(), p)) .collect(), ); rib.insert( @@ -255,7 +253,6 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() - .map(|p| (p.key(), p)) .collect(), ); rib.insert( @@ -268,7 +265,6 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { vlan_id: None, }] .into_iter() - .map(|p| (p.key(), p)) .collect(), ); } diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index 0c399218..1cb7c4e1 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -3,11 +3,22 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use anyhow::Result; -use bgp::{ - messages::Afi, - params::{JitterRange, NeighborResetOp}, -}; +use bgp::{messages::Afi, params::JitterRange}; use clap::{Args, Subcommand, ValueEnum}; + +fn jitter_range_to_api(j: JitterRange) -> types::JitterRange { + types::JitterRange { + min: j.min, + max: j.max, + } +} + +fn afi_to_api(afi: Afi) -> types::Afi { + match afi { + Afi::Ipv4 => types::Afi::Ipv4, + Afi::Ipv6 => types::Afi::Ipv6, + } +} use colored::*; use mg_admin_client::{ Client, @@ -203,21 +214,23 @@ pub enum SoftDirection { }, } -impl From for NeighborResetOp { +impl From for types::NeighborResetOp { fn from(op: NeighborOperation) -> Self { match op { - NeighborOperation::Hard => NeighborResetOp::Hard, + NeighborOperation::Hard => types::NeighborResetOp::Hard, NeighborOperation::Soft { direction } => direction.into(), } } } -impl From for NeighborResetOp { +impl From for types::NeighborResetOp { fn from(direction: SoftDirection) -> Self { match direction { - SoftDirection::Inbound { afi } => NeighborResetOp::SoftInbound(afi), + SoftDirection::Inbound { afi } => { + types::NeighborResetOp::SoftInbound(afi.map(afi_to_api)) + } SoftDirection::Outbound { afi } => { - NeighborResetOp::SoftOutbound(afi) + types::NeighborResetOp::SoftOutbound(afi.map(afi_to_api)) } } } @@ -711,8 +724,10 @@ impl From for types::Neighbor { ipv4_unicast, ipv6_unicast, vlan_id: n.vlan_id, - connect_retry_jitter: n.connect_retry_jitter, - idle_hold_jitter: n.idle_hold_jitter, + connect_retry_jitter: n + .connect_retry_jitter + .map(jitter_range_to_api), + idle_hold_jitter: n.idle_hold_jitter.map(jitter_range_to_api), deterministic_collision_resolution: n .deterministic_collision_resolution, } @@ -911,7 +926,7 @@ fn format_duration_decimal(d: Duration) -> String { } fn display_neighbors_summary( - neighbors: &[(&String, &bgp::params::PeerInfo)], + neighbors: &[(&String, &types::PeerInfo)], ) -> Result<()> { let mut tw = TabWriter::new(stdout()); writeln!( @@ -933,9 +948,7 @@ fn display_neighbors_summary( addr, info.asn, info.fsm_state, - humantime::Duration::from(Duration::from_millis( - info.fsm_state_duration - ),), + humantime::Duration::from(info.fsm_state_duration), humantime::Duration::from(info.timers.hold.configured), humantime::Duration::from(info.timers.hold.negotiated), humantime::Duration::from(info.timers.keepalive.configured), @@ -948,7 +961,7 @@ fn display_neighbors_summary( } fn display_neighbors_detail( - neighbors: &[(&String, &bgp::params::PeerInfo)], + neighbors: &[(&String, &types::PeerInfo)], ) -> Result<()> { for (i, (addr, info)) in neighbors.iter().enumerate() { if i > 0 { @@ -965,9 +978,7 @@ fn display_neighbors_detail( println!(" FSM State: {:?}", info.fsm_state); println!( " FSM State Duration: {}", - humantime::Duration::from(Duration::from_millis( - info.fsm_state_duration - )) + humantime::Duration::from(info.fsm_state_duration) ); if let Some(asn) = info.asn { println!(" Peer ASN: {}", asn); @@ -1013,7 +1024,7 @@ fn display_neighbors_detail( format_duration_decimal(info.timers.connect_retry.configured), format_duration_decimal(info.timers.connect_retry.remaining), ); - match info.timers.connect_retry_jitter { + match &info.timers.connect_retry_jitter { Some(jitter) => { println!(" Jitter: {}-{}", jitter.min, jitter.max) } @@ -1024,7 +1035,7 @@ fn display_neighbors_detail( format_duration_decimal(info.timers.idle_hold.configured), format_duration_decimal(info.timers.idle_hold.remaining), ); - match info.timers.idle_hold_jitter { + match &info.timers.idle_hold_jitter { Some(jitter) => { println!(" Jitter: {}-{}", jitter.min, jitter.max) } diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index c494d691..166e6bbe 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -30,7 +30,7 @@ use mg_api::{ NeighborSelector, Rib, }; use mg_common::lock; -use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicy, Prefix}; +use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicyV1, Prefix}; use rdb::{ImportExportPolicy4, ImportExportPolicy6}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -502,13 +502,13 @@ pub async fn get_exported( .collect(); // Combine per-AF export policies into legacy format for filtering - let allow_export = ImportExportPolicy::from_per_af_policies( + let allow_export = ImportExportPolicyV1::from_per_af_policies( &n.allow_export4, &n.allow_export6, ); let mut exported_routes: Vec = match allow_export { - ImportExportPolicy::NoFiltering => orig_routes, - ImportExportPolicy::Allow(epol) => { + ImportExportPolicyV1::NoFiltering => orig_routes, + ImportExportPolicyV1::Allow(epol) => { orig_routes.retain(|p| epol.contains(p)); orig_routes } @@ -560,7 +560,8 @@ pub async fn get_neighbors( .ok_or(HttpError::for_not_found(None, "ASN not found".to_string()))?; for s in lock!(r.sessions).values() { - let dur = s.current_state_duration().as_millis() % u64::MAX as u128; + let dur = + s.current_state_duration().as_millis().min(u64::MAX as u128) as u64; // If the session runner has a primary connection, pull the config and // runtime state from it. If not, just use the config owned by the @@ -587,7 +588,7 @@ pub async fn get_neighbors( let pi = PeerInfoV2 { state: s.state(), asn: s.remote_asn(), - duration_millis: dur as u64, + duration_millis: dur, timers: PeerTimersV1 { hold: DynamicTimerInfoV1 { configured: conf_holdtime, @@ -620,7 +621,8 @@ pub async fn get_neighbors_v2( .ok_or(HttpError::for_not_found(None, "ASN not found".to_string()))?; for s in lock!(r.sessions).values() { - let dur = s.current_state_duration().as_millis() % u64::MAX as u128; + let dur = + s.current_state_duration().as_millis().min(u64::MAX as u128) as u64; let (conf_holdtime, neg_holdtime, conf_keepalive, neg_keepalive) = if let Some(primary) = s.primary_connection() { @@ -646,7 +648,7 @@ pub async fn get_neighbors_v2( PeerInfoV2 { state: s.state(), asn: s.remote_asn(), - duration_millis: dur as u64, + duration_millis: dur, timers: PeerTimersV1 { hold: DynamicTimerInfoV1 { configured: conf_holdtime, @@ -1701,8 +1703,8 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: rdb::ImportExportPolicy::NoFiltering, - allow_export: rdb::ImportExportPolicy::NoFiltering, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::NoFiltering, vlan_id: None, }], ); @@ -1725,8 +1727,8 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: rdb::ImportExportPolicy::NoFiltering, - allow_export: rdb::ImportExportPolicy::NoFiltering, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::NoFiltering, vlan_id: None, }], ); diff --git a/mgd/src/main.rs b/mgd/src/main.rs index db05172b..8a687b34 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -316,11 +316,11 @@ fn start_bgp_routers( local_pref: nbr.local_pref, enforce_first_as: nbr.enforce_first_as, // Combine per-AF policies into legacy format for API compatibility - allow_import: rdb::ImportExportPolicy::from_per_af_policies( + allow_import: rdb::ImportExportPolicyV1::from_per_af_policies( &nbr.allow_import4, &nbr.allow_import6, ), - allow_export: rdb::ImportExportPolicy::from_per_af_policies( + allow_export: rdb::ImportExportPolicyV1::from_per_af_policies( &nbr.allow_export4, &nbr.allow_export6, ), diff --git a/mgd/src/rib_admin.rs b/mgd/src/rib_admin.rs index 2ab479fd..3abedc09 100644 --- a/mgd/src/rib_admin.rs +++ b/mgd/src/rib_admin.rs @@ -21,7 +21,7 @@ pub async fn get_rib_imported( let query = query.into_inner(); let imported = ctx.db.full_rib(query.address_family); let filtered = filter_rib_by_protocol(imported, query.protocol); - Ok(HttpResponseOk(filtered)) + Ok(HttpResponseOk(filtered.into())) } pub async fn get_rib_selected( @@ -32,7 +32,7 @@ pub async fn get_rib_selected( let query = query.into_inner(); let selected = ctx.db.loc_rib(query.address_family); let filtered = filter_rib_by_protocol(selected, query.protocol); - Ok(HttpResponseOk(filtered)) + Ok(HttpResponseOk(filtered.into())) } pub async fn read_rib_bestpath_fanout( diff --git a/openapi/mg-admin/mg-admin-4.0.0-bf8364.json b/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json similarity index 99% rename from openapi/mg-admin/mg-admin-4.0.0-bf8364.json rename to openapi/mg-admin/mg-admin-4.0.0-9d15bb.json index 4c5798da..3332678a 100644 --- a/openapi/mg-admin/mg-admin-4.0.0-bf8364.json +++ b/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json @@ -4155,11 +4155,6 @@ "format": "uint64", "minimum": 0 }, - "connect_retry_counter": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, "connection_retries": { "type": "integer", "format": "uint64", @@ -4340,6 +4335,11 @@ "format": "uint64", "minimum": 0 }, + "unnegotiated_address_family": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, "update_nexhop_missing": { "type": "integer", "format": "uint64", @@ -4364,7 +4364,6 @@ "required": [ "active_connections_accepted", "active_connections_declined", - "connect_retry_counter", "connection_retries", "connector_panics", "hold_timer_expirations", @@ -4401,6 +4400,7 @@ "unexpected_open_message", "unexpected_route_refresh_message", "unexpected_update_message", + "unnegotiated_address_family", "update_nexhop_missing", "update_send_failure", "updates_received", @@ -4423,9 +4423,7 @@ "$ref": "#/components/schemas/FsmStateKind" }, "fsm_state_duration": { - "type": "integer", - "format": "uint64", - "minimum": 0 + "$ref": "#/components/schemas/Duration" }, "id": { "nullable": true, diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 704edfc2..bb139baa 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-4.0.0-bf8364.json \ No newline at end of file +mg-admin-4.0.0-9d15bb.json \ No newline at end of file diff --git a/rdb/src/bestpath.rs b/rdb/src/bestpath.rs index 98ff5f77..154334a7 100644 --- a/rdb/src/bestpath.rs +++ b/rdb/src/bestpath.rs @@ -2,9 +2,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use std::collections::HashMap; +use std::collections::BTreeSet; -use crate::types::{Path, PathKey}; +use crate::types::Path; use itertools::Itertools; /// The bestpath algorithm chooses the best set of up to `max` paths for a @@ -26,55 +26,39 @@ use itertools::Itertools; /// multi-exit discriminator (MED) on a per-AS basis. /// /// Upon completion of these filtering operations, if the selection group -/// is larger than `max`, return the first `max` entries. If the +/// is larger than `max`, return the first `max` entries. This is a set, +/// so "first" has no semantic meaning, consider it to be random. If the /// selection group is smaller than `max`, the entire group is returned. -pub fn bestpaths( - paths: &HashMap, - max: usize, -) -> Option> { - // Short-circuit: if there are no candidates, there is no best path - if paths.is_empty() { - return None; - } - +pub fn bestpaths(paths: &BTreeSet, max: usize) -> Option> { // Short-circuit: if there's only 1 candidate, then it is the best if paths.len() == 1 { return Some(paths.clone()); } - // Extract references to path values from the HashMap - let path_refs: Vec<&Path> = paths.values().collect(); - // Partition the choice space on whether routes are shutdown or not. If we // only have shutdown routes then use those. Otherwise use active routes - let (active, shutdown): (Vec<&Path>, Vec<&Path>) = - path_refs.into_iter().partition(|x| x.shutdown); + let (shutdown, active): (BTreeSet<&Path>, BTreeSet<&Path>) = + paths.iter().partition(|x| x.shutdown); let candidates = if active.is_empty() { shutdown } else { active }; // Filter down to paths with the best (lowest) RIB priority. This is a // coarse filter to roughly separate RIB paths by protocol (e.g. BGP vs Static), // similar to Administrative Distance on Cisco-like platforms. - let candidates: Vec<&Path> = candidates + let candidates = candidates .into_iter() - .min_set_by_key(|path| path.rib_priority) - .into_iter() - .collect(); + .min_set_by_key(|path| path.rib_priority); // In the case where paths come from multiple protocols but have the same // RIB priority, follow the principle of least surprise. e.g. If a user has // configured a static route with the same RIB priority as BGP is using, // prefer the Static route. // TODO: update this if new upper layer protocols are added - let (b, s): (Vec<&Path>, Vec<&Path>) = + let (b, s): (BTreeSet<&Path>, BTreeSet<&Path>) = candidates.into_iter().partition(|path| path.bgp.is_some()); // Some paths are static, return up to `max` paths from static routes if !s.is_empty() { - let mut result = HashMap::new(); - for path in s.into_iter().take(max) { - result.insert(path.key(), path.clone()); - } - return Some(result); + return Some(s.into_iter().take(max).cloned().collect()); } // None of the remaining paths are static. @@ -85,403 +69,330 @@ pub fn bestpaths( /// The BGP-specific portion of the bestpath algorithm. This evaluates BGP path /// attributes in order to determine up to `max` suitable paths. pub fn bgp_bestpaths( - candidates: Vec<&Path>, + candidates: BTreeSet<&Path>, max: usize, -) -> HashMap { +) -> BTreeSet { // Filter down to paths that are not stale (Graceful Restart). // The `min_set_by_key` method allows us to assign "not stale" paths to the // `0` set, and "stale" paths to the `1` set. The method will then return // the `0` set if any "not stale" paths exist. - let candidates: Vec<&Path> = candidates - .into_iter() - .min_set_by_key(|path| match path.bgp { - Some(ref bgp) => match bgp.stale { - Some(_) => 1, + let candidates = + candidates + .into_iter() + .min_set_by_key(|path| match path.bgp { + Some(ref bgp) => match bgp.stale { + Some(_) => 1, + None => 0, + }, None => 0, - }, - None => 0, - }) - .into_iter() - .collect(); + }); // Filter down to paths with the highest local preference - let candidates: Vec<&Path> = candidates - .into_iter() - .max_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.local_pref.unwrap_or(0), - None => 0, - }) - .into_iter() - .collect(); + let candidates = + candidates + .into_iter() + .max_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.local_pref.unwrap_or(0), + None => 0, + }); // Filter down to paths with the shortest AS-Path length - let candidates: Vec<&Path> = candidates - .into_iter() - .min_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.as_path.len(), - None => 0, - }) - .into_iter() - .collect(); + let candidates = + candidates + .into_iter() + .min_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.as_path.len(), + None => 0, + }); - // Group candidates by AS for MED selection using HashMap - let mut as_groups: HashMap> = HashMap::new(); - for path in candidates { - let origin_as = match path.bgp { - Some(ref bgp) => bgp.origin_as, - None => 0, - }; - as_groups.entry(origin_as).or_default().push(path); - } + // Group candidates by AS for MED selection + let as_groups = candidates.into_iter().chunk_by(|path| match path.bgp { + Some(ref bgp) => bgp.origin_as, + None => 0, + }); // Filter AS groups to paths with lowest MED - let mut candidates: Vec<&Path> = as_groups - .into_iter() - .flat_map(|(_asn, paths)| { - paths - .into_iter() - .min_set_by_key(|path| match path.bgp { - Some(ref bgp) => bgp.med.unwrap_or(0), - None => 0, - }) - .into_iter() - .collect::>() + let candidates = as_groups.into_iter().flat_map(|(_asn, paths)| { + paths.min_set_by_key(|path| match path.bgp { + Some(ref bgp) => bgp.med.unwrap_or(0), + None => 0, }) - .collect(); + }); - // Return up to max elements, in deterministic order (sorted by nexthop for consistency) - candidates.sort_by_key(|p| p.nexthop); - candidates.truncate(max); - candidates - .into_iter() - .cloned() - .map(|p| (p.key(), p)) - .collect() + // Return up to max elements + candidates.take(max).cloned().collect() } #[cfg(test)] mod test { - use std::collections::HashMap; + use std::collections::BTreeSet; use std::net::IpAddr; use std::str::FromStr; use super::bestpaths; use crate::{ BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, - DEFAULT_RIB_PRIORITY_STATIC, Path, PathKey, + DEFAULT_RIB_PRIORITY_STATIC, Path, + types::test_helpers::path_sets_equal, }; - // Helper function to create a BGP path with common attributes - fn make_bgp_path( - nexthop: IpAddr, - peer: IpAddr, - origin_as: u32, - local_pref: u32, - med: u32, - as_path: Vec, - ) -> Path { - Path { - nexthop, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as, - peer, - id: origin_as, - med: Some(med), - local_pref: Some(local_pref), - as_path, - stale: None, - }), - vlan_id: None, - } - } - - // Helper function to create a static path - fn make_static_path( - nexthop: IpAddr, - vlan_id: Option, - priority: u8, - ) -> Path { - Path { - nexthop, - rib_priority: priority, - shutdown: false, - bgp: None, - vlan_id, - } - } - - // Helper function to assert a path exists in the result - fn assert_path_in_result(result: &HashMap, expected: &Path) { - assert!( - result.values().any(|p| p == expected), - "Expected path with nexthop {:?} not found in result", - expected.nexthop - ); - } - // Bestpaths is purely a function of the path info itself, so we don't // need a Rib or Prefix, just a set of candidate paths and a set of // expected paths. #[test] fn test_bestpath() { - let peer1 = IpAddr::from_str("203.0.113.1").unwrap(); - let peer2 = IpAddr::from_str("203.0.113.2").unwrap(); - let peer3 = IpAddr::from_str("203.0.113.3").unwrap(); - let peer4 = IpAddr::from_str("203.0.113.4").unwrap(); - let mut max = 2; + let mut max: usize = 2; + let remote_ip1 = IpAddr::from_str("203.0.113.1").unwrap(); + let remote_ip2 = IpAddr::from_str("203.0.113.2").unwrap(); + let remote_ip3 = IpAddr::from_str("203.0.113.3").unwrap(); + let remote_ip4 = IpAddr::from_str("203.0.113.4").unwrap(); // Add one path and make sure we get it back - let path1 = - make_bgp_path(peer1, peer1, 470, 100, 75, vec![470, 64501, 64502]); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); + let path1 = Path { + nexthop: remote_ip1, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 470, + peer: remote_ip1, + id: 47, + med: Some(75), + local_pref: Some(100), + as_path: vec![470, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; + + let mut candidates = BTreeSet::::new(); + candidates.insert(path1.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_path_in_result(&result, &path1); - - // Add path2 with same local_pref, as_path, and med - let path2 = - make_bgp_path(peer2, peer2, 480, 100, 75, vec![480, 64501, 64502]); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); + assert!(path_sets_equal(&result, &BTreeSet::from([path1.clone()]))); + + // Add path2: + let mut path2 = Path { + nexthop: remote_ip2, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 480, + peer: remote_ip2, + id: 48, + med: Some(75), + local_pref: Some(100), + as_path: vec![480, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; + + candidates.insert(path2.clone()); let result = bestpaths(&candidates, max).unwrap(); - // we expect both paths to be selected (ECMP) + // we expect both paths to be selected because path1 and path2 have: + // - matching local-pref + // - matching as-path-len + // - matching med assert_eq!(result.len(), 2); - assert_path_in_result(&result, &path1); - assert_path_in_result(&result, &path2); - - // Add path3 with worse (higher) MED - let path3 = - make_bgp_path(peer3, peer3, 490, 100, 100, vec![490, 64501, 64502]); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); + assert!(path_sets_equal( + &result, + &BTreeSet::from([path1.clone(), path2.clone()]) + )); + + // Add path3 with: + // - matching local-pref + // - matching as-path-len + // - worse (higher) med + let mut path3 = Path { + nexthop: remote_ip3, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 490, + peer: remote_ip3, + id: 49, + med: Some(100), + local_pref: Some(100), + as_path: vec![490, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; + let mut candidates = result.clone(); + candidates.insert(path3.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 2); - // paths 1 and 2 should be selected (lowest MED) - assert_path_in_result(&result, &path1); - assert_path_in_result(&result, &path2); + // paths 1 and 2 should always be selected since they have the lowest MED + assert!(path_sets_equal( + &result, + &BTreeSet::from([path1.clone(), path2.clone()]) + )); - // Improve path3's MED to match path1 and path2 - let mut path3 = path3; - path3.bgp.as_mut().unwrap().med = Some(75); + // increase max paths to 3 max = 3; - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); + + // set the med to 75 (matching path1/path2) and re-run bestpath w/ + // max paths set to 3. path3 should now be part of the ecmp group returned. + let mut candidates = result.clone(); + candidates.remove(&path3); + path3.bgp.as_mut().unwrap().med = Some(75); + candidates.insert(path3.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 3); - assert_path_in_result(&result, &path1); - assert_path_in_result(&result, &path2); - assert_path_in_result(&result, &path3); - - // Boost path2's local_pref - it should become the only best path - let mut path2 = path2; + assert!(path_sets_equal( + &result, + &BTreeSet::from([path1.clone(), path2.clone(), path3.clone()]) + )); + + // bump the local_pref on path2, this should make it the singular + // best path regardless of max paths + let mut candidates = result.clone(); + candidates.remove(&path2); path2.bgp.as_mut().unwrap().local_pref = Some(125); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); + candidates.insert(path2.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_path_in_result(&result, &path2); - - // Test static route vs BGP with different RIB priorities - // path4 with poor (high) priority should lose to BGP - let path4_low_priority = make_static_path(peer4, None, u8::MAX); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); - candidates.insert(path4_low_priority.key(), path4_low_priority.clone()); + assert!(path_sets_equal(&result, &BTreeSet::from([path2.clone()]))); + + // Add a fourth path (which is static) and make sure that: + // - path 4 loses to BGP paths with higher RIB priority + // - path 4 wins over BGP paths with lower RIB priority + // - path 4 wins over BGP paths with equal RIB priority + // > static is preferred over bgp when RIB priority matches + let mut path4 = Path { + nexthop: remote_ip4, + rib_priority: u8::MAX, + shutdown: false, + bgp: None, + vlan_id: None, + }; + let mut candidates = result.clone(); + candidates.insert(path4.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // BGP path2 wins (priority 20 < 255) - assert_path_in_result(&result, &path2); - - // Lower path4's priority to static default - now it should win - let path4_static_priority = - make_static_path(peer4, None, DEFAULT_RIB_PRIORITY_STATIC); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); - candidates - .insert(path4_static_priority.key(), path4_static_priority.clone()); + // path4 (static) has worse rib priority, path2 should win because it + // has the best (highest) local-pref among bgp paths (paths 1-3) + assert!(path_sets_equal(&result, &BTreeSet::from([path2.clone()]))); + + // Lower the RIB Priority (better) + let mut candidates = result.clone(); + candidates.remove(&path4); + path4.rib_priority = DEFAULT_RIB_PRIORITY_STATIC; + candidates.insert(path4.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // Static path4 wins (priority 1 < 20) - assert_path_in_result(&result, &path4_static_priority); - - // Set path4's priority equal to BGP - static should win due to protocol preference - let path4_bgp_priority = - make_static_path(peer4, None, DEFAULT_RIB_PRIORITY_BGP); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path3.key(), path3.clone()); - candidates.insert(path4_bgp_priority.key(), path4_bgp_priority.clone()); + // path4 (static) has the best (lower) rib priority + assert!(path_sets_equal(&result, &BTreeSet::from([path4.clone()]))); + + // Raise the RIB Priority equal to BGP (paths 1-3) + let mut candidates = result.clone(); + candidates.remove(&path4); + path4.rib_priority = DEFAULT_RIB_PRIORITY_BGP; + candidates.insert(path4.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - // Static path4 wins over BGP (same priority, but static preferred) - assert_path_in_result(&result, &path4_bgp_priority); + // path4 (static) wins due to protocol preference + // i.e. static > bgp when rib priority matches + assert!(path_sets_equal(&result, &BTreeSet::from([path4.clone()]))); } + /// Test that active (non-shutdown) paths are preferred over shutdown paths. + /// Even when max allows multiple paths, shutdown paths should not be + /// included when active paths exist. #[test] - fn test_pathkey_replacement_same_key() { - // Test that when two paths have the same PathKey, the second insertion - // replaces the first one in the HashMap. - let max = 1; - let nexthop = IpAddr::from_str("10.0.0.1").unwrap(); - - // Create path1 with priority 1 - let path1 = make_static_path(nexthop, None, 1); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - assert_eq!(candidates.len(), 1); - - // Create path2 with same nexthop and vlan_id but priority 10 - // This has the same PathKey as path1, so it should replace it - let path2 = make_static_path(nexthop, None, 10); - candidates.insert(path2.key(), path2.clone()); - assert_eq!( - candidates.len(), - 1, - "Expected replacement, but HashMap has 2 entries" - ); - - // Verify that path2 is the only one in the HashMap - assert_eq!(candidates.get(&path2.key()), Some(&path2)); - assert_eq!(candidates.get(&path1.key()), Some(&path2)); - - // Run bestpath and verify it uses path2's priority (10, which is worse than expected) - let result = bestpaths(&candidates, max).unwrap(); - assert_path_in_result(&result, &path2); - } + fn test_bestpath_shutdown_preference() { + let remote_ip1 = IpAddr::from_str("203.0.113.1").unwrap(); + let remote_ip2 = IpAddr::from_str("203.0.113.2").unwrap(); - #[test] - fn test_bestpath_max_parameter_limits() { - // Test that bestpaths respects the max parameter - let mut max = 5; - let nexthop1 = IpAddr::from_str("10.0.0.1").unwrap(); - let nexthop2 = IpAddr::from_str("10.0.0.2").unwrap(); - let peer1 = IpAddr::from_str("192.0.2.1").unwrap(); - let peer2 = IpAddr::from_str("192.0.2.2").unwrap(); - let peer3 = IpAddr::from_str("192.0.2.3").unwrap(); - let peer4 = IpAddr::from_str("192.0.2.4").unwrap(); - let peer5 = IpAddr::from_str("192.0.2.5").unwrap(); - - // Test case 1: 2 candidates, max=5 should return 2 - let path1 = make_bgp_path(nexthop1, peer1, 100, 100, 75, vec![100]); - let path2 = make_bgp_path(nexthop2, peer2, 100, 100, 75, vec![100]); - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1.clone()); - candidates.insert(path2.key(), path2.clone()); - let result = bestpaths(&candidates, max).unwrap(); - assert_eq!( - result.len(), - 2, - "With 2 candidates and max=5, expected 2 results" - ); - - // Test case 2: 5 ECMP candidates, max=2 should return 2 - let path3 = make_bgp_path( - IpAddr::from_str("10.0.0.3").unwrap(), - peer3, - 100, - 100, - 75, - vec![100], - ); - let path4 = make_bgp_path( - IpAddr::from_str("10.0.0.4").unwrap(), - peer4, - 100, - 100, - 75, - vec![100], - ); - let path5 = make_bgp_path( - IpAddr::from_str("10.0.0.5").unwrap(), - peer5, - 100, - 100, - 75, - vec![100], - ); - max = 2; - let mut candidates = HashMap::new(); - candidates.insert(path1.key(), path1); - candidates.insert(path2.key(), path2); - candidates.insert(path3.key(), path3); - candidates.insert(path4.key(), path4); - candidates.insert(path5.key(), path5); - let result = bestpaths(&candidates, max).unwrap(); - assert_eq!( - result.len(), - 2, - "With 5 candidates and max=2, expected 2 results" - ); - - // Test case 3: empty HashMap should return None - max = 10; - let empty: HashMap = HashMap::new(); - let result = bestpaths(&empty, max); - assert_eq!(result, None, "Empty candidates should return None"); - } + // Create two equivalent BGP paths, but one is shutdown + let active_path = Path { + nexthop: remote_ip1, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: Some(BgpPathProperties { + origin_as: 470, + peer: remote_ip1, + id: 47, + med: Some(75), + local_pref: Some(100), + as_path: vec![470, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; - #[test] - fn test_bestpath_deterministic_ordering() { - // Test that bestpath returns results in deterministic order (sorted by nexthop). - // This is important for consistent behavior across runs. - let max = 3; - let nh3 = IpAddr::from_str("10.0.0.3").unwrap(); - let nh1 = IpAddr::from_str("10.0.0.1").unwrap(); - let nh2 = IpAddr::from_str("10.0.0.2").unwrap(); - let peer1 = IpAddr::from_str("192.0.2.1").unwrap(); - let peer2 = IpAddr::from_str("192.0.2.2").unwrap(); - let peer3 = IpAddr::from_str("192.0.2.3").unwrap(); - - // Create 3 ECMP paths with identical attributes but different nexthops - // They have the same local_pref, med, as_path, so they're all equally good - let path3 = make_bgp_path(nh3, peer3, 100, 100, 75, vec![100]); - let path1 = make_bgp_path(nh1, peer1, 100, 100, 75, vec![100]); - let path2 = make_bgp_path(nh2, peer2, 100, 100, 75, vec![100]); - - // Add in reverse order (3, 2, 1) to ensure sorting doesn't just rely on insertion order - let mut candidates = HashMap::new(); - candidates.insert(path3.key(), path3.clone()); - candidates.insert(path2.key(), path2.clone()); - candidates.insert(path1.key(), path1.clone()); - - // Get results - let result = bestpaths(&candidates, max).unwrap(); - assert_eq!(result.len(), 3); + let shutdown_path = Path { + nexthop: remote_ip2, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: true, // This path is shutdown + bgp: Some(BgpPathProperties { + origin_as: 480, + peer: remote_ip2, + id: 48, + med: Some(75), + local_pref: Some(100), + as_path: vec![480, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; + + // Both paths are equivalent except for shutdown status. + // With max=2, we could return both if they were truly equivalent, + // but the active path should win and the shutdown path should be excluded. + let mut candidates = BTreeSet::new(); + candidates.insert(active_path.clone()); + candidates.insert(shutdown_path.clone()); - // Convert to sorted vector to check order - let mut result_vec: Vec<_> = result.values().collect(); - result_vec.sort_by_key(|p| p.nexthop); - - // Verify they're sorted by nexthop - assert_eq!(result_vec[0].nexthop, nh1); - assert_eq!(result_vec[1].nexthop, nh2); - assert_eq!(result_vec[2].nexthop, nh3); - - // Run multiple times to ensure consistency (deterministic) - for _ in 0..5 { - let result = bestpaths(&candidates, max).unwrap(); - let mut result_vec: Vec<_> = result.values().collect(); - result_vec.sort_by_key(|p| p.nexthop); - assert_eq!(result_vec[0].nexthop, nh1); - assert_eq!(result_vec[1].nexthop, nh2); - assert_eq!(result_vec[2].nexthop, nh3); - } + let result = bestpaths(&candidates, 2).unwrap(); + + // Only the active path should be returned, not the shutdown path + assert_eq!(result.len(), 1); + assert!(path_sets_equal( + &result, + &BTreeSet::from([active_path.clone()]) + )); + + // Verify that if ONLY shutdown paths exist, we still get a result + let mut shutdown_only = BTreeSet::new(); + shutdown_only.insert(shutdown_path.clone()); + + let result = bestpaths(&shutdown_only, 2).unwrap(); + assert_eq!(result.len(), 1); + assert!(path_sets_equal( + &result, + &BTreeSet::from([shutdown_path.clone()]) + )); + + // Test with two shutdown paths - both should be returned when max=2 + let shutdown_path2 = Path { + nexthop: remote_ip1, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: true, + bgp: Some(BgpPathProperties { + origin_as: 470, + peer: remote_ip1, + id: 47, + med: Some(75), + local_pref: Some(100), + as_path: vec![470, 64501, 64502], + stale: None, + }), + vlan_id: None, + }; + + let mut two_shutdown = BTreeSet::new(); + two_shutdown.insert(shutdown_path.clone()); + two_shutdown.insert(shutdown_path2.clone()); + + let result = bestpaths(&two_shutdown, 2).unwrap(); + // Both shutdown paths should be returned since no active paths exist + assert_eq!(result.len(), 2); + assert!(path_sets_equal( + &result, + &BTreeSet::from([shutdown_path.clone(), shutdown_path2.clone()]) + )); } } diff --git a/rdb/src/db.rs b/rdb/src/db.rs index db8e8427..6738479b 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -18,7 +18,7 @@ use mg_common::{lock, read_lock, write_lock}; use sled::Tree; use slog::{Logger, error}; use std::cmp::Ordering as CmpOrdering; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv6Addr}; use std::num::NonZeroU8; use std::sync::atomic::{AtomicU64, Ordering}; @@ -68,9 +68,9 @@ const BESTPATH_FANOUT: &str = "bestpath_fanout"; /// Default bestpath fanout value. Maximum number of ECMP paths in RIB. const DEFAULT_BESTPATH_FANOUT: u8 = 1; -pub type Rib = BTreeMap>; -pub type Rib4 = BTreeMap>; -pub type Rib6 = BTreeMap>; +pub type Rib = BTreeMap>; +pub type Rib4 = BTreeMap>; +pub type Rib6 = BTreeMap>; /// The central routing information base. Both persistent an volatile route /// information is managed through this structure. @@ -548,24 +548,14 @@ impl Db { let rib = lock!(self.rib4_in); match rib.get(p4) { None => Vec::new(), - Some(paths) => { - let mut result: Vec = - paths.values().cloned().collect(); - result.sort(); - result - } + Some(p) => p.iter().cloned().collect(), } } Prefix::V6(p6) => { let rib = lock!(self.rib6_in); match rib.get(p6) { None => Vec::new(), - Some(paths) => { - let mut result: Vec = - paths.values().cloned().collect(); - result.sort(); - result - } + Some(p) => p.iter().cloned().collect(), } } } @@ -577,14 +567,14 @@ impl Db { let rib = lock!(self.rib4_loc); match rib.get(p4) { None => Vec::new(), - Some(paths) => paths.values().cloned().collect(), + Some(p) => p.iter().cloned().collect(), } } Prefix::V6(p6) => { let rib = lock!(self.rib6_loc); match rib.get(p6) { None => Vec::new(), - Some(paths) => paths.values().cloned().collect(), + Some(p) => p.iter().cloned().collect(), } } } @@ -605,23 +595,14 @@ impl Db { ); NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() }); - self.do_update_rib4_loc(rib_in, rib_loc, prefix, fanout); - } - fn do_update_rib4_loc( - &self, - rib_in: &Rib4, - rib_loc: &mut Rib4, - prefix: &Prefix4, - fanout: NonZeroU8, - ) { match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { match bestpaths(paths, fanout.get() as usize) { // bestpath found at least 1 path for loc-rib - Some(bp_paths) => { - rib_loc.insert(*prefix, bp_paths); + Some(bp) => { + rib_loc.insert(*prefix, bp.clone()); } // bestpath found no suitable paths None => { @@ -651,23 +632,14 @@ impl Db { ); NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() }); - self.do_update_rib6_loc(rib_in, rib_loc, prefix, fanout); - } - fn do_update_rib6_loc( - &self, - rib_in: &Rib6, - rib_loc: &mut Rib6, - prefix: &Prefix6, - fanout: NonZeroU8, - ) { match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { match bestpaths(paths, fanout.get() as usize) { // bestpath found at least 1 path for loc-rib - Some(bp_paths) => { - rib_loc.insert(*prefix, bp_paths); + Some(bp) => { + rib_loc.insert(*prefix, bp.clone()); } // bestpath found no suitable paths None => { @@ -687,31 +659,15 @@ impl Db { // bestpath is run against via the bestpath_needed closure pub fn trigger_bestpath_when(&self, bestpath_needed: F) where - F: Fn(&Prefix, &HashMap) -> bool, + F: Fn(&Prefix, &BTreeSet) -> bool, { - // Read fanout once to avoid repeated disk I/O during the loop - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); - { // only grab the lock once, release it once the loop ends let rib4_in = lock!(self.rib4_in); let mut rib4_loc = lock!(self.rib4_loc); - for (prefix, paths) in rib4_in.iter() { + for (prefix, paths) in self.full_rib4().iter() { if bestpath_needed(&Prefix::from(*prefix), paths) { - self.do_update_rib4_loc( - &rib4_in, - &mut rib4_loc, - prefix, - fanout, - ); + self.update_rib4_loc(&rib4_in, &mut rib4_loc, prefix); } } } @@ -720,14 +676,9 @@ impl Db { // only grab the lock once, release it once the loop ends let rib6_in = lock!(self.rib6_in); let mut rib6_loc = lock!(self.rib6_loc); - for (prefix, paths) in rib6_in.iter() { + for (prefix, paths) in self.full_rib6().iter() { if bestpath_needed(&Prefix::from(*prefix), paths) { - self.do_update_rib6_loc( - &rib6_in, - &mut rib6_loc, - prefix, - fanout, - ); + self.update_rib6_loc(&rib6_in, &mut rib6_loc, prefix); } } } @@ -740,17 +691,12 @@ impl Db { rib_in: &mut Rib4, rib_loc: &mut Rib4, ) { - let key = path.key(); match rib_in.get_mut(p4) { Some(paths) => { - // HashMap::insert() will replace any existing path with the same key, - // ensuring only one path per source (peer for BGP, nexthop+vlan for static). - paths.insert(key, path.clone()); + paths.replace(path.clone()); } None => { - let mut paths = HashMap::new(); - paths.insert(key, path.clone()); - rib_in.insert(*p4, paths); + rib_in.insert(*p4, BTreeSet::from([path.clone()])); } } self.update_rib4_loc(rib_in, rib_loc, p4); @@ -763,17 +709,12 @@ impl Db { rib_in: &mut Rib6, rib_loc: &mut Rib6, ) { - let key = path.key(); match rib_in.get_mut(p6) { Some(paths) => { - // HashMap::insert() will replace any existing path with the same key, - // ensuring only one path per source (peer for BGP, nexthop+vlan for static). - paths.insert(key, path.clone()); + paths.replace(path.clone()); } None => { - let mut paths = HashMap::new(); - paths.insert(key, path.clone()); - rib_in.insert(*p6, paths); + rib_in.insert(*p6, BTreeSet::from([path.clone()])); } } self.update_rib6_loc(rib_in, rib_loc, p6); @@ -956,39 +897,22 @@ impl Db { pub fn set_nexthop_shutdown(&self, nexthop: IpAddr, shutdown: bool) { let mut pcn = PrefixChangeNotification::default(); let mut pcn6 = PrefixChangeNotification::default(); - - // Read fanout once to avoid repeated disk I/O during the loops - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); - { let mut rib4_in = lock!(self.rib4_in); let mut rib4_loc = lock!(self.rib4_loc); for (prefix, paths) in rib4_in.iter_mut() { - for (key, path) in paths.clone().into_iter() { - if path.nexthop == nexthop && path.shutdown != shutdown { - let mut replacement = path.clone(); + for p in paths.clone().into_iter() { + if p.nexthop == nexthop && p.shutdown != shutdown { + let mut replacement = p.clone(); replacement.shutdown = shutdown; - paths.insert(key, replacement); + paths.insert(replacement); pcn.changed.insert(Prefix::from(*prefix)); } } } for prefix in pcn.changed.iter() { if let Prefix::V4(p4) = prefix { - self.do_update_rib4_loc( - &rib4_in, - &mut rib4_loc, - p4, - fanout, - ); + self.update_rib4_loc(&rib4_in, &mut rib4_loc, p4); } } } @@ -997,23 +921,18 @@ impl Db { let mut rib6_in = lock!(self.rib6_in); let mut rib6_loc = lock!(self.rib6_loc); for (prefix, paths) in rib6_in.iter_mut() { - for (key, path) in paths.clone().into_iter() { - if path.nexthop == nexthop && path.shutdown != shutdown { - let mut replacement = path.clone(); + for p in paths.clone().into_iter() { + if p.nexthop == nexthop && p.shutdown != shutdown { + let mut replacement = p.clone(); replacement.shutdown = shutdown; - paths.insert(key, replacement); + paths.insert(replacement); pcn6.changed.insert(Prefix::from(*prefix)); } } } for prefix in pcn6.changed.iter() { if let Prefix::V6(p6) = prefix { - self.do_update_rib6_loc( - &rib6_in, - &mut rib6_loc, - p6, - fanout, - ); + self.update_rib6_loc(&rib6_in, &mut rib6_loc, p6); } } } @@ -1032,7 +951,7 @@ impl Db { F: Fn(&Path) -> bool, { if let Some(paths) = rib_in.get_mut(prefix) { - paths.retain(|_key, path| !prefix_cmp(path)); + paths.retain(|p| !prefix_cmp(p)); if paths.is_empty() { rib_in.remove(prefix); } @@ -1051,7 +970,7 @@ impl Db { F: Fn(&Path) -> bool, { if let Some(paths) = rib_in.get_mut(prefix) { - paths.retain(|_key, path| !prefix_cmp(path)); + paths.retain(|p| !prefix_cmp(p)); if paths.is_empty() { rib_in.remove(prefix); } @@ -1311,26 +1230,44 @@ impl Db { pub fn mark_bgp_peer_stale4(&self, peer: IpAddr) { let mut rib = lock!(self.rib4_loc); - rib.iter_mut().for_each(|(_prefix, paths)| { - for (_key, path) in paths.iter_mut() { - if let Some(bgp) = path.bgp.as_mut() - && bgp.peer == peer - { - bgp.stale = Some(Utc::now()); - } + rib.iter_mut().for_each(|(_prefix, path)| { + let targets: Vec = path + .iter() + .filter_map(|p| { + if let Some(bgp) = p.bgp.as_ref() + && bgp.peer == peer + { + let mut marked = p.clone(); + marked.bgp = Some(bgp.as_stale()); + return Some(marked); + } + None + }) + .collect(); + for t in targets.into_iter() { + path.replace(t); } }); } pub fn mark_bgp_peer_stale6(&self, peer: IpAddr) { let mut rib = lock!(self.rib6_loc); - rib.iter_mut().for_each(|(_prefix, paths)| { - for (_key, path) in paths.iter_mut() { - if let Some(bgp) = path.bgp.as_mut() - && bgp.peer == peer - { - bgp.stale = Some(Utc::now()); - } + rib.iter_mut().for_each(|(_prefix, path)| { + let targets: Vec = path + .iter() + .filter_map(|p| { + if let Some(bgp) = p.bgp.as_ref() + && bgp.peer == peer + { + let mut marked = p.clone(); + marked.bgp = Some(bgp.as_stale()); + return Some(marked); + } + None + }) + .collect(); + for t in targets.into_iter() { + path.replace(t); } }); } @@ -1391,7 +1328,7 @@ impl Reaper { .unwrap() .iter_mut() .for_each(|(_prefix, paths)| { - paths.retain(|_key, p| { + paths.retain(|p| { p.bgp .as_ref() .map(|b| { @@ -1411,9 +1348,9 @@ impl Reaper { #[cfg(test)] mod test { use crate::{ - AddressFamily, BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, - DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, Prefix6, - StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, + AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, + Prefix6, StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, + types::test_helpers::path_vecs_equal, }; use mg_common::log::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -1431,14 +1368,14 @@ mod test { loc_rib_paths: Vec, ) -> bool { let curr_rib_in_paths = db.get_prefix_paths(prefix); - if curr_rib_in_paths != rib_in_paths { + if !path_vecs_equal(&curr_rib_in_paths, &rib_in_paths) { eprintln!("curr_rib_in_paths: {:?}", curr_rib_in_paths); eprintln!("rib_in_paths: {:?}", rib_in_paths); return false; } let curr_loc_rib_paths = db.get_selected_prefix_paths(prefix); - if curr_loc_rib_paths != loc_rib_paths { + if !path_vecs_equal(&curr_loc_rib_paths, &loc_rib_paths) { eprintln!("curr_loc_rib_paths: {:?}", curr_loc_rib_paths); eprintln!("loc_rib_paths: {:?}", loc_rib_paths); return false; @@ -1446,23 +1383,13 @@ mod test { true } - // Helper function to create a static route key with explicit priority - fn make_static_route( - prefix: Prefix, - nexthop: IpAddr, - vlan_id: Option, - priority: u8, - ) -> StaticRouteKey { - StaticRouteKey { - prefix, - nexthop, - vlan_id, - rib_priority: priority, - } - } - #[test] fn test_rib() { + use crate::StaticRouteKey; + use crate::{ + BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, + DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, db::Db, + }; // init test vars let p0 = Prefix::from("192.168.0.0/24".parse::().unwrap()); let p1 = Prefix::from("192.168.1.0/24".parse::().unwrap()); @@ -1521,19 +1448,33 @@ mod test { }), vlan_id: None, }; - let static_key0 = make_static_route( - p0, - remote_ip0, - None, - DEFAULT_RIB_PRIORITY_STATIC, - ); + // Static routes for testing replacement semantics: + // static_key0 and static_key0_updated have the SAME identity (nexthop, vlan_id) + // but different rib_priority. Adding both should result in replacement. + let static_key0 = StaticRouteKey { + prefix: p0, + nexthop: remote_ip0, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; let static_path0 = Path::from(static_key0); - let static_key1 = make_static_route( - p0, - remote_ip0, - None, - DEFAULT_RIB_PRIORITY_STATIC + 10, - ); + let static_key0_updated = StaticRouteKey { + prefix: p0, + nexthop: remote_ip0, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC + 10, + }; + let static_path0_updated = Path::from(static_key0_updated); + + // Static route for testing ECMP: + // static_key1 has a DIFFERENT identity (different nexthop) than static_key0, + // so both should coexist in the RIB. + let static_key1 = StaticRouteKey { + prefix: p0, + nexthop: remote_ip1, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; let static_path1 = Path::from(static_key1); // setup @@ -1549,33 +1490,57 @@ mod test { assert!(db.full_rib(None).is_empty()); assert!(db.loc_rib(None).is_empty()); - // add static route with DEFAULT_RIB_PRIORITY_STATIC + // ===================================================================== + // Test 1: Replacement semantics + // Adding two static routes with the same identity (nexthop, vlan_id) + // should result in the second replacing the first. + // ===================================================================== db.add_static_routes(&[static_key0]) - .expect("add_static_routes failed for {static_key0}"); + .expect("add static_key0"); - // expected current state - // rib_in: - // - p0 via static_path0 - // loc_rib: - // - p0 via static_path0 + // Verify static_path0 is installed let rib_in_paths = vec![static_path0.clone()]; let loc_rib_paths = vec![static_path0.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); - // add a second static route with higher rib_priority (worse). - // Since it has the same (nexthop, vlan_id), it replaces static_key0. - // Only static_path1 (priority 11) remains in the RIB after replacement. + // Add static_key0_updated (same identity, different rib_priority) + // This should REPLACE static_path0, not add a second path + db.add_static_routes(&[static_key0_updated]) + .expect("add static_key0_updated"); + + // Verify only static_path0_updated exists (replacement occurred) + let rib_in_paths = vec![static_path0_updated.clone()]; + let loc_rib_paths = vec![static_path0_updated.clone()]; + assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); + + // ===================================================================== + // Test 2: ECMP - multiple static routes with different identities + // Adding a static route with a different nexthop should coexist. + // ===================================================================== db.add_static_routes(&[static_key1]) - .expect("add_static_routes failed for {static_key1}"); - let rib_in_paths = vec![static_path1.clone()]; + .expect("add static_key1"); + + // Verify both paths coexist (ECMP) + // static_path0_updated (nexthop=remote_ip0) and static_path1 (nexthop=remote_ip1) + let rib_in_paths = + vec![static_path0_updated.clone(), static_path1.clone()]; + // loc_rib should have static_path0 or static_path1 based on bestpath + // Both have the same rib_priority (static_path0_updated has +10, static_path1 has base) + // so static_path1 wins (lower rib_priority is better) let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); - // remove static_key1 - db.remove_static_routes(&[static_key1]) - .expect("remove_static_routes failed for {static_key1}"); - let rib_in_paths = Vec::new(); - let loc_rib_paths = Vec::new(); + // ===================================================================== + // Test 3: Removal by identity + // Removing static_key0 should only remove static_path0_updated, + // leaving static_path1 intact (different identity). + // ===================================================================== + db.remove_static_routes(&[static_key0]) + .expect("remove static_key0"); + + // Verify static_path1 still exists + let rib_in_paths = vec![static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); // install bgp routes @@ -1585,15 +1550,15 @@ mod test { // expected current state // rib_in: - // - p0 via bgp_path0 + // - p0 via bgp_path0, static_path1 (ordered by nexthop IP) // - p1 via bgp_path{0,1,2} // - p2 via bgp_path{1,2} // loc_rib: - // - p0 via bgp_path0 (only path available) + // - p0 via static_path1 (win by rib_priority/protocol) // - p1 via bgp_path2 (win by local pref) // - p2 via bgp_path2 (win by local pref) - let rib_in_paths = vec![bgp_path0.clone()]; - let loc_rib_paths = vec![bgp_path0.clone()]; + let rib_in_paths = vec![bgp_path0.clone(), static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path0.clone(), bgp_path1.clone(), bgp_path2.clone()]; @@ -1607,15 +1572,15 @@ mod test { db.remove_bgp_prefixes(&[p2], &bgp_path1.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 via bgp_path0 + // - p0 via bgp_path0, static_path1 (ordered by nexthop IP) // - p1 via bgp_path{0,1,2} // - p2 via bgp_path2 // loc_rib: - // - p0 via bgp_path0 (only path available) + // - p0 via static_path1 (win by rib_priority/protocol) // - p1 via bgp_path2 (win by local pref) // - p2 via bgp_path2 (win by local pref) - let rib_in_paths = vec![bgp_path0.clone()]; - let loc_rib_paths = vec![bgp_path0.clone()]; + let rib_in_paths = vec![bgp_path0.clone(), static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path0.clone(), bgp_path1.clone(), bgp_path2.clone()]; @@ -1629,15 +1594,15 @@ mod test { db.remove_bgp_prefixes_from_peer(&bgp_path0.bgp.unwrap().peer); // expected current state // rib_in: - // - p0 is empty (bgp_path0 removed) + // - p0 via static_path1 // - p1 via bgp_path{1,2} // - p2 via bgp_path2 // loc_rib: - // - p0 is empty + // - p0 via static_path1 (only path) // - p1 via bgp_path2 (local pref) // - p2 via bgp_path2 (only path) - let rib_in_paths = vec![]; - let loc_rib_paths = vec![]; + let rib_in_paths = vec![static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path1.clone(), bgp_path2.clone()]; let loc_rib_paths = vec![bgp_path2.clone()]; @@ -1647,17 +1612,17 @@ mod test { assert!(check_prefix_path(&db, &p2, rib_in_paths, loc_rib_paths)); // yank all routes from bgp_path2, simulating peer shutdown - // bgp_path1 should be unaffected, despite also having the same RID + // bgp_path2 should be unaffected, despite also having the same RID db.remove_bgp_prefixes_from_peer(&bgp_path2.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 is empty + // - p0 via static_path1 // - p1 via bgp_path1 // loc_rib: - // - p0 is empty + // - p0 via static_path1 (only path) // - p1 via bgp_path1 (only path) - let rib_in_paths = vec![]; - let loc_rib_paths = vec![]; + let rib_in_paths = vec![static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![bgp_path1.clone()]; let loc_rib_paths = vec![bgp_path1.clone()]; @@ -1667,15 +1632,15 @@ mod test { assert!(check_prefix_path(&db, &p2, rib_in_paths, loc_rib_paths)); // yank all routes from bgp_path1, simulating peer shutdown - // p0 should already be empty from earlier removal + // p0 should be unaffected, still retaining the static path db.remove_bgp_prefixes_from_peer(&bgp_path1.clone().bgp.unwrap().peer); // expected current state // rib_in: - // - p0 is empty + // - p0 via static_path1 // loc_rib: - // - p0 is empty - let rib_in_paths = vec![]; - let loc_rib_paths = vec![]; + // - p0 via static_path1 (only path) + let rib_in_paths = vec![static_path1.clone()]; + let loc_rib_paths = vec![static_path1.clone()]; assert!(check_prefix_path(&db, &p0, rib_in_paths, loc_rib_paths)); let rib_in_paths = vec![]; let loc_rib_paths = vec![]; @@ -1706,216 +1671,6 @@ mod test { assert!(db.loc_rib(None).is_empty()); } - #[test] - fn test_static_pathkey_coexistence() { - let db = get_test_db(); - let prefix = Prefix::from("192.168.1.0/24".parse::().unwrap()); - let nexthop = IpAddr::V4(Ipv4Addr::from_str("10.0.0.1").unwrap()); - - // Create two static routes with same prefix and nexthop but different vlan_id - // These should have different PathKeys and coexist in the RIB - let route_no_vlan = make_static_route( - prefix, - nexthop, - None, - DEFAULT_RIB_PRIORITY_STATIC, - ); - let route_with_vlan = make_static_route( - prefix, - nexthop, - Some(100), - DEFAULT_RIB_PRIORITY_STATIC, - ); - - // Add both routes - db.add_static_routes(&[route_no_vlan, route_with_vlan]) - .expect("Failed to add static routes"); - - // Verify both routes exist in rib_in (2 paths with different PathKeys) - let rib_in_paths = db.get_prefix_paths(&prefix); - assert_eq!( - rib_in_paths.len(), - 2, - "Expected 2 paths in rib_in (different vlan_id should not replace)" - ); - - // Verify both routes exist in loc_rib (with default max=1, only one should be selected) - let loc_rib_paths = db.get_selected_prefix_paths(&prefix); - assert_eq!( - loc_rib_paths.len(), - 1, - "With default bestpath max=1, only 1 should be selected" - ); - - // Now test with fanout=2 to allow both to be selected - db.set_bestpath_fanout(std::num::NonZeroU8::new(2).unwrap()) - .expect("Failed to set bestpath fanout"); - - let loc_rib_paths = db.get_selected_prefix_paths(&prefix); - assert_eq!( - loc_rib_paths.len(), - 2, - "With fanout=2, both paths should be in loc_rib" - ); - } - - #[test] - fn test_static_priority_update_affects_selection() { - let db = get_test_db(); - let prefix = Prefix::from("10.0.0.0/8".parse::().unwrap()); - let static_nexthop = - IpAddr::V4(Ipv4Addr::from_str("192.168.1.1").unwrap()); - let bgp_peer = IpAddr::V4(Ipv4Addr::from_str("203.0.113.1").unwrap()); - - // Add a static route with priority 1 (better than BGP default 20) - let static_route_priority_1 = - make_static_route(prefix, static_nexthop, None, 1); - db.add_static_routes(&[static_route_priority_1]) - .expect("Failed to add static route"); - - // Add a BGP route with priority 20 - let bgp_path = Path { - nexthop: bgp_peer, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as: 1111, - peer: bgp_peer, - id: 1111, - med: Some(0), - local_pref: Some(100), - as_path: vec![1111], - stale: None, - }), - vlan_id: None, - }; - db.add_bgp_prefixes(&[prefix], bgp_path.clone()); - - // Verify static route is selected (priority 1 < 20) - let loc_rib_paths = db.get_selected_prefix_paths(&prefix); - assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); - assert_eq!( - loc_rib_paths[0].nexthop, static_nexthop, - "Static route (priority 1) should be selected" - ); - - // Now update the static route with priority 30 (worse than BGP priority 20) - let static_route_priority_30 = - make_static_route(prefix, static_nexthop, None, 30); - db.add_static_routes(&[static_route_priority_30]) - .expect("Failed to update static route priority"); - - // Verify BGP route is now selected (priority 20 < 30) - let loc_rib_paths = db.get_selected_prefix_paths(&prefix); - assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); - assert_eq!( - loc_rib_paths[0].nexthop, bgp_peer, - "BGP route (priority 20) should now be selected" - ); - - // Update static route back to priority 1 - let static_route_priority_1_again = - make_static_route(prefix, static_nexthop, None, 1); - db.add_static_routes(&[static_route_priority_1_again]) - .expect("Failed to update static route priority back"); - - // Verify static route is selected again - let loc_rib_paths = db.get_selected_prefix_paths(&prefix); - assert_eq!(loc_rib_paths.len(), 1, "Should have 1 path selected"); - assert_eq!( - loc_rib_paths[0].nexthop, static_nexthop, - "Static route (priority 1) should be selected again" - ); - } - - #[test] - fn test_pathkey_identity_matrix() { - let db = get_test_db(); - let prefix = Prefix::from("10.0.0.0/8".parse::().unwrap()); - - // Create paths with all different PathKey combinations: - let peer1 = IpAddr::V4(Ipv4Addr::from_str("203.0.113.1").unwrap()); - let peer2 = IpAddr::V4(Ipv4Addr::from_str("203.0.113.2").unwrap()); - let nh1 = IpAddr::V4(Ipv4Addr::from_str("192.168.1.1").unwrap()); - let nh2 = IpAddr::V4(Ipv4Addr::from_str("192.168.1.2").unwrap()); - - // BGP path 1: peer1 - let bgp_path1 = Path { - nexthop: peer1, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as: 1111, - peer: peer1, - id: 1111, - med: Some(100), - local_pref: Some(100), - as_path: vec![1111], - stale: None, - }), - vlan_id: None, - }; - - // BGP path 2: peer2 (different PathKey than path1) - let bgp_path2 = Path { - nexthop: peer2, - rib_priority: DEFAULT_RIB_PRIORITY_BGP, - shutdown: false, - bgp: Some(BgpPathProperties { - origin_as: 2222, - peer: peer2, - id: 2222, - med: Some(100), - local_pref: Some(100), - as_path: vec![2222], - stale: None, - }), - vlan_id: None, - }; - - // Add BGP paths - db.add_bgp_prefixes(&[prefix], bgp_path1.clone()); - db.add_bgp_prefixes(&[prefix], bgp_path2.clone()); - - // Add static routes - let static_key1 = - make_static_route(prefix, nh1, None, DEFAULT_RIB_PRIORITY_STATIC); - let static_key2 = make_static_route( - prefix, - nh1, - Some(100), - DEFAULT_RIB_PRIORITY_STATIC, - ); - let static_key3 = - make_static_route(prefix, nh2, None, DEFAULT_RIB_PRIORITY_STATIC); - db.add_static_routes(&[static_key1, static_key2, static_key3]) - .expect("Failed to add static routes"); - - // Verify all 5 paths exist in rib_in (all different PathKeys) - let rib_in_paths = db.get_prefix_paths(&prefix); - assert_eq!( - rib_in_paths.len(), - 5, - "Expected all 5 paths with different PathKeys to coexist" - ); - - // Verify the paths have the expected nexthops - let nexthops: Vec = - rib_in_paths.iter().map(|p| p.nexthop).collect(); - assert!(nexthops.contains(&peer1)); - assert!(nexthops.contains(&peer2)); - assert!(nexthops.contains(&nh1)); - assert!(nexthops.contains(&nh2)); - - // Verify static routes are in storage - let stored_routes = db.get_static(Some(AddressFamily::Ipv4)).unwrap(); - assert_eq!( - stored_routes.len(), - 3, - "All 3 static routes should be stored" - ); - } - #[test] fn test_static_routing_ipv4_basic() { let db = get_test_db(); diff --git a/rdb/src/types.rs b/rdb/src/types.rs index d66c2f24..3454d694 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -44,22 +44,6 @@ pub struct Ipv4Marker; #[derive(Clone, Copy, Debug)] pub struct Ipv6Marker; -/// Uniquely identifies a path for deduplication. -/// Two paths with the same PathKey represent the same logical -/// path and should replace each other in the RIB. -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub enum PathKey { - /// BGP path identified by peer IP. - /// All paths from the same peer replace each other. - // XXX: Include AddPathId when AddPath is fully supported - Bgp(IpAddr), - /// Static route identified by (nexthop, vlan_id) tuple. - Static { - nexthop: IpAddr, - vlan_id: Option, - }, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] pub struct Path { pub nexthop: IpAddr, @@ -69,19 +53,6 @@ pub struct Path { pub vlan_id: Option, } -impl Path { - /// Returns the identity key for this path. - pub fn key(&self) -> PathKey { - match &self.bgp { - Some(bgp) => PathKey::Bgp(bgp.peer), - None => PathKey::Static { - nexthop: self.nexthop, - vlan_id: self.vlan_id, - }, - } - } -} - // Define a basic ordering on paths so bestpath selection is deterministic impl PartialOrd for Path { fn partial_cmp(&self, other: &Self) -> Option { @@ -90,6 +61,25 @@ impl PartialOrd for Path { } impl Ord for Path { fn cmp(&self, other: &Self) -> Ordering { + // Paths from the same source are considered equal for set membership. + // This enables BTreeSet::replace() to update paths from the same source. + // + // BGP paths: identified by peer IP + if let (Some(a), Some(b)) = (&self.bgp, &other.bgp) + && a.peer == b.peer + { + return Ordering::Equal; + } + + // Static paths: identified by (nexthop, vlan_id) + if self.bgp.is_none() + && other.bgp.is_none() + && self.nexthop == other.nexthop + && self.vlan_id == other.vlan_id + { + return Ordering::Equal; + } + if self.nexthop != other.nexthop { return self.nexthop.cmp(&other.nexthop); } @@ -398,13 +388,14 @@ pub struct BgpRouterInfo { #[derive( Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, )] -pub enum ImportExportPolicy { +#[schemars(rename = "ImportExportPolicy")] +pub enum ImportExportPolicyV1 { #[default] NoFiltering, Allow(BTreeSet), } -impl ImportExportPolicy { +impl ImportExportPolicyV1 { /// Extract IPv4 prefixes from this policy as a typed IPv4 policy. /// /// If this policy is `NoFiltering`, returns `ImportExportPolicy4::NoFiltering`. @@ -412,8 +403,10 @@ impl ImportExportPolicy { /// If the policy has prefixes but none are IPv4, returns `NoFiltering` for IPv4. pub fn as_ipv4_policy(&self) -> ImportExportPolicy4 { match self { - ImportExportPolicy::NoFiltering => ImportExportPolicy4::NoFiltering, - ImportExportPolicy::Allow(prefixes) => { + ImportExportPolicyV1::NoFiltering => { + ImportExportPolicy4::NoFiltering + } + ImportExportPolicyV1::Allow(prefixes) => { let v4_prefixes: BTreeSet = prefixes .iter() .filter_map(|p| match p { @@ -438,8 +431,10 @@ impl ImportExportPolicy { /// If the policy has prefixes but none are IPv6, returns `NoFiltering` for IPv6. pub fn as_ipv6_policy(&self) -> ImportExportPolicy6 { match self { - ImportExportPolicy::NoFiltering => ImportExportPolicy6::NoFiltering, - ImportExportPolicy::Allow(prefixes) => { + ImportExportPolicyV1::NoFiltering => { + ImportExportPolicy6::NoFiltering + } + ImportExportPolicyV1::Allow(prefixes) => { let v6_prefixes: BTreeSet = prefixes .iter() .filter_map(|p| match p { @@ -469,14 +464,14 @@ impl ImportExportPolicy { ( ImportExportPolicy4::NoFiltering, ImportExportPolicy6::NoFiltering, - ) => ImportExportPolicy::NoFiltering, + ) => ImportExportPolicyV1::NoFiltering, ( ImportExportPolicy4::Allow(v4_prefixes), ImportExportPolicy6::NoFiltering, ) => { let prefixes: BTreeSet = v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); - ImportExportPolicy::Allow(prefixes) + ImportExportPolicyV1::Allow(prefixes) } ( ImportExportPolicy4::NoFiltering, @@ -484,7 +479,7 @@ impl ImportExportPolicy { ) => { let prefixes: BTreeSet = v6_prefixes.iter().map(|p| Prefix::V6(*p)).collect(); - ImportExportPolicy::Allow(prefixes) + ImportExportPolicyV1::Allow(prefixes) } ( ImportExportPolicy4::Allow(v4_prefixes), @@ -493,7 +488,7 @@ impl ImportExportPolicy { let mut prefixes: BTreeSet = v4_prefixes.iter().map(|p| Prefix::V4(*p)).collect(); prefixes.extend(v6_prefixes.iter().map(|p| Prefix::V6(*p))); - ImportExportPolicy::Allow(prefixes) + ImportExportPolicyV1::Allow(prefixes) } } } @@ -522,7 +517,7 @@ pub enum ImportExportPolicy6 { /// Address-family-specific import/export policy wrapper for internal use. /// This is distinct from the API-facing `ImportExportPolicy` type. #[derive(Debug, Clone, Eq, PartialEq)] -pub enum TypedImportExportPolicy { +pub enum ImportExportPolicy { V4(ImportExportPolicy4), V6(ImportExportPolicy6), } @@ -642,3 +637,36 @@ impl Display for PrefixChangeNotification { write!(f, "PrefixChangeNotification [ {pcn}]") } } + +#[cfg(test)] +pub mod test_helpers { + use super::Path; + use std::collections::BTreeSet; + + /// Full structural equality for Path. + /// Compares ALL fields, unlike Ord which only compares identity. + /// + /// This exists because Path's Ord implementation treats paths from the + /// same source (same peer for BGP, same nexthop+vlan for static) as equal, + /// enabling BTreeSet::replace() semantics. But for tests, we often want + /// to verify all fields match exactly. + pub fn paths_equal(a: &Path, b: &Path) -> bool { + a.nexthop == b.nexthop + && a.shutdown == b.shutdown + && a.rib_priority == b.rib_priority + && a.vlan_id == b.vlan_id + && a.bgp == b.bgp + } + + /// Compare two BTreeSet using full structural equality. + pub fn path_sets_equal(a: &BTreeSet, b: &BTreeSet) -> bool { + a.len() == b.len() + && a.iter().zip(b.iter()).all(|(x, y)| paths_equal(x, y)) + } + + /// Compare two Vec or slices using full structural equality. + pub fn path_vecs_equal(a: &[Path], b: &[Path]) -> bool { + a.len() == b.len() + && a.iter().zip(b.iter()).all(|(x, y)| paths_equal(x, y)) + } +}