diff --git a/CHANGELOG.md b/CHANGELOG.md index db5ebcb7..6b8e2f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. ### Added +* **BGP Domain Path (D-PATH) attribute support** ([RFC 10039](https://datatracker.ietf.org/doc/html/rfc10039)): path attribute type 36 is now parsed into a typed model and re-encoded byte-identically instead of falling into the `Unknown` raw catch-all. `AttributeValue::DomainPath(DomainPathAttribute)` holds the sequence of domain segments, each a `DomainPathSegment` of `DomainPathDomain { global_admin, local_admin, isf_safi_type }` entries (Global/Local Administrator are reported as opaque unsigned integers per the RFC). Domain Segment Length counts domains, not octets. Malformed values — a zero-length domain segment, a value shorter than one segment (8 octets), or a segment that overruns the value — are rejected per RFC 10039 §4 and fall back to raw retention with an RFC 7606 validation warning; the encoder refuses segments that cannot be represented instead of emitting invalid wire bytes. Text rendering and byte-level dissection label the attribute `BGP_DOMAIN_PATH`. RFC 10039 §4 restricts D-PATH to UPDATEs that carry EVPN (AFI 25, SAFI 70) or IPVPN (AFI 1/2, SAFI 128) routes, so the announced family decides whether a D-PATH is allowed: any other family, classic IPv4 unicast announcement NLRI alongside an IPVPN MP_REACH_NLRI included, is reported as an RFC 7606 finding whose reason names the required treat-as-withdraw action. An MP_REACH_NLRI that does not decode is still judged by the AFI/SAFI header it starts with, and withdrawals never decide the family. * **`--color auto|always|never` for `--format text`**: colors session keys, section headers, prefixes, and next-hop values with ANSI accents that follow the terminal theme. `auto` (the default) colors only when stdout is a terminal; `NO_COLOR` disables coloring and `CLICOLOR_FORCE` forces it. Library callers opt in with `render::text::Style::ansi()` plus the new `format_record_with_style` / `format_record_with_hex_and_style`; `format_record` and the unstyled output are unchanged, since styling is a post-pass over the rendered block. ### Changed diff --git a/README.md b/README.md index 321996ab..bbaf63f3 100644 --- a/README.md +++ b/README.md @@ -853,6 +853,7 @@ Typed parsing for these RFC-defined BGP path attributes: - [RFC 9026](https://datatracker.ietf.org/doc/html/rfc9026): BFD Discriminator Attribute - [RFC 8669](https://datatracker.ietf.org/doc/html/rfc8669): BGP Prefix-SID Attribute - [RFC 9793](https://datatracker.ietf.org/doc/html/rfc9793): BGP Extensions for BIER +- [RFC 10039](https://datatracker.ietf.org/doc/html/rfc10039): BGP Domain Path (D-PATH) Attribute - [RFC 10005](https://datatracker.ietf.org/doc/html/rfc10005): BGP Link Bandwidth Extended Community Additional known attribute type codes are raw-retained (`AttributeValue::Raw`) and re-encoded faithfully. Deprecated and unassigned codes are also preserved. diff --git a/src/lib.rs b/src/lib.rs index 74711db1..78408ae7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -850,6 +850,7 @@ Typed parsing for these RFC-defined BGP path attributes: - [RFC 9026](https://datatracker.ietf.org/doc/html/rfc9026): BFD Discriminator Attribute - [RFC 8669](https://datatracker.ietf.org/doc/html/rfc8669): BGP Prefix-SID Attribute - [RFC 9793](https://datatracker.ietf.org/doc/html/rfc9793): BGP Extensions for BIER +- [RFC 10039](https://datatracker.ietf.org/doc/html/rfc10039): BGP Domain Path (D-PATH) Attribute - [RFC 10005](https://datatracker.ietf.org/doc/html/rfc10005): BGP Link Bandwidth Extended Community Additional known attribute type codes are raw-retained (`AttributeValue::Raw`) and re-encoded faithfully. Deprecated and unassigned codes are also preserved. diff --git a/src/models/bgp/attributes/mod.rs b/src/models/bgp/attributes/mod.rs index b4b24676..0292daac 100644 --- a/src/models/bgp/attributes/mod.rs +++ b/src/models/bgp/attributes/mod.rs @@ -93,6 +93,7 @@ pub enum AttrType { LARGE_COMMUNITIES = 32, BGPSEC_PATH = 33, ONLY_TO_CUSTOMER = 35, + BGP_DOMAIN_PATH = 36, SFP_ATTRIBUTE = 37, BFD_DISCRIMINATOR = 38, BGP_PREFIX_SID = 40, @@ -212,6 +213,35 @@ impl Attributes { } } + /// RFC 10039 §4 does not allow D-PATH on classic IPv4 unicast NLRI, which an UPDATE can + /// carry next to an IPVPN/EVPN MP_REACH_NLRI announcement. The UPDATE parser calls this + /// once the NLRI fields are parsed, because the attribute-level check cannot see them. + pub(crate) fn check_domain_path_with_classic_nlri(&mut self, has_classic_nlri: bool) { + if !has_classic_nlri { + return; + } + + let has_domain_path = self + .inner + .iter() + .any(|attribute| matches!(attribute.value, AttributeValue::DomainPath(_))); + let already_reported = self.validation_warnings.iter().any(|warning| { + matches!( + warning, + BgpValidationWarning::OptionalAttributeError { attr_type, .. } + if *attr_type == AttrType::BGP_DOMAIN_PATH + ) + }); + if !has_domain_path || already_reported { + return; + } + + self.add_validation_warning(BgpValidationWarning::OptionalAttributeError { + attr_type: AttrType::BGP_DOMAIN_PATH, + reason: "D-PATH is only valid on IPVPN or EVPN routes, this UPDATE also carries classic IPv4 unicast NLRI (AFI 1, SAFI 1): RFC 10039 §4 requires treat-as-withdraw".to_string(), + }); + } + /// Add a validation warning to the attributes pub fn add_validation_warning(&mut self, warning: BgpValidationWarning) { self.validation_warnings.push(warning); @@ -645,6 +675,40 @@ pub struct SfpAttribute { pub tlvs: Vec, } +/// BGP Domain Path (D-PATH) Attribute - RFC 10039 +/// +/// Optional transitive attribute listing the sequence of domains through +/// which an EVPN/IPVPN inter-subnet forwarding route has passed. +#[derive(Debug, PartialEq, Clone, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DomainPathAttribute { + pub segments: Vec, +} + +/// One domain segment of a BGP Domain Path (D-PATH) attribute - RFC 10039 +/// +/// RFC 10039 §4 requires every segment to contain at least one domain. +#[derive(Debug, PartialEq, Clone, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DomainPathSegment { + pub domains: Vec, +} + +/// One domain of a BGP Domain Path (D-PATH) attribute - RFC 10039 +/// +/// The Global/Local Administrator pair forms the 6-octet DOMAIN-ID; RFC 10039 +/// §4 recommends reporting both parts as opaque unsigned integers. +#[derive(Debug, PartialEq, Clone, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DomainPathDomain { + /// 4-octet Global Administrator (opaque; MAY carry an ASN or IPv4 address). + pub global_admin: u32, + /// 2-octet Local Administrator (opaque). + pub local_admin: u16, + /// 1-octet ISF_SAFI_TYPE (0 = gateway PE local ISF route, 70 = EVPN, 128 = IPVPN). + pub isf_safi_type: u8, +} + /// BGP Traffic Engineering Attribute - RFC 5543 #[derive(Debug, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -779,6 +843,9 @@ pub enum AttributeValue { /// SFP attribute - RFC 9015 #[cfg_attr(feature = "ts-rs", ts(type = "Record"))] Sfp(SfpAttribute), + /// BGP Domain Path (D-PATH) attribute - RFC 10039 + #[cfg_attr(feature = "ts-rs", ts(type = "Record"))] + DomainPath(DomainPathAttribute), Development(Vec), Raw(AttrRaw), Deprecated(AttrRaw), @@ -847,6 +914,7 @@ impl AttributeValue { AttributeValue::BgpPrefixSid(_) => AttrType::BGP_PREFIX_SID, AttributeValue::Bier(_) => AttrType::BIER, AttributeValue::Sfp(_) => AttrType::SFP_ATTRIBUTE, + AttributeValue::DomainPath(_) => AttrType::BGP_DOMAIN_PATH, AttributeValue::Development(_) => AttrType::DEVELOPMENT, AttributeValue::Raw(x) | AttributeValue::Deprecated(x) | AttributeValue::Unknown(x) => { x.attr_type() @@ -894,6 +962,7 @@ impl AttributeValue { AttributeValue::BgpPrefixSid(_) => Some(OptionalTransitive), AttributeValue::Bier(_) => Some(OptionalTransitive), AttributeValue::Sfp(_) => Some(OptionalTransitive), + AttributeValue::DomainPath(_) => Some(OptionalTransitive), AttributeValue::AttrSet(_) => Some(OptionalTransitive), _ => None, } @@ -1212,6 +1281,11 @@ mod tests { AttributeValue::Sfp(SfpAttribute { tlvs: vec![] }).attr_category(), Some(AttributeCategory::OptionalTransitive) ); + // BGP Domain Path (RFC 10039): Optional Transitive + assert_eq!( + AttributeValue::DomainPath(DomainPathAttribute { segments: vec![] }).attr_category(), + Some(AttributeCategory::OptionalTransitive) + ); } #[test] diff --git a/src/parser/bgp/attributes/README.md b/src/parser/bgp/attributes/README.md index f6383175..808ec9d5 100644 --- a/src/parser/bgp/attributes/README.md +++ b/src/parser/bgp/attributes/README.md @@ -26,6 +26,7 @@ | BIER | [RFC9793][rfc9793] | 41 | Yes | | Tunnel Encapsulation | [RFC9012][rfc9012] | 23 | Yes | | BGP Link-State | [RFC7752][rfc7752] | 29 | Yes | +| BGP Domain Path | [RFC10039][rfc10039] | 36 | Yes | ## Known Limitations @@ -60,4 +61,5 @@ [rfc9026]: https://datatracker.ietf.org/doc/html/rfc9026 [rfc9234]: https://datatracker.ietf.org/doc/html/rfc9234 [rfc9793]: https://datatracker.ietf.org/doc/html/rfc9793 +[rfc10039]: https://datatracker.ietf.org/doc/html/rfc10039 [iana-bgp]: https://www.iana.org/assignments/bgp-parameters/bgp-parameters.xhtml diff --git a/src/parser/bgp/attributes/attr_36_domain_path.rs b/src/parser/bgp/attributes/attr_36_domain_path.rs new file mode 100644 index 00000000..d5c3b61e --- /dev/null +++ b/src/parser/bgp/attributes/attr_36_domain_path.rs @@ -0,0 +1,537 @@ +use crate::error::{check_max, EncodingError}; +use crate::models::*; +use crate::parser::ReadUtils; +use crate::ParserError; +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +/// Octets per domain: 4-octet Global Administrator, 2-octet Local +/// Administrator, and 1-octet ISF_SAFI_TYPE (RFC 10039 §4). +const DOMAIN_OCTETS: usize = 7; + +/// Smallest valid D-PATH value: one domain segment holding one domain. +/// RFC 10039 §4 marks a total attribute length below 8 octets as malformed. +const MIN_VALUE_OCTETS: usize = 1 + DOMAIN_OCTETS; + +pub fn parse_domain_path(mut input: Bytes) -> Result { + if input.remaining() < MIN_VALUE_OCTETS { + return Err(ParserError::TruncatedMsg(format!( + "BGP Domain Path attribute value is {} octets, must be at least {MIN_VALUE_OCTETS}", + input.remaining() + ))); + } + + let mut segments = Vec::new(); + + while input.remaining() > 0 { + let domain_count = input.read_u8()? as usize; + // RFC 10039 §4: a domain segment MUST contain at least one domain. + if domain_count == 0 { + return Err(ParserError::ParseError( + "BGP Domain Path domain segment length must be at least 1".to_string(), + )); + } + let required = domain_count * DOMAIN_OCTETS; + if input.remaining() < required { + return Err(ParserError::TruncatedMsg(format!( + "truncated BGP Domain Path domain segment: need {required} octets for {domain_count} domains, have {}", + input.remaining() + ))); + } + + let mut domains = Vec::with_capacity(domain_count); + for _ in 0..domain_count { + let global_admin = input.read_u32()?; + let local_admin = input.read_u16()?; + let isf_safi_type = input.read_u8()?; + domains.push(DomainPathDomain { + global_admin, + local_admin, + isf_safi_type, + }); + } + segments.push(DomainPathSegment { domains }); + } + + Ok(AttributeValue::DomainPath(DomainPathAttribute { segments })) +} + +pub fn encode_domain_path( + attr: &DomainPathAttribute, + buf: &mut BytesMut, +) -> Result<(), EncodingError> { + if attr.segments.is_empty() { + return Err(EncodingError::unencodable( + "BGP Domain Path attribute", + "a domain path must contain at least one domain segment (RFC 10039)", + )); + } + for segment in &attr.segments { + if segment.domains.is_empty() { + return Err(EncodingError::unencodable( + "BGP Domain Path domain segment", + "a domain segment must contain at least 1 domain (RFC 10039)", + )); + } + // The segment length field counts domains, not octets (RFC 10039 §4). + check_max( + "BGP Domain Path segment domain count", + segment.domains.len(), + u8::MAX as usize, + )?; + buf.put_u8(segment.domains.len() as u8); + for domain in &segment.domains { + buf.put_u32(domain.global_admin); + buf.put_u16(domain.local_admin); + buf.put_u8(domain.isf_safi_type); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::BgpValidationWarning; + + #[test] + fn test_parse_domain_path_single_segment_single_domain_round_trip() { + let input = Bytes::from_static(&[ + 0x01, // Domain Segment Length: 1 domain + 0x00, 0x00, 0xFD, 0xE8, // Global Administrator: ASN 65000 + 0x00, 0x01, // Local Administrator: 1 + 0x46, // ISF_SAFI_TYPE: 70 (EVPN) + ]); + let value = parse_domain_path(input.clone()).unwrap(); + match value { + AttributeValue::DomainPath(attr) => { + assert_eq!(attr.segments.len(), 1); + assert_eq!(attr.segments[0].domains.len(), 1); + assert_eq!( + attr.segments[0].domains[0], + DomainPathDomain { + global_admin: 65000, + local_admin: 1, + isf_safi_type: 70, + } + ); + let mut buf = BytesMut::new(); + encode_domain_path(&attr, &mut buf).unwrap(); + assert_eq!(buf.freeze(), input); + } + value => panic!("expected BGP Domain Path, got {value:?}"), + } + } + + #[test] + fn test_parse_domain_path_multi_segment_multi_domain_round_trip() { + let input = Bytes::from_static(&[ + 0x02, // Domain Segment Length: 2 domains + 0x00, 0x00, 0x00, 0x0A, 0x00, 0x02, 0x80, // ASN 10 / 2 / 128 (IPVPN) + 0x00, 0x00, 0x00, 0x0B, 0x00, 0x03, 0x46, // ASN 11 / 3 / 70 (EVPN) + 0x01, // Domain Segment Length: 1 domain + 0x00, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x00, // ASN 12 / 4 / 0 (gateway local) + ]); + let value = parse_domain_path(input.clone()).unwrap(); + match value { + AttributeValue::DomainPath(attr) => { + assert_eq!(attr.segments.len(), 2); + assert_eq!(attr.segments[0].domains.len(), 2); + assert_eq!(attr.segments[1].domains.len(), 1); + assert_eq!( + attr.segments[0].domains[1], + DomainPathDomain { + global_admin: 11, + local_admin: 3, + isf_safi_type: 70, + } + ); + assert_eq!( + attr.segments[1].domains[0], + DomainPathDomain { + global_admin: 12, + local_admin: 4, + isf_safi_type: 0, + } + ); + let mut buf = BytesMut::new(); + encode_domain_path(&attr, &mut buf).unwrap(); + assert_eq!(buf.freeze(), input); + } + value => panic!("expected BGP Domain Path, got {value:?}"), + } + } + + #[test] + fn test_parse_domain_path_rejects_truncated_domain() { + // The segment claims 2 domains (14 octets), but only 7 follow. + let input = Bytes::from_static(&[ + 0x02, // + 0x00, 0x00, 0x00, 0x0A, 0x00, 0x02, 0x80, + ]); + assert!(matches!( + parse_domain_path(input), + Err(ParserError::TruncatedMsg(_)) + )); + } + + #[test] + fn test_parse_domain_path_rejects_short_value() { + // Fewer than 8 octets cannot hold a single domain (RFC 10039 §4). + assert!(parse_domain_path(Bytes::new()).is_err()); + assert!(parse_domain_path(Bytes::from_static(&[0x01])).is_err()); + assert!(parse_domain_path(Bytes::from_static(&[ + 0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x02 + ])) + .is_err()); + } + + #[test] + fn test_parse_domain_path_rejects_zero_length_segment() { + // Passes the minimum-length check, but a segment of zero domains is + // malformed per RFC 10039 §4 (MUST be >= 1). + let input = Bytes::from_static(&[ + 0x00, // + 0x00, 0x00, 0x00, 0x0A, 0x00, 0x02, 0x80, + ]); + assert!(matches!( + parse_domain_path(input), + Err(ParserError::ParseError(_)) + )); + } + + #[test] + fn test_encode_domain_path_rejects_oversized_segment() { + // 256 domains cannot be counted by the 1-octet segment length. + let attr = DomainPathAttribute { + segments: vec![DomainPathSegment { + domains: (0..256) + .map(|i| DomainPathDomain { + global_admin: i as u32, + local_admin: 0, + isf_safi_type: 0, + }) + .collect(), + }], + }; + let mut buf = BytesMut::new(); + let err = encode_domain_path(&attr, &mut buf).unwrap_err(); + assert_eq!( + err, + EncodingError::ValueTooLarge { + field: "BGP Domain Path segment domain count", + actual: 256, + max: 255, + } + ); + } + + #[test] + fn test_encode_domain_path_rejects_empty_path_or_segment() { + // Zero segments (a sub-8-octet value) and zero-length segments are + // both malformed per RFC 10039 §4; the encoder refuses to emit them. + let attr = DomainPathAttribute { segments: vec![] }; + let mut buf = BytesMut::new(); + assert!(matches!( + encode_domain_path(&attr, &mut buf), + Err(EncodingError::Unencodable { .. }) + )); + + let attr = DomainPathAttribute { + segments: vec![DomainPathSegment { domains: vec![] }], + }; + let mut buf = BytesMut::new(); + assert!(matches!( + encode_domain_path(&attr, &mut buf), + Err(EncodingError::Unencodable { .. }) + )); + } + + #[test] + fn test_malformed_domain_path_falls_back_to_raw() { + // A declared length below one domain segment is malformed; through + // parse_attributes the bytes must be retained raw with an RFC 7606 + // warning instead of being dropped. + let wire = vec![0xc0, 0x24, 0x02, 0x00, 0x00]; + let attributes = super::super::parse_attributes( + Bytes::from(wire.clone()), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + assert_eq!(attributes.inner.len(), 1); + match &attributes.inner[0].value { + AttributeValue::Raw(raw) => { + assert_eq!(raw.code, 36); + assert_eq!(raw.bytes, Bytes::from_static(&[0x00, 0x00])); + } + value => panic!("expected Raw fallback, got {value:?}"), + } + assert!(attributes.validation_warnings().iter().any(|w| matches!( + w, + BgpValidationWarning::OptionalAttributeError { attr_type, .. } + if *attr_type == AttrType::BGP_DOMAIN_PATH + ))); + assert_eq!( + attributes.encode(AsnLength::Bits16).unwrap(), + Bytes::from(wire) + ); + } + + /// A well-formed D-PATH: one segment, one domain (ASN 65000, local 1, EVPN). + const DPATH: [u8; 11] = [ + 0xc0, 0x24, 0x08, // optional transitive, type 36, length 8 + 0x01, // domain segment length: 1 domain + 0x00, 0x00, 0xFD, 0xE8, // Global Administrator: ASN 65000 + 0x00, 0x01, // Local Administrator: 1 + 0x46, // ISF_SAFI_TYPE: 70 (EVPN) + ]; + + /// A BGP UPDATE body carrying the given attribute bytes, announced NLRI, and withdrawn NLRI. + fn update_bytes(attributes: &[u8], announced: &[u8], withdrawn: &[u8]) -> Vec { + let mut wire = Vec::new(); + wire.extend_from_slice(&(withdrawn.len() as u16).to_be_bytes()); + wire.extend_from_slice(withdrawn); + wire.extend_from_slice(&(attributes.len() as u16).to_be_bytes()); + wire.extend_from_slice(attributes); + wire.extend_from_slice(announced); + wire + } + + /// An MP_REACH_NLRI attribute carrying the given value bytes. + fn mp_reach(value: &[u8]) -> Vec { + let mut bytes = vec![0x80, 0x0e, value.len() as u8]; + bytes.extend_from_slice(value); + bytes + } + + /// An MP_UNREACH_NLRI attribute carrying the given value bytes. + fn mp_unreach(value: &[u8]) -> Vec { + let mut bytes = vec![0x80, 0x0f, value.len() as u8]; + bytes.extend_from_slice(value); + bytes + } + + /// IPv4 next hop, no NLRI: an IPVPN (AFI 1, SAFI 128) MP_REACH value. + const IPVPN_REACH: [u8; 9] = [0x00, 0x01, 0x80, 0x04, 0xc0, 0x00, 0x02, 0x01, 0x00]; + + fn family_warning(attributes: &Attributes) -> Option { + attributes + .validation_warnings() + .iter() + .find_map(|warning| match warning { + BgpValidationWarning::OptionalAttributeError { attr_type, reason } + if *attr_type == AttrType::BGP_DOMAIN_PATH + && reason.contains("treat-as-withdraw") => + { + Some(reason.clone()) + } + _ => None, + }) + } + + #[test] + fn test_domain_path_without_family_context_is_not_flagged() { + // an attribute set on its own does not reveal the family: IPv4 unicast NLRI are parsed + // after the attributes, and the UPDATE parser reports them once it has seen them + let attributes = super::super::parse_attributes( + Bytes::from(DPATH.to_vec()), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + assert!(family_warning(&attributes).is_none()); + } + + #[test] + fn test_domain_path_with_unicast_mp_reach_is_flagged() { + // an IPv4 unicast MP_REACH is decoded, so the family is known and not IPVPN/EVPN + let mut wire = DPATH.to_vec(); + wire.extend(mp_reach(&[ + 0x00, 0x01, // AFI: IPv4 + 0x01, // SAFI: unicast + 0x04, // next hop length + 0xc0, 0x00, 0x02, 0x01, // next hop: 192.0.2.1 + 0x00, // reserved + 0x18, 0xc0, 0x00, 0x02, // NLRI: 192.0.2.0/24 + ])); + let attributes = super::super::parse_attributes( + Bytes::from(wire), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + assert!(family_warning(&attributes).is_some()); + } + + #[test] + fn test_domain_path_on_ipvpn_is_allowed() { + let attributes = super::super::parse_attributes( + Bytes::from(DPATH.to_vec()), + &AsnLength::Bits16, + false, + Some(Afi::Ipv4), + Some(Safi::MplsVpn), + None, + ) + .unwrap(); + + assert!(family_warning(&attributes).is_none()); + } + + #[test] + fn test_domain_path_with_evpn_mp_reach_is_not_flagged() { + // EVPN is AFI 25 with SAFI 70: the NLRI do not decode yet, but the header still + // identifies a family D-PATH is allowed on + let mut wire = DPATH.to_vec(); + wire.extend(mp_reach(&[ + 0x00, 0x19, // AFI: L2VPN (25) + 0x46, // SAFI: EVPN (70) + 0x10, // next hop length: 16 + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, // reserved + ])); + let attributes = super::super::parse_attributes( + Bytes::from(wire), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + assert!(family_warning(&attributes).is_none()); + } + + #[test] + fn test_domain_path_with_mismatched_evpn_safi_is_flagged() { + // SAFI 70 is EVPN only together with AFI 25, so this combination stays unsupported + // even though the MP_REACH attribute itself does not decode + let mut wire = DPATH.to_vec(); + wire.extend(mp_reach(&[ + 0x00, 0x02, // AFI: IPv6 + 0x46, // SAFI: EVPN (70) + 0x10, // next hop length: 16 + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, // reserved + ])); + let attributes = super::super::parse_attributes( + Bytes::from(wire), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + let reason = family_warning(&attributes).expect("a mismatched EVPN SAFI must be reported"); + assert!(reason.contains("SAFI 70"), "{reason}"); + } + + #[test] + fn test_domain_path_with_mpls_vpn_on_a_non_ip_afi_is_flagged() { + // IPVPN is IPv4/IPv6 with SAFI 128; the same SAFI under another AFI is not IPVPN + let attributes = super::super::parse_attributes( + Bytes::from(DPATH.to_vec()), + &AsnLength::Bits16, + false, + Some(Afi::LinkState), + Some(Safi::MplsVpn), + None, + ) + .unwrap(); + + assert!(family_warning(&attributes).is_some()); + } + + #[test] + fn test_domain_path_with_isf_announcement_and_unicast_withdrawal_is_not_flagged() { + // the announced family decides: a unicast withdrawal next to an IPVPN announcement + // must not produce a finding, in either attribute order + let reach = mp_reach(&IPVPN_REACH); + let unreach = mp_unreach(&[0x00, 0x01, 0x01]); // AFI IPv4, SAFI unicast + + for (first, second) in [(&reach, &unreach), (&unreach, &reach)] { + let mut wire = DPATH.to_vec(); + wire.extend_from_slice(first); + wire.extend_from_slice(second); + let attributes = super::super::parse_attributes( + Bytes::from(wire), + &AsnLength::Bits16, + false, + None, + None, + None, + ) + .unwrap(); + + assert!( + attributes + .inner + .iter() + .any(|a| matches!(a.value, AttributeValue::MpReachNlri(_))), + "the IPVPN MP_REACH must decode for this test to mean anything" + ); + assert!(family_warning(&attributes).is_none()); + } + } + + #[test] + fn test_domain_path_with_classic_nlri_beside_ipvpn_announcement_is_flagged() { + // an UPDATE may announce IPVPN routes through MP_REACH and classic IPv4 unicast NLRI, + // which the attribute-level check cannot see + let mut attributes = DPATH.to_vec(); + attributes.extend(mp_reach(&IPVPN_REACH)); + + let update = crate::parser::bgp::messages::parse_bgp_update_message( + Bytes::from(update_bytes(&attributes, &[0x18, 0xc0, 0x00, 0x02], &[])), + false, + &AsnLength::Bits16, + ) + .unwrap(); + + assert_eq!(update.announced_prefixes.len(), 1); + assert!(family_warning(&update.attributes).is_some()); + } + + #[test] + fn test_domain_path_with_classic_announcement_is_flagged() { + let update = crate::parser::bgp::messages::parse_bgp_update_message( + Bytes::from(update_bytes(&DPATH, &[0x18, 0xc0, 0x00, 0x02], &[])), + false, + &AsnLength::Bits16, + ) + .unwrap(); + + assert_eq!(update.announced_prefixes.len(), 1); + assert!(family_warning(&update.attributes).is_some()); + } + + #[test] + fn test_domain_path_with_classic_withdrawal_only_is_not_flagged() { + // withdrawals carry no attributes of the routes they remove, so a D-PATH next to them + // is not evidence of a non-ISF announcement + let update = crate::parser::bgp::messages::parse_bgp_update_message( + Bytes::from(update_bytes(&DPATH, &[], &[0x18, 0xc0, 0x00, 0x02])), + false, + &AsnLength::Bits16, + ) + .unwrap(); + + assert_eq!(update.withdrawn_prefixes.len(), 1); + assert!(family_warning(&update.attributes).is_none()); + } +} diff --git a/src/parser/bgp/attributes/mod.rs b/src/parser/bgp/attributes/mod.rs index 7f739775..2accef7d 100644 --- a/src/parser/bgp/attributes/mod.rs +++ b/src/parser/bgp/attributes/mod.rs @@ -15,6 +15,7 @@ mod attr_26_aigp; mod attr_29_linkstate; mod attr_32_large_communities; mod attr_35_otc; +mod attr_36_domain_path; mod attr_37_sfp; mod attr_38_bfd_discriminator; mod attr_40_bgp_prefix_sid; @@ -64,6 +65,7 @@ use crate::parser::bgp::attributes::attr_32_large_communities::{ use crate::parser::bgp::attributes::attr_35_otc::{ encode_only_to_customer, parse_only_to_customer, }; +use crate::parser::bgp::attributes::attr_36_domain_path::{encode_domain_path, parse_domain_path}; use crate::parser::bgp::attributes::attr_37_sfp::{encode_sfp, parse_sfp}; use crate::parser::bgp::attributes::attr_38_bfd_discriminator::{ encode_bfd_discriminator, parse_bfd_discriminator, @@ -100,7 +102,8 @@ fn validate_attribute_flags( | AttrType::EXTENDED_COMMUNITIES | AttrType::IPV6_ADDRESS_SPECIFIC_EXTENDED_COMMUNITIES | AttrType::LARGE_COMMUNITIES - | AttrType::ONLY_TO_CUSTOMER => AttrFlags::OPTIONAL | AttrFlags::TRANSITIVE, + | AttrType::ONLY_TO_CUSTOMER + | AttrType::BGP_DOMAIN_PATH => AttrFlags::OPTIONAL | AttrFlags::TRANSITIVE, // LOCAL_PREFERENCE is well-known mandatory for IBGP AttrType::LOCAL_PREFERENCE => AttrFlags::TRANSITIVE, // Unknown or development attributes @@ -319,6 +322,117 @@ fn validate_attribute_length( } } +/// Whether RFC 10039 §4 allows the D-PATH attribute on routes of this family: IPVPN, which is +/// IPv4 or IPv6 with SAFI 128, and EVPN, which is AFI 25 with SAFI 70. +fn is_domain_path_family(afi: u16, safi: u8) -> bool { + matches!((afi, safi), (1 | 2, 128) | (25, 70)) +} + +/// The AFI/SAFI header an MP_REACH_NLRI attribute value starts with, when it is long enough to +/// carry one. The header identifies the family even when the rest did not decode. +fn mp_reach_family(bytes: &[u8]) -> Option<(u16, u8)> { + match bytes.len() >= 3 { + true => Some((u16::from_be_bytes([bytes[0], bytes[1]]), bytes[2])), + false => None, + } +} + +/// The route families a D-PATH-carrying message carries, as far as the caller can tell. +#[derive(Debug, Clone, Copy, Default)] +struct DomainPathFamilies { + /// Family the caller already knows, e.g. a RIB entry from its record header. + declared: Option<(u16, u8)>, + /// Family of the routes an MP_REACH_NLRI announces, decoded or read off its header. + announced: Option<(u16, u8)>, + /// Classic IPv4 unicast NLRI, which the UPDATE parser adds once it has parsed them. + classic_ipv4: bool, +} + +impl DomainPathFamilies { + /// The families visible in an attribute set on its own, before any NLRI is parsed. An + /// MP_UNREACH_NLRI announces nothing, so it says nothing about the family here. An + /// MP_REACH_NLRI whose NLRI did not decode still identifies its family through the AFI/SAFI + /// header it starts with, which is enough to judge it. + fn from_attributes(attributes: &[Attribute], declared: Option<(u16, u8)>) -> Self { + let announced = attributes + .iter() + .find_map(|attribute| match &attribute.value { + AttributeValue::MpReachNlri(nlri) => Some((nlri.afi as u16, nlri.safi as u8)), + _ => None, + }) + .or_else(|| { + attributes + .iter() + .find_map(|attribute| match &attribute.value { + AttributeValue::Raw(raw) | AttributeValue::Unknown(raw) + if raw.code == u8::from(AttrType::MP_REACHABLE_NLRI) => + { + mp_reach_family(&raw.bytes) + } + _ => None, + }) + }); + + DomainPathFamilies { + declared, + announced, + classic_ipv4: false, + } + } + + /// Describe a carried family that RFC 10039 §4 does not allow D-PATH on, if there is one. + /// + /// The rule is about the routes a message carries, so a decoded announcement decides the + /// answer and a withdrawal never introduces one. A family this attribute set does not + /// reveal stays unknown: the UPDATE parser reports classic IPv4 unicast NLRI once it has + /// parsed them, and a caller that knows the family up front passes it in. + fn disallowed(&self) -> Option { + let declared = self + .declared + .filter(|(afi, safi)| !is_domain_path_family(*afi, *safi)); + if let Some((afi, safi)) = declared { + return Some(describe_family(afi, safi, "declared")); + } + + if let Some((afi, safi)) = self + .announced + .filter(|(afi, safi)| !is_domain_path_family(*afi, *safi)) + { + return Some(describe_family(afi, safi, "announced")); + } + + if self.classic_ipv4 { + return Some("classic IPv4 unicast NLRI (AFI 1, SAFI 1)".to_string()); + } + + None + } +} + +fn describe_family(afi: u16, safi: u8, source: &str) -> String { + match (afi, safi) { + // a mismatched EVPN SAFI is worth naming, since EVPN is AFI 25 with SAFI 70 + (_, 70) if afi != 25 => { + format!("{source} AFI {afi} routes with SAFI 70 (EVPN requires AFI 25)") + } + _ => format!("{source} AFI {afi} routes with SAFI {safi}"), + } +} + +/// RFC 10039 §4 limits D-PATH to UPDATEs that carry IPVPN or EVPN routes and requires the +/// treat-as-withdraw action for any other family, so report the finding when a carried family +/// is known to be something else. +fn domain_path_family_warning(families: DomainPathFamilies) -> Option { + families.disallowed().map(|family| { + BgpValidationWarning::OptionalAttributeError { + attr_type: AttrType::BGP_DOMAIN_PATH, + reason: format!( + "D-PATH is only valid on IPVPN (AFI 1/2, SAFI 128) or EVPN (AFI 25, SAFI 70) routes, this UPDATE carries {family}: RFC 10039 §4 requires treat-as-withdraw" + ), + } + }) +} + /// Parse BGP attributes given a slice of u8 and some options. /// /// The `data: &[u8]` contains the entirety of the attributes bytes, therefore the size of @@ -453,6 +567,7 @@ pub fn parse_attributes( AttrType::TUNNEL_ENCAPSULATION => parse_tunnel_encapsulation_attribute(attr_data), AttrType::TRAFFIC_ENGINEERING => parse_traffic_engineering(attr_data), AttrType::BGP_LS_ATTRIBUTE => parse_link_state_attribute(attr_data), + AttrType::BGP_DOMAIN_PATH => parse_domain_path(attr_data), AttrType::SFP_ATTRIBUTE => parse_sfp(attr_data), AttrType::BFD_DISCRIMINATOR => parse_bfd_discriminator(attr_data), AttrType::BGP_PREFIX_SID => parse_bgp_prefix_sid(attr_data), @@ -481,7 +596,18 @@ pub fn parse_attributes( }; } - let (validation_warnings, attr_mask) = validation.finish(); + let (mut validation_warnings, attr_mask) = validation.finish(); + if attributes + .iter() + .any(|attribute| matches!(attribute.value, AttributeValue::DomainPath(_))) + { + let declared = afi.zip(safi).map(|(afi, safi)| (afi as u16, safi as u8)); + let families = DomainPathFamilies::from_attributes(&attributes, declared); + if let Some(warning) = domain_path_family_warning(families) { + validation_warnings.push(warning); + } + } + Ok(Attributes { inner: attributes, validation_warnings, @@ -555,6 +681,7 @@ impl Attribute { encode_tunnel_encapsulation_attribute(v, b)? } AttributeValue::TrafficEngineering(v) => encode_traffic_engineering(v, b)?, + AttributeValue::DomainPath(v) => encode_domain_path(v, b)?, AttributeValue::BfdDiscriminator(v) => encode_bfd_discriminator(v, b)?, AttributeValue::BgpPrefixSid(v) => encode_bgp_prefix_sid(v, b)?, AttributeValue::Bier(v) => encode_bier(v, b)?, @@ -962,6 +1089,15 @@ mod tests { "BIER", ), (vec![0xc0, 0x25, 0x05, 0x7f, 0x00, 0x02, 0xde, 0xad], "SFP"), + ( + vec![ + 0xc0, 0x24, 0x0f, // BGP Domain Path, 15-octet value + 0x02, // segment: 2 domains + 0x00, 0x00, 0x00, 0x0A, 0x00, 0x02, 0x80, // ASN 10 / 2 / 128 + 0x00, 0x00, 0x00, 0x0B, 0x00, 0x03, 0x46, // ASN 11 / 3 / 70 + ], + "BGP Domain Path", + ), ]; for (wire, name) in cases { @@ -973,7 +1109,8 @@ mod tests { ("BFD Discriminator", AttributeValue::BfdDiscriminator(_)) | ("BGP Prefix-SID", AttributeValue::BgpPrefixSid(_)) | ("BIER", AttributeValue::Bier(_)) - | ("SFP", AttributeValue::Sfp(_)) => {} + | ("SFP", AttributeValue::Sfp(_)) + | ("BGP Domain Path", AttributeValue::DomainPath(_)) => {} (_, value) => panic!("unexpected value for {name}: {value:?}"), } assert_eq!( diff --git a/src/parser/bgp/dissect.rs b/src/parser/bgp/dissect.rs index 5d9ce19c..a90467e0 100644 --- a/src/parser/bgp/dissect.rs +++ b/src/parser/bgp/dissect.rs @@ -39,6 +39,7 @@ fn attr_name(code: u8) -> &'static str { 32 => "LARGE_COMMUNITY", 33 => "BGPSEC_PATH", 35 => "ONLY_TO_CUSTOMER", + 36 => "BGP_DOMAIN_PATH", 37 => "SFP", 38 => "BFD_DISCRIMINATOR", 40 => "BGP_PREFIX_SID", diff --git a/src/parser/bgp/messages.rs b/src/parser/bgp/messages.rs index 3d63a3de..a205d89c 100644 --- a/src/parser/bgp/messages.rs +++ b/src/parser/bgp/messages.rs @@ -630,6 +630,10 @@ pub fn parse_bgp_update_message( announced_bytes_present || attributes.has_attr(AttrType::MP_REACHABLE_NLRI); let has_standard_nlri = announced_bytes_present; attributes.check_mandatory_attributes(is_announcement, has_standard_nlri); + // an UPDATE can announce classic IPv4 unicast NLRI next to an MP_REACH_NLRI that carries + // IPVPN or EVPN routes; D-PATH is invalid on those classic announcements either way, and + // withdrawals carry no such attributes to judge (RFC 10039 §4) + attributes.check_domain_path_with_classic_nlri(has_standard_nlri); // Attach NLRI parse warnings (RFC 7606 §5.3 treat-as-withdrawal evidence) if let Some(w) = withdrawn_nlri_error { diff --git a/src/parser/mrt/messages/table_dump.rs b/src/parser/mrt/messages/table_dump.rs index 8ede5d32..528e20bc 100644 --- a/src/parser/mrt/messages/table_dump.rs +++ b/src/parser/mrt/messages/table_dump.rs @@ -143,9 +143,16 @@ fn parse_table_dump_entry( data.has_n_remaining(attribute_length)?; let attr_data_slice = data.split_to(attribute_length); - // for TABLE_DUMP type, the AS number length is always 2-byte. - let mut attributes = - parse_attributes(attr_data_slice, &AsnLength::Bits16, false, None, None, None)?; + // for TABLE_DUMP type, the AS number length is always 2-byte. The element type already + // fixed the family: IPv4 (subtype 1) or IPv6 (subtype 2), both unicast. + let mut attributes = parse_attributes( + attr_data_slice, + &AsnLength::Bits16, + false, + Some(*afi), + Some(Safi::Unicast), + None, + )?; // validate mandatory attributes (TABLE_DUMP is always an announcement) attributes.check_mandatory_attributes(true, *afi == Afi::Ipv4); @@ -486,4 +493,36 @@ mod tests { assert!(needs_legacy_length_correction(1, &wire[..wire.len() - 4])); assert!(!needs_legacy_length_correction(1, &wire[..wire.len() - 3])); } + #[test] + fn test_table_dump_entry_domain_path_is_validated_against_its_family() { + // TABLE_DUMP v1 entries are IPv4 or IPv6 unicast, so a D-PATH on one is invalid + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_be_bytes()); // view number + data.extend_from_slice(&0u16.to_be_bytes()); // sequence number + data.extend_from_slice(&[192, 0, 2, 0]); // prefix address 192.0.2.0 + data.push(24); // prefix length + data.push(1); // status + data.extend_from_slice(&0u32.to_be_bytes()); // originated time + data.extend_from_slice(&[10, 0, 0, 1]); // peer address + data.extend_from_slice(&65001u16.to_be_bytes()); // peer ASN + let attributes = [ + 0xc0, 0x24, 0x08, 0x01, 0x00, 0x00, 0xfd, 0xe8, 0x00, 0x01, 0x46, + ]; + data.extend_from_slice(&(attributes.len() as u16).to_be_bytes()); + data.extend_from_slice(&attributes); + + let message = parse_table_dump_message(1, Bytes::from(data)).unwrap(); + + assert!(message + .attributes + .validation_warnings() + .iter() + .any(|warning| { + matches!( + warning, + crate::error::BgpValidationWarning::OptionalAttributeError { attr_type, .. } + if *attr_type == crate::models::AttrType::BGP_DOMAIN_PATH + ) + })); + } } diff --git a/src/parser/mrt/mrt_elem.rs b/src/parser/mrt/mrt_elem.rs index 50802f55..2be39518 100644 --- a/src/parser/mrt/mrt_elem.rs +++ b/src/parser/mrt/mrt_elem.rs @@ -135,6 +135,7 @@ fn get_relevant_attributes( | AttributeValue::TunnelEncapsulation(_) | AttributeValue::TrafficEngineering(_) | AttributeValue::Aigp(_) + | AttributeValue::DomainPath(_) | AttributeValue::BfdDiscriminator(_) | AttributeValue::BgpPrefixSid(_) | AttributeValue::Bier(_) diff --git a/src/render/text.rs b/src/render/text.rs index 23d7bf31..0fe5769e 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -489,6 +489,7 @@ fn render_attribute(value: &AttributeValue) -> Option { } AttributeValue::Aigp(v) => format!("AIGP: {v:?}"), AttributeValue::BfdDiscriminator(v) => format!("BFD_DISCRIMINATOR: {v:?}"), + AttributeValue::DomainPath(v) => format!("BGP_DOMAIN_PATH: {v:?}"), AttributeValue::TrafficEngineering(v) => format!("TRAFFIC_ENGINEERING: {v:?}"), AttributeValue::TunnelEncapsulation(v) => format!("TUNNEL_ENCAPSULATION: {v:?}"), AttributeValue::LinkState(v) => format!("BGP_LS: {v:?}"), diff --git a/src/wasm/js/generated/AttrType.ts b/src/wasm/js/generated/AttrType.ts index 5cacaf7d..e4845237 100644 --- a/src/wasm/js/generated/AttrType.ts +++ b/src/wasm/js/generated/AttrType.ts @@ -7,4 +7,4 @@ * To see the full list, check out IANA at: * */ -export type AttrType = "RESERVED" | "ORIGIN" | "AS_PATH" | "NEXT_HOP" | "MULTI_EXIT_DISCRIMINATOR" | "LOCAL_PREFERENCE" | "ATOMIC_AGGREGATE" | "AGGREGATOR" | "COMMUNITIES" | "ORIGINATOR_ID" | "CLUSTER_LIST" | "MP_REACHABLE_NLRI" | "MP_UNREACHABLE_NLRI" | "EXTENDED_COMMUNITIES" | "AS4_PATH" | "AS4_AGGREGATOR" | "PMSI_TUNNEL" | "TUNNEL_ENCAPSULATION" | "TRAFFIC_ENGINEERING" | "IPV6_ADDRESS_SPECIFIC_EXTENDED_COMMUNITIES" | "AIGP" | "PE_DISTINGUISHER_LABELS" | "BGP_LS_ATTRIBUTE" | "LARGE_COMMUNITIES" | "BGPSEC_PATH" | "ONLY_TO_CUSTOMER" | "SFP_ATTRIBUTE" | "BFD_DISCRIMINATOR" | "BGP_PREFIX_SID" | "BIER" | "ATTR_SET" | "DEVELOPMENT" | { "Unknown": number }; +export type AttrType = "RESERVED" | "ORIGIN" | "AS_PATH" | "NEXT_HOP" | "MULTI_EXIT_DISCRIMINATOR" | "LOCAL_PREFERENCE" | "ATOMIC_AGGREGATE" | "AGGREGATOR" | "COMMUNITIES" | "ORIGINATOR_ID" | "CLUSTER_LIST" | "MP_REACHABLE_NLRI" | "MP_UNREACHABLE_NLRI" | "EXTENDED_COMMUNITIES" | "AS4_PATH" | "AS4_AGGREGATOR" | "PMSI_TUNNEL" | "TUNNEL_ENCAPSULATION" | "TRAFFIC_ENGINEERING" | "IPV6_ADDRESS_SPECIFIC_EXTENDED_COMMUNITIES" | "AIGP" | "PE_DISTINGUISHER_LABELS" | "BGP_LS_ATTRIBUTE" | "LARGE_COMMUNITIES" | "BGPSEC_PATH" | "ONLY_TO_CUSTOMER" | "BGP_DOMAIN_PATH" | "SFP_ATTRIBUTE" | "BFD_DISCRIMINATOR" | "BGP_PREFIX_SID" | "BIER" | "ATTR_SET" | "DEVELOPMENT" | { "Unknown": number }; diff --git a/src/wasm/js/generated/AttributeValue.ts b/src/wasm/js/generated/AttributeValue.ts index a74f7059..c78fb258 100644 --- a/src/wasm/js/generated/AttributeValue.ts +++ b/src/wasm/js/generated/AttributeValue.ts @@ -22,4 +22,4 @@ import type { Origin } from "./Origin"; * variants are fully typed. `AsPath`/`As4Path` payloads have a custom serde * encoding (flat array of ASNs, nested arrays for AS_SETs), inlined here. */ -export type AttributeValue = { "Origin": Origin } | { "AsPath": AsPathWire } | { "As4Path": AsPathWire } | { "NextHop": string } | { "MultiExitDiscriminator": number } | { "LocalPreference": number } | { "OnlyToCustomer": Asn } | "AtomicAggregate" | { "Aggregator": { asn: Asn, id: string, } } | { "As4Aggregator": { asn: Asn, id: string, } } | { "Communities": Array } | { "ExtendedCommunities": Array } | { "Ipv6AddressSpecificExtendedCommunities": Array } | { "LargeCommunities": Array } | { "OriginatorId": string } | { "Clusters": Array } | { "MpReachNlri": Nlri } | { "MpUnreachNlri": Nlri } | { "LinkState": Record } | { "TunnelEncapsulation": Record } | { "TrafficEngineering": Record } | { "BfdDiscriminator": Record } | { "BgpPrefixSid": Record } | { "Bier": Record } | { "Sfp": Record } | { "Development": Array } | { "Raw": AttrRaw } | { "Deprecated": AttrRaw } | { "Unknown": AttrRaw } | { "Aigp": Record } | { "AttrSet": Record }; +export type AttributeValue = { "Origin": Origin } | { "AsPath": AsPathWire } | { "As4Path": AsPathWire } | { "NextHop": string } | { "MultiExitDiscriminator": number } | { "LocalPreference": number } | { "OnlyToCustomer": Asn } | "AtomicAggregate" | { "Aggregator": { asn: Asn, id: string, } } | { "As4Aggregator": { asn: Asn, id: string, } } | { "Communities": Array } | { "ExtendedCommunities": Array } | { "Ipv6AddressSpecificExtendedCommunities": Array } | { "LargeCommunities": Array } | { "OriginatorId": string } | { "Clusters": Array } | { "MpReachNlri": Nlri } | { "MpUnreachNlri": Nlri } | { "LinkState": Record } | { "TunnelEncapsulation": Record } | { "TrafficEngineering": Record } | { "BfdDiscriminator": Record } | { "BgpPrefixSid": Record } | { "Bier": Record } | { "Sfp": Record } | { "DomainPath": Record } | { "Development": Array } | { "Raw": AttrRaw } | { "Deprecated": AttrRaw } | { "Unknown": AttrRaw } | { "Aigp": Record } | { "AttrSet": Record };