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/Cargo.lock b/Cargo.lock index a6310aa6..368d5fd8 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/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/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/clock.rs b/bgp/src/clock.rs index 4d11be0e..b2997f99 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::{DynamicTimerInfo, 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,9 +175,14 @@ 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; } + + /// Get the jitter range for this timer. + pub fn jitter_range(&self) -> Option { + self.jitter_range + } } impl Display for Timer { @@ -210,8 +214,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 { @@ -304,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 { @@ -446,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/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/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..c8274893 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, HeaderErrorSubcode, 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,42 @@ 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::BadBgpIdentifier(id) => ( + OpenErrorSubcode::BadBgpIdentifier, + OpenParseErrorReason::BadBgpIdentifier { id }, + ), + Error::BadVersion(ver) => ( + OpenErrorSubcode::UnsupportedVersionNumber, + OpenParseErrorReason::InvalidVersion { + version: ver, + }, + ), + _ => ( + 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,39 +800,113 @@ 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(), + }, + }, + ))); } } } - 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(), - 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/error.rs b/bgp/src/error.rs index 8b92b5cc..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), @@ -139,6 +142,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 +213,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..e6a4e68f 100644 --- a/bgp/src/fanout.rs +++ b/bgp/src/fanout.rs @@ -3,26 +3,41 @@ // 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, RouteUpdate4, RouteUpdate6, +}; 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 +47,94 @@ 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 and/or withdraw IPv4 routes to all peers. + pub fn send_all(&self, nlri: Vec, withdrawn: Vec) { + for egress in self.egress.values() { + 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 and/or withdraw IPv4 routes to all peers except the origin. + pub fn send_except( + &self, + origin: IpAddr, + nlri: Vec, + withdrawn: Vec, + ) { + for (peer_addr, egress) in &self.egress { + if *peer_addr == origin { continue; } - e.send(update); + 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); + } } } +} - pub fn send_all(&self, update: &UpdateMessage) { - for e in self.egress.values() { - e.send(update); +// IPv6-specific implementation +impl Fanout { + /// Announce and/or withdraw IPv6 routes to all peers. + pub fn send_all(&self, nlri: Vec, withdrawn: Vec) { + for egress in self.egress.values() { + 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 and/or withdraw IPv6 routes to all peers except the origin. + pub fn send_except( + &self, + origin: IpAddr, + nlri: Vec, + withdrawn: Vec, + ) { + for (peer_addr, egress) in &self.egress { + if *peer_addr == origin { + continue; + } + 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); + } + } + } +} + +// 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 +149,25 @@ 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 send_route_update(&self, route_update: RouteUpdate) { + let Some(tx) = self.event_tx.as_ref() else { + return; + }; + + // 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 update to egress fanout: {e}"; + slog::error!( + self.log, + "failed to send route update to egress: {e}"; "component" => COMPONENT_BGP, "module" => MOD_NEIGHBOR, "unit" => UNIT_FANOUT, - "message" => "update", - "message_contents" => format!("{update}"), - "error" => format!("{e}") + "route_update" => update_desc, + "error" => format!("{e}"), ); } } 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 177a50e0..c6a6bf23 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -2,19 +2,20 @@ // 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; use serde::{Deserialize, Serialize}; use std::{ - collections::BTreeSet, + collections::{BTreeSet, HashSet}, fmt::{Display, Formatter}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, }; @@ -40,38 +41,100 @@ 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 +/// 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: NlriSection) -> 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]; - 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 } 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); + let byte_count = usize::from(len).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,37 +169,36 @@ 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]; - 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 } - 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); + let byte_count = usize::from(len).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]; @@ -173,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 @@ -236,17 +300,17 @@ 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()), } } - 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", } } @@ -446,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 } @@ -564,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 { @@ -616,12 +680,27 @@ 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)?; + 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( @@ -709,12 +788,29 @@ pub struct Tlv { Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, JsonSchema, )] pub struct UpdateMessage { - pub withdrawn: Vec, - pub path_attributes: Vec, - pub nlri: Vec, + pub withdrawn: Vec, + pub path_attributes: Vec, // XXX: use map for O(1) lookups? + pub nlri: Vec, + + /// 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(); @@ -755,22 +851,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 { + 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 { - 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, + )?); + } } + Ok(buf) } @@ -779,70 +918,663 @@ 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) } - 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)?; + /// 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 { + // 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 { + 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(NlriSection::Withdrawn), + }); + } + }; + + // 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, + } = 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(NlriSection::Nlri), + }); + } + }; - let (input, len) = be_u16(input)?; - let (input, attrs_input) = take(len)(input)?; - let path_attributes = Self::path_attrs_from_wire(attrs_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 nlri = Self::prefixes_from_wire(input, AddressFamily::Ipv4)?; + // 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(_))); + + 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 { + errors.push(( + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::Origin, + }, + AttributeAction::TreatAsWithdraw, + )); + } + + // 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 { + 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 { + errors.push(( + UpdateParseErrorReason::MissingAttribute { + type_code: PathAttributeTypeCode::NextHop, + }, + AttributeAction::TreatAsWithdraw, + )); + } + } + } Ok(UpdateMessage { withdrawn, path_attributes, nlri, + errors, }) } - fn prefixes_from_wire( - mut buf: &[u8], + /// Parse prefixes from wire format. + /// Dispatches to the appropriate version-specific parser. + 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()), + 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, 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. + pub fn prefixes6_from_wire( + mut buf: &[u8], + ) -> Result, PrefixParseError> { 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)?; + result.push(prefix6); buf = out; } Ok(result) } + /// 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 + /// 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. + /// ``` + /// + /// # 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 { + 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 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)?; - result.push(pa); - buf = out; + + // ===== FRAMING: Parse attribute header (type + length) ===== + + // 1. Parse 2-byte type header (flags + type_code) + let (remaining, type_bytes) = match take_bytes(buf, 2) { + 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 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 + // 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 parse_u16(remaining) { + Ok((r, l)) => (r, usize::from(l)), + 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(remaining) { + Ok((r, l)) => (r, usize::from(l)), + 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_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( + 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; + + // ===== 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 { + match action { + AttributeAction::SessionReset => { + return Err(UpdateParseError { + error_code: ErrorCode::Update, + error_subcode: ErrorSubcode::Update( + UpdateErrorSubcode::MalformedAttributeList, + ), + reason, + }); + } + AttributeAction::TreatAsWithdraw => { + 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 + } + } + } + + // ===== 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) + errors.push(( + UpdateParseErrorReason::DuplicateAttribute { + type_code: type_code_u8, + }, + AttributeAction::Discard, + )); + } + } + 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 + errors.push((reason, action)); + } + AttributeAction::Discard => { + // Record error, skip this attribute, continue parsing + errors.push((reason, action)); + } + } + } + } } - Ok(result) + + Ok(ParsedPathAttrs { + attrs: result, + errors, + }) } - pub fn nexthop4(&self) -> Option { - for a in &self.path_attributes { - if let PathAttributeValue::NextHop(IpAddr::V4(addr)) = a.value { - return Some(addr); - } + /// 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 { + // 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), } - None + } + + pub fn nexthop4(&self) -> Option { + self.path_attributes.iter().find_map(|a| match a.value { + PathAttributeValue::NextHop(addr) => Some(addr), + _ => None, + }) } pub fn graceful_shutdown(&self) -> bool { @@ -923,6 +1655,38 @@ impl UpdateMessage { self.path_attributes .push(PathAttributeValue::Communities(vec![community]).into()); } + + 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 { + PathAttributeValue::MpReachNlri(mp) => Some(mp), + _ => 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 { @@ -950,7 +1714,8 @@ impl Display for UpdateMessage { write!( f, - "Update[ 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" } ) @@ -1013,22 +1778,82 @@ 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)> { + 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) + 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.into(), + flags: typ.flags, + }; + // RFC 7606 Section 3.c: Attribute flag errors must use treat-as-withdraw + return Err((reason, AttributeAction::TreatAsWithdraw)); + } + + Ok(()) } /// Type encoding for a path attribute. @@ -1043,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 { @@ -1052,6 +1877,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 { @@ -1068,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")] @@ -1091,6 +1970,10 @@ pub enum PathAttributeTypeCode { Aggregator = 7, Communities = 8, + /// RFC 4760 + MpReachNlri = 14, + MpUnreachNlri = 15, + /// RFC 6793 As4Path = 17, As4Aggregator = 18, @@ -1114,6 +1997,15 @@ impl From for PathAttributeTypeCode { PathAttributeValue::Communities(_) => { PathAttributeTypeCode::Communities } + PathAttributeValue::AtomicAggregate => { + PathAttributeTypeCode::AtomicAggregate + } + 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? @@ -1127,6 +2019,122 @@ 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 + } + + /// 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) +/// +/// 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 + } + + /// 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. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -1139,33 +2147,38 @@ 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), /// 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]), - //MpReachNlri(MpReachNlri), //TODO for IPv6 + /// 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). + MpUnreachNlri(MpUnreachNlri), } 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()); @@ -1173,10 +2186,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,18 +2203,73 @@ impl PathAttributeValue { } Self::LocalPref(value) => Ok(value.to_be_bytes().into()), Self::MultiExitDisc(value) => Ok(value.to_be_bytes().into()), - x => Err(Error::UnsupportedPathAttributeValue(x.clone())), + 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) => Ok(mp.to_wire()), + Self::MpUnreachNlri(mp) => mp.to_wire(), } } pub fn from_wire( mut input: &[u8], type_code: PathAttributeTypeCode, - ) -> Result { + ) -> Result { + // 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() { + 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 => { - let (_input, origin) = be_u8(input)?; - Ok(PathAttributeValue::Origin(PathOrigin::try_from(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) = 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 { + value: origin, + }) } PathAttributeTypeCode::AsPath => { let mut segments = Vec::new(); @@ -1212,7 +2277,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; } @@ -1221,18 +2291,36 @@ 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)?; - Ok(PathAttributeValue::NextHop( - Ipv4Addr::new(b[0], b[1], b[2], b[3]).into(), - )) + 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], + ))) } PathAttributeTypeCode::MultiExitDisc => { - let (_input, v) = be_u32(input)?; + // 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) = parse_u32(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::MultiExitDisc(v)) } PathAttributeTypeCode::As4Path => { @@ -1241,29 +2329,117 @@ 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; } 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() { break; } - let (out, v) = be_u32(input)?; + let (out, v) = + be_u32::<_, NomErr<'_>>(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; communities.push(Community::from(v)); input = out; } Ok(PathAttributeValue::Communities(communities)) } PathAttributeTypeCode::LocalPref => { - let (_input, v) = be_u32(input)?; + // 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) = parse_u32(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(type_code.into()), + detail: format!("{e}"), + } + })?; Ok(PathAttributeValue::LocalPref(v)) } - x => Err(Error::UnsupportedPathAttributeTypeCode(x)), + PathAttributeTypeCode::MpReachNlri => { + let (_remaining, mp_reach) = MpReachNlri::from_wire(input)?; + Ok(PathAttributeValue::MpReachNlri(mp_reach)) + } + PathAttributeTypeCode::MpUnreachNlri => { + let (_remaining, mp_unreach) = MpUnreachNlri::from_wire(input)?; + Ok(PathAttributeValue::MpUnreachNlri(mp_unreach)) + } + 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.into()), + 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.into()), + detail: e, + } + })?; + Ok(PathAttributeValue::As4Aggregator(agg)) + } } } } @@ -1285,12 +2461,9 @@ 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] - ), + PathAttributeValue::Aggregator(agg) => { + write!(f, "aggregator: {}", agg) + } PathAttributeValue::Communities(comms) => { let comms = comms .iter() @@ -1299,6 +2472,15 @@ impl Display for PathAttributeValue { .join(" "); write!(f, "communities: [{comms}]") } + PathAttributeValue::AtomicAggregate => { + write!(f, "atomic-aggregate") + } + PathAttributeValue::MpReachNlri(reach) => { + write!(f, "mp-reach-nlri: {}", reach) + } + PathAttributeValue::MpUnreachNlri(unreach) => { + write!(f, "mp-unreach-nlri: {}", unreach) + } PathAttributeValue::As4Path(path_segs) => { let path = path_segs .iter() @@ -1307,12 +2489,9 @@ 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] - ), + PathAttributeValue::As4Aggregator(agg) => { + write!(f, "as4-aggregator: {}", agg) + } } } } @@ -1363,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")] @@ -1417,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()); } @@ -1428,17 +2608,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 = usize::from(len_u8).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; @@ -1480,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")] @@ -1499,2073 +2689,6088 @@ pub enum AsPathType { AsSequence = 2, } -/// Notification messages are exchanged between BGP peers when an exceptional -/// event has occurred. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] -pub struct NotificationMessage { - /// Error code associated with the notification - pub error_code: ErrorCode, - - /// Error subcode associated with the notification - pub error_subcode: ErrorSubcode, +/// 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, +} - /* - * Implementation notes for later on the data field ... - * - * What follows is verbatim from RFC 4271 - * - * - * §6.1 Message Header Error Handling - * ================================== - * - * If at least one of the following is true: - * - * - if the Length field of the message header is less than 19 or - * greater than 4096, or - * - * - if the Length field of an OPEN message is less than the minimum - * length of the OPEN message, or - * - * - if the Length field of an UPDATE message is less than the - * minimum length of the UPDATE message, or - * - * - if the Length field of a KEEPALIVE message is not equal to 19, - * or - * - * - if the Length field of a NOTIFICATION message is less than the - * minimum length of the NOTIFICATION message, - * - * then the Error Subcode MUST be set to Bad Message Length. The Data - * field MUST contain the erroneous Length field. - * - * If the Type field of the message header is not recognized, then the - * Error Subcode MUST be set to Bad Message Type. The Data field MUST - * contain the erroneous Type field. - * - * §6.2 Open Message Error Handling - * ================================ - * - * If the version number in the Version field of the received OPEN - * message is not supported, then the Error Subcode MUST be set to - * Unsupported Version Number. The Data field is a 2-octet unsigned - * integer, which indicates the largest, locally-supported version - * number less than the version the remote BGP peer bid - * - * §6.3 Update Message Error Handling - * ================================== - * - * If any recognized attribute has Attribute Flags that conflict with - * the Attribute Type Code, then the Error Subcode MUST be set to - * Attribute Flags Error. The Data field MUST contain the erroneous - * attribute (type, length, and value). - * - * If any recognized attribute has an Attribute Length that conflicts - * with the expected length (based on the attribute type code), then the - * Error Subcode MUST be set to Attribute Length Error. The Data field - * MUST contain the erroneous attribute (type, length, and value). - * - * If any of the well-known mandatory attributes are not present, then - * the Error Subcode MUST be set to Missing Well-known Attribute. The - * Data field MUST contain the Attribute Type Code of the missing, - * well-known attribute. - * - * If any of the well-known mandatory attributes are not recognized, - * then the Error Subcode MUST be set to Unrecognized Well-known - * Attribute. The Data field MUST contain the unrecognized attribute - * (type, length, and value). - * - * If the ORIGIN attribute has an undefined value, then the Error Sub- - * code MUST be set to Invalid Origin Attribute. The Data field MUST - * contain the unrecognized attribute (type, length, and value). - * - * If the NEXT_HOP attribute field is syntactically incorrect, then the - * Error Subcode MUST be set to Invalid NEXT_HOP Attribute. The Data - * field MUST contain the incorrect attribute (type, length, and value). - * Syntactic correctness means that the NEXT_HOP attribute represents a - * valid IP host address. - * - * If an optional attribute is recognized, then the value of this - * attribute MUST be checked. If an error is detected, the attribute - * MUST be discarded, and the Error Subcode MUST be set to Optional - * Attribute Error. The Data field MUST contain the attribute (type, - * length, and value). - * - */ - pub data: Vec, +/// 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, +)] +#[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), + Ipv6Double(Ipv6DoubleNexthop), } -impl NotificationMessage { - pub fn to_wire(&self) -> Result, Error> { - let buf = vec![self.error_code as u8, self.error_subcode.as_u8()]; - //TODO data, see comment above on data field - Ok(buf) +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() != usize::from(nh_len) { + 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(Ipv6DoubleNexthop { + global: Ipv6Addr::from(bytes1), + link_local: Ipv6Addr::from(bytes2), + })) + } + _ => Err(Error::InvalidAddress(format!( + "invalid next-hop length {} for AFI {:?}", + nh_len, afi + ))), + } } - pub fn from_wire(input: &[u8]) -> Result { - let (input, error_code) = parse_u8(input)?; - let error_code = ErrorCode::try_from(error_code)?; + /// 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, + } + } - let (input, error_subcode) = parse_u8(input)?; - let error_subcode = match error_code { - ErrorCode::Header => { - HeaderErrorSubcode::try_from(error_subcode)?.into() - } - ErrorCode::Open => { - OpenErrorSubcode::try_from(error_subcode)?.into() - } - ErrorCode::Update => { - UpdateErrorSubcode::try_from(error_subcode)?.into() - } - ErrorCode::HoldTimerExpired => { - ErrorSubcode::HoldTime(error_subcode) - } - ErrorCode::Fsm => ErrorSubcode::Fsm(error_subcode), - ErrorCode::Cease => { - CeaseErrorSubcode::try_from(error_subcode)?.into() + /// 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(addrs) => addrs + .global + .octets() + .into_iter() + .chain(addrs.link_local.octets()) + .collect(), + } + } +} + +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(addrs) => { + write!(f, "({}, {})", addrs.global, addrs.link_local) } - }; - Ok(NotificationMessage { - error_code, - error_subcode, - data: input.to_owned(), - }) + } } } -impl Display for NotificationMessage { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - write!( - f, - "Notification [ error_code: {}, error_subcode: {}, data: {:?} ]", - self.error_code, self.error_subcode, self.data - ) +impl From for BgpNexthop { + fn from(value: Ipv4Addr) -> Self { + BgpNexthop::Ipv4(value) } } -// A message sent between peers to ask for re-advertisement of all outbound -// routes. Defined in RFC 2918. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] -pub struct RouteRefreshMessage { - /// Address family identifier. - pub afi: u16, - /// Subsequent address family identifier. - pub safi: u8, +impl From for BgpNexthop { + fn from(value: Ipv6Addr) -> Self { + BgpNexthop::Ipv6Single(value) + } } -impl RouteRefreshMessage { - pub fn to_wire(&self) -> Result, Error> { - let mut buf = Vec::new(); - buf.extend_from_slice(&self.afi.to_be_bytes()); - buf.push(0); // reserved - buf.push(self.safi); - Ok(buf) +impl From for BgpNexthop { + fn from(value: IpAddr) -> Self { + match value { + IpAddr::V4(ip4) => BgpNexthop::Ipv4(ip4), + IpAddr::V6(ip6) => BgpNexthop::Ipv6Single(ip6), + } } - pub fn from_wire(input: &[u8]) -> Result { - let (input, afi) = be_u16(input)?; - let (input, _reserved) = parse_u8(input)?; - let (_, safi) = parse_u8(input)?; - Ok(RouteRefreshMessage { afi, safi }) +} + +/// 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) } -impl Display for RouteRefreshMessage { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - write!( - f, - "Route Refresh [ afi: {}, safi: {} ]", - self.afi, self.safi - ) +/// 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) } -/// This enumeration contains possible notification error codes. -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Copy, - TryFromPrimitive, - Serialize, - Deserialize, - JsonSchema, -)] -#[repr(u8)] -#[serde(rename_all = "snake_case")] -pub enum ErrorCode { - Header = 1, - Open, - Update, - HoldTimerExpired, - Fsm, - Cease, +/// 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): +/// +/// 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) | +/// +---------------------------------------------------------+ +/// ``` +#[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 Display for ErrorCode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; - match self { - ErrorCode::Header => write!(f, "{val} (Header)"), - ErrorCode::Open => write!(f, "{val} (Open)"), - ErrorCode::Update => write!(f, "{val} (Update)"), - ErrorCode::HoldTimerExpired => { - write!(f, "{val} (HoldTimerExpired)") - } - ErrorCode::Fsm => write!(f, "{val} (FSM)"), - ErrorCode::Cease => write!(f, "{val} (Cease)"), - } - } +/// 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, + /// 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, } -/// This enumeration contains possible notification error subcodes. -#[derive( - Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, -)] -#[serde(rename_all = "snake_case")] -pub enum ErrorSubcode { - Header(HeaderErrorSubcode), - Open(OpenErrorSubcode), - Update(UpdateErrorSubcode), - HoldTime(u8), - Fsm(u8), - Cease(CeaseErrorSubcode), +/// 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, + /// 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, } -impl From for ErrorSubcode { - fn from(x: HeaderErrorSubcode) -> ErrorSubcode { - ErrorSubcode::Header(x) +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, + } } -} -impl From for ErrorSubcode { - fn from(x: OpenErrorSubcode) -> ErrorSubcode { - ErrorSubcode::Open(x) + /// Returns the SAFI for this MP_REACH_NLRI (always Unicast). + pub fn safi(&self) -> Safi { + Safi::Unicast } -} -impl From for ErrorSubcode { - fn from(x: UpdateErrorSubcode) -> ErrorSubcode { - ErrorSubcode::Update(x) + /// 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, + } } -} -impl From for ErrorSubcode { - fn from(x: CeaseErrorSubcode) -> ErrorSubcode { - ErrorSubcode::Cease(x) + /// 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(), + } } -} -impl ErrorSubcode { - fn as_u8(&self) -> u8 { + /// Returns the number of prefixes in this MP_REACH_NLRI. + pub fn len(&self) -> usize { match self { - Self::Header(h) => *h as u8, - Self::Open(o) => *o as u8, - Self::Update(u) => *u as u8, - Self::HoldTime(x) => *x, - Self::Fsm(x) => *x, - Self::Cease(x) => *x as u8, + Self::Ipv4Unicast(inner) => inner.nlri.len(), + Self::Ipv6Unicast(inner) => inner.nlri.len(), } } -} -impl Display for ErrorSubcode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + /// Create an IPv4 Unicast MP_REACH_NLRI. + pub fn ipv4_unicast(nexthop: BgpNexthop, nlri: Vec) -> Self { + 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, + reserved: 0, // Always send 0 per RFC 4760 + nlri, + }) + } + + /// Serialize to wire format. + pub fn to_wire(&self) -> Vec { + let mut buf = Vec::new(); + + // AFI (2 bytes) + buf.extend_from_slice(&u16::from(self.afi()).to_be_bytes()); + + // SAFI (1 byte) + buf.push(self.safi().into()); + + // Next-hop + 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 { + Self::Ipv4Unicast(inner) => inner.reserved, + Self::Ipv6Unicast(inner) => inner.reserved, + }; + buf.push(reserved); + + // NLRI match self { - ErrorSubcode::Header(header_error_subcode) => { - write!(f, "{header_error_subcode}") - } - ErrorSubcode::Open(open_error_subcode) => { - write!(f, "{open_error_subcode}") - } - ErrorSubcode::Update(update_error_subcode) => { - write!(f, "{update_error_subcode}") + Self::Ipv4Unicast(inner) => { + for prefix in &inner.nlri { + buf.extend_from_slice(&prefix.to_wire()); + } } - ErrorSubcode::HoldTime(i) => write!(f, "{i}"), - ErrorSubcode::Fsm(i) => write!(f, "{i}"), - ErrorSubcode::Cease(cease_error_subcode) => { - write!(f, "{cease_error_subcode}") + Self::Ipv6Unicast(inner) => { + for prefix in &inner.nlri { + buf.extend_from_slice(&prefix.to_wire()); + } } } + + buf } -} -/// Header error subcode types -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Copy, - TryFromPrimitive, - Serialize, - Deserialize, - JsonSchema, -)] -#[repr(u8)] -#[serde(rename_all = "snake_case")] -pub enum HeaderErrorSubcode { - Unspecific = 0, - ConnectionNotSynchronized, - BadMessageLength, - BadMessageType, -} + /// 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> { + type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; -impl Display for HeaderErrorSubcode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; - match self { - HeaderErrorSubcode::Unspecific => write!(f, "{val}(Unspecific)"), - HeaderErrorSubcode::ConnectionNotSynchronized => { - write!(f, "{val}(Connection Not Synchronized)") + 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) = 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, + safi: 0, + } + })?; + + // Parse SAFI (1 byte) + 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}"), + } + })?; + 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) = parse_u8(input).map_err(|e| { + UpdateParseErrorReason::AttributeParseError { + type_code: Some(PathAttributeTypeCode::MpReachNlri.into()), + detail: format!("failed to parse next-hop length: {e}"), } - HeaderErrorSubcode::BadMessageLength => { - write!(f, "{val}(Bad Message Length)") + })?; + + // Extract next-hop bytes + if input.len() < usize::from(nh_len) { + 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[..usize::from(nh_len)]; + let input = &input[usize::from(nh_len)..]; + + // 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: usize::from(nh_len), + } + })?; + + // 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) = 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(NlriSection::MpReach))?; + Ok(( + &[], + Self::Ipv4Unicast(MpReachIpv4Unicast { + nexthop, + reserved, + nlri, + }), + )) } - HeaderErrorSubcode::BadMessageType => { - write!(f, "{val}(Bad Message Type)") + Afi::Ipv6 => { + let nlri = prefixes6_from_wire(input) + .map_err(|e| e.into_reason(NlriSection::MpReach))?; + Ok(( + &[], + Self::Ipv6Unicast(MpReachIpv6Unicast { + nexthop, + reserved, + nlri, + }), + )) } } } } -/// Open message error subcode types -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Copy, - TryFromPrimitive, - Serialize, - Deserialize, - JsonSchema, -)] -#[repr(u8)] -#[serde(rename_all = "snake_case")] -pub enum OpenErrorSubcode { - Unspecific = 0, - UnsupportedVersionNumber, - BadPeerAS, - BadBgpIdentifier, - UnsupportedOptionalParameter, - Deprecated, - UnacceptableHoldTime, - UnsupportedCapability, -} - -impl Display for OpenErrorSubcode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; +impl Display for MpReachNlri { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - OpenErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), - OpenErrorSubcode::UnsupportedVersionNumber => { - write!(f, "{val} (UnsupportedVersionNumber)") - } - OpenErrorSubcode::BadPeerAS => write!(f, "{val} (Bad Peer AS)"), - OpenErrorSubcode::BadBgpIdentifier => { - write!(f, "{val} (Bad BGP Identifier)") - } - OpenErrorSubcode::UnsupportedOptionalParameter => { - write!(f, "{val} (Unsupported Optional Parameter)") - } - OpenErrorSubcode::Deprecated => write!(f, "{val} (Deprecated)"), - OpenErrorSubcode::UnacceptableHoldTime => { - write!(f, "{val} (Unacceptable Hold Time)") - } - OpenErrorSubcode::UnsupportedCapability => { - write!(f, "{val} (Unsupported Capability)") - } + 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() + ), } } } -/// Update message error subcode types -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Copy, - TryFromPrimitive, - Serialize, - Deserialize, - JsonSchema, -)] -#[repr(u8)] -#[serde(rename_all = "snake_case")] -pub enum UpdateErrorSubcode { - Unspecific = 0, - MalformedAttributeList, - UnrecognizedWellKnownAttribute, - MissingWellKnownAttribute, - AttributeFlags, - AttributeLength, - InvalidOriginAttribute, - Deprecated, - InvalidNexthopAttribute, - OptionalAttribute, - InvalidNetworkField, - MalformedAsPath, +/// 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): +/// +/// 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, 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), } -impl Display for UpdateErrorSubcode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; - match self { - UpdateErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), - UpdateErrorSubcode::MalformedAttributeList => { - write!(f, "{val} (Malformed Attribute List)") - } - UpdateErrorSubcode::UnrecognizedWellKnownAttribute => { - write!(f, "{val} (Unrecognized Well-Known Attribute)") - } - UpdateErrorSubcode::MissingWellKnownAttribute => { - write!(f, "{val} (Missing Well-Known Attribute)") - } - UpdateErrorSubcode::AttributeFlags => { - write!(f, "{val} (Attribute Flags)") - } - UpdateErrorSubcode::AttributeLength => { - write!(f, "{val} (Attribute Length)") - } - UpdateErrorSubcode::InvalidOriginAttribute => { - write!(f, "{val} (Invalid Origin Attribute)") - } - UpdateErrorSubcode::Deprecated => write!(f, "{val} (Deprecated)"), - UpdateErrorSubcode::InvalidNexthopAttribute => { - write!(f, "{val} (Invalid Nexthop Attribute)") - } - UpdateErrorSubcode::OptionalAttribute => { - write!(f, "{val} (Optional Attribute)") - } - UpdateErrorSubcode::InvalidNetworkField => { - write!(f, "{val} (Invalid Network Field)") - } - UpdateErrorSubcode::MalformedAsPath => { - write!(f, "{val} (Malformed AS Path)") - } - } - } +/// IPv4 Unicast MP_UNREACH_NLRI contents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpUnreachIpv4Unicast { + pub withdrawn: Vec, } -/// Cease error subcode types from RFC 4486 -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Copy, - TryFromPrimitive, - Serialize, - Deserialize, - JsonSchema, -)] -#[repr(u8)] -#[serde(rename_all = "snake_case")] -pub enum CeaseErrorSubcode { - Unspecific = 0, - MaximumNumberofPrefixesReached, - AdministrativeShutdown, - PeerDeconfigured, - AdministrativeReset, - ConnectionRejected, - OtherConfigurationChange, - ConnectionCollisionResolution, - OutOfResources, +/// IPv6 Unicast MP_UNREACH_NLRI contents. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MpUnreachIpv6Unicast { + pub withdrawn: Vec, } -impl Display for CeaseErrorSubcode { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - let val = *self as u8; +impl MpUnreachNlri { + pub fn afi(&self) -> Afi { match self { - CeaseErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), - CeaseErrorSubcode::MaximumNumberofPrefixesReached => { - write!(f, "{val} (Maximum Number of Prefixes Reached)") - } - CeaseErrorSubcode::AdministrativeShutdown => { - write!(f, "{val} (Administrative Shutdown)") - } - CeaseErrorSubcode::PeerDeconfigured => { - write!(f, "{val} (Peer Deconfigured)") - } - CeaseErrorSubcode::AdministrativeReset => { - write!(f, "{val} (Administratively Reset)") - } - CeaseErrorSubcode::ConnectionRejected => { - write!(f, "{val} (Connection Rejected)") - } - CeaseErrorSubcode::OtherConfigurationChange => { - write!(f, "{val} (Other Configuration Rejected)") - } - CeaseErrorSubcode::ConnectionCollisionResolution => { - write!(f, "{val} (Connection Collision Resolution)") - } - CeaseErrorSubcode::OutOfResources => { - write!(f, "{val} (Out of Resources)") - } + Self::Ipv4Unicast(_) => Afi::Ipv4, + Self::Ipv6Unicast(_) => Afi::Ipv6, } } -} - -/// The IANA/IETF currently defines the following optional parameter types. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", content = "value", rename_all = "snake_case")] -pub enum OptionalParameter { - /// Code 0 - Reserved, - - /// Code 1: RFC 4217, RFC 5492 (deprecated) - Authentication, //TODO - - /// Code 2: RFC 5492 - Capabilities(BTreeSet), - /// Unassigned - Unassigned, + pub fn safi(&self) -> Safi { + Safi::Unicast + } - /// Code 255: RFC 9072 - ExtendedLength, //TODO -} + pub fn is_empty(&self) -> bool { + match self { + Self::Ipv4Unicast(inner) => inner.withdrawn.is_empty(), + Self::Ipv6Unicast(inner) => inner.withdrawn.is_empty(), + } + } -impl Display for OptionalParameter { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + pub fn len(&self) -> usize { match self { - OptionalParameter::Reserved => write!(f, "Reserved (0)"), - OptionalParameter::Authentication => { - write!(f, "Authentication (1)") - } - OptionalParameter::Capabilities(caps) => { - let mut cap_string = String::new(); - for cap in caps { - cap_string.push_str(&format!("{cap}, ")); - } - write!(f, "Capabilities [ {cap_string}]") - } - OptionalParameter::Unassigned => write!(f, "Unassigned"), - OptionalParameter::ExtendedLength => { - write!(f, "Extended Length (255)") - } + Self::Ipv4Unicast(inner) => inner.withdrawn.len(), + Self::Ipv6Unicast(inner) => inner.withdrawn.len(), } } -} -#[derive(Debug, Eq, PartialEq, TryFromPrimitive)] -#[repr(u8)] -pub enum OptionalParameterCode { - Reserved = 0, - Authentication = 1, - Capabilities = 2, - ExtendedLength = 255, -} + /// Create an IPv4 Unicast MP_UNREACH_NLRI. + pub fn ipv4_unicast(withdrawn: Vec) -> Self { + Self::Ipv4Unicast(MpUnreachIpv4Unicast { withdrawn }) + } -impl OptionalParameter { + /// Create an IPv6 Unicast MP_UNREACH_NLRI. + pub fn ipv6_unicast(withdrawn: Vec) -> Self { + Self::Ipv6Unicast(MpUnreachIpv6Unicast { withdrawn }) + } + + /// Serialize to wire format. pub fn to_wire(&self) -> Result, Error> { + let mut buf = Vec::new(); + + // AFI (2 bytes) + buf.extend_from_slice(&u16::from(self.afi()).to_be_bytes()); + + // SAFI (1 byte) + buf.push(self.safi().into()); + + // Withdrawn routes match self { - Self::Reserved => Err(Error::ReservedOptionalParameter), - Self::Unassigned => Err(Error::Unassigned(0)), - Self::Capabilities(cs) => { - let mut buf = vec![OptionalParameterCode::Capabilities as u8]; - let mut csbuf = Vec::new(); - for c in cs { - let cbuf = c.to_wire()?; - csbuf.extend_from_slice(&cbuf); + 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()); } - buf.push(csbuf.len() as u8); - buf.extend_from_slice(&csbuf); - Ok(buf) } - x => Err(Error::UnsupportedOptionalParameter(x.clone())), } + + 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], OptionalParameter), Error> { - let (input, code) = parse_u8(input)?; - let code = OptionalParameterCode::try_from(code)?; - let (input, len) = parse_u8(input)?; - let (input, mut cap_input) = take(len)(input)?; + ) -> Result<(&[u8], Self), UpdateParseErrorReason> { + type NomErr<'a> = nom::error::Error<&'a [u8]>; + type ParseRes<'a, T> = + std::result::Result<(&'a [u8], T), nom::Err>>; - match code { - OptionalParameterCode::Reserved => { - Err(Error::ReservedOptionalParameter) - } - OptionalParameterCode::Capabilities => { - let mut result = BTreeSet::new(); - while !cap_input.is_empty() { - let (out, cap) = Capability::from_wire(cap_input)?; - result.insert(cap); - cap_input = out; - } - Ok((input, OptionalParameter::Capabilities(result))) + 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) = 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, + safi: 0, + } + })?; + + // Parse SAFI (1 byte) + 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, + 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(NlriSection::MpUnreach))?; + Ok((&[], Self::Ipv4Unicast(MpUnreachIpv4Unicast { withdrawn }))) + } + Afi::Ipv6 => { + let withdrawn = prefixes6_from_wire(input) + .map_err(|e| e.into_reason(NlriSection::MpUnreach))?; + Ok((&[], Self::Ipv6Unicast(MpUnreachIpv6Unicast { withdrawn }))) } - x => Err(Error::UnsupportedOptionalParameterCode(x)), } } } -/// The add path element comes as a BGP capability extension as described in -/// RFC 7911. -#[derive( - Debug, - PartialEq, - Eq, - Clone, - Serialize, - Deserialize, - JsonSchema, - PartialOrd, - Ord, -)] -pub struct AddPathElement { - /// Address family identifier. - /// - pub afi: u16, - /// Subsequent address family identifier. There are a large pile of these - /// - pub safi: u8, - /// 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 . - pub send_receive: u8, -} - -impl Display for AddPathElement { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - write!( - f, - "AddPathElement {{ afi: {}, safi: {}, send_receive: {} }}", - self.afi, self.safi, self.send_receive - ) +impl Display for MpUnreachNlri { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ipv4Unicast(inner) => write!( + f, + "MpUnreachNlri::Ipv4Unicast[withdrawn={}]", + inner.withdrawn.len() + ), + Self::Ipv6Unicast(inner) => write!( + f, + "MpUnreachNlri::Ipv6Unicast[withdrawn={}]", + inner.withdrawn.len() + ), + } } } -// An issue tracking the TODOs below is here -// +/// Notification messages are exchanged between BGP peers when an exceptional +/// event has occurred. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +pub struct NotificationMessage { + /// Error code associated with the notification + pub error_code: ErrorCode, -/// Optional capabilities supported by a BGP implementation. -#[derive( - Debug, - PartialEq, - Eq, + /// Error subcode associated with the notification + pub error_subcode: ErrorSubcode, + + /* + * Implementation notes for later on the data field ... + * + * What follows is verbatim from RFC 4271 + * + * + * §6.1 Message Header Error Handling + * ================================== + * + * If at least one of the following is true: + * + * - if the Length field of the message header is less than 19 or + * greater than 4096, or + * + * - if the Length field of an OPEN message is less than the minimum + * length of the OPEN message, or + * + * - if the Length field of an UPDATE message is less than the + * minimum length of the UPDATE message, or + * + * - if the Length field of a KEEPALIVE message is not equal to 19, + * or + * + * - if the Length field of a NOTIFICATION message is less than the + * minimum length of the NOTIFICATION message, + * + * then the Error Subcode MUST be set to Bad Message Length. The Data + * field MUST contain the erroneous Length field. + * + * If the Type field of the message header is not recognized, then the + * Error Subcode MUST be set to Bad Message Type. The Data field MUST + * contain the erroneous Type field. + * + * §6.2 Open Message Error Handling + * ================================ + * + * If the version number in the Version field of the received OPEN + * message is not supported, then the Error Subcode MUST be set to + * Unsupported Version Number. The Data field is a 2-octet unsigned + * integer, which indicates the largest, locally-supported version + * number less than the version the remote BGP peer bid + * + * §6.3 Update Message Error Handling + * ================================== + * + * If any recognized attribute has Attribute Flags that conflict with + * the Attribute Type Code, then the Error Subcode MUST be set to + * Attribute Flags Error. The Data field MUST contain the erroneous + * attribute (type, length, and value). + * + * If any recognized attribute has an Attribute Length that conflicts + * with the expected length (based on the attribute type code), then the + * Error Subcode MUST be set to Attribute Length Error. The Data field + * MUST contain the erroneous attribute (type, length, and value). + * + * If any of the well-known mandatory attributes are not present, then + * the Error Subcode MUST be set to Missing Well-known Attribute. The + * Data field MUST contain the Attribute Type Code of the missing, + * well-known attribute. + * + * If any of the well-known mandatory attributes are not recognized, + * then the Error Subcode MUST be set to Unrecognized Well-known + * Attribute. The Data field MUST contain the unrecognized attribute + * (type, length, and value). + * + * If the ORIGIN attribute has an undefined value, then the Error Sub- + * code MUST be set to Invalid Origin Attribute. The Data field MUST + * contain the unrecognized attribute (type, length, and value). + * + * If the NEXT_HOP attribute field is syntactically incorrect, then the + * Error Subcode MUST be set to Invalid NEXT_HOP Attribute. The Data + * field MUST contain the incorrect attribute (type, length, and value). + * Syntactic correctness means that the NEXT_HOP attribute represents a + * valid IP host address. + * + * If an optional attribute is recognized, then the value of this + * attribute MUST be checked. If an error is detected, the attribute + * MUST be discarded, and the Error Subcode MUST be set to Optional + * Attribute Error. The Data field MUST contain the attribute (type, + * length, and value). + * + */ + pub data: Vec, +} + +impl NotificationMessage { + pub fn to_wire(&self) -> Result, Error> { + let buf = vec![self.error_code.into(), self.error_subcode.as_u8()]; + //TODO data, see comment above on data field + Ok(buf) + } + + 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)?; + + let (input, error_subcode) = parse_u8(input)?; + let error_subcode = match error_code { + ErrorCode::Header => { + HeaderErrorSubcode::try_from(error_subcode)?.into() + } + ErrorCode::Open => { + OpenErrorSubcode::try_from(error_subcode)?.into() + } + ErrorCode::Update => { + UpdateErrorSubcode::try_from(error_subcode)?.into() + } + ErrorCode::HoldTimerExpired => { + ErrorSubcode::HoldTime(error_subcode) + } + ErrorCode::Fsm => ErrorSubcode::Fsm(error_subcode), + ErrorCode::Cease => { + CeaseErrorSubcode::try_from(error_subcode)?.into() + } + }; + Ok(NotificationMessage { + error_code, + error_subcode, + data: input.to_owned(), + }) + } +} + +impl Display for NotificationMessage { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!( + f, + "Notification [ error_code: {}, error_subcode: {}, data: {:?} ]", + self.error_code, self.error_subcode, self.data + ) + } +} + +// A message sent between peers to ask for re-advertisement of all outbound +// routes. Defined in RFC 2918. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RouteRefreshMessage { + /// Address family identifier. + pub afi: u16, + /// Subsequent address family identifier. + pub safi: u8, +} + +impl RouteRefreshMessage { + 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); + buf + } + pub fn from_wire(input: &[u8]) -> Result { + let (input, afi) = be_u16(input)?; + let (input, _reserved) = parse_u8(input)?; + let (_, safi) = parse_u8(input)?; + Ok(RouteRefreshMessage { afi, safi }) + } +} + +impl Display for RouteRefreshMessage { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!( + f, + "Route Refresh [ afi: {}, safi: {} ]", + self.afi, self.safi + ) + } +} + +/// This enumeration contains possible notification error codes. +#[derive( + Debug, + PartialEq, + Eq, Clone, + Copy, + IntoPrimitive, + TryFromPrimitive, Serialize, Deserialize, JsonSchema, - PartialOrd, - Ord, )] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + Header = 1, + Open, + Update, + HoldTimerExpired, + Fsm, + Cease, +} + +impl Display for ErrorCode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + let val: u8 = (*self).into(); + match self { + ErrorCode::Header => write!(f, "{val} (Header)"), + ErrorCode::Open => write!(f, "{val} (Open)"), + ErrorCode::Update => write!(f, "{val} (Update)"), + ErrorCode::HoldTimerExpired => { + write!(f, "{val} (HoldTimerExpired)") + } + ErrorCode::Fsm => write!(f, "{val} (FSM)"), + ErrorCode::Cease => write!(f, "{val} (Cease)"), + } + } +} + +/// This enumeration contains possible notification error subcodes. +#[derive( + Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum ErrorSubcode { + Header(HeaderErrorSubcode), + Open(OpenErrorSubcode), + Update(UpdateErrorSubcode), + HoldTime(u8), + Fsm(u8), + Cease(CeaseErrorSubcode), +} + +impl From for ErrorSubcode { + fn from(x: HeaderErrorSubcode) -> ErrorSubcode { + ErrorSubcode::Header(x) + } +} + +impl From for ErrorSubcode { + fn from(x: OpenErrorSubcode) -> ErrorSubcode { + ErrorSubcode::Open(x) + } +} + +impl From for ErrorSubcode { + fn from(x: UpdateErrorSubcode) -> ErrorSubcode { + ErrorSubcode::Update(x) + } +} + +impl From for ErrorSubcode { + fn from(x: CeaseErrorSubcode) -> ErrorSubcode { + ErrorSubcode::Cease(x) + } +} + +impl ErrorSubcode { + fn as_u8(&self) -> u8 { + match self { + 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).into(), + } + } +} + +impl Display for ErrorSubcode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + ErrorSubcode::Header(header_error_subcode) => { + write!(f, "{header_error_subcode}") + } + ErrorSubcode::Open(open_error_subcode) => { + write!(f, "{open_error_subcode}") + } + ErrorSubcode::Update(update_error_subcode) => { + write!(f, "{update_error_subcode}") + } + ErrorSubcode::HoldTime(i) => write!(f, "{i}"), + ErrorSubcode::Fsm(i) => write!(f, "{i}"), + ErrorSubcode::Cease(cease_error_subcode) => { + write!(f, "{cease_error_subcode}") + } + } + } +} + +/// Header error subcode types +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + IntoPrimitive, + TryFromPrimitive, + Serialize, + Deserialize, + JsonSchema, +)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum HeaderErrorSubcode { + Unspecific = 0, + ConnectionNotSynchronized, + BadMessageLength, + BadMessageType, +} + +impl Display for HeaderErrorSubcode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + let val: u8 = (*self).into(); + match self { + HeaderErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), + HeaderErrorSubcode::ConnectionNotSynchronized => { + write!(f, "{val} (Connection Not Synchronized)") + } + HeaderErrorSubcode::BadMessageLength => { + write!(f, "{val} (Bad Message Length)") + } + HeaderErrorSubcode::BadMessageType => { + write!(f, "{val} (Bad Message Type)") + } + } + } +} + +/// Open message error subcode types +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + IntoPrimitive, + TryFromPrimitive, + Serialize, + Deserialize, + JsonSchema, +)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum OpenErrorSubcode { + Unspecific = 0, + UnsupportedVersionNumber, + BadPeerAS, + BadBgpIdentifier, + UnsupportedOptionalParameter, + Deprecated, + UnacceptableHoldTime, + UnsupportedCapability, +} + +impl Display for OpenErrorSubcode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + let val: u8 = (*self).into(); + match self { + OpenErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), + OpenErrorSubcode::UnsupportedVersionNumber => { + write!(f, "{val} (UnsupportedVersionNumber)") + } + OpenErrorSubcode::BadPeerAS => write!(f, "{val} (Bad Peer AS)"), + OpenErrorSubcode::BadBgpIdentifier => { + write!(f, "{val} (Bad BGP Identifier)") + } + OpenErrorSubcode::UnsupportedOptionalParameter => { + write!(f, "{val} (Unsupported Optional Parameter)") + } + OpenErrorSubcode::Deprecated => write!(f, "{val} (Deprecated)"), + OpenErrorSubcode::UnacceptableHoldTime => { + write!(f, "{val} (Unacceptable Hold Time)") + } + OpenErrorSubcode::UnsupportedCapability => { + write!(f, "{val} (Unsupported Capability)") + } + } + } +} + +/// Update message error subcode types +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + IntoPrimitive, + TryFromPrimitive, + Serialize, + Deserialize, + JsonSchema, +)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum UpdateErrorSubcode { + Unspecific = 0, + MalformedAttributeList, + UnrecognizedWellKnownAttribute, + MissingWellKnownAttribute, + AttributeFlags, + AttributeLength, + InvalidOriginAttribute, + Deprecated, + InvalidNexthopAttribute, + OptionalAttribute, + InvalidNetworkField, + MalformedAsPath, +} + +impl Display for UpdateErrorSubcode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + let val: u8 = (*self).into(); + match self { + UpdateErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), + UpdateErrorSubcode::MalformedAttributeList => { + write!(f, "{val} (Malformed Attribute List)") + } + UpdateErrorSubcode::UnrecognizedWellKnownAttribute => { + write!(f, "{val} (Unrecognized Well-Known Attribute)") + } + UpdateErrorSubcode::MissingWellKnownAttribute => { + write!(f, "{val} (Missing Well-Known Attribute)") + } + UpdateErrorSubcode::AttributeFlags => { + write!(f, "{val} (Attribute Flags)") + } + UpdateErrorSubcode::AttributeLength => { + write!(f, "{val} (Attribute Length)") + } + UpdateErrorSubcode::InvalidOriginAttribute => { + write!(f, "{val} (Invalid Origin Attribute)") + } + UpdateErrorSubcode::Deprecated => write!(f, "{val} (Deprecated)"), + UpdateErrorSubcode::InvalidNexthopAttribute => { + write!(f, "{val} (Invalid Nexthop Attribute)") + } + UpdateErrorSubcode::OptionalAttribute => { + write!(f, "{val} (Optional Attribute)") + } + UpdateErrorSubcode::InvalidNetworkField => { + write!(f, "{val} (Invalid Network Field)") + } + UpdateErrorSubcode::MalformedAsPath => { + write!(f, "{val} (Malformed AS Path)") + } + } + } +} + +/// Cease error subcode types from RFC 4486 +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + IntoPrimitive, + TryFromPrimitive, + Serialize, + Deserialize, + JsonSchema, +)] +#[repr(u8)] +#[serde(rename_all = "snake_case")] +pub enum CeaseErrorSubcode { + Unspecific = 0, + MaximumNumberofPrefixesReached, + AdministrativeShutdown, + PeerDeconfigured, + AdministrativeReset, + ConnectionRejected, + OtherConfigurationChange, + ConnectionCollisionResolution, + OutOfResources, +} + +impl Display for CeaseErrorSubcode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + let val: u8 = (*self).into(); + match self { + CeaseErrorSubcode::Unspecific => write!(f, "{val} (Unspecific)"), + CeaseErrorSubcode::MaximumNumberofPrefixesReached => { + write!(f, "{val} (Maximum Number of Prefixes Reached)") + } + CeaseErrorSubcode::AdministrativeShutdown => { + write!(f, "{val} (Administrative Shutdown)") + } + CeaseErrorSubcode::PeerDeconfigured => { + write!(f, "{val} (Peer Deconfigured)") + } + CeaseErrorSubcode::AdministrativeReset => { + write!(f, "{val} (Administratively Reset)") + } + CeaseErrorSubcode::ConnectionRejected => { + write!(f, "{val} (Connection Rejected)") + } + CeaseErrorSubcode::OtherConfigurationChange => { + write!(f, "{val} (Other Configuration Rejected)") + } + CeaseErrorSubcode::ConnectionCollisionResolution => { + write!(f, "{val} (Connection Collision Resolution)") + } + CeaseErrorSubcode::OutOfResources => { + write!(f, "{val} (Out of Resources)") + } + } + } +} + +/// The IANA/IETF currently defines the following optional parameter types. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum OptionalParameter { + /// Code 0 + Reserved, + + /// Code 1: RFC 4217, RFC 5492 (deprecated) + Authentication, //TODO + + /// Code 2: RFC 5492 + Capabilities(BTreeSet), + + /// Unassigned + Unassigned, + + /// Code 255: RFC 9072 + ExtendedLength, //TODO +} + +impl Display for OptionalParameter { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + OptionalParameter::Reserved => write!(f, "Reserved (0)"), + OptionalParameter::Authentication => { + write!(f, "Authentication (1)") + } + OptionalParameter::Capabilities(caps) => { + let mut cap_string = String::new(); + for cap in caps { + cap_string.push_str(&format!("{cap}, ")); + } + write!(f, "Capabilities [ {cap_string}]") + } + OptionalParameter::Unassigned => write!(f, "Unassigned"), + OptionalParameter::ExtendedLength => { + write!(f, "Extended Length (255)") + } + } + } +} + +#[derive(Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)] +#[repr(u8)] +pub enum OptionalParameterCode { + Reserved = 0, + Authentication = 1, + Capabilities = 2, + ExtendedLength = 255, +} + +impl OptionalParameter { + pub fn to_wire(&self) -> Result, Error> { + match self { + Self::Reserved => Err(Error::ReservedOptionalParameter), + Self::Unassigned => Err(Error::Unassigned(0)), + Self::Capabilities(cs) => { + let mut buf = + vec![u8::from(OptionalParameterCode::Capabilities)]; + let mut csbuf = Vec::new(); + for c in cs { + let cbuf = c.to_wire()?; + csbuf.extend_from_slice(&cbuf); + } + buf.push(csbuf.len() as u8); + buf.extend_from_slice(&csbuf); + Ok(buf) + } + x => Err(Error::UnsupportedOptionalParameter(x.clone())), + } + } + + pub fn from_wire( + input: &[u8], + ) -> Result<(&[u8], OptionalParameter), Error> { + let (input, code) = parse_u8(input)?; + let code = OptionalParameterCode::try_from(code)?; + let (input, len) = parse_u8(input)?; + let (input, mut cap_input) = take(len)(input)?; + + match code { + OptionalParameterCode::Reserved => { + Err(Error::ReservedOptionalParameter) + } + OptionalParameterCode::Capabilities => { + let mut result = BTreeSet::new(); + while !cap_input.is_empty() { + let (out, cap) = Capability::from_wire(cap_input)?; + result.insert(cap); + cap_input = out; + } + Ok((input, OptionalParameter::Capabilities(result))) + } + x => Err(Error::UnsupportedOptionalParameterCode(x)), + } + } +} + +/// The add path element comes as a BGP capability extension as described in +/// RFC 7911. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Serialize, + Deserialize, + JsonSchema, + PartialOrd, + Ord, +)] +pub struct AddPathElement { + /// Address family identifier. + /// + pub afi: u16, + /// Subsequent address family identifier. There are a large pile of these + /// + pub safi: u8, + /// 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 . + pub send_receive: u8, +} + +impl Display for AddPathElement { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!( + f, + "AddPathElement {{ afi: {}, safi: {}, send_receive: {} }}", + self.afi, self.safi, self.send_receive + ) + } +} + +// An issue tracking the TODOs below is here +// + +/// Optional capabilities supported by a BGP implementation. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Serialize, + Deserialize, + JsonSchema, + PartialOrd, + Ord, +)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// Multiprotocol extensions as defined in RFC 2858 + MultiprotocolExtensions { + afi: u16, + safi: u8, + }, + + /// Route refresh capability as defined in RFC 2918. + RouteRefresh {}, + + //TODO + /// Outbound filtering capability as defined in RFC 5291. Note this + /// capability is not yet implemented. + OutboundRouteFiltering {}, + + //TODO + /// Multiple routes to destination capability as defined in RFC 8277 + /// (deprecated). Note this capability is not yet implemented. + MultipleRoutesToDestination {}, + + //TODO + /// Multiple nexthop encoding capability as defined in RFC 8950. Note this + /// capability is not yet implemented. + ExtendedNextHopEncoding {}, + + //TODO + /// Extended message capability as defined in RFC 8654. Note this + /// capability is not yet implemented. + BGPExtendedMessage {}, + + //TODO + /// BGPSec as defined in RFC 8205. Note this capability is not yet + /// implemented. + BgpSec {}, + + //TODO + /// Multiple label support as defined in RFC 8277. Note this capability + /// is not yet implemented. + MultipleLabels {}, + + //TODO + /// BGP role capability as defined in RFC 9234. Note this capability is not + /// yet implemented. + BgpRole {}, + + //TODO + /// Graceful restart as defined in RFC 4724. Note this capability is not + /// yet implemented. + GracefulRestart {}, + + /// Four octet AS numbers as defined in RFC 6793. + FourOctetAs { + asn: u32, + }, + + //TODO + /// Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note + /// this capability is not yet implemented. + DynamicCapability {}, + + //TODO + /// Multi session support as defined in draft-ietf-idr-bgp-multisession. + /// Note this capability is not yet supported. + MultisessionBgp {}, + + /// Add path capability as defined in RFC 7911. + AddPath { + elements: BTreeSet, + }, + + //TODO + /// Enhanced route refresh as defined in RFC 7313. Note this capability is + /// not yet supported. + EnhancedRouteRefresh {}, + + //TODO + /// Long-lived graceful restart as defined in + /// draft-uttaro-idr-bgp-persistence. Note this capability is not yet + /// supported. + LongLivedGracefulRestart {}, + + //TODO + /// Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note + /// this capability is not yet supported. + RoutingPolicyDistribution {}, + + //TODO + /// Fully qualified domain names as defined + /// intdraft-walton-bgp-hostname-capability. Note this capability is not + /// yet supported. + Fqdn {}, + + //TODO + /// Pre-standard route refresh as defined in RFC 8810 (deprecated). Note + /// this capability is not yet supported. + PrestandardRouteRefresh {}, + + //TODO + /// Pre-standard prefix-based outbound route filtering as defined in + /// RFC 8810 (deprecated). Note this is not yet implemented. + PrestandardOrfAndPd {}, + + //TODO + /// Pre-standard outbound route filtering as defined in RFC 8810 + /// (deprecated). Note this is not yet implemented. + PrestandardOutboundRouteFiltering {}, + + //TODO + /// Pre-standard multisession as defined in RFC 8810 (deprecated). Note + /// this is not yet implemented. + PrestandardMultisession {}, + + //TODO + /// Pre-standard fully qualified domain names as defined in RFC 8810 + /// (deprecated). Note this is not yet implemented. + PrestandardFqdn {}, + + //TODO + /// Pre-standard operational messages as defined in RFC 8810 (deprecated). + /// Note this is not yet implemented. + PrestandardOperationalMessage {}, + + /// Experimental capability as defined in RFC 8810. + Experimental { + code: u8, + }, + + Unassigned { + code: u8, + }, + + Reserved { + code: u8, + }, +} + +impl Display for Capability { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + Capability::MultiprotocolExtensions { afi, safi } => { + write!(f, "MP-Extensions {afi}/{safi}") + } + Capability::RouteRefresh {} => { + write!(f, "Route Refresh") + } + Capability::OutboundRouteFiltering {} => { + write!(f, "ORF") + } + Capability::MultipleRoutesToDestination {} => { + write!(f, "Multiple Routes to Destination") + } + Capability::ExtendedNextHopEncoding {} => { + write!(f, "Extended Next Hop Encoding") + } + Capability::BGPExtendedMessage {} => { + write!(f, "BGP Extended Message") + } + Capability::BgpSec {} => { + write!(f, "BGP Sec") + } + Capability::MultipleLabels {} => { + write!(f, "Multiple Labels") + } + Capability::BgpRole {} => { + write!(f, "BGP Role") + } + Capability::GracefulRestart {} => { + write!(f, "Graceful Restart") + } + Capability::FourOctetAs { asn } => { + write!(f, "Four Octet ASN {asn}") + } + Capability::DynamicCapability {} => { + write!(f, "Dynamic Capability") + } + Capability::MultisessionBgp {} => { + write!(f, "Multi-session BGP") + } + Capability::AddPath { elements } => { + let mut elements_string = String::new(); + for e in elements { + elements_string.push_str(&format!("{e}, ")); + } + write!(f, "AddPath [ {elements_string}]") + } + Capability::EnhancedRouteRefresh {} => { + write!(f, "Enhanced Route Refresh") + } + Capability::LongLivedGracefulRestart {} => { + write!(f, "Long-Lived Graceful Restart") + } + Capability::RoutingPolicyDistribution {} => { + write!(f, "Routing Policy Distribution") + } + Capability::Fqdn {} => { + write!(f, "FQDN") + } + Capability::PrestandardRouteRefresh {} => { + write!(f, "Route Refresh (Prestandard)") + } + Capability::PrestandardOrfAndPd {} => { + write!(f, "ORF / Policy Distribution (Prestandard)") + } + Capability::PrestandardOutboundRouteFiltering {} => { + write!(f, "ORF (Prestandard)") + } + Capability::PrestandardMultisession {} => { + write!(f, "Multi-session BGP (Prestandard)") + } + Capability::PrestandardFqdn {} => { + write!(f, "FQDN (Prestandard)") + } + Capability::PrestandardOperationalMessage {} => { + write!(f, "Operational Message (Prestandard)") + } + Capability::Experimental { code } => { + write!(f, "Experimental ({code})") + } + Capability::Unassigned { code } => { + write!(f, "Unassigned ({code})") + } + Capability::Reserved { code } => { + write!(f, "Reserved ({code})") + } + } + } +} + +impl Capability { + pub fn to_wire(&self) -> Result, Error> { + match self { + Self::MultiprotocolExtensions { afi, safi } => { + let mut buf = + 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.into(), 0]; + Ok(buf) + } + Self::GracefulRestart {} => { + //TODO audit + let buf = vec![CapabilityCode::GracefulRestart.into(), 0]; + Ok(buf) + } + Self::FourOctetAs { asn } => { + let mut buf = vec![CapabilityCode::FourOctetAs.into(), 4]; + buf.extend_from_slice(&asn.to_be_bytes()); + Ok(buf) + } + Self::AddPath { elements } => { + 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); + buf.push(e.send_receive); + } + Ok(buf) + } + Self::EnhancedRouteRefresh {} => { + //TODO audit + let buf = vec![CapabilityCode::EnhancedRouteRefresh.into(), 0]; + Ok(buf) + } + Self::Experimental { code: _ } => Err(Error::Experimental), + Self::Unassigned { code } => Err(Error::Unassigned(*code)), + Self::Reserved { code: _ } => Err(Error::ReservedCapability), + x => Err(Error::UnsupportedCapability(x.clone())), + } + } + + /// 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)?; + let len = usize::from(len); + if input.len() < len { + return Err(Error::Eom); + } + let code = match CapabilityCode::try_from(code) { + Ok(code) => code, + Err(_) => { + return Ok((&input[len..], Capability::Unassigned { code })); + } + }; + let mut input = input; + + match code { + CapabilityCode::MultiprotocolExtensions => { + let (input, afi) = be_u16(input)?; + let (input, _) = be_u8(input)?; + let (input, safi) = be_u8(input)?; + Ok((input, Capability::MultiprotocolExtensions { afi, safi })) + } + CapabilityCode::RouteRefresh => { + Ok((&input[len..], Capability::RouteRefresh {})) + } + + CapabilityCode::GracefulRestart => { + //TODO handle for real + Ok((&input[len..], Capability::GracefulRestart {})) + } + CapabilityCode::FourOctetAs => { + let (input, asn) = be_u32(input)?; + Ok((input, Capability::FourOctetAs { asn })) + } + CapabilityCode::AddPath => { + let mut elements = BTreeSet::new(); + while !input.is_empty() { + let (remaining, afi) = be_u16(input)?; + let (remaining, safi) = be_u8(remaining)?; + let (remaining, send_receive) = be_u8(remaining)?; + elements.insert(AddPathElement { + afi, + safi, + send_receive, + }); + input = remaining; + } + Ok((input, Capability::AddPath { elements })) + } + CapabilityCode::EnhancedRouteRefresh => { + //TODO handle for real + Ok((&input[len..], Capability::EnhancedRouteRefresh {})) + } + + CapabilityCode::Fqdn => { + //TODO handle for real + Ok((&input[len..], Capability::Fqdn {})) + } + + CapabilityCode::PrestandardRouteRefresh => { + //TODO handle for real + Ok((&input[len..], Capability::PrestandardRouteRefresh {})) + } + + CapabilityCode::BGPExtendedMessage => { + //TODO handle for real + Ok((&input[len..], Capability::BGPExtendedMessage {})) + } + + CapabilityCode::LongLivedGracefulRestart => { + //TODO handle for real + Ok((&input[len..], Capability::LongLivedGracefulRestart {})) + } + + CapabilityCode::MultipleRoutesToDestination => { + //TODO handle for real + Ok((&input[len..], Capability::MultipleRoutesToDestination {})) + } + + CapabilityCode::ExtendedNextHopEncoding => { + //TODO handle for real + Ok((&input[len..], Capability::ExtendedNextHopEncoding {})) + } + + CapabilityCode::OutboundRouteFiltering => { + //TODO handle for real + Ok((&input[len..], Capability::OutboundRouteFiltering {})) + } + + CapabilityCode::BgpSec => { + //TODO handle for real + Ok((&input[len..], Capability::BgpSec {})) + } + + CapabilityCode::MultipleLabels => { + //TODO handle for real + Ok((&input[len..], Capability::MultipleLabels {})) + } + + CapabilityCode::BgpRole => { + //TODO handle for real + Ok((&input[len..], Capability::BgpRole {})) + } + + CapabilityCode::DynamicCapability => { + //TODO handle for real + Ok((&input[len..], Capability::DynamicCapability {})) + } + + CapabilityCode::MultisessionBgp => { + //TODO handle for real + Ok((&input[len..], Capability::MultisessionBgp {})) + } + + CapabilityCode::RoutingPolicyDistribution => { + //TODO handle for real + Ok((&input[len..], Capability::RoutingPolicyDistribution {})) + } + + CapabilityCode::PrestandardOrfAndPd => { + //TODO handle for real + Ok((&input[len..], Capability::PrestandardOrfAndPd {})) + } + + CapabilityCode::PrestandardOutboundRouteFiltering => { + //TODO handle for real + Ok(( + &input[len..], + Capability::PrestandardOutboundRouteFiltering {}, + )) + } + + CapabilityCode::PrestandardMultisession => { + //TODO handle for real + Ok((&input[len..], Capability::PrestandardMultisession {})) + } + + CapabilityCode::PrestandardFqdn => { + //TODO handle for real + Ok((&input[len..], Capability::PrestandardFqdn {})) + } + + CapabilityCode::PrestandardOperationalMessage => { + //TODO handle for real + Ok(( + &input[len..], + Capability::PrestandardOperationalMessage {}, + )) + } + + CapabilityCode::Experimental0 => { + Ok((&input[len..], Capability::Experimental { code: 0 })) + } + CapabilityCode::Experimental1 => { + Ok((&input[len..], Capability::Experimental { code: 1 })) + } + CapabilityCode::Experimental2 => { + Ok((&input[len..], Capability::Experimental { code: 2 })) + } + CapabilityCode::Experimental3 => { + Ok((&input[len..], Capability::Experimental { code: 3 })) + } + CapabilityCode::Experimental4 => { + Ok((&input[len..], Capability::Experimental { code: 4 })) + } + CapabilityCode::Experimental5 => { + Ok((&input[len..], Capability::Experimental { code: 5 })) + } + CapabilityCode::Experimental6 => { + Ok((&input[len..], Capability::Experimental { code: 6 })) + } + CapabilityCode::Experimental7 => { + Ok((&input[len..], Capability::Experimental { code: 7 })) + } + CapabilityCode::Experimental8 => { + Ok((&input[len..], Capability::Experimental { code: 8 })) + } + CapabilityCode::Experimental9 => { + Ok((&input[len..], Capability::Experimental { code: 9 })) + } + CapabilityCode::Experimental10 => { + Ok((&input[len..], Capability::Experimental { code: 10 })) + } + CapabilityCode::Experimental11 => { + Ok((&input[len..], Capability::Experimental { code: 11 })) + } + CapabilityCode::Experimental12 => { + Ok((&input[len..], Capability::Experimental { code: 12 })) + } + CapabilityCode::Experimental13 => { + Ok((&input[len..], Capability::Experimental { code: 13 })) + } + CapabilityCode::Experimental14 => { + Ok((&input[len..], Capability::Experimental { code: 14 })) + } + CapabilityCode::Experimental15 => { + Ok((&input[len..], Capability::Experimental { code: 15 })) + } + CapabilityCode::Experimental16 => { + Ok((&input[len..], Capability::Experimental { code: 16 })) + } + CapabilityCode::Experimental17 => { + Ok((&input[len..], Capability::Experimental { code: 17 })) + } + CapabilityCode::Experimental18 => { + Ok((&input[len..], Capability::Experimental { code: 18 })) + } + CapabilityCode::Experimental19 => { + Ok((&input[len..], Capability::Experimental { code: 19 })) + } + CapabilityCode::Experimental20 => { + Ok((&input[len..], Capability::Experimental { code: 20 })) + } + CapabilityCode::Experimental21 => { + Ok((&input[len..], Capability::Experimental { code: 21 })) + } + CapabilityCode::Experimental22 => { + Ok((&input[len..], Capability::Experimental { code: 22 })) + } + CapabilityCode::Experimental23 => { + Ok((&input[len..], Capability::Experimental { code: 23 })) + } + CapabilityCode::Experimental24 => { + Ok((&input[len..], Capability::Experimental { code: 24 })) + } + CapabilityCode::Experimental25 => { + Ok((&input[len..], Capability::Experimental { code: 25 })) + } + CapabilityCode::Experimental26 => { + Ok((&input[len..], Capability::Experimental { code: 26 })) + } + CapabilityCode::Experimental27 => { + Ok((&input[len..], Capability::Experimental { code: 27 })) + } + CapabilityCode::Experimental28 => { + Ok((&input[len..], Capability::Experimental { code: 28 })) + } + CapabilityCode::Experimental29 => { + Ok((&input[len..], Capability::Experimental { code: 29 })) + } + CapabilityCode::Experimental30 => { + Ok((&input[len..], Capability::Experimental { code: 30 })) + } + CapabilityCode::Experimental31 => { + Ok((&input[len..], Capability::Experimental { code: 31 })) + } + CapabilityCode::Experimental32 => { + Ok((&input[len..], Capability::Experimental { code: 32 })) + } + CapabilityCode::Experimental33 => { + Ok((&input[len..], Capability::Experimental { code: 33 })) + } + CapabilityCode::Experimental34 => { + Ok((&input[len..], Capability::Experimental { code: 34 })) + } + CapabilityCode::Experimental35 => { + Ok((&input[len..], Capability::Experimental { code: 35 })) + } + CapabilityCode::Experimental36 => { + Ok((&input[len..], Capability::Experimental { code: 36 })) + } + CapabilityCode::Experimental37 => { + Ok((&input[len..], Capability::Experimental { code: 37 })) + } + CapabilityCode::Experimental38 => { + Ok((&input[len..], Capability::Experimental { code: 38 })) + } + CapabilityCode::Experimental39 => { + Ok((&input[len..], Capability::Experimental { code: 39 })) + } + CapabilityCode::Experimental40 => { + Ok((&input[len..], Capability::Experimental { code: 40 })) + } + CapabilityCode::Experimental41 => { + Ok((&input[len..], Capability::Experimental { code: 41 })) + } + CapabilityCode::Experimental42 => { + Ok((&input[len..], Capability::Experimental { code: 42 })) + } + CapabilityCode::Experimental43 => { + Ok((&input[len..], Capability::Experimental { code: 43 })) + } + CapabilityCode::Experimental44 => { + Ok((&input[len..], Capability::Experimental { code: 44 })) + } + CapabilityCode::Experimental45 => { + Ok((&input[len..], Capability::Experimental { code: 45 })) + } + CapabilityCode::Experimental46 => { + Ok((&input[len..], Capability::Experimental { code: 46 })) + } + CapabilityCode::Experimental47 => { + Ok((&input[len..], Capability::Experimental { code: 47 })) + } + CapabilityCode::Experimental48 => { + Ok((&input[len..], Capability::Experimental { code: 48 })) + } + CapabilityCode::Experimental49 => { + Ok((&input[len..], Capability::Experimental { code: 49 })) + } + CapabilityCode::Experimental50 => { + Ok((&input[len..], Capability::Experimental { code: 50 })) + } + CapabilityCode::Experimental51 => { + Ok((&input[len..], Capability::Experimental { code: 51 })) + } + CapabilityCode::Reserved => { + Ok((&input[len..], Capability::Reserved { code: 0 })) + } + } + } + + /// Helper function to generate an IPv4 Unicast MP-BGP capability. + pub fn ipv4_unicast() -> Self { + Self::MultiprotocolExtensions { + 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.into(), + safi: Safi::Unicast.into(), + } + } +} + +/// The set of capability codes supported by this BGP implementation +#[derive( + Clone, Copy, Debug, Eq, IntoPrimitive, PartialEq, TryFromPrimitive, +)] +#[repr(u8)] +pub enum CapabilityCode { + /// RFC 5492 + Reserved = 0, + + /// RFC 2858 + MultiprotocolExtensions = 1, + + /// RFC 2918 + RouteRefresh = 2, + + /// RFC 5291 + OutboundRouteFiltering = 3, + + /// RFC 8277 (deprecated) + MultipleRoutesToDestination = 4, + + /// RFC 8950 + ExtendedNextHopEncoding = 5, + + /// RFC 8654 + BGPExtendedMessage = 6, + + /// RFC 8205 + BgpSec = 7, + + /// RFC 8277 + MultipleLabels = 8, + + /// RFC 9234 + BgpRole = 9, + + /// RFC 4724 + GracefulRestart = 64, + + /// RFC 6793 + FourOctetAs = 65, + + /// draft-ietf-idr-dynamic-cap + DynamicCapability = 67, + + /// draft-ietf-idr-bgp-multisession + MultisessionBgp = 68, + + /// RFC 7911 + AddPath = 69, + + /// RFC 7313 + EnhancedRouteRefresh = 70, + + /// draft-uttaro-idr-bgp-persistence + LongLivedGracefulRestart = 71, + + /// draft-ietf-idr-rpd-04 + RoutingPolicyDistribution = 72, + + /// draft-walton-bgp-hostname-capability + Fqdn = 73, + + /// RFC 8810 (deprecated) + PrestandardRouteRefresh = 128, + + /// RFC 8810 (deprecated) + PrestandardOrfAndPd = 129, + + /// RFC 8810 (deprecated) + PrestandardOutboundRouteFiltering = 130, + + /// RFC 8810 (deprecated) + PrestandardMultisession = 131, + + /// RFC 8810 (deprecated) + PrestandardFqdn = 184, + + /// RFC 8810 (deprecated) + PrestandardOperationalMessage = 185, + + /// RFC 8810 + Experimental0 = 186, + Experimental1, + Experimental2, + Experimental3, + Experimental4, + Experimental5, + Experimental6, + Experimental7, + Experimental8, + Experimental9, + Experimental10, + Experimental11, + Experimental12, + Experimental13, + Experimental14, + Experimental15, + Experimental16, + Experimental17, + Experimental18, + Experimental19, + Experimental20, + Experimental21, + Experimental22, + Experimental23, + Experimental24, + Experimental25, + Experimental26, + Experimental27, + Experimental28, + Experimental29, + Experimental30, + Experimental31, + Experimental32, + Experimental33, + Experimental34, + Experimental35, + Experimental36, + Experimental37, + Experimental38, + Experimental39, + Experimental40, + Experimental41, + Experimental42, + Experimental43, + Experimental44, + Experimental45, + Experimental46, + Experimental47, + Experimental48, + Experimental49, + Experimental50, + Experimental51, +} + +impl From for CapabilityCode { + fn from(value: Capability) -> Self { + match value { + Capability::MultiprotocolExtensions { afi: _, safi: _ } => { + CapabilityCode::MultiprotocolExtensions + } + Capability::RouteRefresh {} => CapabilityCode::RouteRefresh, + Capability::OutboundRouteFiltering {} => { + CapabilityCode::OutboundRouteFiltering + } + Capability::MultipleRoutesToDestination {} => { + CapabilityCode::MultipleRoutesToDestination + } + Capability::ExtendedNextHopEncoding {} => { + CapabilityCode::ExtendedNextHopEncoding + } + Capability::BGPExtendedMessage {} => { + CapabilityCode::BGPExtendedMessage + } + Capability::BgpSec {} => CapabilityCode::BgpSec, + Capability::MultipleLabels {} => CapabilityCode::MultipleLabels, + Capability::BgpRole {} => CapabilityCode::BgpRole, + Capability::GracefulRestart {} => CapabilityCode::GracefulRestart, + Capability::FourOctetAs { asn: _ } => CapabilityCode::FourOctetAs, + Capability::DynamicCapability {} => { + CapabilityCode::DynamicCapability + } + Capability::MultisessionBgp {} => CapabilityCode::MultisessionBgp, + Capability::AddPath { elements: _ } => CapabilityCode::AddPath, + Capability::EnhancedRouteRefresh {} => { + CapabilityCode::EnhancedRouteRefresh + } + Capability::LongLivedGracefulRestart {} => { + CapabilityCode::LongLivedGracefulRestart + } + Capability::RoutingPolicyDistribution {} => { + CapabilityCode::RoutingPolicyDistribution + } + Capability::Fqdn {} => CapabilityCode::Fqdn, + Capability::PrestandardRouteRefresh {} => { + CapabilityCode::PrestandardRouteRefresh + } + Capability::PrestandardOrfAndPd {} => { + CapabilityCode::PrestandardOrfAndPd + } + Capability::PrestandardOutboundRouteFiltering {} => { + CapabilityCode::PrestandardOutboundRouteFiltering + } + Capability::PrestandardMultisession {} => { + CapabilityCode::PrestandardMultisession + } + Capability::PrestandardFqdn {} => CapabilityCode::PrestandardFqdn, + Capability::PrestandardOperationalMessage {} => { + CapabilityCode::PrestandardOperationalMessage + } + Capability::Experimental { code } => match code { + 0 => CapabilityCode::Experimental0, + 1 => CapabilityCode::Experimental1, + 2 => CapabilityCode::Experimental2, + 3 => CapabilityCode::Experimental3, + 4 => CapabilityCode::Experimental4, + 5 => CapabilityCode::Experimental5, + 6 => CapabilityCode::Experimental6, + 7 => CapabilityCode::Experimental7, + 8 => CapabilityCode::Experimental8, + 9 => CapabilityCode::Experimental9, + 10 => CapabilityCode::Experimental10, + 11 => CapabilityCode::Experimental11, + 12 => CapabilityCode::Experimental12, + 13 => CapabilityCode::Experimental13, + 14 => CapabilityCode::Experimental14, + 15 => CapabilityCode::Experimental15, + 16 => CapabilityCode::Experimental16, + 17 => CapabilityCode::Experimental17, + 18 => CapabilityCode::Experimental18, + 19 => CapabilityCode::Experimental19, + 20 => CapabilityCode::Experimental20, + 21 => CapabilityCode::Experimental21, + 22 => CapabilityCode::Experimental22, + 23 => CapabilityCode::Experimental23, + 24 => CapabilityCode::Experimental24, + 25 => CapabilityCode::Experimental25, + 26 => CapabilityCode::Experimental26, + 27 => CapabilityCode::Experimental27, + 28 => CapabilityCode::Experimental28, + 29 => CapabilityCode::Experimental29, + 30 => CapabilityCode::Experimental30, + 31 => CapabilityCode::Experimental31, + 32 => CapabilityCode::Experimental32, + 33 => CapabilityCode::Experimental33, + 34 => CapabilityCode::Experimental34, + 35 => CapabilityCode::Experimental35, + 36 => CapabilityCode::Experimental36, + 37 => CapabilityCode::Experimental37, + 38 => CapabilityCode::Experimental38, + 39 => CapabilityCode::Experimental39, + 40 => CapabilityCode::Experimental40, + 41 => CapabilityCode::Experimental41, + 42 => CapabilityCode::Experimental42, + 43 => CapabilityCode::Experimental43, + 44 => CapabilityCode::Experimental44, + 45 => CapabilityCode::Experimental45, + 46 => CapabilityCode::Experimental46, + 47 => CapabilityCode::Experimental47, + 48 => CapabilityCode::Experimental48, + 49 => CapabilityCode::Experimental49, + 50 => CapabilityCode::Experimental50, + 51 => CapabilityCode::Experimental51, + _ => CapabilityCode::Experimental0, + }, + Capability::Unassigned { code: _ } => CapabilityCode::Reserved, + Capability::Reserved { code: _ } => CapabilityCode::Reserved, + } + } +} + +/// Address families supported by Maghemite BGP. +#[derive( + Debug, + Copy, + Clone, + Deserialize, + Eq, + IntoPrimitive, + JsonSchema, + PartialEq, + Serialize, + TryFromPrimitive, +)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +#[repr(u16)] +pub enum Afi { + /// Internet protocol version 4 + Ipv4 = 1, + /// Internet protocol version 6 + 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, + IntoPrimitive, + JsonSchema, + PartialEq, + Serialize, + TryFromPrimitive, +)] +#[repr(u8)] +pub enum Safi { + /// Network Layer Reachability Information used for unicast forwarding + Unicast = 1, +} + +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()) + } +} + +// ============================================================================ +// BGP Message Parse Error Types +// ============================================================================ +// 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. +// +// 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) + /// 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 + 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, + /// 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 + InvalidMpNextHopLength { + afi: u16, + expected: &'static str, + got: usize, + }, + + // 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, + }, + + // NLRI/prefix parsing errors + /// NLRI section is empty when prefix length byte expected + NlriMissingLength { + /// Which section the error occurred in + section: NlriSection, + }, + /// Prefix length exceeds maximum for address family (32 for IPv4, 128 for IPv6) + InvalidNlriMask { + section: NlriSection, + length: u8, + max: u8, + }, + /// Not enough bytes for declared prefix length + TruncatedNlri { + section: NlriSection, + needed: usize, + available: usize, + }, + + // 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::MessageTooShort { expected_min, got } => { + write!( + f, + "UPDATE message too short: expected minimum {}, got {}", + expected_min, got + ) + } + 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::DuplicateAttribute { type_code } => { + write!(f, "duplicate attribute type {}", type_code) + } + 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) + } + } + } +} + +/// 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. + /// Use the treat_as_withdraw() method to check if any TreatAsWithdraw errors occurred. + pub errors: Vec<(UpdateParseErrorReason, AttributeAction)>, +} + +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. +/// +/// 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 + ) + } +} + +/// 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 + 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 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}") + } + 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}"), + } + } +} + +/// Fatal OPEN parse error requiring session reset. +#[derive(Debug, Clone)] +pub struct OpenParseError { + pub error_code: ErrorCode, + pub error_subcode: ErrorSubcode, + pub reason: OpenParseErrorReason, +} + +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, PathAttributeV1 for attributes +#[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(), + // 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() + .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), + } + } +} + +// ============================================================================ +// 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 Capability { - /// Multiprotocol extensions as defined in RFC 2858 - MultiprotocolExtensions { - afi: u16, - safi: u8, - }, +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]), +} - /// Route refresh capability as defined in RFC 2918. - RouteRefresh {}, +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 + } + } + } +} - //TODO - /// Outbound filtering capability as defined in RFC 5291. Note this - /// capability is not yet implemented. - OutboundRouteFiltering {}, +/// 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, +} - //TODO - /// Multiple routes to destination capability as defined in RFC 8277 - /// (deprecated). Note this capability is not yet implemented. - MultipleRoutesToDestination {}, +impl From for PathAttributeTypeV1 { + fn from(t: PathAttributeType) -> Self { + Self { + flags: t.flags, + type_code: PathAttributeTypeCodeV1::from(t.type_code), + } + } +} - //TODO - /// Multiple nexthop encoding capability as defined in RFC 8950. Note this - /// capability is not yet implemented. - ExtendedNextHopEncoding {}, +/// 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, +} - //TODO - /// Extended message capability as defined in RFC 8654. Note this - /// capability is not yet implemented. - BGPExtendedMessage {}, +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, + }) + } +} - //TODO - /// BGPSec as defined in RFC 8205. Note this capability is not yet - /// implemented. - BgpSec {}, +#[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; - //TODO - /// Multiple label support as defined in RFC 8277. Note this capability - /// is not yet implemented. - MultipleLabels {}, + #[derive(Debug)] + struct PrefixConversionTestCase { + description: &'static str, + address_family: AddressFamily, + prefix_length: u8, + input_bytes: Vec, + expected_address: &'static str, + } - //TODO - /// BGP role capability as defined in RFC 9234. Note this capability is not - /// yet implemented. - BgpRole {}, + impl PrefixConversionTestCase { + fn new_ipv4( + description: &'static str, + prefix_length: u8, + input_addr: Ipv4Addr, + expected_address: &'static str, + ) -> Self { + Self { + description, + address_family: AddressFamily::Ipv4, + prefix_length, + input_bytes: input_addr.octets().to_vec(), + expected_address, + } + } - //TODO - /// Graceful restart as defined in RFC 4724. Note this capability is not - /// yet implemented. - GracefulRestart {}, + fn new_ipv6( + description: &'static str, + prefix_length: u8, + input_addr: Ipv6Addr, + expected_address: &'static str, + ) -> Self { + Self { + description, + address_family: AddressFamily::Ipv6, + prefix_length, + input_bytes: input_addr.octets().to_vec(), + expected_address, + } + } + } - /// Four octet AS numbers as defined in RFC 6793. - FourOctetAs { - asn: u32, - }, + #[test] + fn header_round_trip() { + let h0 = Header { + length: 0x1701, + typ: MessageType::Notification, + }; - //TODO - /// Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note - /// this capability is not yet implemented. - DynamicCapability {}, + let buf = h0.to_wire(); + println!("buf: {}", buf.hex_dump()); - //TODO - /// Multi session support as defined in draft-ietf-idr-bgp-multisession. - /// Note this capability is not yet supported. - MultisessionBgp {}, + assert_eq!( + buf, + vec![ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // marker + 0x17, 0x01, // length + 3, // type + ] + ); - /// Add path capability as defined in RFC 7911. - AddPath { - elements: BTreeSet, - }, + let h1 = Header::from_wire(&buf).expect("header from wire"); + assert_eq!(h0, h1); + } - //TODO - /// Enhanced route refresh as defined in RFC 7313. Note this capability is - /// not yet supported. - EnhancedRouteRefresh {}, + #[test] + fn open_round_trip() { + let om0 = OpenMessage::new4(395849, 0x1234, 0xaabbccdd); - //TODO - /// Long-lived graceful restart as defined in - /// draft-uttaro-idr-bgp-persistence. Note this capability is not yet - /// supported. - LongLivedGracefulRestart {}, + let buf = om0.to_wire().expect("open message to wire"); + println!("buf: {}", buf.hex_dump()); - //TODO - /// Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note - /// this capability is not yet supported. - RoutingPolicyDistribution {}, + let om1 = OpenMessage::from_wire(&buf).expect("open message from wire"); + assert_eq!(om0, om1); + } - //TODO - /// Fully qualified domain names as defined - /// intdraft-walton-bgp-hostname-capability. Note this capability is not - /// yet supported. - Fqdn {}, + #[test] + fn update_round_trip() { + let um0 = UpdateMessage { + withdrawn: vec![rdb::Prefix4::new( + std::net::Ipv4Addr::new(0, 23, 1, 12), + 32, + )], + 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::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), + ], + errors: vec![], + }; - //TODO - /// Pre-standard route refresh as defined in RFC 8810 (deprecated). Note - /// this capability is not yet supported. - PrestandardRouteRefresh {}, + let buf = um0.to_wire().expect("update message to wire"); + println!("buf: {}", buf.hex_dump()); + + let um1 = + UpdateMessage::from_wire(&buf).expect("update message from wire"); + assert_eq!(um0, um1); + } - //TODO - /// Pre-standard prefix-based outbound route filtering as defined in - /// RFC 8810 (deprecated). Note this is not yet implemented. - PrestandardOrfAndPd {}, + #[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![], + }; - //TODO - /// Pre-standard outbound route filtering as defined in RFC 8810 - /// (deprecated). Note this is not yet implemented. - PrestandardOutboundRouteFiltering {}, + let buf = nm0.to_wire().expect("notification message to wire"); + let nm1 = NotificationMessage::from_wire(&buf) + .expect("notification message from wire"); - //TODO - /// Pre-standard multisession as defined in RFC 8810 (deprecated). Note - /// this is not yet implemented. - PrestandardMultisession {}, + assert_eq!(nm0.error_code, nm1.error_code); + assert_eq!(nm0.error_subcode, nm1.error_subcode); + assert_eq!(nm0.data, nm1.data); + } - //TODO - /// Pre-standard fully qualified domain names as defined in RFC 8810 - /// (deprecated). Note this is not yet implemented. - PrestandardFqdn {}, + #[test] + fn route_refresh_round_trip() { + // IPv4 Unicast route refresh + let rr0 = RouteRefreshMessage { + afi: Afi::Ipv4.into(), + safi: Safi::Unicast.into(), + }; - //TODO - /// Pre-standard operational messages as defined in RFC 8810 (deprecated). - /// Note this is not yet implemented. - PrestandardOperationalMessage {}, + let buf = rr0.to_wire(); + let rr1 = RouteRefreshMessage::from_wire(&buf) + .expect("route refresh from wire"); + assert_eq!(rr0, rr1); - /// Experimental capability as defined in RFC 8810. - Experimental { - code: u8, - }, + // IPv6 Unicast route refresh + let rr2 = RouteRefreshMessage { + afi: Afi::Ipv6.into(), + safi: Safi::Unicast.into(), + }; - Unassigned { - code: u8, - }, + let buf = rr2.to_wire(); + let rr3 = RouteRefreshMessage::from_wire(&buf) + .expect("route refresh from wire"); + assert_eq!(rr2, rr3); + } - Reserved { - code: u8, - }, -} + #[test] + fn prefix_within() { + // Test IPv4 prefix containment + let ipv4_prefixes: &[Prefix] = &[ + cidr!("10.10.10.10/32"), + cidr!("10.10.10.0/24"), + cidr!("10.10.0.0/16"), + cidr!("10.0.0.0/8"), + cidr!("0.0.0.0/0"), + ]; -impl Display for Capability { - fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { - match self { - Capability::MultiprotocolExtensions { afi, safi } => { - write!(f, "MP-Extensions {afi}/{safi}") - } - Capability::RouteRefresh {} => { - write!(f, "Route Refresh") - } - Capability::OutboundRouteFiltering {} => { - write!(f, "ORF") - } - Capability::MultipleRoutesToDestination {} => { - write!(f, "Multiple Routes to Destination") - } - Capability::ExtendedNextHopEncoding {} => { - write!(f, "Extended Next Hop Encoding") - } - Capability::BGPExtendedMessage {} => { - write!(f, "BGP Extended Message") - } - Capability::BgpSec {} => { - write!(f, "BGP Sec") - } - Capability::MultipleLabels {} => { - write!(f, "Multiple Labels") - } - Capability::BgpRole {} => { - write!(f, "BGP Role") - } - Capability::GracefulRestart {} => { - write!(f, "Graceful Restart") - } - Capability::FourOctetAs { asn } => { - write!(f, "Four Octet ASN {asn}") - } - Capability::DynamicCapability {} => { - write!(f, "Dynamic Capability") - } - Capability::MultisessionBgp {} => { - write!(f, "Multi-session BGP") - } - Capability::AddPath { elements } => { - let mut elements_string = String::new(); - for e in elements { - elements_string.push_str(&format!("{e}, ")); + for i in 0..ipv4_prefixes.len() { + for j in i..ipv4_prefixes.len() { + // shorter prefixes contain longer or equal + assert!(ipv4_prefixes[i].within(&ipv4_prefixes[j])); + if i != j { + // longer prefixes should not contain shorter + assert!(!ipv4_prefixes[j].within(&ipv4_prefixes[i])) } - write!(f, "AddPath [ {elements_string}]") - } - Capability::EnhancedRouteRefresh {} => { - write!(f, "Enhanced Route Refresh") - } - Capability::LongLivedGracefulRestart {} => { - write!(f, "Long-Lived Graceful Restart") - } - Capability::RoutingPolicyDistribution {} => { - write!(f, "Routing Policy Distribution") - } - Capability::Fqdn {} => { - write!(f, "FQDN") - } - Capability::PrestandardRouteRefresh {} => { - write!(f, "Route Refresh (Prestandard)") - } - Capability::PrestandardOrfAndPd {} => { - write!(f, "ORF / Policy Distribution (Prestandard)") - } - Capability::PrestandardOutboundRouteFiltering {} => { - write!(f, "ORF (Prestandard)") - } - Capability::PrestandardMultisession {} => { - write!(f, "Multi-session BGP (Prestandard)") - } - Capability::PrestandardFqdn {} => { - write!(f, "FQDN (Prestandard)") - } - Capability::PrestandardOperationalMessage {} => { - write!(f, "Operational Message (Prestandard)") - } - Capability::Experimental { code } => { - write!(f, "Experimental ({code})") - } - Capability::Unassigned { code } => { - write!(f, "Unassigned ({code})") - } - Capability::Reserved { code } => { - write!(f, "Reserved ({code})") } } - } -} -impl Capability { - pub fn to_wire(&self) -> Result, Error> { - match self { - Self::MultiprotocolExtensions { afi, safi } => { - let mut buf = - vec![CapabilityCode::MultiprotocolExtensions as u8, 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]; - Ok(buf) - } - Self::GracefulRestart {} => { - //TODO audit - let buf = vec![CapabilityCode::GracefulRestart as u8, 0]; - Ok(buf) - } - Self::FourOctetAs { asn } => { - let mut buf = vec![CapabilityCode::FourOctetAs as u8, 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, - ]; - for e in elements { - buf.extend_from_slice(&e.afi.to_be_bytes()); - buf.push(e.safi); - buf.push(e.send_receive); + // Test IPv6 prefix containment + let ipv6_prefixes: &[Prefix] = &[ + cidr!("2001:db8:1:1::1/128"), + cidr!("2001:db8:1:1::/64"), + cidr!("2001:db8:1::/48"), + cidr!("2001:db8::/32"), + cidr!("::/0"), + ]; + + for i in 0..ipv6_prefixes.len() { + for j in i..ipv6_prefixes.len() { + // shorter prefixes contain longer or equal + assert!(ipv6_prefixes[i].within(&ipv6_prefixes[j])); + if i != j { + // longer prefixes should not contain shorter + assert!(!ipv6_prefixes[j].within(&ipv6_prefixes[i])) } - Ok(buf) - } - Self::EnhancedRouteRefresh {} => { - //TODO audit - let buf = vec![CapabilityCode::EnhancedRouteRefresh as u8, 0]; - Ok(buf) } - Self::Experimental { code: _ } => Err(Error::Experimental), - Self::Unassigned { code } => Err(Error::Unassigned(*code)), - Self::Reserved { code: _ } => Err(Error::ReservedCapability), - x => Err(Error::UnsupportedCapability(x.clone())), } - } - 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; - if input.len() < len { - return Err(Error::Eom); - } - let code = match CapabilityCode::try_from(code) { - Ok(code) => code, - Err(_) => { - return Ok((&input[len..], Capability::Unassigned { code })); - } - }; - let mut input = input; + // Test non-overlapping prefixes + let a: Prefix = cidr!("10.10.0.0/16"); + let b: Prefix = cidr!("10.20.0.0/16"); + assert!(!a.within(&b)); + let a: Prefix = cidr!("10.10.0.0/24"); + assert!(!a.within(&b)); - match code { - CapabilityCode::MultiprotocolExtensions => { - let (input, afi) = be_u16(input)?; - let (input, _) = be_u8(input)?; - let (input, safi) = be_u8(input)?; - Ok((input, Capability::MultiprotocolExtensions { afi, safi })) - } - CapabilityCode::RouteRefresh => { - Ok((&input[len..], Capability::RouteRefresh {})) - } + let a: Prefix = cidr!("2001:db8:1::/48"); + let b: Prefix = cidr!("2001:db8:2::/48"); + assert!(!a.within(&b)); - CapabilityCode::GracefulRestart => { - //TODO handle for real - Ok((&input[len..], Capability::GracefulRestart {})) - } - CapabilityCode::FourOctetAs => { - let (input, asn) = be_u32(input)?; - Ok((input, Capability::FourOctetAs { asn })) - } - CapabilityCode::AddPath => { - let mut elements = BTreeSet::new(); - while !input.is_empty() { - let (remaining, afi) = be_u16(input)?; - let (remaining, safi) = be_u8(remaining)?; - let (remaining, send_receive) = be_u8(remaining)?; - elements.insert(AddPathElement { - afi, - safi, - send_receive, - }); - input = remaining; - } - Ok((input, Capability::AddPath { elements })) - } - CapabilityCode::EnhancedRouteRefresh => { - //TODO handle for real - Ok((&input[len..], Capability::EnhancedRouteRefresh {})) - } + // Test default routes contain same-family prefixes + let ipv4_default: Prefix = cidr!("0.0.0.0/0"); + let ipv6_default: Prefix = cidr!("::/0"); - CapabilityCode::Fqdn => { - //TODO handle for real - Ok((&input[len..], Capability::Fqdn {})) - } + let any_ipv4: Prefix = cidr!("192.168.1.0/24"); + let any_ipv6: Prefix = cidr!("2001:db8::/48"); - CapabilityCode::PrestandardRouteRefresh => { - //TODO handle for real - Ok((&input[len..], Capability::PrestandardRouteRefresh {})) - } + assert!(any_ipv4.within(&ipv4_default)); + assert!(any_ipv6.within(&ipv6_default)); - CapabilityCode::BGPExtendedMessage => { - //TODO handle for real - Ok((&input[len..], Capability::BGPExtendedMessage {})) - } + // Test cross-family default route edge cases + // IPv4 prefixes should NOT be within IPv6 default route + assert!(!any_ipv4.within(&ipv6_default)); + assert!(!ipv4_default.within(&ipv6_default)); - CapabilityCode::LongLivedGracefulRestart => { - //TODO handle for real - Ok((&input[len..], Capability::LongLivedGracefulRestart {})) - } + // IPv6 prefixes should NOT be within IPv4 default route + assert!(!any_ipv6.within(&ipv4_default)); + assert!(!ipv6_default.within(&ipv4_default)); + } - CapabilityCode::MultipleRoutesToDestination => { - //TODO handle for real - Ok((&input[len..], Capability::MultipleRoutesToDestination {})) - } + #[test] + fn prefix_conversion() { + // Test both IPv4 and IPv6 prefix conversions including edge cases and host bit zeroing + let test_cases = vec![ + // IPv4 test cases + // Input: 0.0.0.0 (default route) + PrefixConversionTestCase::new_ipv4( + "IPv4 default route", + 0, + ip!("0.0.0.0"), + "0.0.0.0", + ), + // Input: 10.255.255.255/8 -> 10.0.0.0/8 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 Class A with host bits", + 8, + ip!("10.255.255.255"), + "10.0.0.0", + ), + // Input: 172.31.255.255/12 -> 172.16.0.0/12 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 large private network with host bits", + 12, + ip!("172.31.255.255"), + "172.16.0.0", + ), + // Input: 172.16.255.255/16 -> 172.16.0.0/16 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 common allocation with host bits", + 16, + ip!("172.16.255.255"), + "172.16.0.0", + ), + // Input: 203.0.113.255/20 -> 203.0.112.0/20 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 prefix with host bits in last 12 bits", + 20, + ip!("203.0.113.255"), + "203.0.112.0", + ), + // Input: 192.168.1.123/24 -> 192.168.1.0/24 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 common subnet with host bits", + 24, + ip!("192.168.1.123"), + "192.168.1.0", + ), + // Input: 198.51.100.7/30 -> 198.51.100.4/30 (host bits zeroed) + PrefixConversionTestCase::new_ipv4( + "IPv4 point-to-point link with host bits", + 30, + ip!("198.51.100.7"), + "198.51.100.4", + ), + // Input: 10.0.0.1/32 -> 10.0.0.1/32 (no host bits to zero) + PrefixConversionTestCase::new_ipv4( + "IPv4 host route - no host bits to zero", + 32, + ip!("10.0.0.1"), + "10.0.0.1", + ), + // IPv6 test cases + // Input: :: (all zeros, default route) + PrefixConversionTestCase::new_ipv6( + "IPv6 default route", + 0, + ip!("::"), + "::", + ), + // Input: fd00:ffff:ffff:ffff:ffff:ffff:ffff:ffff/8 -> fd00::/8 (host bits zeroed) + PrefixConversionTestCase::new_ipv6( + "IPv6 unique local address prefix with host bits", + 8, + ip!("fd00:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), + "fd00::", + ), + // Input: 2001:db8:1234:5678:9abc:def0:1122:3344/32 -> 2001:db8::/32 (host bits zeroed) + PrefixConversionTestCase::new_ipv6( + "IPv6 common allocation size with host bits", + 32, + ip!("2001:db8:1234:5678:9abc:def0:1122:3344"), + "2001:db8::", + ), + // Input: 2001:db8:1234:ffff:ffff:ffff:ffff:ffff/48 -> 2001:db8:1234::/48 (host bits zeroed) + PrefixConversionTestCase::new_ipv6( + "IPv6 site prefix with host bits in last 80 bits", + 48, + ip!("2001:db8:1234:ffff:ffff:ffff:ffff:ffff"), + "2001:db8:1234::", + ), + // Input: 2001:db8::1234:5678:9abc:def0/64 -> 2001:db8::/64 (host bits zeroed) + PrefixConversionTestCase::new_ipv6( + "IPv6 common prefix length with host bits", + 64, + ip!("2001:db8::1234:5678:9abc:def0"), + "2001:db8::", + ), + // Input: 2001:db8::ff/120 -> 2001:db8::/120 (host bits zeroed) + PrefixConversionTestCase::new_ipv6( + "IPv6 leaves only 8 host bits", + 120, + ip!("2001:db8::ff"), + "2001:db8::", + ), + // Input: 2001:db8::1/128 -> 2001:db8::1/128 (no host bits to zero) + PrefixConversionTestCase::new_ipv6( + "IPv6 host route - no host bits to zero", + 128, + ip!("2001:db8::1"), + "2001:db8::1", + ), + ]; - CapabilityCode::ExtendedNextHopEncoding => { - //TODO handle for real - Ok((&input[len..], Capability::ExtendedNextHopEncoding {})) - } + for test_case in test_cases { + let prefix = match test_case.address_family { + AddressFamily::Ipv4 => { + let mut octets = [0u8; 4]; + octets.copy_from_slice(&test_case.input_bytes); + rdb::Prefix::V4(rdb::Prefix4::new( + Ipv4Addr::from(octets), + test_case.prefix_length, + )) + } + AddressFamily::Ipv6 => { + let mut octets = [0u8; 16]; + octets.copy_from_slice(&test_case.input_bytes); + rdb::Prefix::V6(rdb::Prefix6::new( + Ipv6Addr::from(octets), + test_case.prefix_length, + )) + } + }; - CapabilityCode::OutboundRouteFiltering => { - //TODO handle for real - Ok((&input[len..], Capability::OutboundRouteFiltering {})) + match test_case.address_family { + AddressFamily::Ipv4 => { + if let rdb::Prefix::V4(rdb_prefix4) = prefix { + assert_eq!( + rdb_prefix4.length, test_case.prefix_length, + "IPv4 length mismatch for {}", + test_case.description + ); + assert_eq!( + rdb_prefix4.value, + Ipv4Addr::from_str(test_case.expected_address) + .unwrap(), + "IPv4 address mismatch for {}: expected {}, got {}", + test_case.description, + test_case.expected_address, + rdb_prefix4.value + ); + assert!( + rdb_prefix4.host_bits_are_unset(), + "IPv4 host bits not properly zeroed for {}", + test_case.description + ); + } else { + panic!("Expected IPv4 prefix"); + } + } + AddressFamily::Ipv6 => { + if let rdb::Prefix::V6(rdb_prefix6) = prefix { + assert_eq!( + rdb_prefix6.length, test_case.prefix_length, + "IPv6 length mismatch for {}", + test_case.description + ); + assert_eq!( + rdb_prefix6.value, + Ipv6Addr::from_str(test_case.expected_address) + .unwrap(), + "IPv6 address mismatch for {}: expected {}, got {}", + test_case.description, + test_case.expected_address, + rdb_prefix6.value + ); + assert!( + rdb_prefix6.host_bits_are_unset(), + "IPv6 host bits not properly zeroed for {}", + test_case.description + ); + } else { + panic!("Expected IPv6 prefix"); + } + } } + } + } - CapabilityCode::BgpSec => { - //TODO handle for real - Ok((&input[len..], Capability::BgpSec {})) - } + #[test] + fn test_nexthop_length_validation() { + // Test that NEXT_HOP path attribute with incorrect length is rejected - CapabilityCode::MultipleLabels => { - //TODO handle for real - Ok((&input[len..], Capability::MultipleLabels {})) - } + // Build a minimal valid UPDATE message manually, then corrupt the NEXT_HOP length + let mut buf = Vec::new(); - CapabilityCode::BgpRole => { - //TODO handle for real - Ok((&input[len..], Capability::BgpRole {})) - } + // Withdrawn routes length (0) + buf.extend_from_slice(&0u16.to_be_bytes()); - CapabilityCode::DynamicCapability => { - //TODO handle for real - Ok((&input[len..], Capability::DynamicCapability {})) - } + // Path attributes length (will be filled in later) + let path_attrs_len_offset = buf.len(); + buf.extend_from_slice(&0u16.to_be_bytes()); + + let path_attrs_start = buf.len(); + + // ORIGIN attribute (well-known, transitive, complete) + buf.push(0x40); // flags + buf.push(1); // type code (ORIGIN) + buf.push(1); // length + buf.push(0); // IGP - CapabilityCode::MultisessionBgp => { - //TODO handle for real - Ok((&input[len..], Capability::MultisessionBgp {})) - } + // AS_PATH attribute (well-known, transitive, complete) + buf.push(0x40); // flags + buf.push(2); // type code (AS_PATH) + buf.push(6); // length + buf.push(2); // AS_SEQUENCE + buf.push(1); // path segment length + buf.extend_from_slice(&(65000u32).to_be_bytes()); - CapabilityCode::RoutingPolicyDistribution => { - //TODO handle for real - Ok((&input[len..], Capability::RoutingPolicyDistribution {})) - } + // NEXT_HOP attribute with WRONG LENGTH (16 bytes instead of 4) + buf.push(0x40); // flags + buf.push(3); // type code (NEXT_HOP) + buf.push(16); // length - THIS IS WRONG, should be 4 for IPv4! + buf.extend_from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 0, 2, 1, + ]); // :: (IPv6) - CapabilityCode::PrestandardOrfAndPd => { - //TODO handle for real - Ok((&input[len..], Capability::PrestandardOrfAndPd {})) - } + // Fill in path attributes length + let path_attrs_len = (buf.len() - path_attrs_start) as u16; + buf[path_attrs_len_offset..path_attrs_len_offset + 2] + .copy_from_slice(&path_attrs_len.to_be_bytes()); - CapabilityCode::PrestandardOutboundRouteFiltering => { - //TODO handle for real - Ok(( - &input[len..], - Capability::PrestandardOutboundRouteFiltering {}, - )) - } + // NLRI: 198.51.100.0/24 + buf.push(24); // prefix length + buf.extend_from_slice(&[198, 51, 100]); // prefix bytes - CapabilityCode::PrestandardMultisession => { - //TODO handle for real - Ok((&input[len..], Capability::PrestandardMultisession {})) - } + // 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"); - CapabilityCode::PrestandardFqdn => { - //TODO handle for real - Ok((&input[len..], Capability::PrestandardFqdn {})) - } + let msg = result.unwrap(); + assert!( + msg.treat_as_withdraw(), + "Expected treat_as_withdraw to be true for bad NEXT_HOP length" + ); - CapabilityCode::PrestandardOperationalMessage => { - //TODO handle for real - Ok(( - &input[len..], - Capability::PrestandardOperationalMessage {}, - )) - } + // 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"); - CapabilityCode::Experimental0 => { - Ok((&input[len..], Capability::Experimental { code: 0 })) - } - CapabilityCode::Experimental1 => { - Ok((&input[len..], Capability::Experimental { code: 1 })) - } - CapabilityCode::Experimental2 => { - Ok((&input[len..], Capability::Experimental { code: 2 })) - } - CapabilityCode::Experimental3 => { - Ok((&input[len..], Capability::Experimental { code: 3 })) - } - CapabilityCode::Experimental4 => { - Ok((&input[len..], Capability::Experimental { code: 4 })) - } - CapabilityCode::Experimental5 => { - Ok((&input[len..], Capability::Experimental { code: 5 })) - } - CapabilityCode::Experimental6 => { - Ok((&input[len..], Capability::Experimental { code: 6 })) - } - CapabilityCode::Experimental7 => { - Ok((&input[len..], Capability::Experimental { code: 7 })) - } - CapabilityCode::Experimental8 => { - Ok((&input[len..], Capability::Experimental { code: 8 })) - } - CapabilityCode::Experimental9 => { - Ok((&input[len..], Capability::Experimental { code: 9 })) - } - CapabilityCode::Experimental10 => { - Ok((&input[len..], Capability::Experimental { code: 10 })) - } - CapabilityCode::Experimental11 => { - Ok((&input[len..], Capability::Experimental { code: 11 })) - } - CapabilityCode::Experimental12 => { - Ok((&input[len..], Capability::Experimental { code: 12 })) - } - CapabilityCode::Experimental13 => { - Ok((&input[len..], Capability::Experimental { code: 13 })) - } - CapabilityCode::Experimental14 => { - Ok((&input[len..], Capability::Experimental { code: 14 })) - } - CapabilityCode::Experimental15 => { - Ok((&input[len..], Capability::Experimental { code: 15 })) - } - CapabilityCode::Experimental16 => { - Ok((&input[len..], Capability::Experimental { code: 16 })) - } - CapabilityCode::Experimental17 => { - Ok((&input[len..], Capability::Experimental { code: 17 })) - } - CapabilityCode::Experimental18 => { - Ok((&input[len..], Capability::Experimental { code: 18 })) - } - CapabilityCode::Experimental19 => { - Ok((&input[len..], Capability::Experimental { code: 19 })) - } - CapabilityCode::Experimental20 => { - Ok((&input[len..], Capability::Experimental { code: 20 })) - } - CapabilityCode::Experimental21 => { - Ok((&input[len..], Capability::Experimental { code: 21 })) - } - CapabilityCode::Experimental22 => { - Ok((&input[len..], Capability::Experimental { code: 22 })) - } - CapabilityCode::Experimental23 => { - Ok((&input[len..], Capability::Experimental { code: 23 })) - } - CapabilityCode::Experimental24 => { - Ok((&input[len..], Capability::Experimental { code: 24 })) - } - CapabilityCode::Experimental25 => { - Ok((&input[len..], Capability::Experimental { code: 25 })) - } - CapabilityCode::Experimental26 => { - Ok((&input[len..], Capability::Experimental { code: 26 })) - } - CapabilityCode::Experimental27 => { - Ok((&input[len..], Capability::Experimental { code: 27 })) - } - CapabilityCode::Experimental28 => { - Ok((&input[len..], Capability::Experimental { code: 28 })) - } - CapabilityCode::Experimental29 => { - Ok((&input[len..], Capability::Experimental { code: 29 })) - } - CapabilityCode::Experimental30 => { - Ok((&input[len..], Capability::Experimental { code: 30 })) - } - CapabilityCode::Experimental31 => { - Ok((&input[len..], Capability::Experimental { code: 31 })) - } - CapabilityCode::Experimental32 => { - Ok((&input[len..], Capability::Experimental { code: 32 })) - } - CapabilityCode::Experimental33 => { - Ok((&input[len..], Capability::Experimental { code: 33 })) - } - CapabilityCode::Experimental34 => { - Ok((&input[len..], Capability::Experimental { code: 34 })) - } - CapabilityCode::Experimental35 => { - Ok((&input[len..], Capability::Experimental { code: 35 })) - } - CapabilityCode::Experimental36 => { - Ok((&input[len..], Capability::Experimental { code: 36 })) - } - CapabilityCode::Experimental37 => { - Ok((&input[len..], Capability::Experimental { code: 37 })) - } - CapabilityCode::Experimental38 => { - Ok((&input[len..], Capability::Experimental { code: 38 })) - } - CapabilityCode::Experimental39 => { - Ok((&input[len..], Capability::Experimental { code: 39 })) - } - CapabilityCode::Experimental40 => { - Ok((&input[len..], Capability::Experimental { code: 40 })) - } - CapabilityCode::Experimental41 => { - Ok((&input[len..], Capability::Experimental { code: 41 })) - } - CapabilityCode::Experimental42 => { - Ok((&input[len..], Capability::Experimental { code: 42 })) - } - CapabilityCode::Experimental43 => { - Ok((&input[len..], Capability::Experimental { code: 43 })) - } - CapabilityCode::Experimental44 => { - Ok((&input[len..], Capability::Experimental { code: 44 })) - } - CapabilityCode::Experimental45 => { - Ok((&input[len..], Capability::Experimental { code: 45 })) - } - CapabilityCode::Experimental46 => { - Ok((&input[len..], Capability::Experimental { code: 46 })) - } - CapabilityCode::Experimental47 => { - Ok((&input[len..], Capability::Experimental { code: 47 })) - } - CapabilityCode::Experimental48 => { - Ok((&input[len..], Capability::Experimental { code: 48 })) - } - CapabilityCode::Experimental49 => { - Ok((&input[len..], Capability::Experimental { code: 49 })) - } - CapabilityCode::Experimental50 => { - Ok((&input[len..], Capability::Experimental { code: 50 })) - } - CapabilityCode::Experimental51 => { - Ok((&input[len..], Capability::Experimental { code: 51 })) - } - CapabilityCode::Reserved => { - Ok((&input[len..], Capability::Reserved { code: 0 })) - } + // 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"); } -} -/// The set of capability codes supported by this BGP implementation -#[derive(Debug, Eq, PartialEq, TryFromPrimitive, Copy, Clone)] -#[repr(u8)] -pub enum CapabilityCode { - /// RFC 5492 - Reserved = 0, + // ========================================================================= + // BgpNexthop tests + // ========================================================================= - /// RFC 2858 - MultiprotocolExtensions = 1, + #[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))); + } - /// RFC 2918 - RouteRefresh = 2, + #[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)); + } - /// RFC 5291 - OutboundRouteFiltering = 3, + #[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(Ipv6DoubleNexthop { global, link_local }) + ); + } - /// RFC 8277 (deprecated) - MultipleRoutesToDestination = 4, + #[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()); + } - /// RFC 8950 - ExtendedNextHopEncoding = 5, + #[test] + fn bgp_nexthop_length_mismatch() { + // Test that nh_bytes.len() != nh_len is always rejected. + // The function checks bytes.len() == nh_len first before parsing. - /// RFC 8654 - BGPExtendedMessage = 6, + // 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(), + "IPv4: should reject when nh_len > bytes.len()" + ); - /// RFC 8205 - BgpSec = 7, + // 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()" + ); - /// RFC 8277 - MultipleLabels = 8, + // 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()" + ); - /// RFC 9234 - BgpRole = 9, + // 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()" + ); + } - /// RFC 4724 - GracefulRestart = 64, + #[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(Ipv6DoubleNexthop { + global: Ipv6Addr::from_str("2001:db8::1").unwrap(), + link_local: Ipv6Addr::from_str("fe80::1").unwrap(), + }); + assert_eq!(ipv6_double.byte_len(), 32); + } - /// RFC 6793 - FourOctetAs = 65, + #[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" + ); - /// draft-ietf-idr-dynamic-cap - DynamicCapability = 67, + // IPv6 double + 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) + .expect("IPv6 double should decode"); + assert_eq!( + ipv6_double, decoded, + "IPv6 double nexthop should round-trip" + ); + } - /// draft-ietf-idr-bgp-multisession - MultisessionBgp = 68, + // ========================================================================= + // MpReachNlri tests + // ========================================================================= - /// RFC 7911 - AddPath = 69, + #[test] + fn mp_reach_nlri_ipv4_unicast() { + let nh = BgpNexthop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)); + let nlri = vec![ + rdb::Prefix4::new(Ipv4Addr::new(10, 0, 0, 0), 8), + rdb::Prefix4::new(Ipv4Addr::new(172, 16, 0, 0), 12), + ]; - /// RFC 7313 - EnhancedRouteRefresh = 70, + let mp_reach = MpReachNlri::ipv4_unicast(nh, nlri.clone()); - /// draft-uttaro-idr-bgp-persistence - LongLivedGracefulRestart = 71, + 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); - /// draft-ietf-idr-rpd-04 - RoutingPolicyDistribution = 72, + // Verify inner struct + if let MpReachNlri::Ipv4Unicast(inner) = &mp_reach { + assert_eq!(inner.nlri, nlri); + } else { + panic!("Expected Ipv4Unicast variant"); + } + } - /// draft-walton-bgp-hostname-capability - Fqdn = 73, + #[test] + fn mp_reach_nlri_ipv6_unicast() { + 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), + ]; - /// RFC 8810 (deprecated) - PrestandardRouteRefresh = 128, + let mp_reach = MpReachNlri::ipv6_unicast(nh, nlri.clone()); - /// RFC 8810 (deprecated) - PrestandardOrfAndPd = 129, + 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); - /// RFC 8810 (deprecated) - PrestandardOutboundRouteFiltering = 130, + // Verify inner struct + if let MpReachNlri::Ipv6Unicast(inner) = &mp_reach { + assert_eq!(inner.nlri, nlri); + } else { + panic!("Expected Ipv6Unicast variant"); + } + } - /// RFC 8810 (deprecated) - PrestandardMultisession = 131, + #[test] + fn mp_reach_nlri_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 = MpReachNlri::ipv6_unicast(nh, nlri.clone()); + let wire = original.to_wire(); + let (remaining, parsed) = + 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.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"); + } + } - /// RFC 8810 (deprecated) - PrestandardFqdn = 184, + // ========================================================================= + // MpUnreachNlri tests + // ========================================================================= - /// RFC 8810 (deprecated) - PrestandardOperationalMessage = 185, + #[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), + ]; - /// RFC 8810 - Experimental0 = 186, - Experimental1, - Experimental2, - Experimental3, - Experimental4, - Experimental5, - Experimental6, - Experimental7, - Experimental8, - Experimental9, - Experimental10, - Experimental11, - Experimental12, - Experimental13, - Experimental14, - Experimental15, - Experimental16, - Experimental17, - Experimental18, - Experimental19, - Experimental20, - Experimental21, - Experimental22, - Experimental23, - Experimental24, - Experimental25, - Experimental26, - Experimental27, - Experimental28, - Experimental29, - Experimental30, - Experimental31, - Experimental32, - Experimental33, - Experimental34, - Experimental35, - Experimental36, - Experimental37, - Experimental38, - Experimental39, - Experimental40, - Experimental41, - Experimental42, - Experimental43, - Experimental44, - Experimental45, - Experimental46, - Experimental47, - Experimental48, - Experimental49, - Experimental50, - Experimental51, -} + let mp_unreach = MpUnreachNlri::ipv4_unicast(withdrawn.clone()); -impl From for CapabilityCode { - fn from(value: Capability) -> Self { - match value { - Capability::MultiprotocolExtensions { afi: _, safi: _ } => { - CapabilityCode::MultiprotocolExtensions - } - Capability::RouteRefresh {} => CapabilityCode::RouteRefresh, - Capability::OutboundRouteFiltering {} => { - CapabilityCode::OutboundRouteFiltering - } - Capability::MultipleRoutesToDestination {} => { - CapabilityCode::MultipleRoutesToDestination - } - Capability::ExtendedNextHopEncoding {} => { - CapabilityCode::ExtendedNextHopEncoding - } - Capability::BGPExtendedMessage {} => { - CapabilityCode::BGPExtendedMessage - } - Capability::BgpSec {} => CapabilityCode::BgpSec, - Capability::MultipleLabels {} => CapabilityCode::MultipleLabels, - Capability::BgpRole {} => CapabilityCode::BgpRole, - Capability::GracefulRestart {} => CapabilityCode::GracefulRestart, - Capability::FourOctetAs { asn: _ } => CapabilityCode::FourOctetAs, - Capability::DynamicCapability {} => { - CapabilityCode::DynamicCapability - } - Capability::MultisessionBgp {} => CapabilityCode::MultisessionBgp, - Capability::AddPath { elements: _ } => CapabilityCode::AddPath, - Capability::EnhancedRouteRefresh {} => { - CapabilityCode::EnhancedRouteRefresh - } - Capability::LongLivedGracefulRestart {} => { - CapabilityCode::LongLivedGracefulRestart - } - Capability::RoutingPolicyDistribution {} => { - CapabilityCode::RoutingPolicyDistribution - } - Capability::Fqdn {} => CapabilityCode::Fqdn, - Capability::PrestandardRouteRefresh {} => { - CapabilityCode::PrestandardRouteRefresh - } - Capability::PrestandardOrfAndPd {} => { - CapabilityCode::PrestandardOrfAndPd - } - Capability::PrestandardOutboundRouteFiltering {} => { - CapabilityCode::PrestandardOutboundRouteFiltering - } - Capability::PrestandardMultisession {} => { - CapabilityCode::PrestandardMultisession - } - Capability::PrestandardFqdn {} => CapabilityCode::PrestandardFqdn, - Capability::PrestandardOperationalMessage {} => { - CapabilityCode::PrestandardOperationalMessage - } - Capability::Experimental { code } => match code { - 0 => CapabilityCode::Experimental0, - 1 => CapabilityCode::Experimental1, - 2 => CapabilityCode::Experimental2, - 3 => CapabilityCode::Experimental3, - 4 => CapabilityCode::Experimental4, - 5 => CapabilityCode::Experimental5, - 6 => CapabilityCode::Experimental6, - 7 => CapabilityCode::Experimental7, - 8 => CapabilityCode::Experimental8, - 9 => CapabilityCode::Experimental9, - 10 => CapabilityCode::Experimental10, - 11 => CapabilityCode::Experimental11, - 12 => CapabilityCode::Experimental12, - 13 => CapabilityCode::Experimental13, - 14 => CapabilityCode::Experimental14, - 15 => CapabilityCode::Experimental15, - 16 => CapabilityCode::Experimental16, - 17 => CapabilityCode::Experimental17, - 18 => CapabilityCode::Experimental18, - 19 => CapabilityCode::Experimental19, - 20 => CapabilityCode::Experimental20, - 21 => CapabilityCode::Experimental21, - 22 => CapabilityCode::Experimental22, - 23 => CapabilityCode::Experimental23, - 24 => CapabilityCode::Experimental24, - 25 => CapabilityCode::Experimental25, - 26 => CapabilityCode::Experimental26, - 27 => CapabilityCode::Experimental27, - 28 => CapabilityCode::Experimental28, - 29 => CapabilityCode::Experimental29, - 30 => CapabilityCode::Experimental30, - 31 => CapabilityCode::Experimental31, - 32 => CapabilityCode::Experimental32, - 33 => CapabilityCode::Experimental33, - 34 => CapabilityCode::Experimental34, - 35 => CapabilityCode::Experimental35, - 36 => CapabilityCode::Experimental36, - 37 => CapabilityCode::Experimental37, - 38 => CapabilityCode::Experimental38, - 39 => CapabilityCode::Experimental39, - 40 => CapabilityCode::Experimental40, - 41 => CapabilityCode::Experimental41, - 42 => CapabilityCode::Experimental42, - 43 => CapabilityCode::Experimental43, - 44 => CapabilityCode::Experimental44, - 45 => CapabilityCode::Experimental45, - 46 => CapabilityCode::Experimental46, - 47 => CapabilityCode::Experimental47, - 48 => CapabilityCode::Experimental48, - 49 => CapabilityCode::Experimental49, - 50 => CapabilityCode::Experimental50, - 51 => CapabilityCode::Experimental51, - _ => CapabilityCode::Experimental0, - }, - Capability::Unassigned { code: _ } => CapabilityCode::Reserved, - Capability::Reserved { code: _ } => CapabilityCode::Reserved, - } - } -} + assert_eq!(mp_unreach.afi(), Afi::Ipv4); + assert_eq!(mp_unreach.safi(), Safi::Unicast); + assert_eq!(mp_unreach.len(), 2); -/// Address families supported by Maghemite BGP. -#[repr(u16)] -pub enum Afi { - /// Internet protocol version 4 - Ipv4 = 1, - /// Internet protocol version 6 - Ipv6 = 2, -} + // Verify inner struct + if let MpUnreachNlri::Ipv4Unicast(inner) = &mp_unreach { + assert_eq!(inner.withdrawn, withdrawn); + } else { + panic!("Expected Ipv4Unicast variant"); + } + } -/// Subsequent address families supported by Maghemite BGP. -#[repr(u8)] -pub enum Safi { - /// Network Layer Reachability Information used for unicast forwarding - NlriUnicast = 1, -} + #[test] + 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), + ]; -/// 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())) + let mp_unreach = MpUnreachNlri::ipv6_unicast(withdrawn.clone()); + + 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"); } - AddressFamily::Ipv6 => { - let (remaining, prefix6) = Prefix6::from_wire(input) - .map_err(|_| Error::InvalidNlriPrefix(input.to_vec()))?; - Ok((remaining, prefix6.into())) + } + + #[test] + fn mp_unreach_nlri_round_trip() { + let withdrawn = vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:dead::").unwrap(), + 48, + )]; + + let original = MpUnreachNlri::ipv6_unicast(withdrawn.clone()); + let wire = original.to_wire().expect("to_wire should succeed"); + let (remaining, parsed) = + MpUnreachNlri::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()); + + // 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"); } } -} -// ============================================================================ -// 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). + // ========================================================================= + // RFC 7606 validation tests + // ========================================================================= -/// 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, -} + /// 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 = MpReachNlri::ipv6_unicast( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + 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(), + // 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![], + errors: vec![], }; - // 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 } - } -} + // Encode to wire format + let wire = update.to_wire().expect("encoding should succeed"); -/// 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, -} + // 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) -impl From for UpdateMessageV1 { - fn from(msg: UpdateMessage) -> Self { - Self { - withdrawn: msg.withdrawn.into_iter().map(PrefixV1::from).collect(), - path_attributes: msg.path_attributes, - nlri: msg.nlri.into_iter().map(PrefixV1::from).collect(), - } + // 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, + u8::from(PathAttributeTypeCode::MpReachNlri), + "MP_REACH_NLRI should be encoded as the first path attribute" + ); } -} -/// 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), -} + /// 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 = MpReachNlri::ipv6_unicast( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8::").unwrap(), + 32, + )], + ); -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), - } + 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)], + errors: 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 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" + ); } -} -#[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; + /// 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 = MpReachNlri::ipv6_unicast( + BgpNexthop::Ipv6Single(Ipv6Addr::from_str("2001:db8::1").unwrap()), + vec![rdb::Prefix6::new( + Ipv6Addr::from_str("2001:db8:1::").unwrap(), + 48, + )], + ); - #[derive(Debug)] - struct PrefixConversionTestCase { - description: &'static str, - address_family: AddressFamily, - prefix_length: u8, - input_bytes: Vec, - expected_address: &'static str, + let mp_unreach = MpUnreachNlri::ipv6_unicast(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![], + errors: 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"); } - impl PrefixConversionTestCase { - fn new_ipv4( - description: &'static str, - prefix_length: u8, - input_addr: Ipv4Addr, - expected_address: &'static str, - ) -> Self { - Self { - description, - address_family: AddressFamily::Ipv4, - prefix_length, - input_bytes: input_addr.octets().to_vec(), - expected_address, - } - } + /// 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(), + errors: vec![], + }; - fn new_ipv6( - description: &'static str, - prefix_length: u8, - input_addr: Ipv6Addr, - expected_address: &'static str, - ) -> Self { - Self { - description, - address_family: AddressFamily::Ipv6, - prefix_length, - input_bytes: input_addr.octets().to_vec(), - expected_address, - } - } + // 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 header_round_trip() { - let h0 = Header { - length: 0x1701, - typ: MessageType::Notification, + 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![], + errors: vec![], }; - let buf = h0.to_wire(); - println!("buf: {}", buf.hex_dump()); + 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![], + 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!( - buf, - vec![ - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // marker - 0x17, 0x01, // length - 3, // type - ] + 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![], + 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] + 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 + u8::from(PathAttributeTypeCode::Origin), // type + 1, // length + u8::from(PathOrigin::Igp), // value + // Second ORIGIN attribute (EGP = 1) - should be discarded + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::Origin), + 1, + u8::from(PathOrigin::Egp), + ]; + + // 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)" + ); + } + + /// 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" + ); + } - let h1 = Header::from_wire(&buf).expect("header from wire"); - assert_eq!(h0, h1); - } + #[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 open_round_trip() { - let om0 = OpenMessage::new4(395849, 0x1234, 0xaabbccdd); + #[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" + ); + } - let buf = om0.to_wire().expect("open message to wire"); - println!("buf: {}", buf.hex_dump()); + #[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" + ); + } - let om1 = OpenMessage::from_wire(&buf).expect("open message from wire"); - assert_eq!(om0, om1); + #[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" + ); + } } - #[test] - fn update_round_trip() { - let um0 = UpdateMessage { - withdrawn: vec![rdb::Prefix::V4(rdb::Prefix4::new( - 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, - }, - value: PathAttributeValue::As4Path(vec![As4PathSegment { - typ: AsPathType::AsSequence, - value: vec![395849, 123456, 987654, 111111], - }]), - }], - 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, - )), - ], + /// Tests for RFC 7606 attribute flag validation. + mod rfc7606_flag_validation { + use crate::messages::{ + AttributeAction, PathAttributeType, PathAttributeTypeCode, + UpdateParseErrorReason, path_attribute_flags, + validate_attribute_flags, }; - let buf = um0.to_wire().expect("update message to wire"); - println!("buf: {}", buf.hex_dump()); + /// Helper to construct PathAttributeType for testing + fn make_typ( + type_code: PathAttributeTypeCode, + flags: u8, + ) -> PathAttributeType { + PathAttributeType { flags, type_code } + } - let um1 = - UpdateMessage::from_wire(&buf).expect("update message from wire"); - assert_eq!(um0, um1); - } + #[test] + fn well_known_attributes_require_transitive_not_optional() { + // Well-known mandatory/discretionary: Optional=0, Transitive=1 + let correct_flags = path_attribute_flags::TRANSITIVE; - #[test] - fn prefix_within() { - // Test IPv4 prefix containment - let ipv4_prefixes: &[Prefix] = &[ - cidr!("10.10.10.10/32"), - cidr!("10.10.10.0/24"), - cidr!("10.10.0.0/16"), - cidr!("10.0.0.0/8"), - cidr!("0.0.0.0/0"), - ]; + // 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" + ); + } - for i in 0..ipv4_prefixes.len() { - for j in i..ipv4_prefixes.len() { - // shorter prefixes contain longer or equal - assert!(ipv4_prefixes[i].within(&ipv4_prefixes[j])); - if i != j { - // longer prefixes should not contain shorter - assert!(!ipv4_prefixes[j].within(&ipv4_prefixes[i])) - } - } + #[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 IPv6 prefix containment - let ipv6_prefixes: &[Prefix] = &[ - cidr!("2001:db8:1:1::1/128"), - cidr!("2001:db8:1:1::/64"), - cidr!("2001:db8:1::/48"), - cidr!("2001:db8::/32"), - cidr!("::/0"), - ]; + #[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" + ); + } - for i in 0..ipv6_prefixes.len() { - for j in i..ipv6_prefixes.len() { - // shorter prefixes contain longer or equal - assert!(ipv6_prefixes[i].within(&ipv6_prefixes[j])); - if i != j { - // longer prefixes should not contain shorter - assert!(!ipv6_prefixes[j].within(&ipv6_prefixes[i])) + #[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, + u8::from(PathAttributeTypeCode::Origin), + "should include the attribute type code" + ); + assert_eq!( + flags, bad_flags, + "should include the invalid flags" + ); } + _ => panic!("expected InvalidAttributeFlags error"), } - } - - // Test non-overlapping prefixes - let a: Prefix = cidr!("10.10.0.0/16"); - let b: Prefix = cidr!("10.20.0.0/16"); - assert!(!a.within(&b)); - let a: Prefix = cidr!("10.10.0.0/24"); - assert!(!a.within(&b)); - let a: Prefix = cidr!("2001:db8:1::/48"); - let b: Prefix = cidr!("2001:db8:2::/48"); - assert!(!a.within(&b)); + assert_eq!( + action, + AttributeAction::TreatAsWithdraw, + "ORIGIN flag errors should treat-as-withdraw" + ); + } + } - // Test default routes contain same-family prefixes - let ipv4_default: Prefix = cidr!("0.0.0.0/0"); - let ipv6_default: Prefix = cidr!("::/0"); + /// 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, + }; - let any_ipv4: Prefix = cidr!("192.168.1.0/24"); - let any_ipv6: Prefix = cidr!("2001:db8::/48"); + /// 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 + } - assert!(any_ipv4.within(&ipv4_default)); - assert!(any_ipv6.within(&ipv6_default)); + /// Helper to build a valid ORIGIN attribute (IGP). + fn origin_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + u8::from(PathAttributeTypeCode::Origin), // type 1 + 1, // length + 0, // IGP + ] + } - // Test cross-family default route edge cases - // IPv4 prefixes should NOT be within IPv6 default route - assert!(!any_ipv4.within(&ipv6_default)); - assert!(!ipv4_default.within(&ipv6_default)); + /// 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) + u8::from(PathAttributeTypeCode::AsPath), // 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 + } - // IPv6 prefixes should NOT be within IPv4 default route - assert!(!any_ipv6.within(&ipv4_default)); - assert!(!ipv6_default.within(&ipv4_default)); - } + /// 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) + u8::from(PathAttributeTypeCode::NextHop), // type 3 + 4, // length + ]; + attr.extend_from_slice(&ip); + attr + } - #[test] - fn prefix_conversion() { - // Test both IPv4 and IPv6 prefix conversions including edge cases and host bit zeroing - let test_cases = vec![ - // IPv4 test cases - // Input: 0.0.0.0 (default route) - PrefixConversionTestCase::new_ipv4( - "IPv4 default route", + /// 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) + u8::from(PathAttributeTypeCode::NextHop), // type 3 + 16, // length - WRONG, should be 4 0, - ip!("0.0.0.0"), - "0.0.0.0", - ), - // Input: 10.255.255.255/8 -> 10.0.0.0/8 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 Class A with host bits", - 8, - ip!("10.255.255.255"), - "10.0.0.0", - ), - // Input: 172.31.255.255/12 -> 172.16.0.0/12 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 large private network with host bits", - 12, - ip!("172.31.255.255"), - "172.16.0.0", - ), - // Input: 172.16.255.255/16 -> 172.16.0.0/16 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 common allocation with host bits", - 16, - ip!("172.16.255.255"), - "172.16.0.0", - ), - // Input: 203.0.113.255/20 -> 203.0.112.0/20 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 prefix with host bits in last 12 bits", - 20, - ip!("203.0.113.255"), - "203.0.112.0", - ), - // Input: 192.168.1.123/24 -> 192.168.1.0/24 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 common subnet with host bits", - 24, - ip!("192.168.1.123"), - "192.168.1.0", - ), - // Input: 198.51.100.7/30 -> 198.51.100.4/30 (host bits zeroed) - PrefixConversionTestCase::new_ipv4( - "IPv4 point-to-point link with host bits", - 30, - ip!("198.51.100.7"), - "198.51.100.4", - ), - // Input: 10.0.0.1/32 -> 10.0.0.1/32 (no host bits to zero) - PrefixConversionTestCase::new_ipv4( - "IPv4 host route - no host bits to zero", - 32, - ip!("10.0.0.1"), - "10.0.0.1", - ), - // IPv6 test cases - // Input: :: (all zeros, default route) - PrefixConversionTestCase::new_ipv6( - "IPv6 default route", 0, - ip!("::"), - "::", - ), - // Input: fd00:ffff:ffff:ffff:ffff:ffff:ffff:ffff/8 -> fd00::/8 (host bits zeroed) - PrefixConversionTestCase::new_ipv6( - "IPv6 unique local address prefix with host bits", - 8, - ip!("fd00:ffff:ffff:ffff:ffff:ffff:ffff:ffff"), - "fd00::", - ), - // Input: 2001:db8:1234:5678:9abc:def0:1122:3344/32 -> 2001:db8::/32 (host bits zeroed) - PrefixConversionTestCase::new_ipv6( - "IPv6 common allocation size with host bits", - 32, - ip!("2001:db8:1234:5678:9abc:def0:1122:3344"), - "2001:db8::", - ), - // Input: 2001:db8:1234:ffff:ffff:ffff:ffff:ffff/48 -> 2001:db8:1234::/48 (host bits zeroed) - PrefixConversionTestCase::new_ipv6( - "IPv6 site prefix with host bits in last 80 bits", - 48, - ip!("2001:db8:1234:ffff:ffff:ffff:ffff:ffff"), - "2001:db8:1234::", - ), - // Input: 2001:db8::1234:5678:9abc:def0/64 -> 2001:db8::/64 (host bits zeroed) - PrefixConversionTestCase::new_ipv6( - "IPv6 common prefix length with host bits", - 64, - ip!("2001:db8::1234:5678:9abc:def0"), - "2001:db8::", - ), - // Input: 2001:db8::ff/120 -> 2001:db8::/120 (host bits zeroed) - PrefixConversionTestCase::new_ipv6( - "IPv6 leaves only 8 host bits", - 120, - ip!("2001:db8::ff"), - "2001:db8::", - ), - // Input: 2001:db8::1/128 -> 2001:db8::1/128 (no host bits to zero) - PrefixConversionTestCase::new_ipv6( - "IPv6 host route - no host bits to zero", - 128, - ip!("2001:db8::1"), - "2001:db8::1", - ), - ]; + 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) + u8::from(PathAttributeTypeCode::MultiExitDisc), // type 4 + 2, // length - WRONG, should be 4 + 0, + 100, // only 2 bytes + ] + } - for test_case in test_cases { - let prefix = match test_case.address_family { - AddressFamily::Ipv4 => { - let mut octets = [0u8; 4]; - octets.copy_from_slice(&test_case.input_bytes); - rdb::Prefix::V4(rdb::Prefix4::new( - Ipv4Addr::from(octets), - test_case.prefix_length, - )) - } - AddressFamily::Ipv6 => { - let mut octets = [0u8; 16]; - octets.copy_from_slice(&test_case.input_bytes); - rdb::Prefix::V6(rdb::Prefix6::new( - Ipv6Addr::from(octets), - test_case.prefix_length, - )) - } - }; + /// 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 + u8::from(PathAttributeTypeCode::Aggregator), // type 7 + 3, // length - WRONG + 0, + 100, + 1, // only 3 bytes + ] + } - match test_case.address_family { - AddressFamily::Ipv4 => { - if let rdb::Prefix::V4(rdb_prefix4) = prefix { - assert_eq!( - rdb_prefix4.length, test_case.prefix_length, - "IPv4 length mismatch for {}", - test_case.description - ); - assert_eq!( - rdb_prefix4.value, - Ipv4Addr::from_str(test_case.expected_address) - .unwrap(), - "IPv4 address mismatch for {}: expected {}, got {}", - test_case.description, - test_case.expected_address, - rdb_prefix4.value - ); - assert!( - rdb_prefix4.host_bits_are_unset(), - "IPv4 host bits not properly zeroed for {}", - test_case.description - ); - } else { - panic!("Expected IPv4 prefix"); + /// Helper to build a malformed ORIGIN attribute (invalid value). + fn bad_origin_attr() -> Vec { + vec![ + path_attribute_flags::TRANSITIVE, // flags (0x40) + u8::from(PathAttributeTypeCode::Origin), // 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 } - } - AddressFamily::Ipv6 => { - if let rdb::Prefix::V6(rdb_prefix6) = prefix { - assert_eq!( - rdb_prefix6.length, test_case.prefix_length, - "IPv6 length mismatch for {}", - test_case.description - ); - assert_eq!( - rdb_prefix6.value, - Ipv6Addr::from_str(test_case.expected_address) - .unwrap(), - "IPv6 address mismatch for {}: expected {}, got {}", - test_case.description, - test_case.expected_address, - rdb_prefix6.value - ); - assert!( - rdb_prefix6.host_bits_are_unset(), - "IPv6 host bits not properly zeroed for {}", - test_case.description - ); - } else { - panic!("Expected IPv6 prefix"); + )), + "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! + u8::from(PathAttributeTypeCode::Origin), + 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::{Ipv4Addr, Ipv6Addr}; + use std::str::FromStr; + + use crate::messages::{ + 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. + 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, + u8::from(PathAttributeTypeCode::Origin), + 1, + 0, // IGP + ] + } + + fn as_path_attr(asn: u32) -> Vec { + let mut attr = vec![ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::AsPath), + 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, + u8::from(PathAttributeTypeCode::NextHop), + 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(); + + let mut attr = vec![ + path_attribute_flags::OPTIONAL, + u8::from(PathAttributeTypeCode::MpReachNlri), + ]; + // 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, + u8::from(PathAttributeTypeCode::MpUnreachNlri), + ]; + 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" + ); + } + + // ===================================================================== + // 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 test_nexthop_length_validation() { - // Test that NEXT_HOP path attribute with incorrect length is rejected + #[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() + ); + } - // Build a minimal valid UPDATE message manually, then corrupt the NEXT_HOP length - let mut buf = Vec::new(); + #[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"), + } + } - // Withdrawn routes length (0) - buf.extend_from_slice(&0u16.to_be_bytes()); + #[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" + ); + } - // Path attributes length (will be filled in later) - let path_attrs_len_offset = buf.len(); - buf.extend_from_slice(&0u16.to_be_bytes()); + #[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"); + } - let path_attrs_start = buf.len(); + // ===================================================================== + // Phase 3 - Aggregator and AtomicAggregate Tests + // ===================================================================== - // ORIGIN attribute (well-known, transitive, complete) - buf.push(0x40); // flags - buf.push(1); // type code (ORIGIN) - buf.push(1); // length - buf.push(0); // IGP + #[test] + fn aggregator_structure_parsing() { + let asn = 65000u16; + let address = Ipv4Addr::new(192, 0, 2, 1); + let agg = Aggregator { asn, address }; - // AS_PATH attribute (well-known, transitive, complete) - buf.push(0x40); // flags - buf.push(2); // type code (AS_PATH) - buf.push(6); // length - buf.push(2); // AS_SEQUENCE - buf.push(1); // path segment length - buf.extend_from_slice(&(65000u32).to_be_bytes()); + // Test to_wire and from_wire round-trip + let wire = agg.to_wire(); + assert_eq!(wire.len(), 6, "AGGREGATOR should serialize to 6 bytes"); - // NEXT_HOP attribute with WRONG LENGTH (16 bytes instead of 4) - buf.push(0x40); // flags - buf.push(3); // type code (NEXT_HOP) - buf.push(16); // length - THIS IS WRONG, should be 4 for IPv4! - buf.extend_from_slice(&[ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 0, 2, 1, - ]); // :: (IPv6) + 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"); + } - // Fill in path attributes length - let path_attrs_len = (buf.len() - path_attrs_start) as u16; - buf[path_attrs_len_offset..path_attrs_len_offset + 2] - .copy_from_slice(&path_attrs_len.to_be_bytes()); + #[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)); + } - // NLRI: 198.51.100.0/24 - buf.push(24); // prefix length - buf.extend_from_slice(&[198, 51, 100]); // prefix bytes + #[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"); + } - // Try to parse - should fail with BadLength error - let result = UpdateMessage::from_wire(&buf); - assert!( - result.is_err(), - "Expected parsing to fail with bad NEXT_HOP length" - ); + #[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, + u8::from(PathAttributeTypeCode::Origin), + 1, + 0, // IGP + ]); + + // AS_PATH (empty) + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::AsPath), + 0, // empty + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::NextHop), + 4, + 192, + 0, + 2, + 1, + ]); - 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"); + // AGGREGATOR + attrs.extend([ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::Aggregator), + 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"), } - other => panic!("Expected BadLength error, got: {:?}", other), + } + + #[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, + u8::from(PathAttributeTypeCode::Origin), + 1, + 0, // IGP + ]); + + // AS_PATH + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::AsPath), + 0, + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::NextHop), + 4, + 192, + 0, + 2, + 1, + ]); + + // ATOMIC_AGGREGATE (zero-length) + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::AtomicAggregate), + 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, + u8::from(PathAttributeTypeCode::Origin), + 1, + 0, + ]); + + // AS_PATH + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::AsPath), + 0, + ]); + + // NEXT_HOP + attrs.extend([ + path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::NextHop), + 4, + 192, + 0, + 2, + 1, + ]); + + // AGGREGATOR with WRONG length (5 bytes instead of 6) + attrs.extend([ + path_attribute_flags::OPTIONAL + | path_attribute_flags::TRANSITIVE, + u8::from(PathAttributeTypeCode::Aggregator), + 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/params.rs b/bgp/src/params.rs index a7867e56..2a038c43 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -2,16 +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 rdb::{ImportExportPolicy, PolicyAction, Prefix4, Prefix6}; +use crate::{ + config::PeerConfig, + messages::{AddPathElement, Afi, Capability}, + session::{FsmStateKind, SessionCounters, SessionInfo}, +}; +use rdb::{ + ImportExportPolicy4, ImportExportPolicy6, ImportExportPolicyV1, + 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)] @@ -29,13 +35,125 @@ 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. +#[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 }) + } +} + +/// 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, Default, Clone, Deserialize, Serialize, JsonSchema, PartialEq, +)] +pub struct Ipv4UnicastConfig { + pub nexthop: Option, + pub import_policy: ImportExportPolicy4, + pub export_policy: ImportExportPolicy4, +} + +/// Per-address-family configuration for IPv6 Unicast +#[derive( + Debug, Default, Clone, Deserialize, Serialize, JsonSchema, PartialEq, +)] +pub struct Ipv6UnicastConfig { + pub nexthop: Option, + 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, @@ -56,8 +174,92 @@ pub struct Neighbor { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + /// 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, + 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_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) +#[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: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, } @@ -65,6 +267,23 @@ 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, + delay_open: rq.delay_open, + connect_retry: rq.connect_retry, + keepalive: rq.keepalive, + resolution: rq.resolution, + } + } +} + +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, @@ -76,7 +295,76 @@ impl From for PeerConfig { } } +impl NeighborV1 { + pub fn from_bgp_peer_config_v1( + asn: u32, + group: String, + rq: BgpPeerConfigV1, + ) -> Self { + 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, + allow_import: rq.allow_import, + allow_export: rq.allow_export, + vlan_id: rq.vlan_id, + } + } + + pub fn from_rdb_neighbor_info(asn: u32, rq: &rdb::BgpNeighborInfo) -> Self { + 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, + // 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, + } + } +} + 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, @@ -101,13 +389,38 @@ 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, + 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, } } 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 { + nexthop: rq.nexthop4, + import_policy: rq.allow_import4.clone(), + export_policy: rq.allow_export4.clone(), + }) + } else { + None + }; + + let ipv6_unicast = if rq.ipv6_enabled { + Some(Ipv6UnicastConfig { + nexthop: rq.nexthop6, + import_policy: rq.allow_import6.clone(), + export_policy: rq.allow_export6.clone(), + }) + } else { + None + }; + Self { asn, remote_asn: rq.remote_asn, @@ -127,9 +440,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(), + 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, } } } @@ -204,28 +523,282 @@ 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, 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: StaticTimerInfo, + pub connect_retry_jitter: Option, + pub idle_hold: StaticTimerInfo, + pub idle_hold_jitter: Option, + pub delay_open: StaticTimerInfo, +} + +/// 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 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, + pub unnegotiated_address_family: 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 { + 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), + unnegotiated_address_family: value + .unnegotiated_address_family + .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: Duration, 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)] @@ -240,9 +813,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 @@ -265,9 +839,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: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, + 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, @@ -277,7 +877,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, @@ -286,9 +886,61 @@ pub struct BgpPeerConfig { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + /// 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 { + 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_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, + } + } } pub enum PolicySource { @@ -318,6 +970,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. @@ -332,7 +985,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. @@ -363,15 +1016,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, @@ -380,3 +1034,57 @@ 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 +// ============================================================================ +// 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/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..5be14edc 100644 --- a/bgp/src/proptest.rs +++ b/bgp/src/proptest.rs @@ -4,29 +4,266 @@ //! 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::{ + As4PathSegment, AsPathType, BgpNexthop, BgpWireFormat, Ipv6DoubleNexthop, + MpReachNlri, MpUnreachNlri, PathAttribute, PathAttributeType, + PathAttributeTypeCode, PathAttributeValue, PathOrigin, UpdateMessage, + path_attribute_flags, +}; use proptest::prelude::*; use rdb::types::{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_bits, link_local_bits)| { + BgpNexthop::Ipv6Double(Ipv6DoubleNexthop { + global: Ipv6Addr::from(global_bits), + link_local: Ipv6Addr::from(link_local_bits), + }) + }) +} + +// ============================================================================= +// 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 + }) +} + +// ============================================================================= +// MpReachNlri/MpUnreachNlri Strategies +// ============================================================================= + +/// 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 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 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 MpReachNlri +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 MpUnreachNlri +fn mp_unreach_v4_strategy() -> impl Strategy { + ipv4_prefixes_strategy().prop_map(MpUnreachNlri::ipv4_unicast) +} + +/// 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 MpUnreachNlri +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, + errors: vec![], + }) +} + +/// 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![], + errors: 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![], + errors: 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 +285,527 @@ 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(), + errors: vec![], + }; + + 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![], + errors: 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 = MpReachNlri::ipv6_unicast(nexthop, prefixes.clone()); + + 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![], + 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 decoded_prefixes = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv6Unicast(inner)) => { + Some(inner.nlri.clone()) + } + _ => None, + }) + .expect("should have MP_REACH_NLRI with IPv6 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 = MpUnreachNlri::ipv6_unicast(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![], + 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 decoded_prefixes = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(MpUnreachNlri::Ipv6Unicast(inner)) => { + Some(inner.withdrawn.clone()) + } + _ => None, + }) + .expect("should have MP_UNREACH_NLRI with IPv6 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 = MpReachNlri::ipv4_unicast(nexthop, prefixes.clone()); + + 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![], + 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 decoded_prefixes = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpReachNlri(MpReachNlri::Ipv4Unicast(inner)) => { + Some(inner.nlri.clone()) + } + _ => None, + }) + .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()) { + let mp_unreach = MpUnreachNlri::ipv4_unicast(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![], + 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 decoded_prefixes = decoded.path_attributes.iter() + .find_map(|a| match &a.value { + PathAttributeValue::MpUnreachNlri(MpUnreachNlri::Ipv4Unicast(inner)) => { + Some(inner.withdrawn.clone()) + } + _ => None, + }) + .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 MpReachNlri) + // ------------------------------------------------------------------------- + + /// 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 = MpReachNlri::ipv4_unicast(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![], + errors: 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 MpReachNlri preserves next-hop + #[test] + fn prop_nexthop_ipv6_single_via_mp_reach(nexthop in nexthop_ipv6_single_strategy()) { + let mp_reach = MpReachNlri::ipv6_unicast(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![], + errors: 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 MpReachNlri preserves next-hop + #[test] + fn prop_nexthop_ipv6_double_via_mp_reach(nexthop in nexthop_ipv6_double_strategy()) { + let mp_reach = MpReachNlri::ipv6_unicast(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![], + errors: 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 + ); + } + } + + // ------------------------------------------------------------------------- + // 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(), + 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![], + 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/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..2bdfa07d 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,12 @@ 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. 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>>, } unsafe impl Send for Router {} @@ -90,7 +95,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(), } } @@ -167,29 +173,55 @@ 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) { - self.add_fanout(*addr, endpoint.event_tx.clone()); - } + for session in sessions.values() { 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.clone()), + 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), + 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( @@ -263,6 +295,7 @@ impl Router { let neighbor = NeighborInfo { name: Arc::new(Mutex::new(peer.name.clone())), + peer_group: peer.group.clone(), host: peer.host, }; @@ -271,18 +304,10 @@ 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); lock!(self.sessions).insert(neighbor.host.ip(), runner.clone()); Ok(runner) @@ -336,7 +361,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 +381,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).send_all(prefixes, vec![]); + } - if !update.withdrawn.is_empty() { - read_lock!(self.fanout).send_all(&update); + 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).send_all(vec![], prefixes); } pub fn create_origin6(&self, prefixes: Vec) -> Result<(), Error> { @@ -428,7 +465,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 +485,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).send_all(prefixes, vec![]); + } - if !update.withdrawn.is_empty() { - read_lock!(self.fanout).send_all(&update); + 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).send_all(vec![], prefixes); } pub fn base_attributes(&self) -> Vec { @@ -572,16 +621,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).send_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).send_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..809213e2 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -9,38 +9,91 @@ 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, + MessageParseError, MpReachNlri, MpUnreachNlri, NotificationMessage, + OpenErrorSubcode, OpenMessage, PathAttributeValue, RouteRefreshMessage, + Safi, UpdateMessage, + }, + params::{ + BgpCapability, DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, + JitterRange, PeerCounters, PeerInfo, PeerTimers, StaticTimerInfo, + TimerConfig, }, 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::{ + AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy, + ImportExportPolicy4, ImportExportPolicy6, 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, Ipv4Addr, Ipv6Addr, SocketAddr}, sync::{ Arc, Mutex, RwLock, atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{Receiver, Sender}, }, time::{Duration, Instant}, }; 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 @@ -58,6 +111,10 @@ pub struct PeerConnection { pub asn: u32, /// The actual capabilities received from the peer (runtime state) pub caps: BTreeSet, + /// 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 { @@ -67,6 +124,8 @@ impl Clone for PeerConnection { id: self.id, asn: self.asn, caps: self.caps.clone(), + ipv4_unicast: self.ipv4_unicast, + ipv6_unicast: self.ipv6_unicast, } } } @@ -94,6 +153,111 @@ 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, + } + } +} + +/// 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 @@ -124,7 +288,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. @@ -133,7 +297,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), } @@ -168,7 +332,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) @@ -219,16 +383,122 @@ impl From<&FsmState> for FsmStateKind { } } +/// IPv4 route update +#[derive(Clone, Debug)] +pub enum RouteUpdate4 { + Announce(Vec), + Withdraw(Vec), +} + +/// IPv6 route update +#[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(RouteUpdate4), + V6(RouteUpdate6), +} + +impl RouteUpdate { + pub fn is_empty(&self) -> bool { + match self { + RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => nlri.is_empty(), + RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { + withdrawn.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, + } + } + + pub fn nlri_count(&self) -> usize { + match self { + 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(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(RouteUpdate4::Announce(nlri)) => { + write!(f, "ipv4 announce {} prefixes", nlri.len()) + } + 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()) + } + } + } +} + #[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. 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. @@ -247,28 +517,33 @@ 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, - - /// Fires when path attributes have changed. - PathAttributesChanged, + ReAdvertiseRoutes(Afi), } 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", + 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::PathAttributesChanged => "path attributes changed", + 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)", + }, } } } @@ -286,6 +561,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 @@ -302,12 +584,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" @@ -315,6 +614,8 @@ impl ConnectionEvent { ConnectionEvent::DelayOpenTimerExpires(_) => { "delay open timer expires" } + ConnectionEvent::ParseError { .. } => "parse error", + ConnectionEvent::TcpConnectionFails(_) => "tcp connection fails", } } } @@ -460,10 +761,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, @@ -483,7 +780,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", @@ -529,10 +825,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, - /// Policy governing imported routes. - pub allow_import: ImportExportPolicy, - /// Policy governing exported routes. - pub allow_export: ImportExportPolicy, + /// 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 @@ -549,12 +845,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. @@ -577,8 +873,12 @@ impl SessionInfo { communities: BTreeSet::new(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::default(), - allow_export: ImportExportPolicy::default(), + ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, + 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), @@ -586,9 +886,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, } } @@ -598,6 +900,7 @@ impl SessionInfo { #[derive(Debug, Clone)] pub struct NeighborInfo { pub name: Arc>, + pub peer_group: String, pub host: SocketAddr, } @@ -752,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, @@ -793,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, @@ -884,6 +1187,42 @@ 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); + + 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) + }}; +} + /// Registry for tracking active connections #[derive(Debug)] pub enum ConnectionRegistry { @@ -1150,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, @@ -1160,12 +1496,13 @@ pub struct SessionRunner { id: u32, /// Capabilities to send to the peer - caps_tx: Arc>>, + pub caps_tx: Arc>>, shutdown: AtomicBool, running: AtomicBool, db: Db, - fanout: Arc>>, + fanout4: Arc>>, + fanout6: Arc>>, router: Arc>, /// Registry of active connections with typestate enforcement @@ -1192,67 +1529,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,27 +1544,20 @@ 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 { session: session.clone(), - 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 +1568,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()), @@ -1562,9 +1832,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) } @@ -1778,12 +2058,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 +2070,13 @@ impl SessionRunner { }, Capability::RouteRefresh {}, ]); + if lock!(self.session).ipv4_unicast.is_some() { + caps.insert(Capability::ipv4_unicast()); + } + if lock!(self.session).ipv6_unicast.is_some() { + caps.insert(Capability::ipv6_unicast()); + } + *lock!(self.caps_tx) = caps; } /// Initial state. Refuse all incoming BGP connections. No resources @@ -1857,9 +2140,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -2025,6 +2307,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; + } } } } @@ -2089,9 +2385,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -2310,8 +2605,6 @@ impl SessionRunner { } session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); return FsmState::Idle; } @@ -2350,6 +2643,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; + } } } } @@ -2434,9 +2745,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log_lite!( self, @@ -2516,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); @@ -2583,13 +2891,31 @@ impl SessionRunner { continue; } - } - } - FsmEvent::Session(session_event) => match session_event { - /* - * In response to a ConnectRetryTimer_Expires event (Event 9), the - * local system: + 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; + } + } + } + + FsmEvent::Session(session_event) => match session_event { + /* + * In response to a ConnectRetryTimer_Expires event (Event 9), the + * local system: * * - restarts the ConnectRetryTimer (with initial value), * @@ -2791,9 +3117,8 @@ impl SessionRunner { | AdminEvent::ShaperChanged(_) | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log!( self, @@ -2969,8 +3294,6 @@ impl SessionRunner { } session_timer!(self, connect_retry).stop(); - self.connect_retry_counter - .fetch_add(1, Ordering::Relaxed); session_log!( self, @@ -3153,6 +3476,62 @@ 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.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; + } } } } @@ -3234,11 +3613,16 @@ impl SessionRunner { // hold_timer set in handle_open(), enable it here conn_timer!(conn, hold).enable(); + let caps = om.get_capabilities(); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); + let pc = PeerConnection { conn, id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4_unicast, + ipv6_unicast, }; // Upgrade this connection from Partial to Full in the registry @@ -3309,9 +3693,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); session_log!( self, @@ -3502,6 +3885,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 { @@ -3781,9 +4218,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); collision_log!( self, @@ -4170,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( @@ -4310,6 +4741,166 @@ impl SessionRunner { } } } + + 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, + }; + + 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; + } + } + } + + // 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; + } + } + } } } } @@ -4372,11 +4963,16 @@ impl SessionRunner { .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: om.get_capabilities(), + caps, + ipv4_unicast, + ipv6_unicast, }; conn_timer!(new_pc.conn, hold).restart(); @@ -4472,9 +5068,8 @@ impl SessionRunner { | AdminEvent::ExportPolicyChanged(_) | AdminEvent::CheckerChanged(_) | AdminEvent::ManualStart - | AdminEvent::SendRouteRefresh - | AdminEvent::ReAdvertiseRoutes - | AdminEvent::PathAttributesChanged => { + | AdminEvent::SendRouteRefresh(_) + | AdminEvent::ReAdvertiseRoutes(_) => { let title = admin_event.title(); collision_log!( self, @@ -4596,7 +5191,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()) { @@ -4651,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); @@ -4682,11 +5274,16 @@ impl SessionRunner { conn_timer!(exist, hold).restart(); conn_timer!(exist, keepalive).restart(); + let caps = om.get_capabilities(); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); + let exist_pc = PeerConnection { conn: exist.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4_unicast, + ipv6_unicast, }; // Upgrade existing connection from Partial to Full in the registry @@ -4729,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); } @@ -4757,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); @@ -4813,12 +5406,18 @@ impl SessionRunner { self.stop(Some(&exist), None, StopReason::CollisionResolution); + let caps = om.get_capabilities(); + let (ipv4_unicast, ipv6_unicast) = active_afi!(self, caps); + let new_pc = PeerConnection { conn: new.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4_unicast, + ipv6_unicast, }; + conn_timer!(new, hold).restart(); conn_timer!(new, keepalive).restart(); @@ -4845,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); } @@ -5006,40 +5603,199 @@ impl SessionRunner { }, } } - } - } - } - } - } - /// Collision Resolution logic. - /// - /// Performs Connection Collision Resolution per RFC 4271 6.8. - /// - /// The winning connection is returned to the FSM state that aligns with - /// the messages received by the connection. We send Full connections to - /// OpenConfirm instead of SessionSetup because they haven't received a - /// Keepalive yet. We know this because a Full connection either: - /// - Came directly from OpenConfirm (waiting for Keepalive) - /// - Came from OpenSent and just received an Open (no Keepalive yet) - fn collision_conn_kind( - &self, - rx: &ConnectionId, - exist: &ConnectionId, - new: &ConnectionId, - ) -> CollisionConnectionKind { - match self.get_conn(rx) { - Some(conn) => { - if rx == new { - CollisionConnectionKind::New - } else if rx == exist { - CollisionConnectionKind::Exist - } else { - CollisionConnectionKind::Unexpected(conn) - } - } - None => CollisionConnectionKind::Missing, - } + 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; + } + } + } + } + } + } + } + } + + /// Collision Resolution logic. + /// + /// Performs Connection Collision Resolution per RFC 4271 6.8. + /// + /// The winning connection is returned to the FSM state that aligns with + /// the messages received by the connection. We send Full connections to + /// OpenConfirm instead of SessionSetup because they haven't received a + /// Keepalive yet. We know this because a Full connection either: + /// - Came directly from OpenConfirm (waiting for Keepalive) + /// - Came from OpenSent and just received an Open (no Keepalive yet) + fn collision_conn_kind( + &self, + rx: &ConnectionId, + exist: &ConnectionId, + new: &ConnectionId, + ) -> CollisionConnectionKind { + match self.get_conn(rx) { + Some(conn) => { + if rx == new { + CollisionConnectionKind::New + } else if rx == exist { + CollisionConnectionKind::Exist + } else { + CollisionConnectionKind::Unexpected(conn) + } + } + None => CollisionConnectionKind::Missing, + } } /// Sync up with peers. @@ -5050,55 +5806,102 @@ 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_unicast.negotiated() { + 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_unicast.negotiated() { + 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_unicast.negotiated() { + 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_unicast.negotiated() { + 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(RouteUpdate4::Announce(originated4)), + &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(RouteUpdate6::Announce(originated6)), + &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 +5911,40 @@ 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(RouteUpdate4::Announce(originated4)), + pc, + sa, + )?; } - 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(RouteUpdate6::Announce(originated6)), + pc, + sa, + )?; } + Ok(()) } @@ -5155,10 +5973,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,27 +5986,35 @@ 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. + session_log!( + self, + debug, + pc.conn, + "received route-update event: {route_update}" + ); + if let Err(e) = self.send_update( - update, + route_update, &pc, - ShaperApplication::Current, + &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) } AdminEvent::ShaperChanged(previous) => { match self.originate_update( &pc, - ShaperApplication::Difference(previous), + &ShaperApplication::Difference(previous), ) { Err(e) => { session_log!( @@ -5207,84 +6031,214 @@ impl SessionRunner { } AdminEvent::ExportPolicyChanged(previous) => { - 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 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(), - }; - let session = lock!(self.session); - let current = &session.allow_export; - let originated_after: BTreeSet = match current { - ImportExportPolicy::NoFiltering => { - originated.iter().cloned().collect() - } - ImportExportPolicy::Allow(list) => originated - .clone() - .into_iter() - .filter(|x| list.contains(&Prefix::from(*x))) - .collect(), - }; - drop(session); + 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); + } + }; - let to_withdraw: BTreeSet<&Prefix4> = originated_before - .difference(&originated_after) - .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 to_announce: BTreeSet<&Prefix4> = originated_after - .difference(&originated_before) - .collect(); + let originated_after: BTreeSet = + match session + .ipv4_unicast + .as_ref() + .map(|c| &c.export_policy) + { + Some(ImportExportPolicy4::NoFiltering) + | None => { + originated.iter().cloned().collect() + } + Some(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); + } - if to_withdraw.is_empty() && to_announce.is_empty() { - return FsmState::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 IPv4 export policy withdraw: {e}"; + "error" => format!("{e}") + ); + return self.exit_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(), - }; + 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); + } + }; + + // 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() + } + }; + + let originated_after: BTreeSet = + match session + .ipv6_unicast + .as_ref() + .map(|c| &c.export_policy) + { + Some(ImportExportPolicy6::NoFiltering) + | None => { + originated.iter().cloned().collect() + } + Some(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); + } + + 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); + } - 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); + FsmState::Established(pc) + } } - - FsmState::Established(pc) } AdminEvent::CheckerChanged(_previous) => { @@ -5292,15 +6246,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_react(&pc) { + AdminEvent::ReAdvertiseRoutes(af) => { + if let Err(e) = self.refresh_react(af, &pc) { session_log!( self, error, @@ -5313,23 +6269,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!( @@ -5639,11 +6578,17 @@ impl SessionRunner { msg_kind, false, ); + let caps = om.get_capabilities(); + let (ipv4_unicast, ipv6_unicast) = + active_afi!(self, caps); + let new_pc = PeerConnection { conn: incoming_conn.clone(), id: om.id, asn: om.asn(), - caps: om.get_capabilities(), + caps, + ipv4_unicast, + ipv6_unicast, }; // Clean up the old established connection @@ -5872,6 +6817,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) + } }, } } @@ -5940,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 { @@ -5988,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); } @@ -6037,7 +7032,7 @@ impl SessionRunner { } } - fn send_route_refresh(&self, conn: &Cnx) { + fn send_route_refresh(&self, conn: &Cnx, af: Afi) { session_log!( self, info, @@ -6045,10 +7040,11 @@ impl SessionRunner { "sending route refresh"; "message" => "route refresh" ); - if let Err(e) = conn.send(Message::RouteRefresh(RouteRefreshMessage { - afi: Afi::Ipv4 as u16, - safi: Safi::NlriUnicast as u8, - })) { + let rr = Message::RouteRefresh(RouteRefreshMessage { + afi: af.into(), + safi: Safi::Unicast.into(), + }); + if let Err(e) = conn.send(rr) { session_log!( self, error, @@ -6057,11 +7053,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); } } @@ -6254,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()) } } } @@ -6305,81 +7301,269 @@ impl SessionRunner { Ok(former.difference(¤t)) } - /// Send an update message to the session peer. - fn send_update( + /// 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, - mut update: UpdateMessage, + nlri_afi: Afi, pc: &PeerConnection, - shaper_application: ShaperApplication, - ) -> Result<(), Error> { - let nexthop = pc.conn.local().ip().to_canonical(); + ) -> Result { + 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), + }; + + select_nexthop(nlri_afi, pc.conn.local().ip(), configured_nexthop) + } - 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) -> 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()); } - if let ImportExportPolicy::Allow(ref policy) = - lock!(self.session).allow_export + Ok(()) + } + + /// Apply export policy filtering to UPDATE message. + /// Filters NLRI based on per-AF export policy configuration. + fn apply_export_policy( + &self, + update: &mut UpdateMessage, + ) -> Result<(), Error> { + let session = lock!(self.session); + + // Filter traditional NLRI field (IPv4) using IPv4 export policy + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.export_policy { - 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::>(); - - update.nlri.retain(|x| message_policy.contains(x)); - }; + 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 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 Some(config6) = &session.ipv6_unicast + && let ImportExportPolicy6::Allow(ref policy6) = + config6.export_policy + { + reach6.nlri.retain(|p| policy6.contains(p)); + } + } + } + } + + 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> { + // 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(()); + } + + // 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( + "IPv4 routes require IPv4 next-hop".into(), + )); + } + }; + + let mut path_attributes = self.router.base_attributes(); + path_attributes.push(PathAttributeValue::NextHop(nh4).into()); + + UpdateMessage { + withdrawn: vec![], + path_attributes, + nlri, + ..Default::default() + } + } + 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(), + )); + } - let out = match self.shape_update(update, shaper_application)? { - ShaperResult::Emit(msg) => msg, - ShaperResult::Drop => return Ok(()), + let mut path_attrs = self.router.base_attributes(); + let reach = MpReachNlri::ipv6_unicast(nh6, nlri); + path_attrs.push(PathAttributeValue::MpReachNlri(reach).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()]; + + UpdateMessage { + withdrawn: vec![], + path_attributes: path_attrs, + nlri: vec![], + ..Default::default() + } + } }; - lock!(self.message_history).send(out.clone(), *pc.conn.id()); + // 3. Add peer-specific enrichments + self.enrich_update(&mut update)?; + + // 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 +7574,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 { @@ -6418,9 +7602,13 @@ 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); - write_lock!(self.fanout).remove_egress(self.neighbor.host.ip()); + if pc.ipv4_unicast.negotiated() { + write_lock!(self.fanout4).remove_egress(self.neighbor.host.ip()); + } + if pc.ipv6_unicast.negotiated() { + 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()); @@ -6511,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); @@ -6527,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); @@ -6543,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); @@ -6564,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); @@ -6591,6 +7767,22 @@ 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 + .connection_retries + .fetch_add(1, Ordering::Relaxed); + session_timer!(self, connect_retry).stop(); + } } if let Some(c1) = conn1 { @@ -6620,6 +7812,23 @@ impl SessionRunner { ); return; } + + // 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, + "AFI/SAFI negotiation check failed: {e}"; + "error" => format!("{e}"), + "message" => "update" + ); + return; + } + self.apply_static_update_policy(&mut update); if let Some(checker) = read_lock!(self.router.policy.checker).as_ref() { @@ -6648,32 +7857,182 @@ 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::>(); - - update.nlri.retain(|x| message_policy.contains(x)); - }; - - self.update_rib(&update, pc); - - // NOTE: for now we are only acting as an edge router. This means we - // do not redistribute announcements. If this changes, uncomment - // the following to enable redistribution. - // - // self.fanout_update(&update); + { + let session = lock!(self.session); + + // Filter traditional NLRI field (IPv4) using IPv4 import policy + if let Some(config4) = &session.ipv4_unicast + && let ImportExportPolicy4::Allow(ref policy4) = + config4.import_policy + { + 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 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 Some(config6) = &session.ipv6_unicast + && let ImportExportPolicy6::Allow(ref policy6) = + config6.import_policy + { + reach6.nlri.retain(|p| policy6.contains(p)); + } + } + } + } + } + + self.update_rib(&update, pc); + + // NOTE: for now we are only acting as an edge router. This means we + // do not redistribute announcements. If this changes, uncomment + // the following to enable redistribution. + // + // self.fanout_update(&update); + } + + /// 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 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). + /// + /// This approach aligns with RFC 4760 Section 7's "AFI/SAFI disable" + /// concept: routes for unnegotiated address families are simply ignored. + /// + /// ## Note on Error Handling + /// + /// 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> { + // 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(); + + for attr in &update.path_attributes { + match &attr.value { + PathAttributeValue::MpReachNlri(mp_reach) => { + // 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(); + + // 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, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6_unicast, + }; + + if !afi_state.negotiated() { + session_log!( + self, + warn, + pc.conn, + "MP_REACH_NLRI for unnegotiated AFI/SAFI: {}/{}", + afi, safi; + ); + + self.counters + .unnegotiated_address_family + .fetch_add(1, Ordering::Relaxed); + + // Don't send notification - just filter silently + continue; + } + + // The attribute is already validated, keep it + validated_attributes.push(attr.clone()); + } + + PathAttributeValue::MpUnreachNlri(mp_unreach) => { + // 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 afi_state = match (afi, safi) { + (Afi::Ipv4, Safi::Unicast) => pc.ipv4_unicast, + (Afi::Ipv6, Safi::Unicast) => pc.ipv6_unicast, + }; + + if !afi_state.negotiated() { + session_log!( + self, + warn, + pc.conn, + "MP_UNREACH_NLRI for unnegotiated AFI/SAFI: {}/{}", + afi, safi; + ); + + self.counters + .unnegotiated_address_family + .fetch_add(1, Ordering::Relaxed); + + // Don't send notification - just filter silently + continue; + } + + // The attribute is already validated, keep it + validated_attributes.push(attr.clone()); + } + + _ => { + // Keep all other attributes as-is + validated_attributes.push(attr.clone()); + } + } + } + + update.path_attributes = validated_attributes; + Ok(()) } - pub fn refresh_react(&self, pc: &PeerConnection) -> Result<(), Error> { - // XXX: Update for IPv6 + pub fn refresh_react4( + &self, + pc: &PeerConnection, + ) -> Result<(), Error> { + if !pc.ipv4_unicast.negotiated() { + return Ok(()); + } + let originated = match self.db.get_origin4() { Ok(value) => value, Err(e) => { @@ -6681,62 +8040,132 @@ 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(RouteUpdate4::Announce(originated)), + pc, + &ShaperApplication::Current, + )?; + } + Ok(()) + } + + pub fn refresh_react6( + &self, + pc: &PeerConnection, + ) -> Result<(), Error> { + if !pc.ipv6_unicast.negotiated() { + return Ok(()); + } + + 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(RouteUpdate6::Announce(originated)), + pc, + &ShaperApplication::Current, + )?; } 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, pc: &PeerConnection, ) -> Result<(), Error> { - // XXX: Update for IPv6 - if msg.afi != Afi::Ipv4 as u16 { - return Ok(()); + if msg.safi != u8::from(Safi::Unicast) { + return Err(Error::UnsupportedAddressFamily(msg.afi, msg.safi)); } - self.refresh_react(pc) + + let af = match Afi::try_from(msg.afi) { + Ok(afi) => afi, + Err(_) => { + return Err(Error::UnsupportedAddressFamily(msg.afi, msg.safi)); + } + }; + + self.refresh_react(af, 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: {e}"; "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(addrs) => IpAddr::V6(addrs.global), + }, + 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 +8174,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 +8191,7 @@ impl SessionRunner { } } let path = rdb::Path { - nexthop: nexthop.into(), + nexthop, shutdown: update.graceful_shutdown(), rib_priority: DEFAULT_RIB_PRIORITY_BGP, bgp: Some(BgpPathProperties { @@ -6827,7 +8209,184 @@ 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(addrs) => { + IpAddr::V6(addrs.global) + } + }; + + 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(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) + 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. @@ -6836,8 +8395,10 @@ impl SessionRunner { update: &UpdateMessage, peer_as: u32, ) -> Result<(), Error> { + // 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 +8440,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 +8476,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) @@ -6946,6 +8492,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 { @@ -7011,8 +8568,10 @@ impl SessionRunner { info: SessionInfo, ) -> Result { let mut reset_needed = false; - let mut path_attributes_changed = false; - let mut refresh_needed = 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); current.passive_tcp_establishment = info.passive_tcp_establishment; @@ -7039,29 +8598,30 @@ 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 { 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; - } - if current.vlan_id != info.vlan_id { current.vlan_id = info.vlan_id; reset_needed = true; @@ -7080,28 +8640,97 @@ 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); + // ===== 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) + { + 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( + ImportExportPolicy::V4(previous4), + ))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } + + current.ipv4_unicast = info.ipv4_unicast.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) + { + let previous6 = current_v6 + .map(|c| c.export_policy.clone()) + .unwrap_or_default(); + self.event_tx + .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( + ImportExportPolicy::V6(previous6), + ))) + .map_err(|e| Error::EventSend(e.to_string()))?; + } + + current.ipv6_unicast = info.ipv6_unicast.clone(); + } + + drop(current); + + if readvertise_needed4 { self.event_tx - .send(FsmEvent::Admin(AdminEvent::ExportPolicyChanged( - previous, - ))) + .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::ReAdvertiseRoutes(Afi::Ipv6))) .map_err(|e| Error::EventSend(e.to_string()))?; - } else { - drop(current); } - if path_attributes_changed { + if refresh_needed4 { self.event_tx - .send(FsmEvent::Admin(AdminEvent::PathAttributesChanged)) + .send(FsmEvent::Admin(AdminEvent::SendRouteRefresh(Afi::Ipv4))) .map_err(|e| Error::EventSend(e.to_string()))?; } - if refresh_needed { + 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()))?; } @@ -7126,6 +8755,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()) + } + + pub fn get_peer_info(&self) -> PeerInfo { + let fsm_state = self.state(); + 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(); + + // 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_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! + + // 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) => { + 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, + 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, + 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, + counters, + ipv4_unicast, + ipv6_unicast, + } + } + } + } } // ============================================================================ @@ -7180,6 +8969,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() { @@ -7347,4 +9138,169 @@ mod tests { assert_eq!(record.previous_state, Some(FsmStateKind::Idle)); assert!(record.details.is_some()); } + + // ========================================================================= + // RouteUpdate tests + // ========================================================================= + + #[test] + fn route_update_is_announcement_and_withdrawal() { + let v4_announce = + RouteUpdate::V4(RouteUpdate4::Announce(vec![Prefix4::new( + ip!("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( + ip!("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( + ip!("2001:db8::"), + 32, + )])); + assert!(v6_announce.is_announcement()); + assert!(!v6_announce.is_withdrawal()); + + let v6_withdraw = + RouteUpdate::V6(RouteUpdate6::Withdraw(vec![Prefix6::new( + 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 5dc80133..ed565a7a 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::{Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange}, router::Router, session::{ AdminEvent, ConnectionKind, FsmEvent, FsmStateKind, SessionEndpoint, @@ -18,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, Prefix}; +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 @@ -48,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))) }; @@ -96,18 +108,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 NeighborConfig { + peer_name: String, + remote_host: SocketAddr, + session_info: SessionInfo, } -struct Neighbor { - peer_config: PeerConfig, - session_info: Option, +/// 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( @@ -134,15 +269,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"); @@ -188,17 +335,26 @@ 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(), + group: String::new(), + 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( @@ -256,26 +412,21 @@ fn basic_peering_helper< Listener: BgpListener + 'static, >( passive: bool, + route_exchange: RouteExchange, r1_addr: SocketAddr, 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 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 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", }; let routers = vec![ @@ -285,14 +436,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({ - let mut info = - SessionInfo::from_peer_config(&peer_config_r1); - info.passive_tcp_establishment = passive; - info - }), + 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 { @@ -301,18 +453,15 @@ fn basic_peering_helper< 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: create_test_session_info( + route_exchange, + r2_addr, + r1_addr, + !passive, + ), }], }, ]; @@ -436,25 +585,28 @@ 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, ) { 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", }; let routers = vec![ @@ -464,9 +616,82 @@ fn basic_update_helper< id: 1, listen_addr: r1_addr, bind_addr: Some(r1_addr), - neighbors: vec![Neighbor { - peer_config: PeerConfig { + 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 { + 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: create_test_session_info( + route_exchange, + r2_addr, + r1_addr, + false, + ), + }], + }, + ]; + + let (test_routers, _ip_guard) = + test_setup::(test_name, &routers); + + let r1 = &test_routers[0]; + let r2 = &test_routers[1]; + + // let the session get into established state + let r1_session = r1 + .router + .get_session(r2_addr.ip()) + .expect("get session one"); + let r2_session = r2 + .router + .get_session(r1_addr.ip()) + .expect("get session two"); + wait_for_eq!(r1_session.state(), FsmStateKind::Established); + wait_for_eq!(r2_session.state(), FsmStateKind::Established); + + // Originate and verify routes based on route_exchange variant + match route_exchange { + RouteExchange::Ipv4 { + nexthop: initial_nexthop, + } => { + // 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()); + + // 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, @@ -474,8 +699,246 @@ fn basic_update_helper< connect_retry: 1, keepalive: 3, resolution: 100, - }, - session_info: None, + }; + 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 { + nexthop: initial_nexthop, + } => { + // 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()); + + // 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 { + 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")]) + .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()); + + // 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); + 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", + }; + + // 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(), + group: String::new(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }), }], }, LogicalRouter { @@ -484,68 +947,98 @@ fn basic_update_helper< id: 2, listen_addr: r2_addr, bind_addr: Some(r2_addr), - neighbors: vec![Neighbor { - peer_config: PeerConfig { - name: "r1".into(), - host: r1_addr, + neighbors: vec![ + NeighborConfig { + peer_name: "r1".to_string(), + 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, + 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(), + group: String::new(), + 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(), + group: String::new(), + host: r2_addr, hold_time: 6, idle_hold_time: 0, delay_open: 0, connect_retry: 1, keepalive: 3, resolution: 100, - }, - session_info: None, + }), }], }, ]; let (test_routers, _ip_guard) = - test_setup::(test_name, &routers); + test_setup::(test_str, &routers); let r1 = &test_routers[0]; let r2 = &test_routers[1]; + let r3 = &test_routers[2]; - // let the session get into established state - let r1_session = r1 + // Get sessions from each router + let r1_r2_session = r1 .router .get_session(r2_addr.ip()) - .expect("get session one"); - let r2_session = r2 + .expect("get r1->r2 session"); + let r2_r1_session = r2 .router .get_session(r1_addr.ip()) - .expect("get session two"); - 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")); - - wait_for!(!r2.router.db.get_prefix_paths(&prefix).is_empty()); + .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"); - // 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()); + // 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 properly - r2.shutdown(); + // Clean up + for router in test_routers.iter() { + router.shutdown(); + } } // Channels vs TCP: @@ -584,6 +1077,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}")), ) @@ -593,6 +1087,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}")), ) @@ -602,6 +1097,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}")), ) @@ -614,6 +1110,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}")), ) @@ -623,6 +1120,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}")), ) @@ -631,6 +1129,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}")), ) @@ -638,129 +1137,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, - }], - }, - ]; - - 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 + ensure_loop_ips(&[r1_addr.ip(), r2_addr.ip(), r3_addr.ip()]); - // 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. @@ -794,6 +1192,30 @@ fn test_neighbor_thread_lifecycle_no_leaks() { let baseline = 0; eprintln!("=== Baseline BGP thread count: {baseline} ==="); + let r1_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 r2_peer_config = PeerConfig { + name: "r1".into(), + group: String::new(), + 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(), @@ -801,18 +1223,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 { @@ -821,18 +1235,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), }], }, ]; @@ -925,3 +1331,398 @@ 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::ImportExportPolicy4; + 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 = + [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 = + [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 { + 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 r1_session_info = { + let mut info = SessionInfo::from_peer_config(&r1_peer_config); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.export_policy = + ImportExportPolicy4::Allow(export_allow.clone()); + } + info + }; + + // 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, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let r2_session_info = { + let mut info = SessionInfo::from_peer_config(&r2_peer_config); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.import_policy = + ImportExportPolicy4::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![NeighborConfig { + peer_name: "r2".to_string(), + remote_host: r2_addr, + session_info: 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![NeighborConfig { + peer_name: "r1".to_string(), + remote_host: r1_addr, + session_info: 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 ExportPolicy4Changed 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); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.export_policy = ImportExportPolicy4::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); + if let Some(ref mut cfg) = info.ipv4_unicast { + cfg.import_policy = ImportExportPolicy4::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(); +} + +// 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::( + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::1]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_basic_update_ipv6_tcp() { + basic_update_helper::( + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::a]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::b]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_passive() { + basic_peering_helper::( + true, + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::2]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::3]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_active() { + basic_peering_helper::( + false, + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::4]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::5]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_passive_tcp() { + basic_peering_helper::( + true, + RouteExchange::Ipv6 { nexthop: None }, + sockaddr!(&format!("[3fff::6]:{TEST_BGP_PORT}")), + sockaddr!(&format!("[3fff::7]:{TEST_BGP_PORT}")), + ) +} + +#[test] +fn test_ipv6_basic_peering_active_tcp() { + basic_peering_helper::( + false, + 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/src/lib.rs b/mg-admin-client/src/lib.rs index 4095ad5c..247d6d49 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, + Duration = std::time::Duration, } ); diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 69721dab..1f2e2528 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, + NeighborResetOpV1, NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, + PeerInfoV2, 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,38 +115,78 @@ 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; - #[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; @@ -237,14 +279,28 @@ 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>; - #[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; @@ -419,6 +475,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, @@ -426,6 +490,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/mg-common/src/log.rs b/mg-common/src/log.rs index 33292e03..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 { @@ -11,7 +10,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 { diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index b0f027de..8e1e568c 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,23 +318,32 @@ 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 = { - 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")] @@ -342,9 +352,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); @@ -458,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]) @@ -467,16 +488,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/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 2e94bde1..1cb7c4e1 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -3,20 +3,37 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use anyhow::Result; +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, types::{ - self, ImportExportPolicy, NeighborResetOp as MgdNeighborResetOp, - NeighborResetRequest, + self, ImportExportPolicy4, ImportExportPolicy6, Ipv4UnicastConfig, + Ipv6UnicastConfig, NeighborResetRequest, }, }; -use rdb::types::{PolicyAction, Prefix, Prefix4, Prefix6}; -use std::fs::read_to_string; -use std::io::{Write, stdout}; -use std::net::{IpAddr, SocketAddr}; -use std::time::Duration; +use rdb::types::{PolicyAction, Prefix4, Prefix6}; +use std::{ + fs::read_to_string, + io::{Write, stdout}, + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Duration, +}; use tabwriter::TabWriter; #[derive(Subcommand, Debug)] @@ -58,6 +75,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)] @@ -70,6 +96,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. @@ -132,27 +162,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)] @@ -166,14 +175,67 @@ 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 types::NeighborResetOp { + fn from(op: NeighborOperation) -> Self { + match op { + NeighborOperation::Hard => types::NeighborResetOp::Hard, + NeighborOperation::Soft { direction } => direction.into(), + } + } +} + +impl From for types::NeighborResetOp { + fn from(direction: SoftDirection) -> Self { + match direction { + SoftDirection::Inbound { afi } => { + types::NeighborResetOp::SoftInbound(afi.map(afi_to_api)) + } + SoftDirection::Outbound { afi } => { + types::NeighborResetOp::SoftOutbound(afi.map(afi_to_api)) + } + } + } +} + #[derive(Debug, Args)] pub struct OmicronSubcommand { #[command(subcommand)] @@ -491,10 +553,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, @@ -503,9 +573,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)] @@ -543,11 +617,37 @@ pub struct Neighbor { #[arg(long)] pub vlan_id: Option, + /// Enable IPv4 unicast address family. #[arg(long)] - pub allow_export: Option>, + pub enable_ipv4: bool, + + /// IPv4 prefixes to allow importing (requires --enable-ipv4). + #[arg(long, requires = "enable_ipv4")] + pub allow_import4: Option>, + + /// IPv4 prefixes to allow exporting (requires --enable-ipv4). + #[arg(long, requires = "enable_ipv4")] + pub allow_export4: Option>, + /// 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 allow_import: Option>, + 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, 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)] @@ -556,6 +656,52 @@ 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 { + nexthop: n.nexthop4, + 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 { + nexthop: n.nexthop6, + import_policy, + export_policy, + }) + } else { + None + }; + types::Neighbor { asn: n.asn, remote_asn: n.remote_asn, @@ -567,7 +713,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(), @@ -575,27 +721,15 @@ 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, + 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, } } } @@ -675,7 +809,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?, }, @@ -711,8 +847,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 { @@ -761,11 +897,37 @@ async fn delete_router(asn: u32, c: Client) -> Result<()> { Ok(()) } -async fn get_neighbors(c: Client, asn: u32) -> Result<()> { - let result = c.get_neighbors_v2(asn).await?; +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, &types::PeerInfo)], +) -> Result<()> { let mut tw = TabWriter::new(stdout()); writeln!( &mut tw, @@ -779,28 +941,18 @@ 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{}/{}", addr, info.asn, - info.state, - humantime::Duration::from(Duration::from_millis( - info.duration_millis - ),), - 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, - )), + info.fsm_state, + 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), + humantime::Duration::from(info.timers.keepalive.negotiated), ) .unwrap(); } @@ -808,6 +960,184 @@ async fn get_neighbors(c: Client, asn: u32) -> Result<()> { Ok(()) } +fn display_neighbors_detail( + neighbors: &[(&String, &types::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(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 }) @@ -819,42 +1149,42 @@ 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(()) } 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(()) @@ -941,7 +1271,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..3320076d 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,13 +177,55 @@ 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, + 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, @@ -285,17 +327,31 @@ 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, + 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 653bb1ac..166e6bbe 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, }, }; @@ -27,10 +26,12 @@ 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}; +use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicyV1, Prefix}; +use rdb::{ImportExportPolicy4, ImportExportPolicy6}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}; @@ -168,7 +169,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(); @@ -180,7 +181,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)) @@ -188,18 +189,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}")) @@ -212,17 +213,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()) } @@ -235,13 +236,96 @@ 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) +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( @@ -417,9 +501,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 } @@ -471,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 @@ -495,16 +585,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 { + duration_millis: dur, + timers: PeerTimersV1 { + hold: DynamicTimerInfoV1 { configured: conf_holdtime, negotiated: neg_holdtime, }, - keepalive: DynamicTimerInfo { + keepalive: DynamicTimerInfoV1 { configured: conf_keepalive, negotiated: neg_keepalive, }, @@ -520,7 +610,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(); @@ -531,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() { @@ -554,16 +645,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 { + duration_millis: dur, + timers: PeerTimersV1 { + hold: DynamicTimerInfoV1 { configured: conf_holdtime, negotiated: neg_holdtime, }, - keepalive: DynamicTimerInfo { + keepalive: DynamicTimerInfoV1 { configured: conf_keepalive, negotiated: neg_keepalive, }, @@ -575,7 +666,42 @@ 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(); + + // 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)) +} + 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 { @@ -1027,9 +1153,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; @@ -1039,6 +1165,10 @@ 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(); + // XXX: Do we really want both rq and info? // SessionInfo and Neighbor types could probably be merged. let info = SessionInfo { @@ -1050,8 +1180,13 @@ 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(), + // 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(), + }), + ipv6_unicast: None, vlan_id: rq.vlan_id, remote_id: None, bind_addr: None, @@ -1061,8 +1196,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((0.75, 1.0)), - connect_retry_jitter: None, + // 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, + }), deterministic_collision_resolution: false, }; @@ -1107,8 +1246,149 @@ 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, + 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, + nexthop4: None, + nexthop6: None, + })?; + + 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_address_families() + .map_err(Error::InvalidRequest)?; + + // Validate nexthop address families + rq.validate_nexthop().map_err(Error::InvalidRequest)?; + + 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: rq.idle_hold_jitter, + connect_retry_jitter: rq.connect_retry_jitter, + deterministic_collision_resolution: rq + .deterministic_collision_resolution, + }; + + 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 + }; + + // 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, nexthop6) = match &rq.ipv6_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), + None => ( + ImportExportPolicy6::NoFiltering, + ImportExportPolicy6::NoFiltering, + None, + ), + }; + + 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, + // 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, + allow_export6, + nexthop4, + nexthop6, vlan_id: rq.vlan_id, })?; @@ -1121,46 +1401,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()))?; - 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)) + .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)) - .map_err(|e| { - Error::InternalCommunication(format!( - "failed to trigger outbound update {e}" - )) - })?, } Ok(HttpResponseUpdatedNoContent()) @@ -1324,9 +1645,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, ImportExportPolicy}; + use rdb::Db; use std::{ collections::{BTreeMap, HashMap}, env::temp_dir, @@ -1365,7 +1686,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, @@ -1382,14 +1703,14 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::NoFiltering, - allow_export: ImportExportPolicy::NoFiltering, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::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, @@ -1406,13 +1727,13 @@ mod tests { communities: Vec::default(), local_pref: None, enforce_first_as: false, - allow_import: ImportExportPolicy::NoFiltering, - allow_export: ImportExportPolicy::NoFiltering, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::NoFiltering, vlan_id: None, }], ); - let mut req = ApplyRequest { + let mut req = ApplyRequestV1 { asn: 47, originate: Vec::default(), checker: None, @@ -1420,7 +1741,7 @@ mod tests { peers, }; - do_bgp_apply(&ctx, req.clone()) + do_bgp_apply(&ctx, req.clone().into()) .await .expect("bgp apply request"); @@ -1431,7 +1752,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/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/mgd/src/main.rs b/mgd/src/main.rs index 96fac887..8a687b34 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, @@ -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/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json b/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json new file mode 100644 index 00000000..3332678a --- /dev/null +++ b/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json @@ -0,0 +1,4894 @@ +{ + "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_v2", + "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_v3", + "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" + ] + }, + "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": { + "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", + "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" + ] + } + ] + }, + "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": "A BGP next-hop address in one of three formats: IPv4, IPv6 single, or 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" + }, + "remaining": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "negotiated", + "remaining" + ] + }, + "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": { + "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": { + "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" + ] + } + ] + }, + "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 + }, + "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 + }, + "unnegotiated_address_family": { + "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", + "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", + "unnegotiated_address_family", + "update_nexhop_missing", + "update_send_failure", + "updates_received", + "updates_sent" + ] + }, + "PeerInfo": { + "type": "object", + "properties": { + "asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "counters": { + "$ref": "#/components/schemas/PeerCounters" + }, + "fsm_state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "fsm_state_duration": { + "$ref": "#/components/schemas/Duration" + }, + "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": [ + "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/StaticTimerInfo" + }, + "connect_retry_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "$ref": "#/components/schemas/StaticTimerInfo" + }, + "hold": { + "$ref": "#/components/schemas/DynamicTimerInfo" + }, + "idle_hold": { + "$ref": "#/components/schemas/StaticTimerInfo" + }, + "idle_hold_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "keepalive": { + "$ref": "#/components/schemas/DynamicTimerInfo" + } + }, + "required": [ + "connect_retry", + "delay_open", + "hold", + "idle_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" + ] + }, + "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" + ] + }, + "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..bb139baa 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-9d15bb.json \ No newline at end of file diff --git a/rdb-types/src/lib.rs b/rdb-types/src/lib.rs index b3ddeec1..99fdf415 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; diff --git a/rdb/src/bestpath.rs b/rdb/src/bestpath.rs index e6099d4d..154334a7 100644 --- a/rdb/src/bestpath.rs +++ b/rdb/src/bestpath.rs @@ -37,7 +37,7 @@ pub fn bestpaths(paths: &BTreeSet, max: usize) -> Option> { // 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>) = + let (shutdown, active): (BTreeSet<&Path>, BTreeSet<&Path>) = paths.iter().partition(|x| x.shutdown); let candidates = if active.is_empty() { shutdown } else { active }; @@ -133,6 +133,7 @@ mod test { use crate::{ BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, DEFAULT_RIB_PRIORITY_STATIC, Path, + types::test_helpers::path_sets_equal, }; // Bestpaths is purely a function of the path info itself, so we don't @@ -168,7 +169,7 @@ mod test { let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result, BTreeSet::from([path1.clone()])); + assert!(path_sets_equal(&result, &BTreeSet::from([path1.clone()]))); // Add path2: let mut path2 = Path { @@ -195,7 +196,10 @@ mod test { // - matching as-path-len // - matching med assert_eq!(result.len(), 2); - assert_eq!(result, BTreeSet::from([path1.clone(), path2.clone()])); + assert!(path_sets_equal( + &result, + &BTreeSet::from([path1.clone(), path2.clone()]) + )); // Add path3 with: // - matching local-pref @@ -221,7 +225,10 @@ mod test { 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()])); + assert!(path_sets_equal( + &result, + &BTreeSet::from([path1.clone(), path2.clone()]) + )); // increase max paths to 3 max = 3; @@ -234,10 +241,10 @@ mod test { candidates.insert(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_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 @@ -247,7 +254,7 @@ mod test { candidates.insert(path2.clone()); let result = bestpaths(&candidates, max).unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result, BTreeSet::from([path2.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 @@ -267,7 +274,7 @@ mod test { 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()])); + assert!(path_sets_equal(&result, &BTreeSet::from([path2.clone()]))); // Lower the RIB Priority (better) let mut candidates = result.clone(); @@ -277,7 +284,7 @@ mod test { 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()])); + assert!(path_sets_equal(&result, &BTreeSet::from([path4.clone()]))); // Raise the RIB Priority equal to BGP (paths 1-3) let mut candidates = result.clone(); @@ -288,6 +295,104 @@ mod test { 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()])); + 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_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(); + + // 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, + }; + + 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()); + + 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 52c7c119..6738479b 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(); @@ -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 { @@ -1343,6 +1350,7 @@ mod test { use crate::{ 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}; @@ -1360,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; @@ -1440,6 +1448,9 @@ mod test { }), vlan_id: None, }; + // 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, @@ -1447,12 +1458,23 @@ mod test { rib_priority: DEFAULT_RIB_PRIORITY_STATIC, }; let static_path0 = Path::from(static_key0); - let static_key1 = StaticRouteKey { + 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 @@ -1468,25 +1490,55 @@ 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}", - ); + // ===================================================================== + // 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_key0"); - // expected current state - // rib_in: - // - p0 via static_path0, static_path1 - // loc_rib: - // - p0 via static_path0 (win by rib_priority) - let rib_in_paths = vec![static_path0.clone(), static_path1.clone()]; + // 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)); - // rib_priority differs, so removal of static_key0 - // should not affect path from static_key1 + // 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_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)); + + // ===================================================================== + // 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_routes_failed for {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)); @@ -1498,14 +1550,14 @@ mod test { // expected current state // rib_in: - // - p0 via static_path1, 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 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![static_path1.clone(), 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 = @@ -1520,14 +1572,14 @@ 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, static_path1 (ordered by nexthop IP) // - p1 via bgp_path{0,1,2} // - p2 via bgp_path2 // loc_rib: // - 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![static_path1.clone(), 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 = 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 8fc19b9d..3454d694 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, @@ -35,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); } @@ -328,15 +373,156 @@ pub struct BgpRouterInfo { pub graceful_shutdown: bool, } +// ============================================================================ +// API Compatibility Type (ImportExportPolicy) +// ============================================================================ +// 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 ImportExportPolicy4/6 for type safety. + +/// 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 typed variants using +/// `as_ipv4_policy()` and `as_ipv6_policy()`. #[derive( Default, Debug, Serialize, Deserialize, Clone, JsonSchema, Eq, PartialEq, )] -pub enum ImportExportPolicy { +#[schemars(rename = "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 wrapper for internal use. +/// This is distinct from the API-facing `ImportExportPolicy` type. +#[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, @@ -357,11 +543,42 @@ pub struct BgpNeighborInfo { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicy, - pub allow_export: ImportExportPolicy, + /// 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, + /// 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, + /// 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, } +/// 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. @@ -420,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)) + } +}