feature: locally originated routes, session registry, outbound UPDATEs

Phase 1 of the BGP teaching demo: the daemon can now originate routes and
advertise them to every established peer.

- rib: add LocalRib actor holding locally originated routes with owner,
  origin, AS-path prepend, MED, standard and large communities. Enforces a
  LocalRibPolicy (prefix allowlist, per-owner limit, owner match on
  replace/withdraw) and emits LocalRibEvents.
- server: add SessionRegistry with per-session outbound queues; sessions
  register on Established and deregister via a drop guard. Emits
  SessionEvent::{Up,Down}.
- server: advertiser task fans LocalRib changes out to all sessions and
  replays the table to new sessions. UPDATE builders handle IPv4 (legacy
  NLRI) and IPv6 (MP_REACH/MP_UNREACH).
- fsm: merge the timer and timer-less established loops into one select!
  with an outbound branch; reset the hold timer on any received message
  per RFC 4271 §8.2.2 (previously only on KEEPALIVE).
- config: new local_route_policy proto message.
- packet: fix Communitites typo, expose CommunitiesPathAttribute inner vec.
- primitives: IpPrefix::contains.
- integration tests: announce/withdraw/replay/fan-out/policy coverage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-16 22:56:29 +02:00
1 parent 1ec60255ea
commit ad07233196
19 files changed
+1144 -70

No files matched your search

Generated
+3
View File
@@ -159,6 +159,8 @@ version = "0.0.0"
dependencies = [ dependencies = [
"bgp-packet", "bgp-packet",
"bgp-primitives", "bgp-primitives",
"serde",
"thiserror 2.0.12",
"tokio", "tokio",
"tracing", "tracing",
] ]
@@ -177,6 +179,7 @@ dependencies = [
"prometheus", "prometheus",
"protobuf", "protobuf",
"protobuf-codegen", "protobuf-codegen",
"thiserror 2.0.12",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
+13 -1
View File
@@ -1,6 +1,6 @@
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use bgp_packet::message::{BGP_HEADER_LEN, Message}; use bgp_packet::message::{BGP_HEADER_LEN, Message, UpdateMessage};
use bgp_packet::open::{Capability, OpenMessage, OpenOption}; use bgp_packet::open::{Capability, OpenMessage, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi}; use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::parser::ParserContext; use bgp_primitives::parser::ParserContext;
@@ -49,6 +49,18 @@ impl TestPeer {
Ok(msg) Ok(msg)
} }
/// Read messages until an UPDATE arrives, skipping KEEPALIVEs. Any other
/// message type is an error.
pub async fn recv_update(&mut self) -> Result<UpdateMessage> {
loop {
match self.recv().await? {
Message::Update(u) => return Ok(u),
Message::KeepAlive => {}
other => bail!("Expected UPDATE, got {:?}", other),
}
}
}
/// Receive and unwrap as `OpenMessage`, failing if a different type arrives. /// Receive and unwrap as `OpenMessage`, failing if a different type arrives.
pub async fn recv_open(&mut self) -> Result<OpenMessage> { pub async fn recv_open(&mut self) -> Result<OpenMessage> {
match self.recv().await? { match self.recv().await? {
+17 -4
View File
@@ -1,8 +1,9 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use bgp_rib::AdjRibIn; use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
use bgp_server::config::Config; use bgp_server::config::Config;
use bgp_server::server::BgpServerBuilder; use bgp_server::server::BgpServerBuilder;
use bgp_server::session::SessionRegistry;
use eyre::Result; use eyre::Result;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -14,6 +15,10 @@ pub struct TestServer {
pub addr: SocketAddr, pub addr: SocketAddr,
/// Handle to the server's Adj-RIB-In for test introspection. /// Handle to the server's Adj-RIB-In for test introspection.
pub adj_rib_in: AdjRibIn, pub adj_rib_in: AdjRibIn,
/// Handle to the server's locally originated routes.
pub local_rib: LocalRib,
/// Live view of the server's established sessions.
pub registry: SessionRegistry,
} }
impl TestServer { impl TestServer {
@@ -24,16 +29,22 @@ impl TestServer {
/// Start a server with an explicit hold time (useful for timer tests). /// Start a server with an explicit hold time (useful for timer tests).
pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result<Self> { pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
Self::start_inner(local_asn, hold_time_secs).await Self::start_inner(local_asn, hold_time_secs, LocalRibPolicy::default()).await
} }
async fn start_inner(local_asn: u32, hold_time_secs: u16) -> Result<Self> { /// Start a server with a policy restricting locally originated routes.
pub async fn start_with_policy(local_asn: u32, policy: LocalRibPolicy) -> Result<Self> {
Self::start_inner(local_asn, 90, policy).await
}
async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result<Self> {
let config = Config { let config = Config {
router_id: Ipv4Addr::new(127, 0, 0, 1), router_id: Ipv4Addr::new(127, 0, 0, 1),
local_asn, local_asn,
hold_time_secs, hold_time_secs,
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)], listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
peers: vec![], peers: vec![],
local_route_policy: policy,
}; };
let bound = BgpServerBuilder::new(config).bind().await?; let bound = BgpServerBuilder::new(config).bind().await?;
@@ -43,9 +54,11 @@ impl TestServer {
.next() .next()
.expect("at least one listener"); .expect("at least one listener");
let adj_rib_in = bound.adj_rib_in.clone(); let adj_rib_in = bound.adj_rib_in.clone();
let local_rib = bound.local_rib.clone();
let registry = bound.registry.clone();
let handle = tokio::spawn(bound.run()); let handle = tokio::spawn(bound.run());
Ok(Self { handle, addr, adj_rib_in }) Ok(Self { handle, addr, adj_rib_in, local_rib, registry })
} }
} }
+256
View File
@@ -0,0 +1,256 @@
//! End-to-end tests for locally originated routes: announcing via the
//! `LocalRib` handle must produce UPDATEs on every established session.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity,
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRibError, LocalRibPolicy, LocalRoute, LocalRouteAttrs};
use bgp_server::session::SessionEvent;
use eyre::Result;
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute {
LocalRoute::new(prefix(p), "alice", attrs)
}
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
Ok(peer)
}
async fn next_update(peer: &mut TestPeer) -> Result<UpdateMessage> {
tokio::time::timeout(TIMEOUT, peer.recv_update()).await?
}
/// Waits until the registry reports `n` established sessions.
async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> {
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().len() != n {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Session registry
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_registry_tracks_session_lifecycle() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut events = server.registry.subscribe();
assert!(server.registry.list().is_empty());
let peer = established_peer(&server).await?;
let up = tokio::time::timeout(TIMEOUT, events.recv()).await??;
let SessionEvent::Up(handle) = up else { panic!("expected Up, got {up:?}") };
assert_eq!(handle.peer.asn, PEER_ASN);
assert_eq!(handle.local_addr, server.addr);
assert!(handle.four_octet_asn);
assert_eq!(server.registry.list().len(), 1);
drop(peer);
let down = tokio::time::timeout(TIMEOUT, events.recv()).await??;
assert!(matches!(down, SessionEvent::Down(p) if p == handle.peer), "got {down:?}");
assert!(server.registry.list().is_empty());
Ok(())
}
// ---------------------------------------------------------------------------
// Announce / withdraw
// ---------------------------------------------------------------------------
/// A route announced while the session is up is sent immediately, with the
/// attributes the announcer chose and our address as the next hop.
#[tokio::test]
async fn test_announce_after_session_up() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server
.local_rib
.announce(route(
"10.100.1.0/24",
LocalRouteAttrs {
origin: OriginPathAttribute::IGP,
prepend: 0,
med: None,
communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
},
))
.await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.withdrawn_routes.is_empty());
assert_eq!(
update.path_attributes,
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![LOCAL_ASN]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
}),
]
);
Ok(())
}
/// Routes announced before any session exists are replayed when a session
/// reaches Established.
#[tokio::test]
async fn test_routes_replayed_on_session_up() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
server.local_rib.announce(route("10.100.2.0/24", LocalRouteAttrs::default())).await?;
let mut peer = established_peer(&server).await?;
let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]
.into_iter()
.flat_map(|u| u.announced_routes)
.map(|p| p.to_string())
.collect::<Vec<_>>();
seen.sort();
assert_eq!(seen, vec!["10.100.1.0/24", "10.100.2.0/24"]);
Ok(())
}
/// An announcement is fanned out to every established session.
#[tokio::test]
async fn test_announce_reaches_all_sessions() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut a = established_peer(&server).await?;
let mut b = established_peer(&server).await?;
wait_for_sessions(&server, 2).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
for peer in [&mut a, &mut b] {
let update = next_update(peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
}
Ok(())
}
#[tokio::test]
async fn test_withdraw_sends_withdrawn_route() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let _ = next_update(&mut peer).await?;
server.local_rib.withdraw(prefix("10.100.1.0/24"), Some("alice")).await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.announced_routes.is_empty());
assert!(update.path_attributes.is_empty());
Ok(())
}
/// Re-announcing with new attributes sends a fresh UPDATE (implicit replace).
#[tokio::test]
async fn test_reannounce_with_prepend_and_med() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let first = next_update(&mut peer).await?;
assert_eq!(first.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN]));
server
.local_rib
.announce(route(
"10.100.1.0/24",
LocalRouteAttrs { prepend: 3, med: Some(42), ..LocalRouteAttrs::default() },
))
.await?;
let second = next_update(&mut peer).await?;
assert_eq!(second.announced_routes, vec![prefix("10.100.1.0/24")]);
assert_eq!(second.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN; 4]));
assert!(second
.path_attributes
.iter()
.any(|a| matches!(a, PathAttribute::MultiExitDisc(m) if m.0 == 42)));
Ok(())
}
/// The session stays healthy (keepalives keep flowing) while UPDATEs are sent.
#[tokio::test]
async fn test_keepalives_continue_alongside_updates() -> Result<()> {
let server = TestServer::start_with_hold_time(LOCAL_ASN, 3).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 3).await?;
wait_for_sessions(&server, 1).await?;
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let mut saw_update = false;
let mut saw_keepalive = false;
tokio::time::timeout(TIMEOUT, async {
while !(saw_update && saw_keepalive) {
match peer.recv().await? {
bgp_packet::message::Message::Update(_) => saw_update = true,
bgp_packet::message::Message::KeepAlive => saw_keepalive = true,
other => eyre::bail!("unexpected {other:?}"),
}
}
Ok::<_, eyre::Error>(())
})
.await??;
Ok(())
}
// ---------------------------------------------------------------------------
// Policy
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_policy_rejects_prefix_outside_allowlist() -> Result<()> {
let server = TestServer::start_with_policy(
LOCAL_ASN,
LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 2 },
)
.await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
let err = server
.local_rib
.announce(route("192.0.2.0/24", LocalRouteAttrs::default()))
.await
.unwrap_err();
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
// An allowed prefix still goes through on the same session.
server.local_rib.announce(route("10.100.9.0/24", LocalRouteAttrs::default())).await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.9.0/24")]);
Ok(())
}
+11 -11
View File
@@ -313,7 +313,7 @@ pub enum PathAttribute {
LocalPref(LocalPrefPathAttribute) = 5, LocalPref(LocalPrefPathAttribute) = 5,
AtomicAggregate(AtomicAggregatePathAttribute) = 6, AtomicAggregate(AtomicAggregatePathAttribute) = 6,
Aggregator(AggregatorPathAttribute) = 7, Aggregator(AggregatorPathAttribute) = 7,
Communitites(CommunitiesPathAttribute) = 8, Communities(CommunitiesPathAttribute) = 8,
MpReachNlri(MpReachNlriPathAttribute) = 14, MpReachNlri(MpReachNlriPathAttribute) = 14,
MpUnreachNlri(MpUnreachNlriPathAttribute) = 15, MpUnreachNlri(MpUnreachNlriPathAttribute) = 15,
ExtendedCommunities(ExtendedCommunitiesPathAttribute) = 16, ExtendedCommunities(ExtendedCommunitiesPathAttribute) = 16,
@@ -335,7 +335,7 @@ impl From<u8> for PathAttributeDiscriminants {
v if v == Self::LocalPref as u8 => Self::LocalPref, v if v == Self::LocalPref as u8 => Self::LocalPref,
v if v == Self::AtomicAggregate as u8 => Self::AtomicAggregate, v if v == Self::AtomicAggregate as u8 => Self::AtomicAggregate,
v if v == Self::Aggregator as u8 => Self::Aggregator, v if v == Self::Aggregator as u8 => Self::Aggregator,
v if v == Self::Communitites as u8 => Self::Communitites, v if v == Self::Communities as u8 => Self::Communities,
v if v == Self::MpReachNlri as u8 => Self::MpReachNlri, v if v == Self::MpReachNlri as u8 => Self::MpReachNlri,
v if v == Self::MpUnreachNlri as u8 => Self::MpUnreachNlri, v if v == Self::MpUnreachNlri as u8 => Self::MpUnreachNlri,
v if v == Self::ExtendedCommunities as u8 => Self::ExtendedCommunities, v if v == Self::ExtendedCommunities as u8 => Self::ExtendedCommunities,
@@ -355,7 +355,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => PathAttributeDiscriminants::LocalPref as u8, PathAttribute::LocalPref(_) => PathAttributeDiscriminants::LocalPref as u8,
PathAttribute::AtomicAggregate(_) => PathAttributeDiscriminants::AtomicAggregate as u8, PathAttribute::AtomicAggregate(_) => PathAttributeDiscriminants::AtomicAggregate as u8,
PathAttribute::Aggregator(_) => PathAttributeDiscriminants::Aggregator as u8, PathAttribute::Aggregator(_) => PathAttributeDiscriminants::Aggregator as u8,
PathAttribute::Communitites(_) => PathAttributeDiscriminants::Communitites as u8, PathAttribute::Communities(_) => PathAttributeDiscriminants::Communities as u8,
PathAttribute::MpReachNlri(_) => PathAttributeDiscriminants::MpReachNlri as u8, PathAttribute::MpReachNlri(_) => PathAttributeDiscriminants::MpReachNlri as u8,
PathAttribute::MpUnreachNlri(_) => PathAttributeDiscriminants::MpUnreachNlri as u8, PathAttribute::MpUnreachNlri(_) => PathAttributeDiscriminants::MpUnreachNlri as u8,
PathAttribute::ExtendedCommunities(_) => { PathAttribute::ExtendedCommunities(_) => {
@@ -377,7 +377,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => false, PathAttribute::LocalPref(_) => false,
PathAttribute::AtomicAggregate(_) => true, PathAttribute::AtomicAggregate(_) => true,
PathAttribute::Aggregator(_) => true, PathAttribute::Aggregator(_) => true,
PathAttribute::Communitites(_) => true, PathAttribute::Communities(_) => true,
PathAttribute::MpReachNlri(_) => true, PathAttribute::MpReachNlri(_) => true,
PathAttribute::MpUnreachNlri(_) => true, PathAttribute::MpUnreachNlri(_) => true,
PathAttribute::ExtendedCommunities(_) => true, PathAttribute::ExtendedCommunities(_) => true,
@@ -395,7 +395,7 @@ impl PathAttribute {
PathAttribute::LocalPref(_) => true, PathAttribute::LocalPref(_) => true,
PathAttribute::AtomicAggregate(_) => true, PathAttribute::AtomicAggregate(_) => true,
PathAttribute::Aggregator(_) => false, PathAttribute::Aggregator(_) => false,
PathAttribute::Communitites(_) => true, PathAttribute::Communities(_) => true,
PathAttribute::MpReachNlri(_) => false, PathAttribute::MpReachNlri(_) => false,
PathAttribute::MpUnreachNlri(_) => false, PathAttribute::MpUnreachNlri(_) => false,
PathAttribute::ExtendedCommunities(_) => true, PathAttribute::ExtendedCommunities(_) => true,
@@ -467,8 +467,8 @@ impl PathAttribute {
PathAttributeDiscriminants::Aggregator => { PathAttributeDiscriminants::Aggregator => {
PathAttribute::Aggregator(AggregatorPathAttribute::from_wire(ctx, pa_buf)?.1) PathAttribute::Aggregator(AggregatorPathAttribute::from_wire(ctx, pa_buf)?.1)
} }
PathAttributeDiscriminants::Communitites => { PathAttributeDiscriminants::Communities => {
PathAttribute::Communitites(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1) PathAttribute::Communities(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1)
} }
PathAttributeDiscriminants::MpReachNlri => { PathAttributeDiscriminants::MpReachNlri => {
PathAttribute::MpReachNlri(MpReachNlriPathAttribute::from_wire(ctx, pa_buf)?.1) PathAttribute::MpReachNlri(MpReachNlriPathAttribute::from_wire(ctx, pa_buf)?.1)
@@ -538,7 +538,7 @@ impl PathAttribute {
PathAttribute::Aggregator(aggregator_path_attribute) => { PathAttribute::Aggregator(aggregator_path_attribute) => {
write_path_attribute!(out, aggregator_path_attribute); write_path_attribute!(out, aggregator_path_attribute);
} }
PathAttribute::Communitites(communities_path_attribute) => { PathAttribute::Communities(communities_path_attribute) => {
write_path_attribute!(out, communities_path_attribute); write_path_attribute!(out, communities_path_attribute);
} }
PathAttribute::MpReachNlri(mp_reach_nlri_path_attribute) => { PathAttribute::MpReachNlri(mp_reach_nlri_path_attribute) => {
@@ -583,7 +583,7 @@ impl PathAttribute {
PathAttribute::LocalPref(a) => a.wire_len(ctx)?, PathAttribute::LocalPref(a) => a.wire_len(ctx)?,
PathAttribute::AtomicAggregate(a) => a.wire_len(ctx)?, PathAttribute::AtomicAggregate(a) => a.wire_len(ctx)?,
PathAttribute::Aggregator(a) => a.wire_len(ctx)?, PathAttribute::Aggregator(a) => a.wire_len(ctx)?,
PathAttribute::Communitites(a) => a.wire_len(ctx)?, PathAttribute::Communities(a) => a.wire_len(ctx)?,
PathAttribute::MpReachNlri(a) => a.wire_len(ctx)?, PathAttribute::MpReachNlri(a) => a.wire_len(ctx)?,
PathAttribute::MpUnreachNlri(a) => a.wire_len(ctx)?, PathAttribute::MpUnreachNlri(a) => a.wire_len(ctx)?,
PathAttribute::ExtendedCommunities(a) => a.wire_len(ctx)?, PathAttribute::ExtendedCommunities(a) => a.wire_len(ctx)?,
@@ -903,7 +903,7 @@ impl AggregatorPathAttribute {
} }
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct CommunitiesPathAttribute(Vec<(u16, u16)>); pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>);
impl CommunitiesPathAttribute { impl CommunitiesPathAttribute {
pub fn from_wire<'a>( pub fn from_wire<'a>(
@@ -1624,7 +1624,7 @@ mod tests {
test_path_attribute_roundtrip!( test_path_attribute_roundtrip!(
test_communities, test_communities,
&[0xc0, 0x08, 0x04, 0xca, 0xfe, 0xba, 0xbe], &[0xc0, 0x08, 0x04, 0xca, 0xfe, 0xba, 0xbe],
PathAttribute::Communitites(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)])) PathAttribute::Communities(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)]))
); );
// MP Reach NLRI Path Attribute. // MP Reach NLRI Path Attribute.
+35
View File
@@ -34,6 +34,25 @@ impl IpPrefix {
}) })
} }
/// Returns true if `other` is equal to or more specific than `self`, i.e.
/// `self` covers `other`. Prefixes of different address families never
/// contain each other.
pub fn contains(&self, other: &IpPrefix) -> bool {
if self.address_family != other.address_family || other.length < self.length {
return false;
}
let full_bytes = (self.length / 8) as usize;
if self.prefix[..full_bytes] != other.prefix[..full_bytes] {
return false;
}
let rem_bits = self.length % 8;
if rem_bits == 0 {
return true;
}
let mask = u8::MAX << (8 - rem_bits);
(self.prefix[full_bytes] & mask) == (other.prefix[full_bytes] & mask)
}
pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<(), ToWireError> { pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<(), ToWireError> {
// Verify that there is enough space to write the IpPrefix. // Verify that there is enough space to write the IpPrefix.
if out.remaining() < (self.prefix.len() + 1) { if out.remaining() < (self.prefix.len() + 1) {
@@ -205,6 +224,22 @@ mod tests {
32 32
); );
#[test]
fn contains_covers_more_specifics() {
let p = |s: &str| IpPrefix::try_from(s).unwrap();
assert!(p("10.100.0.0/16").contains(&p("10.100.7.0/24")));
assert!(p("10.100.0.0/16").contains(&p("10.100.0.0/16")));
assert!(!p("10.100.0.0/16").contains(&p("10.101.0.0/24")));
assert!(!p("10.100.7.0/24").contains(&p("10.100.0.0/16")));
assert!(p("0.0.0.0/0").contains(&p("192.0.2.0/24")));
// Non-byte-aligned boundary.
assert!(p("10.100.0.0/19").contains(&p("10.100.31.0/24")));
assert!(!p("10.100.0.0/19").contains(&p("10.100.32.0/24")));
// Cross-family never matches.
assert!(!p("::/0").contains(&p("10.0.0.0/8")));
assert!(p("2001:db8::/32").contains(&p("2001:db8:cafe::/48")));
}
verify_roundtrip!( verify_roundtrip!(
verify_roundtrip_ipv6_0, verify_roundtrip_ipv6_0,
"::/0", "::/0",
+2
View File
@@ -5,5 +5,7 @@ edition.workspace = true
[dependencies] [dependencies]
bgp-packet.workspace = true bgp-packet.workspace = true
bgp-primitives.workspace = true bgp-primitives.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tracing.workspace = true tracing.workspace = true
+2
View File
@@ -1,7 +1,9 @@
mod adj_rib_in; mod adj_rib_in;
mod entry; mod entry;
mod event; mod event;
mod local_rib;
pub use adj_rib_in::AdjRibIn; pub use adj_rib_in::AdjRibIn;
pub use entry::{PeerId, RibEntry, SnapshotEntry}; pub use entry::{PeerId, RibEntry, SnapshotEntry};
pub use event::RibEvent; pub use event::RibEvent;
pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRibPolicy, LocalRoute, LocalRouteAttrs};
+305
View File
@@ -0,0 +1,305 @@
//! Locally originated routes (the "Loc-RIB" of routes this router announces
//! on its own behalf, as opposed to routes learned from peers).
//!
//! The actor stores one route per prefix together with the attributes chosen
//! by whoever announced it. It does not know about peers or sessions: the
//! server subscribes to [`LocalRibEvent`]s and turns them into UPDATEs.
use std::collections::HashMap;
use std::time::SystemTime;
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
use bgp_primitives::ip_prefix::IpPrefix;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::warn;
const EVENT_CHANNEL_CAPACITY: usize = 1024;
const CMD_CHANNEL_CAPACITY: usize = 256;
/// User-selectable attributes for a locally originated route.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRouteAttrs {
pub origin: OriginPathAttribute,
/// Number of *additional* copies of the local ASN to prepend to the
/// AS_PATH. `0` yields the plain `[local_asn]` path.
pub prepend: u8,
pub med: Option<u32>,
/// RFC 1997 standard communities as `(high, low)` 16-bit halves.
pub communities: Vec<(u16, u16)>,
/// RFC 8092 large communities.
pub large_communities: Vec<LargeCommunity>,
}
impl Default for LocalRouteAttrs {
fn default() -> Self {
Self {
origin: OriginPathAttribute::IGP,
prepend: 0,
med: None,
communities: vec![],
large_communities: vec![],
}
}
}
/// A route originated by this router.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRoute {
pub prefix: IpPrefix,
/// Free-form identity of whoever announced the route (e.g. a student
/// name). Used to stop one owner replacing or withdrawing another's route.
pub owner: String,
pub attrs: LocalRouteAttrs,
pub announced_at: SystemTime,
}
impl LocalRoute {
pub fn new(prefix: IpPrefix, owner: impl Into<String>, attrs: LocalRouteAttrs) -> Self {
Self { prefix, owner: owner.into(), attrs, announced_at: SystemTime::now() }
}
}
/// Limits on what may be announced. Enforced inside the actor so every caller
/// (HTTP API, tests, future CLI) goes through the same checks.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LocalRibPolicy {
/// A route is accepted only if it is covered by at least one of these
/// prefixes. Empty means no restriction.
pub allowed_prefixes: Vec<IpPrefix>,
/// Maximum number of routes a single owner may hold. `0` means unlimited.
pub max_routes_per_owner: usize,
}
impl LocalRibPolicy {
fn allows(&self, prefix: &IpPrefix) -> bool {
self.allowed_prefixes.is_empty() || self.allowed_prefixes.iter().any(|p| p.contains(prefix))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LocalRibError {
#[error("prefix {0} is not covered by the allowed prefixes")]
NotAllowed(IpPrefix),
#[error("owner {owner:?} already has {limit} routes")]
OwnerLimit { owner: String, limit: usize },
#[error("prefix {prefix} is owned by {owner:?}")]
OwnedByOther { prefix: IpPrefix, owner: String },
#[error("local RIB actor has stopped")]
Closed,
}
#[derive(Debug, Clone)]
pub enum LocalRibEvent {
/// A route was added or its attributes replaced.
Announced(LocalRoute),
Withdrawn { prefix: IpPrefix },
}
enum Command {
Announce { route: LocalRoute, reply: oneshot::Sender<Result<(), LocalRibError>> },
Withdraw {
prefix: IpPrefix,
requester: Option<String>,
reply: oneshot::Sender<Result<Option<LocalRoute>, LocalRibError>>,
},
Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> },
}
/// Handle to the local RIB actor. Cheap to clone; all clones share one table.
#[derive(Clone)]
pub struct LocalRib {
tx: mpsc::Sender<Command>,
events: broadcast::Sender<LocalRibEvent>,
}
impl LocalRib {
pub fn spawn(policy: LocalRibPolicy) -> Self {
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tokio::spawn(run_actor(rx, events.clone(), policy));
Self { tx, events }
}
/// Adds a route, or replaces the attributes of an existing route with the
/// same prefix and owner.
pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Announce { route, reply })
.await
.map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)?
}
/// Removes a route. `requester` must match the route's owner; pass `None`
/// to withdraw regardless of owner (administrative override). Returns the
/// removed route, or `None` if the prefix was not present.
pub async fn withdraw(
&self,
prefix: IpPrefix,
requester: Option<&str>,
) -> Result<Option<LocalRoute>, LocalRibError> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply })
.await
.map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)?
}
/// Point-in-time copy of every route, in no particular order.
pub async fn snapshot(&self) -> Vec<LocalRoute> {
let (reply, rx) = oneshot::channel();
let _ = self.tx.send(Command::Snapshot { reply }).await;
rx.await.unwrap_or_default()
}
/// Subscribe to mutations. See [`crate::AdjRibIn::subscribe`] for lag
/// semantics.
pub fn subscribe(&self) -> broadcast::Receiver<LocalRibEvent> {
self.events.subscribe()
}
}
async fn run_actor(
mut rx: mpsc::Receiver<Command>,
events: broadcast::Sender<LocalRibEvent>,
policy: LocalRibPolicy,
) {
let mut table: HashMap<IpPrefix, LocalRoute> = HashMap::new();
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Announce { route, reply } => {
let result = check_announce(&table, &policy, &route).map(|()| {
table.insert(route.prefix.clone(), route.clone());
let _ = events.send(LocalRibEvent::Announced(route));
});
let _ = reply.send(result);
}
Command::Withdraw { prefix, requester, reply } => {
let result = match (table.get(&prefix), requester) {
(Some(existing), Some(req)) if existing.owner != req => {
Err(LocalRibError::OwnedByOther {
prefix: prefix.clone(),
owner: existing.owner.clone(),
})
}
_ => {
let removed = table.remove(&prefix);
if removed.is_some() {
let _ = events.send(LocalRibEvent::Withdrawn { prefix });
}
Ok(removed)
}
};
let _ = reply.send(result);
}
Command::Snapshot { reply } => {
if reply.send(table.values().cloned().collect()).is_err() {
warn!("Snapshot reply channel closed before send");
}
}
}
}
}
fn check_announce(
table: &HashMap<IpPrefix, LocalRoute>,
policy: &LocalRibPolicy,
route: &LocalRoute,
) -> Result<(), LocalRibError> {
if !policy.allows(&route.prefix) {
return Err(LocalRibError::NotAllowed(route.prefix.clone()));
}
if let Some(existing) = table.get(&route.prefix) {
if existing.owner != route.owner {
return Err(LocalRibError::OwnedByOther {
prefix: route.prefix.clone(),
owner: existing.owner.clone(),
});
}
// Replacing our own route never changes the per-owner count.
return Ok(());
}
if policy.max_routes_per_owner > 0 {
let held = table.values().filter(|r| r.owner == route.owner).count();
if held >= policy.max_routes_per_owner {
return Err(LocalRibError::OwnerLimit {
owner: route.owner.clone(),
limit: policy.max_routes_per_owner,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn route(p: &str, owner: &str) -> LocalRoute {
LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default())
}
#[tokio::test]
async fn announce_and_withdraw_emit_events() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
let mut events = rib.subscribe();
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24")));
assert_eq!(rib.snapshot().await.len(), 1);
let removed = rib.withdraw(prefix("10.0.0.0/24"), Some("alice")).await.unwrap();
assert_eq!(removed.unwrap().owner, "alice");
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24")));
assert!(rib.snapshot().await.is_empty());
// Withdrawing an absent prefix is a no-op, not an error.
assert_eq!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap(), None);
}
#[tokio::test]
async fn policy_allowlist_is_enforced() {
let rib = LocalRib::spawn(LocalRibPolicy {
allowed_prefixes: vec![prefix("10.100.0.0/16")],
max_routes_per_owner: 0,
});
rib.announce(route("10.100.3.0/24", "alice")).await.unwrap();
let err = rib.announce(route("192.0.2.0/24", "alice")).await.unwrap_err();
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
}
#[tokio::test]
async fn owner_limit_ignores_replacements() {
let rib = LocalRib::spawn(LocalRibPolicy { allowed_prefixes: vec![], max_routes_per_owner: 1 });
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
// Replacing the same prefix is fine.
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.1.0/24", "alice")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnerLimit { limit: 1, .. }));
// Other owners are unaffected.
rib.announce(route("10.0.1.0/24", "bob")).await.unwrap();
}
#[tokio::test]
async fn other_owner_cannot_replace_or_withdraw() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.0.0/24", "bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
let err = rib.withdraw(prefix("10.0.0.0/24"), Some("bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
// Administrative override works.
assert!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap().is_some());
}
}
+1
View File
@@ -15,6 +15,7 @@ tokio.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
tracing.workspace = true tracing.workspace = true
protobuf.workspace = true protobuf.workspace = true
thiserror.workspace = true
[build-dependencies] [build-dependencies]
protobuf-codegen.workspace = true protobuf-codegen.workspace = true
+14
View File
@@ -17,6 +17,20 @@ message BgpConfig {
// Peers configured on this server. // Peers configured on this server.
repeated Peer peers = 4; repeated Peer peers = 4;
// Limits on routes originated locally (e.g. via the demo UI).
LocalRoutePolicy local_route_policy = 5;
}
// Limits on locally originated routes.
message LocalRoutePolicy {
// A locally originated route is accepted only if it is covered by one of
// these prefixes, written in CIDR notation (e.g. "10.100.0.0/16").
// Empty means any prefix may be announced.
repeated string allowed_prefixes = 1;
// Maximum number of routes a single owner may announce. 0 means unlimited.
uint32 max_routes_per_owner = 2;
} }
// An IP address and port pair. // An IP address and port pair.
+267
View File
@@ -0,0 +1,267 @@
//! Turns locally originated routes into UPDATE messages for every established
//! session.
//!
//! The advertiser task listens for [`LocalRibEvent`]s and [`SessionEvent`]s.
//! A route change is fanned out to all live sessions; a new session receives
//! the full local table. Sending the same route twice is harmless (BGP treats
//! a re-announcement as an implicit replace), so the two event streams need
//! no coordination beyond being handled by one task.
use std::net::IpAddr;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, Message,
MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, MultiExitDiscPathAttribute,
NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRib, LocalRibEvent, LocalRoute};
use tokio::sync::broadcast::error::RecvError;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
use crate::session::{SessionEvent, SessionHandle, SessionRegistry};
pub fn spawn_advertiser(
local_rib: LocalRib,
registry: SessionRegistry,
local_asn: u32,
) -> JoinHandle<()> {
tokio::spawn(run(local_rib, registry, local_asn))
}
async fn run(local_rib: LocalRib, registry: SessionRegistry, local_asn: u32) {
let mut rib_events = local_rib.subscribe();
let mut session_events = registry.subscribe();
loop {
tokio::select! {
ev = rib_events.recv() => match ev {
Ok(LocalRibEvent::Announced(route)) => {
for session in registry.list() {
send_route(&session, &route, local_asn).await;
}
}
Ok(LocalRibEvent::Withdrawn { prefix }) => {
for session in registry.list() {
send_withdraw(&session, &prefix).await;
}
}
Err(RecvError::Lagged(n)) => {
warn!(missed = n, "Advertiser lagged on local RIB events, resyncing all sessions");
resync_all(&local_rib, &registry, local_asn).await;
}
Err(RecvError::Closed) => return,
},
ev = session_events.recv() => match ev {
Ok(SessionEvent::Up(session)) => {
let routes = local_rib.snapshot().await;
info!(peer = %session.peer, routes = routes.len(), "Session up, sending local routes");
for route in &routes {
send_route(&session, route, local_asn).await;
}
}
Ok(SessionEvent::Down(_)) => {}
Err(RecvError::Lagged(n)) => {
warn!(missed = n, "Advertiser lagged on session events, resyncing all sessions");
resync_all(&local_rib, &registry, local_asn).await;
}
Err(RecvError::Closed) => return,
},
}
}
}
async fn resync_all(local_rib: &LocalRib, registry: &SessionRegistry, local_asn: u32) {
let routes = local_rib.snapshot().await;
for session in registry.list() {
for route in &routes {
send_route(&session, route, local_asn).await;
}
}
}
async fn send_route(session: &SessionHandle, route: &LocalRoute, local_asn: u32) {
match build_announce(route, local_asn, session.local_addr.ip()) {
Some(update) => {
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
if let Err(e) = session.send(Message::Update(update)).await {
warn!(peer = %session.peer, err = %e, "Failed to queue announce");
}
}
None => debug!(
peer = %session.peer,
prefix = %route.prefix,
"Skipping route: address family does not match session transport"
),
}
}
async fn send_withdraw(session: &SessionHandle, prefix: &IpPrefix) {
debug!(peer = %session.peer, %prefix, "Withdrawing local route");
if let Err(e) = session.send(Message::Update(build_withdraw(prefix))).await {
warn!(peer = %session.peer, err = %e, "Failed to queue withdraw");
}
}
/// Builds the UPDATE announcing `route` with `next_hop` as the next hop.
///
/// Returns `None` when the prefix family does not match the next-hop family:
/// carrying IPv4 NLRI over an IPv6 next hop (RFC 8950) or vice versa is not
/// supported yet.
pub fn build_announce(route: &LocalRoute, local_asn: u32, next_hop: IpAddr) -> Option<UpdateMessage> {
let attrs = &route.attrs;
// Path attributes in ascending type-code order, as most implementations
// expect them.
let mut path_attributes = vec![
PathAttribute::Origin(attrs.origin),
AsPathAttribute::from_asns(vec![local_asn; 1 + attrs.prepend as usize]),
];
let mut announced_routes = vec![];
match (route.prefix.address_family, next_hop) {
(AddressFamilyId::Ipv4, IpAddr::V4(nh)) => {
path_attributes.push(PathAttribute::NextHop(NextHopPathAttribute(nh)));
announced_routes.push(route.prefix.clone());
}
(AddressFamilyId::Ipv6, IpAddr::V6(_)) => {}
_ => return None,
}
if let Some(med) = attrs.med {
path_attributes.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(med)));
}
if !attrs.communities.is_empty() {
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
attrs.communities.clone(),
)));
}
if let (AddressFamilyId::Ipv6, IpAddr::V6(nh)) = (route.prefix.address_family, next_hop) {
path_attributes.push(PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
next_hop: NlriNextHop::Ipv6(nh),
prefixes: vec![route.prefix.clone()],
}));
}
if !attrs.large_communities.is_empty() {
path_attributes.push(PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: attrs.large_communities.clone(),
}));
}
Some(UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes })
}
/// Builds the UPDATE withdrawing `prefix`.
pub fn build_withdraw(prefix: &IpPrefix) -> UpdateMessage {
match prefix.address_family {
AddressFamilyId::Ipv4 => UpdateMessage {
withdrawn_routes: vec![prefix.clone()],
path_attributes: vec![],
announced_routes: vec![],
},
AddressFamilyId::Ipv6 => UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
prefixes: vec![prefix.clone()],
})],
announced_routes: vec![],
},
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
use bgp_primitives::parser::ParserContext;
use bgp_rib::LocalRouteAttrs;
use bytes::BytesMut;
use super::*;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
#[test]
fn ipv4_announce_has_expected_attributes_and_roundtrips() {
let route = LocalRoute::new(
prefix("10.100.1.0/24"),
"alice",
LocalRouteAttrs {
origin: OriginPathAttribute::IGP,
prepend: 2,
med: Some(50),
communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
},
);
let update = build_announce(&route, 65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))).unwrap();
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.withdrawn_routes.is_empty());
assert_eq!(
update.path_attributes,
vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![65000, 65000, 65000]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
}),
]
);
// Serialise and parse back to make sure the wire encoding is sound.
let ctx = ParserContext { four_octet_asn: Some(true), address_family: None };
let msg = Message::Update(update.clone());
let mut buf = BytesMut::new();
msg.to_wire(&ctx, &mut buf).unwrap();
let (_, parsed) = Message::from_wire(&ctx, &buf).unwrap();
let Message::Update(parsed) = parsed else { panic!("expected UPDATE") };
assert_eq!(parsed, update);
}
#[test]
fn ipv6_announce_uses_mp_reach() {
let route = LocalRoute::new(prefix("2001:db8:1::/48"), "alice", LocalRouteAttrs::default());
let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
let update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap();
assert!(update.announced_routes.is_empty());
assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_))));
assert!(update.path_attributes.iter().any(|a| matches!(
a,
PathAttribute::MpReachNlri(MpReachNlriPathAttribute { next_hop: NlriNextHop::Ipv6(h), prefixes, .. })
if *h == nh && *prefixes == vec![prefix("2001:db8:1::/48")]
)));
}
#[test]
fn family_mismatch_is_skipped() {
let v6 = LocalRoute::new(prefix("2001:db8::/32"), "a", LocalRouteAttrs::default());
assert!(build_announce(&v6, 65000, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none());
let v4 = LocalRoute::new(prefix("10.0.0.0/8"), "a", LocalRouteAttrs::default());
assert!(build_announce(&v4, 65000, IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none());
}
#[test]
fn withdraw_by_family() {
let v4 = build_withdraw(&prefix("10.0.0.0/8"));
assert_eq!(v4.withdrawn_routes, vec![prefix("10.0.0.0/8")]);
assert!(v4.path_attributes.is_empty());
let v6 = build_withdraw(&prefix("2001:db8::/32"));
assert!(v6.withdrawn_routes.is_empty());
assert!(matches!(&v6.path_attributes[..], [PathAttribute::MpUnreachNlri(m)] if m.prefixes == vec![prefix("2001:db8::/32")]));
}
}
+25 -2
View File
@@ -1,7 +1,9 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr; use std::str::FromStr;
use eyre::{Result, bail, eyre}; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRibPolicy;
use eyre::{Result, WrapErr, bail, eyre};
use crate::proto::config::BgpConfig; use crate::proto::config::BgpConfig;
@@ -16,6 +18,8 @@ pub struct Config {
pub hold_time_secs: u16, pub hold_time_secs: u16,
pub listen_endpoints: Vec<SocketAddr>, pub listen_endpoints: Vec<SocketAddr>,
pub peers: Vec<PeerConfig>, pub peers: Vec<PeerConfig>,
/// Limits on locally originated routes.
pub local_route_policy: LocalRibPolicy,
} }
pub struct PeerConfig { pub struct PeerConfig {
@@ -74,7 +78,26 @@ impl Config {
}) })
.collect::<Result<_>>()?; .collect::<Result<_>>()?;
Ok(Self { router_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, peers }) let local_route_policy = match proto.local_route_policy.into_option() {
Some(policy) => LocalRibPolicy {
allowed_prefixes: policy
.allowed_prefixes
.iter()
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid allowed prefix {p:?}")))
.collect::<Result<_>>()?,
max_routes_per_owner: policy.max_routes_per_owner as usize,
},
None => LocalRibPolicy::default(),
};
Ok(Self {
router_id,
local_asn: proto.local_asn,
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
listen_endpoints,
peers,
local_route_policy,
})
} }
} }
+45 -49
View File
@@ -12,11 +12,18 @@ use eyre::{Result, bail, eyre};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::mpsc;
use tokio::time::{Instant, interval_at, sleep_until}; use tokio::time::{Instant, interval_at, sleep_until};
use tracing::{info, warn}; use tracing::{info, warn};
use bgp_packet::message::{AS_TRANS, BGP4_VERSION}; use bgp_packet::message::{AS_TRANS, BGP4_VERSION};
use crate::session::{SessionHandle, SessionRegistry};
/// Number of outbound messages that may be queued for a session before
/// senders are back-pressured.
const OUTBOUND_QUEUE_CAPACITY: usize = 1024;
/// Minimal per-session config extracted from the server config. /// Minimal per-session config extracted from the server config.
#[derive(Clone)] #[derive(Clone)]
pub struct SessionConfig { pub struct SessionConfig {
@@ -24,6 +31,7 @@ pub struct SessionConfig {
pub local_asn: u32, pub local_asn: u32,
pub hold_time_secs: u16, pub hold_time_secs: u16,
pub adj_rib_in: AdjRibIn, pub adj_rib_in: AdjRibIn,
pub registry: SessionRegistry,
} }
/// Drive the BGP FSM for a single accepted TCP connection. /// Drive the BGP FSM for a single accepted TCP connection.
@@ -34,6 +42,7 @@ pub async fn run_session(
peer_addr: SocketAddr, peer_addr: SocketAddr,
config: SessionConfig, config: SessionConfig,
) -> Result<()> { ) -> Result<()> {
let local_addr = stream.local_addr()?;
let (mut reader, mut writer) = stream.into_split(); let (mut reader, mut writer) = stream.into_split();
// --- OpenSent ----------------------------------------------------------- // --- OpenSent -----------------------------------------------------------
@@ -136,6 +145,16 @@ pub async fn run_session(
let peer_id = PeerId { addr: peer_addr, asn: peer_asn }; let peer_id = PeerId { addr: peer_addr, asn: peer_asn };
// --- Established -------------------------------------------------------- // --- Established --------------------------------------------------------
// Register so the rest of the server can see this session and queue
// messages for it. The guard deregisters on any exit path.
let (outbound_tx, mut outbound_rx) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY);
let _guard = config.registry.register(SessionHandle::new(
peer_id,
local_addr,
peer_four_byte_asn,
outbound_tx,
));
established_loop( established_loop(
&mut reader, &mut reader,
&mut writer, &mut writer,
@@ -143,6 +162,7 @@ pub async fn run_session(
negotiated_hold_time, negotiated_hold_time,
peer_id, peer_id,
&config.adj_rib_in, &config.adj_rib_in,
&mut outbound_rx,
) )
.await .await
} }
@@ -154,14 +174,15 @@ async fn established_loop(
hold_time_secs: u16, hold_time_secs: u16,
peer_id: PeerId, peer_id: PeerId,
adj_rib_in: &AdjRibIn, adj_rib_in: &AdjRibIn,
outbound: &mut mpsc::Receiver<Message>,
) -> Result<()> { ) -> Result<()> {
// When hold_time is 0, keepalives and the hold timer are disabled (RFC 4271 §4.4). // When hold_time is 0, keepalives and the hold timer are disabled
if hold_time_secs == 0 { // (RFC 4271 §4.4). The timer branches below are gated off in that case;
return no_timer_loop(reader, writer, ctx, peer_id, adj_rib_in).await; // the placeholder durations are never observed.
} let timers_enabled = hold_time_secs != 0;
let secs = if timers_enabled { hold_time_secs as u64 } else { 3 };
let hold_duration = Duration::from_secs(hold_time_secs as u64); let hold_duration = Duration::from_secs(secs);
let keepalive_interval = Duration::from_secs(hold_time_secs as u64 / 3); let keepalive_interval = Duration::from_secs(secs / 3);
// interval_at avoids firing immediately; first keepalive is sent after one interval. // interval_at avoids firing immediately; first keepalive is sent after one interval.
let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval); let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval);
@@ -178,6 +199,7 @@ async fn established_loop(
hold_timer.as_mut().reset(Instant::now() + hold_duration); hold_timer.as_mut().reset(Instant::now() + hold_duration);
} }
Message::Update(update) => { Message::Update(update) => {
hold_timer.as_mut().reset(Instant::now() + hold_duration);
info!( info!(
peer = %peer_id, peer = %peer_id,
withdrawn = update.withdrawn_routes.len(), withdrawn = update.withdrawn_routes.len(),
@@ -195,13 +217,26 @@ async fn established_loop(
send_notification(writer, BgpError::FsmError(FsmSubcode::UnexpectedEstablished), ctx).await; send_notification(writer, BgpError::FsmError(FsmSubcode::UnexpectedEstablished), ctx).await;
bail!("Received OPEN in Established state from {}", peer_id); bail!("Received OPEN in Established state from {}", peer_id);
} }
Message::RouteRefresh => {} Message::RouteRefresh => {
hold_timer.as_mut().reset(Instant::now() + hold_duration);
}
} }
} }
_ = keepalive_timer.tick() => { Some(msg) = outbound.recv() => {
if let Message::Update(update) = &msg {
info!(
peer = %peer_id,
withdrawn = update.withdrawn_routes.len(),
announced = update.announced_routes.len(),
"Sending UPDATE",
);
}
send_message(writer, &msg, ctx).await?;
}
_ = keepalive_timer.tick(), if timers_enabled => {
send_message(writer, &Message::KeepAlive, ctx).await?; send_message(writer, &Message::KeepAlive, ctx).await?;
} }
_ = &mut hold_timer => { _ = &mut hold_timer, if timers_enabled => {
warn!(peer = %peer_id, "Hold timer expired"); warn!(peer = %peer_id, "Hold timer expired");
send_notification(writer, BgpError::HoldTimer, ctx).await; send_notification(writer, BgpError::HoldTimer, ctx).await;
bail!("Hold timer expired for peer {}", peer_id); bail!("Hold timer expired for peer {}", peer_id);
@@ -210,45 +245,6 @@ async fn established_loop(
} }
} }
/// Hold-timer-disabled path for hold_time == 0.
async fn no_timer_loop(
reader: &mut OwnedReadHalf,
writer: &mut OwnedWriteHalf,
ctx: &ParserContext,
peer_id: PeerId,
adj_rib_in: &AdjRibIn,
) -> Result<()> {
loop {
match read_message(reader, ctx).await? {
Message::KeepAlive => {}
Message::Update(update) => {
info!(
peer = %peer_id,
withdrawn = update.withdrawn_routes.len(),
announced = update.announced_routes.len(),
"Received UPDATE",
);
apply_update(adj_rib_in, peer_id, &update.path_attributes,
&update.withdrawn_routes, &update.announced_routes).await;
}
Message::Notification(notif) => {
info!(peer = %peer_id, error = %notif.error, "Received NOTIFICATION, closing session");
return Ok(());
}
Message::Open(_) => {
send_notification(
writer,
BgpError::FsmError(FsmSubcode::UnexpectedEstablished),
ctx,
)
.await;
bail!("Received OPEN in Established state from {}", peer_id);
}
Message::RouteRefresh => {}
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Wire helpers // Wire helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -1,7 +1,9 @@
pub mod advertise;
pub mod config; pub mod config;
pub mod connection; pub mod connection;
pub mod fsm; pub mod fsm;
pub mod server; pub mod server;
pub mod session;
pub mod tracing; pub mod tracing;
pub mod proto { pub mod proto {
+17 -2
View File
@@ -7,11 +7,13 @@ use futures::stream::{FuturesUnordered, StreamExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info}; use tracing::{error, info};
use bgp_rib::AdjRibIn; use bgp_rib::{AdjRibIn, LocalRib};
use crate::advertise::spawn_advertiser;
use crate::config::Config; use crate::config::Config;
use crate::connection::handle_connection; use crate::connection::handle_connection;
use crate::fsm::SessionConfig; use crate::fsm::SessionConfig;
use crate::session::SessionRegistry;
pub struct BgpServerBuilder { pub struct BgpServerBuilder {
config: Config, config: Config,
@@ -25,6 +27,10 @@ pub struct BgpServer {
/// Shared RIB handle — created at bind time so callers can clone it before /// Shared RIB handle — created at bind time so callers can clone it before
/// the accept loop starts (e.g. tests that want to introspect the RIB). /// the accept loop starts (e.g. tests that want to introspect the RIB).
pub adj_rib_in: AdjRibIn, pub adj_rib_in: AdjRibIn,
/// Routes this router originates; announced to every established peer.
pub local_rib: LocalRib,
/// Live view of established sessions.
pub registry: SessionRegistry,
} }
impl BgpServerBuilder { impl BgpServerBuilder {
@@ -46,9 +52,11 @@ impl BgpServerBuilder {
listeners.push((bound_addr, listener)); listeners.push((bound_addr, listener));
} }
Ok(BgpServer { Ok(BgpServer {
config: self.config,
listeners, listeners,
adj_rib_in: AdjRibIn::spawn(), adj_rib_in: AdjRibIn::spawn(),
local_rib: LocalRib::spawn(self.config.local_route_policy.clone()),
registry: SessionRegistry::new(),
config: self.config,
}) })
} }
@@ -77,8 +85,15 @@ impl BgpServer {
local_asn: self.config.local_asn, local_asn: self.config.local_asn,
hold_time_secs: self.config.hold_time_secs, hold_time_secs: self.config.hold_time_secs,
adj_rib_in: self.adj_rib_in.clone(), adj_rib_in: self.adj_rib_in.clone(),
registry: self.registry.clone(),
}); });
let _advertiser = spawn_advertiser(
self.local_rib.clone(),
self.registry.clone(),
self.config.local_asn,
);
let mut accepting: FuturesUnordered<_> = self let mut accepting: FuturesUnordered<_> = self
.listeners .listeners
.into_iter() .into_iter()
+118
View File
@@ -0,0 +1,118 @@
//! Registry of established BGP sessions.
//!
//! A session registers itself when it reaches Established and is removed when
//! its [`SessionGuard`] drops, so a session that errors out is always
//! deregistered. Other parts of the server (the advertiser, the HTTP API) use
//! the registry to enumerate live sessions and queue outbound messages.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
use bgp_packet::message::Message;
use bgp_rib::PeerId;
use tokio::sync::{broadcast, mpsc};
const EVENT_CHANNEL_CAPACITY: usize = 256;
/// Handle to one established session. Cloning is cheap.
#[derive(Debug, Clone)]
pub struct SessionHandle {
pub peer: PeerId,
/// Our side of the TCP connection; used as the next hop for routes we
/// originate to this peer.
pub local_addr: SocketAddr,
pub established_at: SystemTime,
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool,
outbound: mpsc::Sender<Message>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("session with {0} has closed")]
pub struct SessionClosed(pub PeerId);
impl SessionHandle {
pub fn new(
peer: PeerId,
local_addr: SocketAddr,
four_octet_asn: bool,
outbound: mpsc::Sender<Message>,
) -> Self {
Self { peer, local_addr, established_at: SystemTime::now(), four_octet_asn, outbound }
}
/// Queues a message for transmission on this session. Waits for queue
/// space if the session's writer is behind.
pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> {
self.outbound.send(msg).await.map_err(|_| SessionClosed(self.peer))
}
}
#[derive(Debug, Clone)]
pub enum SessionEvent {
Up(SessionHandle),
Down(PeerId),
}
#[derive(Clone)]
pub struct SessionRegistry {
inner: Arc<RwLock<HashMap<PeerId, SessionHandle>>>,
events: broadcast::Sender<SessionEvent>,
}
impl Default for SessionRegistry {
fn default() -> Self {
Self::new()
}
}
impl SessionRegistry {
pub fn new() -> Self {
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self { inner: Arc::new(RwLock::new(HashMap::new())), events }
}
/// Adds a session and emits [`SessionEvent::Up`]. The returned guard
/// removes the session again when dropped.
pub fn register(&self, handle: SessionHandle) -> SessionGuard {
let peer = handle.peer;
self.inner.write().expect("session registry poisoned").insert(peer, handle.clone());
let _ = self.events.send(SessionEvent::Up(handle));
SessionGuard { registry: self.clone(), peer }
}
pub fn get(&self, peer: &PeerId) -> Option<SessionHandle> {
self.inner.read().expect("session registry poisoned").get(peer).cloned()
}
/// All currently established sessions, in no particular order.
pub fn list(&self) -> Vec<SessionHandle> {
self.inner.read().expect("session registry poisoned").values().cloned().collect()
}
pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
self.events.subscribe()
}
fn remove(&self, peer: &PeerId) {
let removed = self.inner.write().expect("session registry poisoned").remove(peer);
if removed.is_some() {
let _ = self.events.send(SessionEvent::Down(*peer));
}
}
}
/// Removes the session from its registry on drop.
pub struct SessionGuard {
registry: SessionRegistry,
peer: PeerId,
}
impl Drop for SessionGuard {
fn drop(&mut self) {
self.registry.remove(&self.peer);
}
}
+6 -1
View File
@@ -27,7 +27,10 @@ The base protocol specification.
| §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ | | §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ |
| §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ | | §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ |
| §9 | Decision process / best-path selection | ❌ | | §9 | Decision process / best-path selection | ❌ |
| §9 | RIB (Adj-RIB-In, Loc-RIB, Adj-RIB-Out) | ❌ | | §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
| §9 | Locally originated routes, advertised to all established peers | ✅ |
| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ |
| §9.2 | Hold timer reset on any received message (not only KEEPALIVE) | ✅ |
--- ---
@@ -122,6 +125,7 @@ https://datatracker.ietf.org/doc/html/rfc1997
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| `COMMUNITIES` path attribute (parse, serialise) | ✅ | | `COMMUNITIES` path attribute (parse, serialise) | ✅ |
| Attach to locally originated routes | ✅ |
### RFC 4360 — BGP Extended Communities Attribute ### RFC 4360 — BGP Extended Communities Attribute
https://datatracker.ietf.org/doc/html/rfc4360 https://datatracker.ietf.org/doc/html/rfc4360
@@ -137,6 +141,7 @@ https://datatracker.ietf.org/doc/html/rfc8092
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ | | `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ |
| Attach to locally originated routes | ✅ |
--- ---
+5
View File
@@ -8,3 +8,8 @@ listen_endpoints {
address: "[::1]" address: "[::1]"
port: 179 port: 179
} }
local_route_policy {
allowed_prefixes: "10.100.0.0/16"
allowed_prefixes: "2001:db8:100::/48"
max_routes_per_owner: 4
}