feature: active dialer for configured peers
Phase 4 of the teaching demo: the daemon now brings up its own sessions. - New dialer task per configured peer: connect with a timeout, run the same FSM as the passive side, back off exponentially (from connect_retry_seconds, capped at two minutes) and retry forever. Stays idle while a session with the same remote IP and ASN already exists. - The peer's OPEN must carry the configured ASN, otherwise the session is refused with a Bad Peer AS notification. Inbound connections from a configured peer's address get the same check plus the configured name and hold time. - SessionRegistry tracks configured peers and their dial state (Idle / Connecting / Backoff with last error and retry time). - /api/peers merges configured peers with live sessions so the page shows peers that are down, why, and when the next attempt is; unconfigured inbound sessions are listed after them. - fake_peer gains --listen so it can stand in for a router the daemon dials. - Integration tests: dial and establish, reconnect after the peer closes, wrong ASN refused, inbound from a configured address checked and named. Not implemented: RFC 4271 §6.8 collision detection; a real router on the other end resolves the rare race by closing one connection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb48fc0a85
commit
e2de44ba78
16 files changed
+530
-44
No files matched your search
@@ -20,8 +20,10 @@ We aim for the 1.0 release to be functional and stable to operate peering sessio
|
||||
- ✅ Capabilities from RFC4271
|
||||
- ✅ Update message
|
||||
- ☐ Notification message
|
||||
- ☐ Implement scaffolding required to start and configure a server
|
||||
- ☐ Implement connection logic to establish sessions with peers
|
||||
- ✅ Implement scaffolding required to start and configure a server
|
||||
- ✅ Implement connection logic to establish sessions with peers (passive and active)
|
||||
- ✅ Locally originated routes with communities, announced to all peers
|
||||
- ✅ HTTP API, live status page and Prometheus metrics
|
||||
- ☐ Implement an efficient data structure for storing prefixes (e.g. Poptrie)
|
||||
- ☐ Filtering for peer RIB in / out
|
||||
- ☐ Main / auxillary RIBs for exchanging routes between peers
|
||||
@@ -8,6 +8,9 @@
|
||||
//! With `--echo`, every prefix received is re-announced back to the server
|
||||
//! with this peer's ASN prepended, which lets a single ferrix instance show
|
||||
//! its own routes "coming back" in the received table.
|
||||
//!
|
||||
//! With `--listen ADDR` it instead waits for the server to dial it (one
|
||||
//! connection at a time), which exercises the server's active side.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
@@ -20,12 +23,17 @@ use bgp_packet::message::{
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use clap::Parser;
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
/// Address of the ferrix_bgp listener.
|
||||
#[arg(long, default_value = "127.0.0.1:1790")]
|
||||
/// Address of the ferrix_bgp listener to connect to.
|
||||
#[arg(long, default_value = "127.0.0.1:1790", conflicts_with = "listen")]
|
||||
server: String,
|
||||
/// Instead of connecting, listen here and accept connections from the
|
||||
/// server, one at a time, forever.
|
||||
#[arg(long)]
|
||||
listen: Option<String>,
|
||||
/// ASN this peer speaks as.
|
||||
#[arg(long, default_value_t = 65001)]
|
||||
asn: u32,
|
||||
@@ -49,8 +57,26 @@ const NO_EXPORT: (u16, u16) = (0xFFFF, 0xFF01);
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let addr = args.server.parse()?;
|
||||
let mut peer = TestPeer::connect(addr).await?;
|
||||
match &args.listen {
|
||||
Some(listen) => {
|
||||
let listener = TcpListener::bind(listen).await?;
|
||||
println!("listening on {}", listener.local_addr()?);
|
||||
loop {
|
||||
let (stream, from) = listener.accept().await?;
|
||||
println!("accepted connection from {from}");
|
||||
if let Err(e) = run(&args, TestPeer::from_stream(stream), from).await {
|
||||
println!("session ended: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let addr = args.server.parse()?;
|
||||
run(&args, TestPeer::connect(addr).await?, addr).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(args: &Args, mut peer: TestPeer, addr: std::net::SocketAddr) -> Result<()> {
|
||||
let open = peer.handshake(args.asn, args.router_id, args.hold_time).await?;
|
||||
println!("established with {} (AS{} router-id {})", addr, open.asn, open.identifier);
|
||||
|
||||
@@ -80,7 +106,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Message::Notification(n) => {
|
||||
println!("NOTIFICATION {}; exiting", n.error);
|
||||
println!("NOTIFICATION {}; session over", n.error);
|
||||
return Ok(());
|
||||
}
|
||||
other => println!("unexpected {other:?}"),
|
||||
|
||||
@@ -19,7 +19,13 @@ pub struct TestPeer {
|
||||
impl TestPeer {
|
||||
pub async fn connect(addr: SocketAddr) -> Result<Self> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
Ok(Self { stream, ctx: ParserContext::default() })
|
||||
Ok(Self::from_stream(stream))
|
||||
}
|
||||
|
||||
/// Wraps an already-connected stream, e.g. one accepted from a server
|
||||
/// that dialled us.
|
||||
pub fn from_stream(stream: TcpStream) -> Self {
|
||||
Self { stream, ctx: ParserContext::default() }
|
||||
}
|
||||
|
||||
/// Send a BGP message.
|
||||
|
||||
@@ -41,15 +41,27 @@ impl TestServer {
|
||||
}
|
||||
|
||||
async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result<Self> {
|
||||
let config = Config {
|
||||
Self::start_with(local_asn, |c| {
|
||||
c.hold_time_secs = hold_time_secs;
|
||||
c.local_route_policy = policy;
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start a server after letting `customize` edit the default test config
|
||||
/// (e.g. to add configured peers).
|
||||
pub async fn start_with(local_asn: u32, customize: impl FnOnce(&mut Config)) -> Result<Self> {
|
||||
let mut config = Config {
|
||||
router_id: Ipv4Addr::new(127, 0, 0, 1),
|
||||
local_asn,
|
||||
hold_time_secs,
|
||||
hold_time_secs: 90,
|
||||
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
|
||||
peers: vec![],
|
||||
local_route_policy: policy,
|
||||
connect_retry: std::time::Duration::from_secs(5),
|
||||
local_route_policy: LocalRibPolicy::default(),
|
||||
http_endpoint: None,
|
||||
};
|
||||
customize(&mut config);
|
||||
|
||||
let bound = BgpServerBuilder::new(config).bind().await?;
|
||||
let addr = bound
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! The active side: the server dials its configured peers and keeps
|
||||
//! retrying.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::errors::{BgpError, OpenMsgSubcode};
|
||||
use bgp_packet::message::Message;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use bgp_server::config::PeerConfig;
|
||||
use bgp_server::session::DialState;
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const LOCAL_ASN: u32 = 65000;
|
||||
const PEER_ASN: u32 = 65001;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Starts a server configured to dial `listener`'s address as peer "lab".
|
||||
async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> {
|
||||
let endpoint: SocketAddr = listener.local_addr()?;
|
||||
TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.connect_retry = Duration::from_millis(200);
|
||||
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn, hold_time_secs: 90 }];
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn accept(listener: &TcpListener) -> Result<TestPeer> {
|
||||
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
Ok(TestPeer::from_stream(stream))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_dials_configured_peer_and_establishes() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut peer = accept(&listener).await?;
|
||||
// Both sides send OPEN on connect; the test peer's handshake order works
|
||||
// whichever side opened TCP.
|
||||
let open = peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
assert_eq!(open.asn, LOCAL_ASN as u16);
|
||||
|
||||
let mut events = server.registry.subscribe();
|
||||
// Either the Up event is still queued or the session is already listed.
|
||||
let listed = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
if let Some(s) = server.registry.list().first().cloned() {
|
||||
return s;
|
||||
}
|
||||
let _ = events.recv().await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(listed.name.as_deref(), Some("lab"));
|
||||
assert_eq!(listed.peer.asn, PEER_ASN);
|
||||
assert_eq!(listed.peer.addr, listener.local_addr()?);
|
||||
|
||||
// Configured view reflects it; the dialer is not in backoff.
|
||||
let (cfg, state) = server.registry.configured_peers().into_iter().next().unwrap();
|
||||
assert_eq!(cfg.name, "lab");
|
||||
assert!(matches!(state, DialState::Connecting | DialState::Idle), "{state:?}");
|
||||
|
||||
// Local routes flow over the dialled session too.
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default()))
|
||||
.await?;
|
||||
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
||||
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_reconnects_after_peer_closes() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut first = accept(&listener).await?;
|
||||
first.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
drop(first);
|
||||
|
||||
// Session goes away, then the dialer comes back.
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while !server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let (_, state) = server.registry.configured_peers().into_iter().next().unwrap();
|
||||
assert!(matches!(state, DialState::Backoff { .. }), "{state:?}");
|
||||
|
||||
let mut second = accept(&listener).await?;
|
||||
second.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wrong_asn_is_refused_with_bad_peer_as() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let _server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut peer = accept(&listener).await?;
|
||||
peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let _ = peer.recv_open().await?;
|
||||
let notif = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
match peer.recv().await? {
|
||||
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), "{notif:?}");
|
||||
|
||||
// The dialer retries rather than giving up.
|
||||
let _again = accept(&listener).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An inbound connection from a configured peer's address is also checked
|
||||
/// against the configured ASN and gets the configured name.
|
||||
#[tokio::test]
|
||||
async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
|
||||
// Configure a peer at the loopback address on a port nobody listens on,
|
||||
// so the dialer never succeeds; then connect inbound from loopback.
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.connect_retry = Duration::from_secs(60);
|
||||
c.peers = vec![PeerConfig {
|
||||
name: "lab".into(),
|
||||
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
||||
remote_asn: PEER_ASN,
|
||||
hold_time_secs: 90,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wrong ASN inbound → refused.
|
||||
let mut bad = TestPeer::connect(server.addr).await?;
|
||||
bad.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let _ = bad.recv_open().await?;
|
||||
let msg = tokio::time::timeout(TIMEOUT, bad.recv()).await??;
|
||||
assert!(matches!(&msg, Message::Notification(n) if matches!(n.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs))), "{msg:?}");
|
||||
|
||||
// Right ASN inbound → established, named.
|
||||
let mut good = TestPeer::connect(server.addr).await?;
|
||||
good.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let listed = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
if let Some(s) = server.registry.list().first().cloned() {
|
||||
return s;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(listed.name.as_deref(), Some("lab"));
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,6 +24,10 @@ message BgpConfig {
|
||||
// Where to serve the HTTP API, status page and metrics. The port is
|
||||
// required. Unset disables the HTTP server.
|
||||
TcpEndpoint http_endpoint = 6;
|
||||
|
||||
// Initial delay between attempts to connect to a configured peer, in
|
||||
// seconds. Doubles after each failure up to two minutes. Defaults to 5.
|
||||
uint32 connect_retry_seconds = 7;
|
||||
}
|
||||
|
||||
// Limits on locally originated routes.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRibPolicy;
|
||||
@@ -9,6 +10,7 @@ use crate::proto::config::BgpConfig;
|
||||
|
||||
const DEFAULT_BGP_PORT: u16 = 179;
|
||||
const DEFAULT_HOLD_TIME_SECS: u16 = 90;
|
||||
const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Validated, Rust-native server configuration derived from the proto config.
|
||||
pub struct Config {
|
||||
@@ -18,12 +20,16 @@ pub struct Config {
|
||||
pub hold_time_secs: u16,
|
||||
pub listen_endpoints: Vec<SocketAddr>,
|
||||
pub peers: Vec<PeerConfig>,
|
||||
/// Initial delay between attempts to connect to a configured peer;
|
||||
/// doubles on each failure.
|
||||
pub connect_retry: Duration,
|
||||
/// Limits on locally originated routes.
|
||||
pub local_route_policy: LocalRibPolicy,
|
||||
/// Address for the HTTP API; `None` disables it.
|
||||
pub http_endpoint: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerConfig {
|
||||
pub name: String,
|
||||
pub endpoint: SocketAddr,
|
||||
@@ -102,12 +108,19 @@ impl Config {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let connect_retry = if proto.connect_retry_seconds == 0 {
|
||||
DEFAULT_CONNECT_RETRY
|
||||
} else {
|
||||
Duration::from_secs(proto.connect_retry_seconds as u64)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
router_id,
|
||||
local_asn: proto.local_asn,
|
||||
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
|
||||
listen_endpoints,
|
||||
peers,
|
||||
connect_retry,
|
||||
local_route_policy,
|
||||
http_endpoint,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Active side of the FSM: dial each configured peer and keep the session up.
|
||||
//!
|
||||
//! One task per configured peer loops forever: connect, run the session,
|
||||
//! back off, repeat. While a session with the same remote IP and ASN is
|
||||
//! already established (for example because the peer dialled us first) the
|
||||
//! dialer stays idle rather than opening a second one. Full RFC 4271 §6.8
|
||||
//! collision detection is not implemented; a real router on the other end
|
||||
//! will resolve any remaining race by closing one of the two connections.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::PeerConfig;
|
||||
use crate::fsm::{SessionConfig, run_session};
|
||||
use crate::session::{DialState, SessionRegistry};
|
||||
|
||||
/// Upper bound for exponential backoff between connection attempts.
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(120);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn spawn_dialers(
|
||||
peers: &[PeerConfig],
|
||||
base: &SessionConfig,
|
||||
connect_retry: Duration,
|
||||
) -> Vec<JoinHandle<()>> {
|
||||
peers
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
let config = SessionConfig {
|
||||
hold_time_secs: peer.hold_time_secs,
|
||||
peer_name: Some(peer.name.clone()),
|
||||
expected_asn: Some(peer.remote_asn),
|
||||
..base.clone()
|
||||
};
|
||||
tokio::spawn(dial_loop(Arc::new(peer.clone()), config, connect_retry))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn dial_loop(peer: Arc<PeerConfig>, config: SessionConfig, connect_retry: Duration) {
|
||||
let registry: SessionRegistry = config.registry.clone();
|
||||
let mut backoff = connect_retry;
|
||||
|
||||
loop {
|
||||
if registry.find(peer.endpoint.ip(), peer.remote_asn).is_some() {
|
||||
// Peer dialled us; nothing to do until that session goes away.
|
||||
registry.set_dial_state(&peer.name, DialState::Idle);
|
||||
tokio::time::sleep(connect_retry).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
registry.set_dial_state(&peer.name, DialState::Connecting);
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer");
|
||||
|
||||
let outcome = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(peer.endpoint)).await {
|
||||
Ok(Ok(stream)) => {
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "TCP connected, starting session");
|
||||
backoff = connect_retry;
|
||||
run_session(stream, peer.endpoint, config.clone()).await.map(|()| "session closed by peer".to_string())
|
||||
}
|
||||
Ok(Err(e)) => Err(eyre::eyre!("connect failed: {e}")),
|
||||
Err(_) => Err(eyre::eyre!("connect timed out after {CONNECT_TIMEOUT:?}")),
|
||||
};
|
||||
|
||||
let reason = match outcome {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
warn!(peer = %peer.name, endpoint = %peer.endpoint, err = %e, "Session attempt ended");
|
||||
e.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
registry.set_dial_state(
|
||||
&peer.name,
|
||||
DialState::Backoff { until: SystemTime::now() + backoff, last_error: reason },
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ pub struct SessionConfig {
|
||||
pub hold_time_secs: u16,
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
pub registry: SessionRegistry,
|
||||
/// Configured name of the peer, if known.
|
||||
pub peer_name: Option<String>,
|
||||
/// If set, the peer's OPEN must carry this ASN or the session is
|
||||
/// refused with a Bad Peer AS notification.
|
||||
pub expected_asn: Option<u32>,
|
||||
}
|
||||
|
||||
/// Drive the BGP FSM for a single accepted TCP connection.
|
||||
@@ -112,6 +117,13 @@ pub async fn run_session(
|
||||
peer_open.asn as u32
|
||||
};
|
||||
|
||||
if let Some(expected) = config.expected_asn
|
||||
&& peer_asn != expected
|
||||
{
|
||||
send_notification(&mut writer, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), &ctx).await;
|
||||
bail!("Peer {peer_addr} announced AS{peer_asn} but AS{expected} was configured");
|
||||
}
|
||||
|
||||
info!(
|
||||
%peer_addr,
|
||||
peer_asn,
|
||||
@@ -152,6 +164,7 @@ pub async fn run_session(
|
||||
let (outbound_tx, mut outbound_rx) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY);
|
||||
let _guard = config.registry.register(SessionHandle::new(
|
||||
peer_id,
|
||||
config.peer_name.clone(),
|
||||
local_addr,
|
||||
peer_four_byte_asn,
|
||||
outbound_tx,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod advertise;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod dialer;
|
||||
pub mod fsm;
|
||||
pub mod metrics;
|
||||
pub mod server;
|
||||
|
||||
@@ -12,8 +12,9 @@ use bgp_rib::{AdjRibIn, LocalRib};
|
||||
use crate::advertise::spawn_advertiser;
|
||||
use crate::config::Config;
|
||||
use crate::connection::handle_connection;
|
||||
use crate::dialer::spawn_dialers;
|
||||
use crate::fsm::SessionConfig;
|
||||
use crate::session::SessionRegistry;
|
||||
use crate::session::{ConfiguredPeer, SessionRegistry};
|
||||
|
||||
pub struct BgpServerBuilder {
|
||||
config: Config,
|
||||
@@ -55,7 +56,13 @@ impl BgpServerBuilder {
|
||||
listeners,
|
||||
adj_rib_in: AdjRibIn::spawn(),
|
||||
local_rib: LocalRib::spawn(self.config.local_route_policy.clone()),
|
||||
registry: SessionRegistry::new(),
|
||||
registry: SessionRegistry::with_configured(
|
||||
self.config
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| ConfiguredPeer { name: p.name.clone(), endpoint: p.endpoint, remote_asn: p.remote_asn })
|
||||
.collect(),
|
||||
),
|
||||
config: self.config,
|
||||
})
|
||||
}
|
||||
@@ -85,19 +92,22 @@ impl BgpServer {
|
||||
"BGP server started"
|
||||
);
|
||||
|
||||
let session_config = Arc::new(SessionConfig {
|
||||
let session_config = 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(),
|
||||
registry: self.registry.clone(),
|
||||
});
|
||||
peer_name: None,
|
||||
expected_asn: None,
|
||||
};
|
||||
|
||||
let _advertiser = spawn_advertiser(
|
||||
self.local_rib.clone(),
|
||||
self.registry.clone(),
|
||||
self.config.local_asn,
|
||||
);
|
||||
let _dialers = spawn_dialers(&self.config.peers, &session_config, self.config.connect_retry);
|
||||
|
||||
let mut accepting: FuturesUnordered<_> = self
|
||||
.listeners
|
||||
@@ -108,11 +118,15 @@ impl BgpServer {
|
||||
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),
|
||||
));
|
||||
// Inbound connections from a configured peer inherit its
|
||||
// name, expected ASN and hold time.
|
||||
let mut config = session_config.clone();
|
||||
if let Some(peer) = self.config.peers.iter().find(|p| p.endpoint.ip() == peer_addr.ip()) {
|
||||
config.peer_name = Some(peer.name.clone());
|
||||
config.expected_asn = Some(peer.remote_asn);
|
||||
config.hold_time_secs = peer.hold_time_secs;
|
||||
}
|
||||
tokio::spawn(handle_connection(stream, peer_addr, Arc::new(config)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%listen_addr, err = %e, "Accept error");
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! the registry to enumerate live sessions and queue outbound messages.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -18,10 +18,30 @@ use crate::metrics::SESSIONS_ESTABLISHED;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// Where the dialer is in bringing up a configured peer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DialState {
|
||||
/// Not dialling: the peer connected to us, or the dialer has not started.
|
||||
Idle,
|
||||
Connecting,
|
||||
/// The last attempt failed or the session ended; waiting to retry.
|
||||
Backoff { until: SystemTime, last_error: String },
|
||||
}
|
||||
|
||||
/// A peer from the configuration file, whether or not it is currently up.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredPeer {
|
||||
pub name: String,
|
||||
pub endpoint: SocketAddr,
|
||||
pub remote_asn: u32,
|
||||
}
|
||||
|
||||
/// Handle to one established session. Cloning is cheap.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionHandle {
|
||||
pub peer: PeerId,
|
||||
/// Name from the configuration, if this peer is configured.
|
||||
pub name: Option<String>,
|
||||
/// Our side of the TCP connection; used as the next hop for routes we
|
||||
/// originate to this peer.
|
||||
pub local_addr: SocketAddr,
|
||||
@@ -39,11 +59,12 @@ pub struct SessionClosed(pub PeerId);
|
||||
impl SessionHandle {
|
||||
pub fn new(
|
||||
peer: PeerId,
|
||||
name: Option<String>,
|
||||
local_addr: SocketAddr,
|
||||
four_octet_asn: bool,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
) -> Self {
|
||||
Self { peer, local_addr, established_at: SystemTime::now(), four_octet_asn, outbound }
|
||||
Self { peer, name, local_addr, established_at: SystemTime::now(), four_octet_asn, outbound }
|
||||
}
|
||||
|
||||
/// Queues a message for transmission on this session. Waits for queue
|
||||
@@ -62,6 +83,8 @@ pub enum SessionEvent {
|
||||
#[derive(Clone)]
|
||||
pub struct SessionRegistry {
|
||||
inner: Arc<RwLock<HashMap<PeerId, SessionHandle>>>,
|
||||
configured: Arc<Vec<ConfiguredPeer>>,
|
||||
dial_states: Arc<RwLock<HashMap<String, DialState>>>,
|
||||
events: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
@@ -73,8 +96,48 @@ impl Default for SessionRegistry {
|
||||
|
||||
impl SessionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::with_configured(vec![])
|
||||
}
|
||||
|
||||
pub fn with_configured(configured: Vec<ConfiguredPeer>) -> Self {
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self { inner: Arc::new(RwLock::new(HashMap::new())), events }
|
||||
let dial_states = configured.iter().map(|p| (p.name.clone(), DialState::Idle)).collect();
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(HashMap::new())),
|
||||
configured: Arc::new(configured),
|
||||
dial_states: Arc::new(RwLock::new(dial_states)),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers from the configuration file, each with the dialer's current
|
||||
/// state for it.
|
||||
pub fn configured_peers(&self) -> Vec<(ConfiguredPeer, DialState)> {
|
||||
let states = self.dial_states.read().expect("dial states poisoned");
|
||||
self.configured
|
||||
.iter()
|
||||
.map(|p| (p.clone(), states.get(&p.name).cloned().unwrap_or(DialState::Idle)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The configured peer whose endpoint IP is `ip`, if any.
|
||||
pub fn configured_by_ip(&self, ip: IpAddr) -> Option<&ConfiguredPeer> {
|
||||
self.configured.iter().find(|p| p.endpoint.ip() == ip)
|
||||
}
|
||||
|
||||
pub fn set_dial_state(&self, name: &str, state: DialState) {
|
||||
self.dial_states.write().expect("dial states poisoned").insert(name.to_string(), state);
|
||||
}
|
||||
|
||||
/// An established session with this remote IP and ASN, regardless of
|
||||
/// which side opened the TCP connection.
|
||||
pub fn find(&self, ip: IpAddr, asn: u32) -> Option<SessionHandle> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("session registry poisoned")
|
||||
.values()
|
||||
.find(|h| h.peer.addr.ip() == ip && h.peer.asn == asn)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Adds a session and emits [`SessionEvent::Up`]. The returned guard
|
||||
|
||||
+68
-12
@@ -11,7 +11,7 @@ use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry};
|
||||
use bgp_server::advertise::build_announce;
|
||||
use bgp_server::session::SessionHandle;
|
||||
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
|
||||
use bytes::BytesMut;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -77,27 +77,85 @@ pub struct StatusView {
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PeerView {
|
||||
/// Name from the configuration file; absent for unconfigured inbound peers.
|
||||
pub name: Option<String>,
|
||||
pub peer_addr: String,
|
||||
pub peer_asn: u32,
|
||||
pub local_addr: String,
|
||||
pub established_at_unix: u64,
|
||||
pub uptime_secs: u64,
|
||||
pub four_octet_asn: bool,
|
||||
/// `Established`, `Connecting`, `Backoff` or `Idle`.
|
||||
pub state: String,
|
||||
pub local_addr: Option<String>,
|
||||
pub established_at_unix: Option<u64>,
|
||||
pub uptime_secs: Option<u64>,
|
||||
pub four_octet_asn: Option<bool>,
|
||||
/// Why the last connection attempt or session ended, while in Backoff.
|
||||
pub last_error: Option<String>,
|
||||
pub retry_at_unix: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<&SessionHandle> for PeerView {
|
||||
fn from(s: &SessionHandle) -> Self {
|
||||
Self {
|
||||
name: s.name.clone(),
|
||||
peer_addr: s.peer.addr.to_string(),
|
||||
peer_asn: s.peer.asn,
|
||||
local_addr: s.local_addr.to_string(),
|
||||
established_at_unix: unix_secs(s.established_at),
|
||||
uptime_secs: s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0),
|
||||
four_octet_asn: s.four_octet_asn,
|
||||
state: "Established".into(),
|
||||
local_addr: Some(s.local_addr.to_string()),
|
||||
established_at_unix: Some(unix_secs(s.established_at)),
|
||||
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
|
||||
four_octet_asn: Some(s.four_octet_asn),
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerView {
|
||||
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
|
||||
let (state_name, last_error, retry_at_unix) = match state {
|
||||
DialState::Idle => ("Idle", None, None),
|
||||
DialState::Connecting => ("Connecting", None, None),
|
||||
DialState::Backoff { until, last_error } => ("Backoff", Some(last_error.clone()), Some(unix_secs(*until))),
|
||||
};
|
||||
Self {
|
||||
name: Some(peer.name.clone()),
|
||||
peer_addr: peer.endpoint.to_string(),
|
||||
peer_asn: peer.remote_asn,
|
||||
state: state_name.into(),
|
||||
local_addr: None,
|
||||
established_at_unix: None,
|
||||
uptime_secs: None,
|
||||
four_octet_asn: None,
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured peers first (with their live session when established), then
|
||||
/// any unconfigured inbound sessions.
|
||||
pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
|
||||
let sessions = registry.list();
|
||||
let mut claimed = std::collections::HashSet::new();
|
||||
let mut views: Vec<PeerView> = registry
|
||||
.configured_peers()
|
||||
.iter()
|
||||
.map(|(peer, state)| {
|
||||
match sessions.iter().find(|s| s.peer.addr.ip() == peer.endpoint.ip() && s.peer.asn == peer.remote_asn) {
|
||||
Some(session) => {
|
||||
claimed.insert(session.peer);
|
||||
PeerView { name: Some(peer.name.clone()), ..PeerView::from(session) }
|
||||
}
|
||||
None => PeerView::from_configured(peer, state),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut rest: Vec<PeerView> =
|
||||
sessions.iter().filter(|s| !claimed.contains(&s.peer)).map(PeerView::from).collect();
|
||||
rest.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
|
||||
views.extend(rest);
|
||||
views
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LocalRouteView {
|
||||
pub prefix: String,
|
||||
@@ -281,9 +339,7 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
|
||||
}
|
||||
|
||||
pub async fn peers(State(state): State<AppState>) -> Json<Vec<PeerView>> {
|
||||
let mut peers: Vec<PeerView> = state.registry.list().iter().map(PeerView::from).collect();
|
||||
peers.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
|
||||
Json(peers)
|
||||
Json(peer_views(&state.registry))
|
||||
}
|
||||
|
||||
pub async fn local_routes(State(state): State<AppState>) -> Json<Vec<LocalRouteView>> {
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<section id="peers">
|
||||
<h2>Peers <span class="count" id="peers-count"></span></h2>
|
||||
<table>
|
||||
<thead><tr><th>peer</th><th>ASN</th><th>state</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte ASN</th></tr></thead>
|
||||
<thead><tr><th>name</th><th>peer</th><th>ASN</th><th>state</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte ASN</th><th>detail</th></tr></thead>
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
@@ -163,16 +163,22 @@
|
||||
const s = state.status; if (!s) return;
|
||||
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
|
||||
$('s-version').textContent = s.version; $('s-uptime').textContent = fmtDur(s.uptime_secs + (now() - s._at));
|
||||
$('s-sessions').textContent = state.peers.length; $('s-local').textContent = state.local.length;
|
||||
$('s-sessions').textContent = state.peers.filter(p => p.state === 'Established').length; $('s-local').textContent = state.local.length;
|
||||
$('s-received').textContent = state.received.length;
|
||||
}
|
||||
|
||||
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'err', Idle: 'muted' };
|
||||
function renderPeers() {
|
||||
const b = $('peers-body'); $('peers-count').textContent = state.peers.length;
|
||||
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="7" class="empty">No established sessions. Peers must connect to this router.</td></tr>'; return; }
|
||||
b.innerHTML = state.peers.map(p => `<tr>
|
||||
<td>${esc(p.peer_addr)}</td><td>${p.peer_asn}</td><td class="ok">Established</td><td>${esc(p.local_addr)}</td>
|
||||
<td>${fmtTime(p.established_at_unix)}</td><td>${age(p.established_at_unix)}</td><td>${p.four_octet_asn ? 'yes' : 'no'}</td></tr>`).join('');
|
||||
const b = $('peers-body'); $('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} up / ${state.peers.length}`;
|
||||
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="9" class="empty">No peers configured and none connected.</td></tr>'; return; }
|
||||
b.innerHTML = state.peers.map(p => {
|
||||
const up = p.state === 'Established';
|
||||
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : '';
|
||||
return `<tr>
|
||||
<td>${esc(p.name ?? '')}${p.name ? '' : '<span class="muted">(inbound)</span>'}</td><td>${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
|
||||
<td class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</td><td>${up ? esc(p.local_addr) : '—'}</td>
|
||||
<td>${up ? fmtTime(p.established_at_unix) : '—'}</td><td>${up ? age(p.established_at_unix) : '—'}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : '—'}</td>
|
||||
<td class="wrap muted">${detail}</td></tr>`; }).join('');
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
@@ -247,7 +253,7 @@
|
||||
}
|
||||
function describe(ev) {
|
||||
switch (ev.type) {
|
||||
case 'session_up': return [`session UP with ${esc(ev.peer.peer_addr)} AS${ev.peer.peer_asn}`, 'ok'];
|
||||
case 'session_up': return [`session UP with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn}`, 'ok'];
|
||||
case 'session_down': return [`session DOWN with ${esc(ev.peer_addr)} AS${ev.peer_asn}`, 'err'];
|
||||
case 'local_announced': return [`announced ${esc(ev.route.prefix)} by ${esc(ev.route.owner)} [${ev.route.communities.map(c => state.wellKnown[c] || c).map(esc).join(' ')}]`, ''];
|
||||
case 'local_withdrawn': return [`withdrew ${esc(ev.prefix)}`, 'warn'];
|
||||
|
||||
+3
-1
@@ -25,7 +25,9 @@ The base protocol specification.
|
||||
| §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 | ❌ |
|
||||
| §8 | FSM — active (outbound) side: dial configured peers, exponential retry | ✅ |
|
||||
| §6.2 | Bad Peer AS check against configured ASN | ✅ |
|
||||
| §6.8 | Connection collision detection (router-ID comparison) | ❌ |
|
||||
| §9 | Decision process / best-path selection | ❌ |
|
||||
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
||||
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
||||
|
||||
Vendored
+7
@@ -8,6 +8,13 @@ listen_endpoints {
|
||||
address: "::1"
|
||||
port: 179
|
||||
}
|
||||
peers {
|
||||
name: "lab-r1"
|
||||
endpoint { address: "127.0.0.1" port: 1791 }
|
||||
remote_asn: 65001
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
local_route_policy {
|
||||
allowed_prefixes: "10.100.0.0/16"
|
||||
allowed_prefixes: "2001:db8:100::/48"
|
||||
|
||||
Reference in new issue
Block a user