Phase 1 of the BGP teaching demo: the daemon can now originate routes and
advertise them to every established peer.
- rib: add LocalRib actor holding locally originated routes with owner,
origin, AS-path prepend, MED, standard and large communities. Enforces a
LocalRibPolicy (prefix allowlist, per-owner limit, owner match on
replace/withdraw) and emits LocalRibEvents.
- server: add SessionRegistry with per-session outbound queues; sessions
register on Established and deregister via a drop guard. Emits
SessionEvent::{Up,Down}.
- server: advertiser task fans LocalRib changes out to all sessions and
replays the table to new sessions. UPDATE builders handle IPv4 (legacy
NLRI) and IPv6 (MP_REACH/MP_UNREACH).
- fsm: merge the timer and timer-less established loops into one select!
with an outbound branch; reset the hold timer on any received message
per RFC 4271 §8.2.2 (previously only on KEEPALIVE).
- config: new local_route_policy proto message.
- packet: fix Communitites typo, expose CommunitiesPathAttribute inner vec.
- primitives: IpPrefix::contains.
- integration tests: announce/withdraw/replay/fan-out/policy coverage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
306 lines
11 KiB
Rust
306 lines
11 KiB
Rust
//! Locally originated routes (the "Loc-RIB" of routes this router announces
|
|
//! on its own behalf, as opposed to routes learned from peers).
|
|
//!
|
|
//! The actor stores one route per prefix together with the attributes chosen
|
|
//! by whoever announced it. It does not know about peers or sessions: the
|
|
//! server subscribes to [`LocalRibEvent`]s and turns them into UPDATEs.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::SystemTime;
|
|
|
|
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
|
|
use bgp_primitives::ip_prefix::IpPrefix;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::{broadcast, mpsc, oneshot};
|
|
use tracing::warn;
|
|
|
|
const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
|
const CMD_CHANNEL_CAPACITY: usize = 256;
|
|
|
|
/// User-selectable attributes for a locally originated route.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct LocalRouteAttrs {
|
|
pub origin: OriginPathAttribute,
|
|
/// Number of *additional* copies of the local ASN to prepend to the
|
|
/// AS_PATH. `0` yields the plain `[local_asn]` path.
|
|
pub prepend: u8,
|
|
pub med: Option<u32>,
|
|
/// RFC 1997 standard communities as `(high, low)` 16-bit halves.
|
|
pub communities: Vec<(u16, u16)>,
|
|
/// RFC 8092 large communities.
|
|
pub large_communities: Vec<LargeCommunity>,
|
|
}
|
|
|
|
impl Default for LocalRouteAttrs {
|
|
fn default() -> Self {
|
|
Self {
|
|
origin: OriginPathAttribute::IGP,
|
|
prepend: 0,
|
|
med: None,
|
|
communities: vec![],
|
|
large_communities: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A route originated by this router.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct LocalRoute {
|
|
pub prefix: IpPrefix,
|
|
/// Free-form identity of whoever announced the route (e.g. a student
|
|
/// name). Used to stop one owner replacing or withdrawing another's route.
|
|
pub owner: String,
|
|
pub attrs: LocalRouteAttrs,
|
|
pub announced_at: SystemTime,
|
|
}
|
|
|
|
impl LocalRoute {
|
|
pub fn new(prefix: IpPrefix, owner: impl Into<String>, attrs: LocalRouteAttrs) -> Self {
|
|
Self { prefix, owner: owner.into(), attrs, announced_at: SystemTime::now() }
|
|
}
|
|
}
|
|
|
|
/// Limits on what may be announced. Enforced inside the actor so every caller
|
|
/// (HTTP API, tests, future CLI) goes through the same checks.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct LocalRibPolicy {
|
|
/// A route is accepted only if it is covered by at least one of these
|
|
/// prefixes. Empty means no restriction.
|
|
pub allowed_prefixes: Vec<IpPrefix>,
|
|
/// Maximum number of routes a single owner may hold. `0` means unlimited.
|
|
pub max_routes_per_owner: usize,
|
|
}
|
|
|
|
impl LocalRibPolicy {
|
|
fn allows(&self, prefix: &IpPrefix) -> bool {
|
|
self.allowed_prefixes.is_empty() || self.allowed_prefixes.iter().any(|p| p.contains(prefix))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum LocalRibError {
|
|
#[error("prefix {0} is not covered by the allowed prefixes")]
|
|
NotAllowed(IpPrefix),
|
|
#[error("owner {owner:?} already has {limit} routes")]
|
|
OwnerLimit { owner: String, limit: usize },
|
|
#[error("prefix {prefix} is owned by {owner:?}")]
|
|
OwnedByOther { prefix: IpPrefix, owner: String },
|
|
#[error("local RIB actor has stopped")]
|
|
Closed,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum LocalRibEvent {
|
|
/// A route was added or its attributes replaced.
|
|
Announced(LocalRoute),
|
|
Withdrawn { prefix: IpPrefix },
|
|
}
|
|
|
|
enum Command {
|
|
Announce { route: LocalRoute, reply: oneshot::Sender<Result<(), LocalRibError>> },
|
|
Withdraw {
|
|
prefix: IpPrefix,
|
|
requester: Option<String>,
|
|
reply: oneshot::Sender<Result<Option<LocalRoute>, LocalRibError>>,
|
|
},
|
|
Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> },
|
|
}
|
|
|
|
/// Handle to the local RIB actor. Cheap to clone; all clones share one table.
|
|
#[derive(Clone)]
|
|
pub struct LocalRib {
|
|
tx: mpsc::Sender<Command>,
|
|
events: broadcast::Sender<LocalRibEvent>,
|
|
}
|
|
|
|
impl LocalRib {
|
|
pub fn spawn(policy: LocalRibPolicy) -> Self {
|
|
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
|
|
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
|
tokio::spawn(run_actor(rx, events.clone(), policy));
|
|
Self { tx, events }
|
|
}
|
|
|
|
/// Adds a route, or replaces the attributes of an existing route with the
|
|
/// same prefix and owner.
|
|
pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> {
|
|
let (reply, rx) = oneshot::channel();
|
|
self.tx
|
|
.send(Command::Announce { route, reply })
|
|
.await
|
|
.map_err(|_| LocalRibError::Closed)?;
|
|
rx.await.map_err(|_| LocalRibError::Closed)?
|
|
}
|
|
|
|
/// Removes a route. `requester` must match the route's owner; pass `None`
|
|
/// to withdraw regardless of owner (administrative override). Returns the
|
|
/// removed route, or `None` if the prefix was not present.
|
|
pub async fn withdraw(
|
|
&self,
|
|
prefix: IpPrefix,
|
|
requester: Option<&str>,
|
|
) -> Result<Option<LocalRoute>, LocalRibError> {
|
|
let (reply, rx) = oneshot::channel();
|
|
self.tx
|
|
.send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply })
|
|
.await
|
|
.map_err(|_| LocalRibError::Closed)?;
|
|
rx.await.map_err(|_| LocalRibError::Closed)?
|
|
}
|
|
|
|
/// Point-in-time copy of every route, in no particular order.
|
|
pub async fn snapshot(&self) -> Vec<LocalRoute> {
|
|
let (reply, rx) = oneshot::channel();
|
|
let _ = self.tx.send(Command::Snapshot { reply }).await;
|
|
rx.await.unwrap_or_default()
|
|
}
|
|
|
|
/// Subscribe to mutations. See [`crate::AdjRibIn::subscribe`] for lag
|
|
/// semantics.
|
|
pub fn subscribe(&self) -> broadcast::Receiver<LocalRibEvent> {
|
|
self.events.subscribe()
|
|
}
|
|
}
|
|
|
|
async fn run_actor(
|
|
mut rx: mpsc::Receiver<Command>,
|
|
events: broadcast::Sender<LocalRibEvent>,
|
|
policy: LocalRibPolicy,
|
|
) {
|
|
let mut table: HashMap<IpPrefix, LocalRoute> = HashMap::new();
|
|
|
|
while let Some(cmd) = rx.recv().await {
|
|
match cmd {
|
|
Command::Announce { route, reply } => {
|
|
let result = check_announce(&table, &policy, &route).map(|()| {
|
|
table.insert(route.prefix.clone(), route.clone());
|
|
let _ = events.send(LocalRibEvent::Announced(route));
|
|
});
|
|
let _ = reply.send(result);
|
|
}
|
|
Command::Withdraw { prefix, requester, reply } => {
|
|
let result = match (table.get(&prefix), requester) {
|
|
(Some(existing), Some(req)) if existing.owner != req => {
|
|
Err(LocalRibError::OwnedByOther {
|
|
prefix: prefix.clone(),
|
|
owner: existing.owner.clone(),
|
|
})
|
|
}
|
|
_ => {
|
|
let removed = table.remove(&prefix);
|
|
if removed.is_some() {
|
|
let _ = events.send(LocalRibEvent::Withdrawn { prefix });
|
|
}
|
|
Ok(removed)
|
|
}
|
|
};
|
|
let _ = reply.send(result);
|
|
}
|
|
Command::Snapshot { reply } => {
|
|
if reply.send(table.values().cloned().collect()).is_err() {
|
|
warn!("Snapshot reply channel closed before send");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_announce(
|
|
table: &HashMap<IpPrefix, LocalRoute>,
|
|
policy: &LocalRibPolicy,
|
|
route: &LocalRoute,
|
|
) -> Result<(), LocalRibError> {
|
|
if !policy.allows(&route.prefix) {
|
|
return Err(LocalRibError::NotAllowed(route.prefix.clone()));
|
|
}
|
|
if let Some(existing) = table.get(&route.prefix) {
|
|
if existing.owner != route.owner {
|
|
return Err(LocalRibError::OwnedByOther {
|
|
prefix: route.prefix.clone(),
|
|
owner: existing.owner.clone(),
|
|
});
|
|
}
|
|
// Replacing our own route never changes the per-owner count.
|
|
return Ok(());
|
|
}
|
|
if policy.max_routes_per_owner > 0 {
|
|
let held = table.values().filter(|r| r.owner == route.owner).count();
|
|
if held >= policy.max_routes_per_owner {
|
|
return Err(LocalRibError::OwnerLimit {
|
|
owner: route.owner.clone(),
|
|
limit: policy.max_routes_per_owner,
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn prefix(s: &str) -> IpPrefix {
|
|
IpPrefix::try_from(s).unwrap()
|
|
}
|
|
|
|
fn route(p: &str, owner: &str) -> LocalRoute {
|
|
LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn announce_and_withdraw_emit_events() {
|
|
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
|
let mut events = rib.subscribe();
|
|
|
|
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
|
|
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24")));
|
|
assert_eq!(rib.snapshot().await.len(), 1);
|
|
|
|
let removed = rib.withdraw(prefix("10.0.0.0/24"), Some("alice")).await.unwrap();
|
|
assert_eq!(removed.unwrap().owner, "alice");
|
|
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24")));
|
|
assert!(rib.snapshot().await.is_empty());
|
|
|
|
// Withdrawing an absent prefix is a no-op, not an error.
|
|
assert_eq!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap(), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn policy_allowlist_is_enforced() {
|
|
let rib = LocalRib::spawn(LocalRibPolicy {
|
|
allowed_prefixes: vec![prefix("10.100.0.0/16")],
|
|
max_routes_per_owner: 0,
|
|
});
|
|
rib.announce(route("10.100.3.0/24", "alice")).await.unwrap();
|
|
let err = rib.announce(route("192.0.2.0/24", "alice")).await.unwrap_err();
|
|
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn owner_limit_ignores_replacements() {
|
|
let rib = LocalRib::spawn(LocalRibPolicy { allowed_prefixes: vec![], max_routes_per_owner: 1 });
|
|
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
|
|
// Replacing the same prefix is fine.
|
|
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
|
|
let err = rib.announce(route("10.0.1.0/24", "alice")).await.unwrap_err();
|
|
assert!(matches!(err, LocalRibError::OwnerLimit { limit: 1, .. }));
|
|
// Other owners are unaffected.
|
|
rib.announce(route("10.0.1.0/24", "bob")).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn other_owner_cannot_replace_or_withdraw() {
|
|
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
|
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
|
|
|
|
let err = rib.announce(route("10.0.0.0/24", "bob")).await.unwrap_err();
|
|
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
|
|
|
|
let err = rib.withdraw(prefix("10.0.0.0/24"), Some("bob")).await.unwrap_err();
|
|
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
|
|
|
|
// Administrative override works.
|
|
assert!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap().is_some());
|
|
}
|
|
}
|