feature: BGP server, FSM, Adj-RIB-In actor, and integration tests

- Implement passive-side BGP FSM (OpenSent → OpenConfirm → Established)
  with hold timer, keepalive timer, and NOTIFICATION on expiry
- Add bgp-server crate: config validation, TCP listener accept loop
  (BgpServerBuilder → BgpServer), connection dispatch
- Add bgp-rib crate: Adj-RIB-In actor owning route state with event
  broadcast for streaming consumers and snapshot for introspection
- Wire Adj-RIB-In into FSM: announce/withdraw on UPDATE for both legacy
  IPv4 NLRI fields and MP_REACH/MP_UNREACH path attributes
- Add bgp-integration-tests crate with 15 end-to-end tests covering
  session establishment, timers, UPDATE handling, and RIB introspection
- Fix require_empty! macro bug in open.rs (checked wrong buffer)
- Fix FourByteAsn capability parser returning consumed buffer as remainder
- Fix tracing subscriber not being installed in TracingArgs::install
- Expand docs/RFCs.md with full implementation status across 15 RFCs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Sonnet 4.6 committed 2026-04-19 00:21:51 +02:00
1 parent 4ba644827d
commit 1ec60255ea
24 files changed
+1685 -26

No files matched your search

Generated
+123 -1
View File
@@ -111,6 +111,19 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "bgp-integration-tests"
version = "0.0.0"
dependencies = [
"bgp-packet",
"bgp-primitives",
"bgp-rib",
"bgp-server",
"bytes",
"eyre",
"tokio",
]
[[package]] [[package]]
name = "bgp-packet" name = "bgp-packet"
version = "0.0.2" version = "0.0.2"
@@ -140,12 +153,27 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
[[package]]
name = "bgp-rib"
version = "0.0.0"
dependencies = [
"bgp-packet",
"bgp-primitives",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "bgp-server" name = "bgp-server"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bgp-packet",
"bgp-primitives",
"bgp-rib",
"bytes",
"clap", "clap",
"eyre", "eyre",
"futures",
"prometheus", "prometheus",
"protobuf", "protobuf",
"protobuf-codegen", "protobuf-codegen",
@@ -282,6 +310,94 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -630,7 +746,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -707,6 +823,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.14.0" version = "1.14.0"
+3
View File
@@ -17,7 +17,9 @@ version = "0.0.2"
# --- Our crates --- # --- Our crates ---
bgp-packet = { path = "crates/packet" } bgp-packet = { path = "crates/packet" }
bgp-primitives = { path = "crates/primitives" } bgp-primitives = { path = "crates/primitives" }
bgp-rib = { path = "crates/rib" }
bgp-server = { path = "crates/server" } bgp-server = { path = "crates/server" }
bgp-integration-tests = { path = "crates/integration-tests" }
# --- General --- # --- General ---
bitfield = "0.19.0" bitfield = "0.19.0"
@@ -36,3 +38,4 @@ prometheus = "0.14.0"
protobuf = { package = "protobuf", version = "3.7.2" } protobuf = { package = "protobuf", version = "3.7.2" }
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" } protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
futures = "0.3"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "bgp-integration-tests"
edition.workspace = true
publish = false
[dependencies]
bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
bytes.workspace = true
eyre.workspace = true
tokio.workspace = true
+2
View File
@@ -0,0 +1,2 @@
pub mod test_peer;
pub mod test_server;
+127
View File
@@ -0,0 +1,127 @@
use std::net::{Ipv4Addr, SocketAddr};
use bgp_packet::message::{BGP_HEADER_LEN, Message};
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::parser::ParserContext;
use bytes::BytesMut;
use eyre::{Result, bail, eyre};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// A minimal BGP peer for use in tests. Connects over TCP and can exchange
/// framed BGP messages using the `bgp-packet` wire format.
pub struct TestPeer {
stream: TcpStream,
pub ctx: ParserContext,
}
impl TestPeer {
pub async fn connect(addr: SocketAddr) -> Result<Self> {
let stream = TcpStream::connect(addr).await?;
Ok(Self { stream, ctx: ParserContext::default() })
}
/// Send a BGP message.
pub async fn send(&mut self, msg: &Message) -> Result<()> {
let mut buf = BytesMut::new();
msg.to_wire(&self.ctx, &mut buf)?;
self.stream.write_all(&buf).await?;
Ok(())
}
/// Read exactly one BGP message.
pub async fn recv(&mut self) -> Result<Message> {
let mut header = [0u8; BGP_HEADER_LEN as usize];
self.stream.read_exact(&mut header).await?;
let total_len = u16::from_be_bytes([header[16], header[17]]) as usize;
if total_len < BGP_HEADER_LEN as usize {
bail!("Received BGP message with invalid length {total_len}");
}
let mut buf = vec![0u8; total_len];
buf[..BGP_HEADER_LEN as usize].copy_from_slice(&header);
self.stream.read_exact(&mut buf[BGP_HEADER_LEN as usize..]).await?;
let (_, msg) = Message::from_wire(&self.ctx, &buf)
.map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?;
Ok(msg)
}
/// Receive and unwrap as `OpenMessage`, failing if a different type arrives.
pub async fn recv_open(&mut self) -> Result<OpenMessage> {
match self.recv().await? {
Message::Open(o) => {
// Enable 4-octet ASN parsing if the peer advertised it.
let four_byte = o.options.iter().any(|opt| {
matches!(opt, OpenOption::Capabilities(caps)
if caps.iter().any(|c| matches!(c, Capability::FourByteAsn { .. })))
});
self.ctx.four_octet_asn = Some(four_byte);
Ok(o)
}
other => bail!("Expected OPEN, got {:?}", other),
}
}
/// Send an OPEN message with the given parameters and standard capabilities.
pub async fn send_open(
&mut self,
local_asn: u32,
router_id: Ipv4Addr,
hold_time: u16,
) -> Result<()> {
let asn_field = if local_asn > u16::MAX as u32 { 23456 } else { local_asn as u16 };
let open = Message::Open(OpenMessage {
version: 4,
asn: asn_field,
hold_time,
identifier: router_id,
options: vec![OpenOption::Capabilities(vec![
Capability::FourByteAsn { asn: local_asn },
Capability::MultiProtocol {
afi: AddressFamilyId::Ipv4,
safi: SubsequentAfi::Unicast,
},
Capability::RouteRefresh,
])],
});
self.send(&open).await
}
/// Complete a full OPEN/KEEPALIVE handshake as the peer side.
/// Returns the server's OPEN message.
pub async fn handshake(
&mut self,
local_asn: u32,
router_id: Ipv4Addr,
hold_time: u16,
) -> Result<OpenMessage> {
self.send_open(local_asn, router_id, hold_time).await?;
let server_open = self.recv_open().await?;
// Expect server KEEPALIVE
match self.recv().await? {
Message::KeepAlive => {}
other => bail!("Expected KEEPALIVE from server, got {:?}", other),
}
// Send our KEEPALIVE
self.send(&Message::KeepAlive).await?;
Ok(server_open)
}
/// Send raw bytes without framing — useful for testing invalid input.
pub async fn send_raw(&mut self, bytes: &[u8]) -> Result<()> {
self.stream.write_all(bytes).await?;
Ok(())
}
/// Returns true if the remote end has closed the connection.
pub async fn is_closed(&mut self) -> bool {
let mut buf = [0u8; 1];
match self.stream.read(&mut buf).await {
Ok(0) | Err(_) => true,
Ok(_) => false,
}
}
}
@@ -0,0 +1,56 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use bgp_rib::AdjRibIn;
use bgp_server::config::Config;
use bgp_server::server::BgpServerBuilder;
use eyre::Result;
use tokio::task::JoinHandle;
/// A BGP server started on an ephemeral port for testing.
/// Aborts the server task when dropped.
pub struct TestServer {
handle: JoinHandle<Result<()>>,
/// The single IPv4 loopback address the server is bound to.
pub addr: SocketAddr,
/// Handle to the server's Adj-RIB-In for test introspection.
pub adj_rib_in: AdjRibIn,
}
impl TestServer {
/// Start a server with `local_asn` and the default 90 s hold time.
pub async fn start(local_asn: u32) -> Result<Self> {
Self::start_with_hold_time(local_asn, 90).await
}
/// 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> {
Self::start_inner(local_asn, hold_time_secs).await
}
async fn start_inner(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
let config = Config {
router_id: Ipv4Addr::new(127, 0, 0, 1),
local_asn,
hold_time_secs,
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
peers: vec![],
};
let bound = BgpServerBuilder::new(config).bind().await?;
let addr = bound
.local_addrs()
.into_iter()
.next()
.expect("at least one listener");
let adj_rib_in = bound.adj_rib_in.clone();
let handle = tokio::spawn(bound.run());
Ok(Self { handle, addr, adj_rib_in })
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.handle.abort();
}
}
@@ -0,0 +1,376 @@
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::errors::{BgpError, CeaseSubcode};
use bgp_packet::message::{
Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, NlriNextHop,
NotificationMessage, PathAttribute, UpdateMessage,
};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::RibEvent;
use eyre::Result;
// ---------------------------------------------------------------------------
// Connectivity
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_server_starts() -> Result<()> {
let server = TestServer::start(65000).await?;
assert_ne!(server.addr.port(), 0);
Ok(())
}
#[tokio::test]
async fn test_server_accepts_tcp_connection() -> Result<()> {
let server = TestServer::start(65000).await?;
let _peer = TestPeer::connect(server.addr).await?;
Ok(())
}
#[tokio::test]
async fn test_server_accepts_multiple_connections() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peers = Vec::new();
for _ in 0..5 {
peers.push(TestPeer::connect(server.addr).await?);
}
assert_eq!(peers.len(), 5);
Ok(())
}
// ---------------------------------------------------------------------------
// OpenSent state
// ---------------------------------------------------------------------------
/// Server sends an OPEN immediately after TCP is established.
#[tokio::test]
async fn test_server_sends_open() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
let open = peer.recv_open().await?;
assert_eq!(open.version, 4);
assert_eq!(open.asn, 65000);
Ok(())
}
/// Server sends a NOTIFICATION and closes if we send garbage instead of OPEN.
#[tokio::test]
async fn test_bad_marker_does_not_crash_server() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut bad = TestPeer::connect(server.addr).await?;
// Discard the server's OPEN, then send garbage.
let _ = bad.recv_open().await?;
bad.send_raw(&[0x00; 19]).await?;
drop(bad);
// New connections are still accepted.
let _good = TestPeer::connect(server.addr).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// OpenConfirm → Established
// ---------------------------------------------------------------------------
/// Full OPEN/KEEPALIVE handshake reaches Established.
#[tokio::test]
async fn test_session_established() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
let server_open = peer
.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90)
.await?;
assert_eq!(server_open.version, 4);
Ok(())
}
/// After Established, the server sends periodic KEEPALIVEs.
#[tokio::test]
async fn test_server_sends_keepalives() -> Result<()> {
// Use a short hold time so the keepalive interval (hold/3 = 1 s) fires quickly.
let server = TestServer::start_with_hold_time(65000, 3).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 3).await?;
// We should receive a KEEPALIVE within ~1 second (hold/3).
let msg = tokio::time::timeout(Duration::from_secs(3), peer.recv()).await??;
assert!(
matches!(msg, Message::KeepAlive),
"Expected KEEPALIVE, got {:?}",
msg
);
Ok(())
}
/// Server closes with a NOTIFICATION when the hold timer expires.
#[tokio::test]
async fn test_hold_timer_expires() -> Result<()> {
// hold_time = 3 s → server expects a KEEPALIVE within 3 s.
let server = TestServer::start_with_hold_time(65000, 3).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 3).await?;
// Don't send any keepalives. The server will send keepalives until its own
// hold timer fires and it sends a NOTIFICATION. Drain messages until we
// see it (or the outer timeout fires).
let notif = tokio::time::timeout(Duration::from_secs(6), async {
loop {
match peer.recv().await? {
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
Message::KeepAlive => {} // server keepalive — keep waiting
other => eyre::bail!("Unexpected message: {:?}", other),
}
}
})
.await??;
assert!(
matches!(notif.error, BgpError::HoldTimer),
"Expected HoldTimer NOTIFICATION, got {:?}",
notif
);
Ok(())
}
/// A peer-sent NOTIFICATION is handled gracefully (session closes cleanly).
#[tokio::test]
async fn test_peer_sends_notification() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90)
.await?;
peer.send(&Message::Notification(NotificationMessage {
error: BgpError::Cease(CeaseSubcode::AdminShutdown),
data: vec![],
}))
.await?;
// Server should close the connection cleanly.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(peer.is_closed().await);
Ok(())
}
// ---------------------------------------------------------------------------
// UPDATE
// ---------------------------------------------------------------------------
/// An UPDATE message is accepted without crashing the server.
#[tokio::test]
async fn test_server_accepts_update() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90)
.await?;
let update = Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![],
// 10.0.0.0/8 — one byte of prefix for a /8.
announced_routes: vec![IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?],
});
peer.send(&update).await?;
// Server should stay up — we can still exchange a keepalive.
peer.send(&Message::KeepAlive).await?;
Ok(())
}
/// An IPv6 UPDATE via MP_REACH_NLRI is accepted without crashing the server.
#[tokio::test]
async fn test_server_accepts_ipv6_update() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90)
.await?;
// 2001:db8::/32
let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?;
let update = Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()),
prefixes: vec![prefix],
})],
announced_routes: vec![],
});
peer.send(&update).await?;
// Server should stay up — we can still exchange a keepalive.
peer.send(&Message::KeepAlive).await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Adj-RIB-In introspection
// ---------------------------------------------------------------------------
/// IPv4 prefix announced via the legacy NLRI field appears in the RIB.
#[tokio::test]
async fn test_rib_records_ipv4_announce() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut events = server.adj_rib_in.subscribe();
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
let prefix = IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![],
announced_routes: vec![prefix.clone()],
}))
.await?;
let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
assert!(
matches!(&event, RibEvent::Announced { prefix: p, .. } if p == &prefix),
"Expected Announced for 10.0.0.0/8, got {event:?}",
);
let snapshot = server.adj_rib_in.snapshot().await;
assert!(
snapshot.iter().any(|e| e.prefix == prefix),
"10.0.0.0/8 missing from RIB snapshot",
);
Ok(())
}
/// Withdrawn IPv4 prefix is removed from the RIB.
#[tokio::test]
async fn test_rib_records_ipv4_withdraw() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut events = server.adj_rib_in.subscribe();
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
let prefix = IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?;
// Announce first.
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![],
announced_routes: vec![prefix.clone()],
}))
.await?;
tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
// Now withdraw.
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![prefix.clone()],
path_attributes: vec![],
announced_routes: vec![],
}))
.await?;
let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
assert!(
matches!(&event, RibEvent::Withdrawn { prefix: p, .. } if p == &prefix),
"Expected Withdrawn for 10.0.0.0/8, got {event:?}",
);
let snapshot = server.adj_rib_in.snapshot().await;
assert!(
!snapshot.iter().any(|e| e.prefix == prefix),
"10.0.0.0/8 should have been removed from RIB snapshot",
);
Ok(())
}
/// IPv6 prefix announced via MP_REACH_NLRI appears in the RIB.
#[tokio::test]
async fn test_rib_records_ipv6_announce() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut events = server.adj_rib_in.subscribe();
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()),
prefixes: vec![prefix.clone()],
})],
announced_routes: vec![],
}))
.await?;
let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
assert!(
matches!(&event, RibEvent::Announced { prefix: p, .. } if p == &prefix),
"Expected Announced for 2001:db8::/32, got {event:?}",
);
let snapshot = server.adj_rib_in.snapshot().await;
assert!(
snapshot.iter().any(|e| e.prefix == prefix),
"2001:db8::/32 missing from RIB snapshot",
);
Ok(())
}
/// IPv6 prefix withdrawn via MP_UNREACH_NLRI is removed from the RIB.
#[tokio::test]
async fn test_rib_records_ipv6_withdraw() -> Result<()> {
let server = TestServer::start(65000).await?;
let mut events = server.adj_rib_in.subscribe();
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?;
// Announce first.
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()),
prefixes: vec![prefix.clone()],
})],
announced_routes: vec![],
}))
.await?;
tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
// Now withdraw via MP_UNREACH_NLRI.
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
afi: AddressFamilyId::Ipv6,
safi: SubsequentAfi::Unicast,
prefixes: vec![prefix.clone()],
})],
announced_routes: vec![],
}))
.await?;
let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??;
assert!(
matches!(&event, RibEvent::Withdrawn { prefix: p, .. } if p == &prefix),
"Expected Withdrawn for 2001:db8::/32, got {event:?}",
);
let snapshot = server.adj_rib_in.snapshot().await;
assert!(
!snapshot.iter().any(|e| e.prefix == prefix),
"2001:db8::/32 should have been removed from RIB snapshot",
);
Ok(())
}
+7 -1
View File
@@ -1446,7 +1446,13 @@ impl NotificationMessage {
let error = BgpError::from_code_subcode(error_code, error_subcode) let error = BgpError::from_code_subcode(error_code, error_subcode)
.map_err(|e| Failure(BgpParserError::Eyre(e.into())))?; .map_err(|e| Failure(BgpParserError::Eyre(e.into())))?;
Ok((&[], Self { error, data: buf.to_vec() })) Ok((
&[],
Self {
error,
data: buf.to_vec(),
},
))
} }
pub fn to_wire(&self, _ctx: &ParserContext, buf: &mut BytesMut) -> Result<()> { pub fn to_wire(&self, _ctx: &ParserContext, buf: &mut BytesMut) -> Result<()> {
+2 -2
View File
@@ -314,7 +314,7 @@ impl Capability {
let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?; let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?;
macro_rules! require_empty { macro_rules! require_empty {
($buf:expr, $cap_type:expr) => { ($buf:expr, $cap_type:expr) => {
if !buf.is_empty() { if !$buf.is_empty() {
return Err(nom::Err::Failure(BgpParserError::Eyre(eyre!( return Err(nom::Err::Failure(BgpParserError::Eyre(eyre!(
"Unexpected leftover bytes {:x?} while parsing {} capability", "Unexpected leftover bytes {:x?} while parsing {} capability",
$buf, $buf,
@@ -456,7 +456,7 @@ impl Capability {
v if v == CapabilityDiscriminants::FourByteAsn as u8 => { v if v == CapabilityDiscriminants::FourByteAsn as u8 => {
let (cap_buf, asn) = be_u32(cap_buf)?; let (cap_buf, asn) = be_u32(cap_buf)?;
require_empty!(cap_buf, "FourByteAsn"); require_empty!(cap_buf, "FourByteAsn");
Ok((cap_buf, Self::FourByteAsn { asn })) Ok((buf, Self::FourByteAsn { asn }))
} }
v if v == CapabilityDiscriminants::AddPath as u8 => { v if v == CapabilityDiscriminants::AddPath as u8 => {
let (cap_buf, afi) = be_u16(cap_buf)?; let (cap_buf, afi) = be_u16(cap_buf)?;
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "bgp-rib"
edition.workspace = true
[dependencies]
bgp-packet.workspace = true
bgp-primitives.workspace = true
tokio = { workspace = true, features = ["full"] }
tracing.workspace = true
+96
View File
@@ -0,0 +1,96 @@
use std::collections::HashMap;
use bgp_primitives::ip_prefix::IpPrefix;
use tokio::sync::{broadcast, mpsc, oneshot};
use tracing::warn;
use crate::entry::{PeerId, RibEntry, SnapshotEntry};
use crate::event::RibEvent;
const EVENT_CHANNEL_CAPACITY: usize = 1024;
const CMD_CHANNEL_CAPACITY: usize = 256;
enum RibCommand {
Announce { peer: PeerId, prefix: IpPrefix, entry: RibEntry },
Withdraw { peer: PeerId, prefix: IpPrefix },
Snapshot { reply: oneshot::Sender<Vec<SnapshotEntry>> },
}
/// Handle to the Adj-RIB-In actor. Cheap to clone — all clones share the same
/// backing task. The actor is stopped when all handles are dropped.
#[derive(Clone)]
pub struct AdjRibIn {
tx: mpsc::Sender<RibCommand>,
events: broadcast::Sender<RibEvent>,
}
impl AdjRibIn {
/// Spawns the backing actor and returns a handle.
pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let events_clone = events.clone();
tokio::spawn(run_actor(rx, events_clone));
Self { tx, events }
}
pub async fn announce(&self, peer: PeerId, prefix: IpPrefix, entry: RibEntry) {
let _ = self.tx.send(RibCommand::Announce { peer, prefix, entry }).await;
}
pub async fn withdraw(&self, peer: PeerId, prefix: IpPrefix) {
let _ = self.tx.send(RibCommand::Withdraw { peer, prefix }).await;
}
/// Returns a point-in-time snapshot of all routes in the RIB.
pub async fn snapshot(&self) -> Vec<SnapshotEntry> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.tx.send(RibCommand::Snapshot { reply: reply_tx }).await;
reply_rx.await.unwrap_or_default()
}
/// Subscribe to a stream of RIB mutation events.
///
/// The receiver will see every [`RibEvent`] from the point of subscription.
/// If the consumer falls behind by more than [`EVENT_CHANNEL_CAPACITY`]
/// events it will receive a [`broadcast::error::RecvError::Lagged`] error.
pub fn subscribe(&self) -> broadcast::Receiver<RibEvent> {
self.events.subscribe()
}
}
async fn run_actor(mut rx: mpsc::Receiver<RibCommand>, events: broadcast::Sender<RibEvent>) {
let mut table: HashMap<(PeerId, IpPrefix), RibEntry> = HashMap::new();
while let Some(cmd) = rx.recv().await {
match cmd {
RibCommand::Announce { peer, prefix, entry } => {
table.insert((peer, prefix.clone()), entry.clone());
if let Err(e) = events.send(RibEvent::Announced { peer, prefix, entry }) {
// No active subscribers — not an error.
let _ = e;
}
}
RibCommand::Withdraw { peer, prefix } => {
if table.remove(&(peer, prefix.clone())).is_some() {
if let Err(e) = events.send(RibEvent::Withdrawn { peer, prefix }) {
let _ = e;
}
}
}
RibCommand::Snapshot { reply } => {
let snap = table
.iter()
.map(|((peer, prefix), entry)| SnapshotEntry {
peer: *peer,
prefix: prefix.clone(),
entry: entry.clone(),
})
.collect();
if reply.send(snap).is_err() {
warn!("Snapshot reply channel closed before send");
}
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
use std::net::SocketAddr;
use std::time::SystemTime;
use bgp_packet::message::PathAttribute;
use bgp_primitives::ip_prefix::IpPrefix;
/// Identifies a BGP peer by its TCP address and negotiated ASN.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PeerId {
pub addr: SocketAddr,
pub asn: u32,
}
impl std::fmt::Display for PeerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}(AS{})", self.addr, self.asn)
}
}
/// A single route stored in the Adj-RIB-In.
#[derive(Debug, Clone)]
pub struct RibEntry {
pub path_attributes: Vec<PathAttribute>,
pub received_at: SystemTime,
}
impl RibEntry {
pub fn new(path_attributes: Vec<PathAttribute>) -> Self {
Self { path_attributes, received_at: SystemTime::now() }
}
}
/// A flattened row returned by [`crate::AdjRibIn::snapshot`].
#[derive(Debug, Clone)]
pub struct SnapshotEntry {
pub peer: PeerId,
pub prefix: IpPrefix,
pub entry: RibEntry,
}
+9
View File
@@ -0,0 +1,9 @@
use bgp_primitives::ip_prefix::IpPrefix;
use crate::entry::{PeerId, RibEntry};
#[derive(Debug, Clone)]
pub enum RibEvent {
Announced { peer: PeerId, prefix: IpPrefix, entry: RibEntry },
Withdrawn { peer: PeerId, prefix: IpPrefix },
}
+7
View File
@@ -0,0 +1,7 @@
mod adj_rib_in;
mod entry;
mod event;
pub use adj_rib_in::AdjRibIn;
pub use entry::{PeerId, RibEntry, SnapshotEntry};
pub use event::RibEvent;
+5
View File
@@ -3,7 +3,12 @@ name = "bgp-server"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bytes.workspace = true
clap.workspace = true clap.workspace = true
futures.workspace = true
eyre.workspace = true eyre.workspace = true
prometheus.workspace = true prometheus.workspace = true
tokio.workspace = true tokio.workspace = true
+19 -7
View File
@@ -4,29 +4,41 @@ package bgp.server;
// Top level BGP Server configuration. // Top level BGP Server configuration.
message BgpConfig { message BgpConfig {
// The IPv4 address used as the router ID in BGP. // The router ID, written as a dotted-quad IPv4 address (e.g. "192.0.2.1").
// Required even on IPv6-only routers per RFC 4271.
string router_id = 1; string router_id = 1;
// The local AS Number to use when communicating with peers.
// The local 4-byte AS number (RFC 6793).
uint32 local_asn = 2; uint32 local_asn = 2;
// Endpoints to listen on. // Endpoints to accept incoming BGP connections on.
// Defaults to 0.0.0.0:179 and [::]:179 when empty.
repeated TcpEndpoint listen_endpoints = 3; repeated TcpEndpoint listen_endpoints = 3;
// Peers configured on this server. // Peers configured on this server.
repeated Peer peers = 4; repeated Peer peers = 4;
} }
// Represents an IP address and port that can be used for listening or connecting. // An IP address and port pair.
message TcpEndpoint { message TcpEndpoint {
// Address to listen on // IP address (V4 or V6).
string address = 1; string address = 1;
// Port to listen on. // TCP port. Defaults to 179 for BGP when unset.
uint32 port = 2; uint32 port = 2;
} }
// Configuration of a BGP peer. // Configuration of a BGP peer.
message Peer { message Peer {
// Human-readable name for logging and diagnostics.
string name = 1; string name = 1;
TcpEndpoint address = 2;
// Address and port to connect to for this peer.
TcpEndpoint endpoint = 2;
// The peer's 4-byte AS number (RFC 6793).
uint32 remote_asn = 3; uint32 remote_asn = 3;
// Hold time to advertise in the OPEN message, in seconds (RFC 4271 §4.2).
// Must be 0 or >= 3. Defaults to 90 when unset.
uint32 hold_time_seconds = 4;
} }
+94
View File
@@ -0,0 +1,94 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr;
use eyre::{Result, bail, eyre};
use crate::proto::config::BgpConfig;
const DEFAULT_BGP_PORT: u16 = 179;
const DEFAULT_HOLD_TIME_SECS: u16 = 90;
/// Validated, Rust-native server configuration derived from the proto config.
pub struct Config {
pub router_id: Ipv4Addr,
pub local_asn: u32,
/// Default hold time advertised to peers that have no per-peer override.
pub hold_time_secs: u16,
pub listen_endpoints: Vec<SocketAddr>,
pub peers: Vec<PeerConfig>,
}
pub struct PeerConfig {
pub name: String,
pub endpoint: SocketAddr,
pub remote_asn: u32,
pub hold_time_secs: u16,
}
impl Config {
pub fn from_proto(proto: BgpConfig) -> Result<Self> {
let router_id = Ipv4Addr::from_str(&proto.router_id)
.map_err(|_| eyre!("Invalid router_id {:?}: must be a dotted-quad IPv4 address", proto.router_id))?;
if proto.local_asn == 0 {
bail!("local_asn must be non-zero");
}
let listen_endpoints = if proto.listen_endpoints.is_empty() {
vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DEFAULT_BGP_PORT),
SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), DEFAULT_BGP_PORT),
]
} else {
proto.listen_endpoints
.into_iter()
.map(|ep| parse_endpoint(&ep.address, ep.port))
.collect::<Result<_>>()?
};
let peers = proto.peers
.into_iter()
.map(|p| {
let ep = p.endpoint.as_ref()
.ok_or_else(|| eyre!("Peer {:?} is missing endpoint", p.name))?;
let endpoint = parse_endpoint(&ep.address, ep.port)?;
if p.remote_asn == 0 {
bail!("Peer {:?} remote_asn must be non-zero", p.name);
}
let hold_time_secs = if p.hold_time_seconds == 0 {
DEFAULT_HOLD_TIME_SECS
} else if p.hold_time_seconds < 3 {
bail!("Peer {:?} hold_time_seconds must be 0 or >= 3", p.name);
} else {
p.hold_time_seconds as u16
};
Ok(PeerConfig {
name: p.name,
endpoint,
remote_asn: p.remote_asn,
hold_time_secs,
})
})
.collect::<Result<_>>()?;
Ok(Self { router_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, peers })
}
}
fn parse_endpoint(address: &str, port: u32) -> Result<SocketAddr> {
let port = if port == 0 {
DEFAULT_BGP_PORT
} else if port > u16::MAX as u32 {
bail!("Port {port} is out of range");
} else {
port as u16
};
let ip = IpAddr::from_str(address)
.map_err(|_| eyre!("Invalid IP address {address:?}"))?;
Ok(SocketAddr::new(ip, port))
}
+17
View File
@@ -0,0 +1,17 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpStream;
use tracing::warn;
use crate::fsm::{SessionConfig, run_session};
pub async fn handle_connection(
stream: TcpStream,
peer_addr: SocketAddr,
config: Arc<SessionConfig>,
) {
if let Err(e) = run_session(stream, peer_addr, (*config).clone()).await {
warn!(%peer_addr, err = %e, "BGP session terminated with error");
}
}
+389
View File
@@ -0,0 +1,389 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use bgp_packet::errors::{BgpError, FsmSubcode, OpenMsgSubcode};
use bgp_packet::message::{BGP_HEADER_LEN, Message, NotificationMessage, PathAttribute};
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
use bgp_primitives::parser::ParserContext;
use bgp_rib::{AdjRibIn, PeerId, RibEntry};
use bytes::BytesMut;
use eyre::{Result, bail, eyre};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::time::{Instant, interval_at, sleep_until};
use tracing::{info, warn};
use bgp_packet::message::{AS_TRANS, BGP4_VERSION};
/// Minimal per-session config extracted from the server config.
#[derive(Clone)]
pub struct SessionConfig {
pub router_id: Ipv4Addr,
pub local_asn: u32,
pub hold_time_secs: u16,
pub adj_rib_in: AdjRibIn,
}
/// Drive the BGP FSM for a single accepted TCP connection.
///
/// States visited: OpenSent → OpenConfirm → Established.
pub async fn run_session(
stream: TcpStream,
peer_addr: SocketAddr,
config: SessionConfig,
) -> Result<()> {
let (mut reader, mut writer) = stream.into_split();
// --- OpenSent -----------------------------------------------------------
// Send our OPEN immediately after the TCP connection is established.
let our_open = build_open(&config);
send_message(&mut writer, &our_open, &ParserContext::default()).await?;
// RFC 4271 §8.1: use a large initial hold timer (4 minutes) before we
// know the negotiated value.
let peer_msg = recv_with_timeout(
&mut reader,
&ParserContext::default(),
Duration::from_secs(240),
)
.await;
let peer_open = match peer_msg {
Ok(Message::Open(o)) => o,
Ok(other) => {
send_notification(
&mut writer,
BgpError::FsmError(FsmSubcode::UnexpectedOpenSent),
&ParserContext::default(),
)
.await;
bail!("Expected OPEN in OpenSent state, got {:?}", other);
}
Err(e) => {
send_notification(
&mut writer,
BgpError::FsmError(FsmSubcode::UnexpectedOpenSent),
&ParserContext::default(),
)
.await;
return Err(e);
}
};
if let Err(bgp_err) = validate_open(&peer_open) {
send_notification(&mut writer, bgp_err, &ParserContext::default()).await;
bail!("Peer {} sent invalid OPEN", peer_addr);
}
let negotiated_hold_time = config.hold_time_secs.min(peer_open.hold_time);
let peer_four_byte_asn = peer_open.options.iter().any(|o| {
matches!(o, OpenOption::Capabilities(caps)
if caps.iter().any(|c| matches!(c, Capability::FourByteAsn { .. })))
});
let ctx = ParserContext {
four_octet_asn: Some(peer_four_byte_asn),
address_family: None,
};
let peer_asn = if peer_four_byte_asn {
peer_open
.options
.iter()
.find_map(|o| match o {
OpenOption::Capabilities(caps) => caps.iter().find_map(|c| match c {
Capability::FourByteAsn { asn } => Some(*asn),
_ => None,
}),
})
.unwrap_or(peer_open.asn as u32)
} else {
peer_open.asn as u32
};
info!(
%peer_addr,
peer_asn,
negotiated_hold_time,
"OPEN received, transitioning to OpenConfirm"
);
// --- OpenConfirm --------------------------------------------------------
send_message(&mut writer, &Message::KeepAlive, &ctx).await?;
let hold_duration = Duration::from_secs(negotiated_hold_time as u64);
match recv_with_timeout(&mut reader, &ctx, hold_duration).await {
Ok(Message::KeepAlive) => {}
Ok(other) => {
send_notification(
&mut writer,
BgpError::FsmError(FsmSubcode::UnexpectedOpenConfirm),
&ctx,
)
.await;
bail!("Expected KEEPALIVE in OpenConfirm state, got {:?}", other);
}
Err(e) => {
send_notification(&mut writer, BgpError::HoldTimer, &ctx).await;
return Err(e);
}
}
info!(%peer_addr, peer_asn, "Session established");
let peer_id = PeerId { addr: peer_addr, asn: peer_asn };
// --- Established --------------------------------------------------------
established_loop(
&mut reader,
&mut writer,
&ctx,
negotiated_hold_time,
peer_id,
&config.adj_rib_in,
)
.await
}
async fn established_loop(
reader: &mut OwnedReadHalf,
writer: &mut OwnedWriteHalf,
ctx: &ParserContext,
hold_time_secs: u16,
peer_id: PeerId,
adj_rib_in: &AdjRibIn,
) -> Result<()> {
// When hold_time is 0, keepalives and the hold timer are disabled (RFC 4271 §4.4).
if hold_time_secs == 0 {
return no_timer_loop(reader, writer, ctx, peer_id, adj_rib_in).await;
}
let hold_duration = Duration::from_secs(hold_time_secs as u64);
let keepalive_interval = Duration::from_secs(hold_time_secs as u64 / 3);
// interval_at avoids firing immediately; first keepalive is sent after one interval.
let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval);
// sleep_until must be pinned so we can call .as_mut().reset() inside select!.
let hold_timer = sleep_until(Instant::now() + hold_duration);
tokio::pin!(hold_timer);
loop {
tokio::select! {
result = read_message(reader, ctx) => {
match result? {
Message::KeepAlive => {
hold_timer.as_mut().reset(Instant::now() + hold_duration);
}
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 => {}
}
}
_ = keepalive_timer.tick() => {
send_message(writer, &Message::KeepAlive, ctx).await?;
}
_ = &mut hold_timer => {
warn!(peer = %peer_id, "Hold timer expired");
send_notification(writer, BgpError::HoldTimer, ctx).await;
bail!("Hold timer expired for peer {}", peer_id);
}
}
}
}
/// 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
// ---------------------------------------------------------------------------
async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result<Message> {
let mut header = [0u8; BGP_HEADER_LEN as usize];
reader.read_exact(&mut header).await?;
let total_len = u16::from_be_bytes([header[16], header[17]]) as usize;
if total_len < BGP_HEADER_LEN as usize {
bail!("BGP message length {total_len} below minimum");
}
let mut buf = vec![0u8; total_len];
buf[..BGP_HEADER_LEN as usize].copy_from_slice(&header);
reader
.read_exact(&mut buf[BGP_HEADER_LEN as usize..])
.await?;
let (_, msg) =
Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?;
Ok(msg)
}
async fn recv_with_timeout(
reader: &mut OwnedReadHalf,
ctx: &ParserContext,
timeout: Duration,
) -> Result<Message> {
tokio::time::timeout(timeout, read_message(reader, ctx))
.await
.map_err(|_| eyre!("Timed out waiting for BGP message"))?
}
async fn send_message(
writer: &mut OwnedWriteHalf,
msg: &Message,
ctx: &ParserContext,
) -> Result<()> {
let mut buf = BytesMut::new();
msg.to_wire(ctx, &mut buf)?;
writer.write_all(&buf).await?;
Ok(())
}
/// Best-effort NOTIFICATION send — errors are logged and swallowed since we
/// are already tearing down the session.
async fn send_notification(writer: &mut OwnedWriteHalf, error: BgpError, ctx: &ParserContext) {
let msg = Message::Notification(NotificationMessage {
error,
data: vec![],
});
if let Err(e) = send_message(writer, &msg, ctx).await {
warn!("Failed to send NOTIFICATION: {e}");
}
}
// ---------------------------------------------------------------------------
// RIB helpers
// ---------------------------------------------------------------------------
/// Applies a received UPDATE to the Adj-RIB-In.
///
/// Handles both the legacy IPv4 NLRI fields and the MP_REACH/MP_UNREACH path
/// attributes used for all other address families (RFC 2858).
async fn apply_update(
rib: &AdjRibIn,
peer: PeerId,
path_attributes: &[PathAttribute],
withdrawn: &[bgp_primitives::ip_prefix::IpPrefix],
announced: &[bgp_primitives::ip_prefix::IpPrefix],
) {
// Legacy IPv4 fields in the UPDATE body.
for prefix in withdrawn {
rib.withdraw(peer, prefix.clone()).await;
}
for prefix in announced {
rib.announce(peer, prefix.clone(), RibEntry::new(path_attributes.to_vec())).await;
}
// Multiprotocol attributes carry NLRI for all other AFIs.
for attr in path_attributes {
match attr {
PathAttribute::MpReachNlri(mp) => {
for prefix in &mp.prefixes {
rib.announce(peer, prefix.clone(), RibEntry::new(path_attributes.to_vec())).await;
}
}
PathAttribute::MpUnreachNlri(mp) => {
for prefix in &mp.prefixes {
rib.withdraw(peer, prefix.clone()).await;
}
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// OPEN construction and validation
// ---------------------------------------------------------------------------
fn build_open(config: &SessionConfig) -> Message {
// Use AS_TRANS in the 2-byte field when the ASN doesn't fit; the real ASN
// is carried in the FourByteAsn capability (RFC 6793).
let asn_field = if config.local_asn > u16::MAX as u32 {
AS_TRANS
} else {
config.local_asn as u16
};
Message::Open(OpenMessage {
version: BGP4_VERSION,
asn: asn_field,
hold_time: config.hold_time_secs,
identifier: config.router_id,
options: vec![OpenOption::Capabilities(vec![
Capability::FourByteAsn {
asn: config.local_asn,
},
Capability::MultiProtocol {
afi: AddressFamilyId::Ipv4,
safi: SubsequentAfi::Unicast,
},
Capability::RouteRefresh,
])],
})
}
fn validate_open(open: &OpenMessage) -> Result<(), BgpError> {
if open.version != BGP4_VERSION {
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnsupportedVersion));
}
if open.hold_time != 0 && open.hold_time < 3 {
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnacceptableHoldTime));
}
Ok(())
}
+4
View File
@@ -1,3 +1,7 @@
pub mod config;
pub mod connection;
pub mod fsm;
pub mod server;
pub mod tracing; pub mod tracing;
pub mod proto { pub mod proto {
+15 -12
View File
@@ -1,26 +1,29 @@
use bgp_server::proto; use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs;
use clap::Parser; use clap::Parser;
use eyre::{Context, Result}; use eyre::{Context, Result};
use tracing::info;
use crate::proto::config::BgpConfig;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct ServerArgs { struct ServerArgs {
/// Path to the configuration file. /// Path to the textproto configuration file.
config: String, config: String,
#[command(flatten)]
tracing: TracingArgs,
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
info!("Starting bgp server");
let args = ServerArgs::parse(); let args = ServerArgs::parse();
args.tracing.install()?;
let config_string = let config_string =
std::fs::read_to_string(args.config).wrap_err("Failed to open config file")?; std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)?; let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
info!("Read config file: {}", config); BgpServerBuilder::new(config).run().await
Ok(())
} }
+114
View File
@@ -0,0 +1,114 @@
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use eyre::{Result, WrapErr};
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info};
use bgp_rib::AdjRibIn;
use crate::config::Config;
use crate::connection::handle_connection;
use crate::fsm::SessionConfig;
pub struct BgpServerBuilder {
config: Config,
}
/// A `BgpServerBuilder` that has bound all its listeners and is ready to accept.
/// Call [`BgpServer::run`] to start the accept loop.
pub struct BgpServer {
config: Config,
listeners: Vec<(SocketAddr, TcpListener)>,
/// 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).
pub adj_rib_in: AdjRibIn,
}
impl BgpServerBuilder {
pub fn new(config: Config) -> Self {
Self { config }
}
/// Binds all configured listen endpoints and returns a [`BgpServer`].
/// Separating bind from run lets callers (e.g. tests) inspect the actual
/// bound addresses before the accept loop starts.
pub async fn bind(self) -> Result<BgpServer> {
let mut listeners = Vec::with_capacity(self.config.listen_endpoints.len());
for &addr in &self.config.listen_endpoints {
let listener = TcpListener::bind(addr)
.await
.wrap_err_with(|| format!("Failed to bind listener on {addr}"))?;
let bound_addr = listener.local_addr()?;
info!(%bound_addr, "Listening for BGP connections");
listeners.push((bound_addr, listener));
}
Ok(BgpServer {
config: self.config,
listeners,
adj_rib_in: AdjRibIn::spawn(),
})
}
/// Convenience method: bind then run immediately.
pub async fn run(self) -> Result<()> {
self.bind().await?.run().await
}
}
impl BgpServer {
/// The addresses actually bound (useful when the config uses port 0).
pub fn local_addrs(&self) -> Vec<SocketAddr> {
self.listeners.iter().map(|(addr, _)| *addr).collect()
}
pub async fn run(self) -> Result<()> {
info!(
router_id = %self.config.router_id,
local_asn = self.config.local_asn,
peers = self.config.peers.len(),
"BGP server started"
);
let session_config = Arc::new(SessionConfig {
router_id: self.config.router_id,
local_asn: self.config.local_asn,
hold_time_secs: self.config.hold_time_secs,
adj_rib_in: self.adj_rib_in.clone(),
});
let mut accepting: FuturesUnordered<_> = self
.listeners
.into_iter()
.map(|(addr, listener)| accept_one(addr, listener))
.collect();
while let Some((listen_addr, result, listener)) = accepting.next().await {
match result {
Ok((stream, peer_addr)) => {
tokio::spawn(handle_connection(
stream,
peer_addr,
Arc::clone(&session_config),
));
}
Err(e) => {
error!(%listen_addr, err = %e, "Accept error");
}
}
accepting.push(accept_one(listen_addr, listener));
}
Ok(())
}
}
async fn accept_one(
addr: SocketAddr,
listener: TcpListener,
) -> (SocketAddr, io::Result<(TcpStream, SocketAddr)>, TcpListener) {
let result = listener.accept().await;
(addr, result, listener)
}
+1
View File
@@ -25,6 +25,7 @@ impl TracingArgs {
}, },
)?; )?;
tracing_subscriber::fmt().with_env_filter(filter).init();
Ok(()) Ok(())
} }
} }
+158 -3
View File
@@ -1,9 +1,164 @@
# RFCs # RFCs
This document refers to the relevant RFCs for BGP and associated networking protocol semantics that we implement in this project. Tracks which RFCs are implemented, partially implemented, or only represented
in the wire format layer.
## RFC 4271 - Border Gateway protocol **Legend**
- ✅ Implemented — functional end-to-end
- 🟡 Wire format only — parsed and serialised correctly, but not yet acted on
- ❌ Not implemented
---
## Core BGP
### RFC 4271 — A Border Gateway Protocol 4 (BGP-4)
https://datatracker.ietf.org/doc/html/rfc4271 https://datatracker.ietf.org/doc/html/rfc4271
This is the main specification document for the Border Gateway Protocol, and defines the core protocol semantics and wire format. The base protocol specification.
| Section | Description | Status |
|---------|-------------|--------|
| §4.1 | Message framing (marker, length, type) | ✅ |
| §4.2 | OPEN message | ✅ |
| §4.3 | UPDATE message (path attributes, NLRI, withdrawn routes) | ✅ |
| §4.4 | KEEPALIVE message | ✅ |
| §4.5 | NOTIFICATION message (all error codes and subcodes) | ✅ |
| §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ |
| §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ |
| §9 | Decision process / best-path selection | ❌ |
| §9 | RIB (Adj-RIB-In, Loc-RIB, Adj-RIB-Out) | ❌ |
---
## Capability and Session Extensions
### RFC 6793 — BGP Support for Four-Octet Autonomous System (AS) Number Space
https://datatracker.ietf.org/doc/html/rfc6793
| Feature | Status |
|---------|--------|
| `AS_TRANS` (23456) sentinel | ✅ |
| `FourByteAsn` capability advertisement and parsing | ✅ |
| 4-byte AS_PATH segment encoding/decoding via `ParserContext` | ✅ |
| 4-byte ASN negotiation in FSM | ✅ |
### RFC 9072 — Extended Optional Parameters Length for BGP OPEN Message
https://datatracker.ietf.org/doc/html/rfc9072
| Feature | Status |
|---------|--------|
| Parsing extended-length OPEN (0xFF sentinel) | ✅ |
| Serialising extended-length OPEN when options exceed 255 bytes | ✅ |
### RFC 2918 — Route Refresh Capability for BGP-4
https://datatracker.ietf.org/doc/html/rfc2918
| Feature | Status |
|---------|--------|
| `RouteRefresh` capability advertisement | ✅ |
| `ROUTE-REFRESH` message type (5) framing | ✅ |
| AFI/SAFI payload parsing | ❌ |
| Acting on received ROUTE-REFRESH (re-advertising routes) | ❌ |
### RFC 7313 — Enhanced Route Refresh Capability for BGP-4
https://datatracker.ietf.org/doc/html/rfc7313
| Feature | Status |
|---------|--------|
| `EnhancedRouteRefresh` capability wire format | 🟡 |
### RFC 4724 — Graceful Restart Mechanism for BGP
https://datatracker.ietf.org/doc/html/rfc4724
| Feature | Status |
|---------|--------|
| `GracefulRestart` capability wire format | 🟡 |
| Restart state and forwarding preserved across sessions | ❌ |
### RFC 9494 — Long-Lived Graceful Restart for BGP
https://datatracker.ietf.org/doc/html/rfc9494
| Feature | Status |
|---------|--------|
| `LongLivedGracefulRestart` capability wire format | 🟡 |
### RFC 8654 — Extended Message Support for BGP
https://datatracker.ietf.org/doc/html/rfc8654
| Feature | Status |
|---------|--------|
| `ExtendedMessage` capability wire format | 🟡 |
| Raising `BGP_MAX_MSG_LEN` to 65535 when negotiated | ❌ |
---
## Multiprotocol and NLRI
### RFC 2858 — Multiprotocol Extensions for BGP-4
https://datatracker.ietf.org/doc/html/rfc2858
| Feature | Status |
|---------|--------|
| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ |
| `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ |
| `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ |
| IPv4 and IPv6 next-hop encoding | ✅ |
### RFC 8950 — Advertising IPv4 Network Layer Reachability Information (NLRI) with an IPv6 Next Hop
https://datatracker.ietf.org/doc/html/rfc8950
| Feature | Status |
|---------|--------|
| `ExtendedNextHop` capability wire format | 🟡 |
---
## Path Attributes and Communities
### RFC 1997 — BGP Communities Attribute
https://datatracker.ietf.org/doc/html/rfc1997
| Feature | Status |
|---------|--------|
| `COMMUNITIES` path attribute (parse, serialise) | ✅ |
### RFC 4360 — BGP Extended Communities Attribute
https://datatracker.ietf.org/doc/html/rfc4360
| Feature | Status |
|---------|--------|
| `EXTENDED_COMMUNITIES` path attribute (parse, serialise) | ✅ |
| AS-specific, IPv4-address-specific, opaque, route-target, route-origin subtypes | ✅ |
### RFC 8092 — BGP Large Communities Attribute
https://datatracker.ietf.org/doc/html/rfc8092
| Feature | Status |
|---------|--------|
| `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ |
---
## Other Capabilities (Wire Format Only)
The following capabilities are parsed and serialised correctly but have no
functional behaviour implemented beyond that.
| RFC | Capability | Status |
|-----|-----------|--------|
| RFC 5291 | Outbound Route Filtering (ORF) | 🟡 |
| RFC 7911 | ADD-PATH | 🟡 |
| RFC 8205 | BGPsec | 🟡 |
---
## Error Handling
### RFC 9687 — BGP Send Hold Timer
https://datatracker.ietf.org/doc/html/rfc9687
| Feature | Status |
|---------|--------|
| `SendHoldTimer` error code (8) defined | ✅ |
| Send hold timer enforcement | ❌ |