New crate bgp-fib. It follows the routing table's best routes and keeps
the kernel's routing table the same, and learns the machine's interfaces
and addresses from the kernel, which is what says whether a next hop can
be reached.
- netlink: the rtnetlink messages used (routes, addresses, links, errors),
as bytes and back. No I/O; property tested and fuzzed.
- interfaces: what is directly reachable, and from which interface.
- plan: what to ask the kernel for, given the best routes and what the
kernel has. No I/O; tested against a model kernel.
- kernel (Linux only): the sockets and the task. Requests go in batches;
routes of ours found at startup are kept if still right and removed if
not; a change of interfaces has every route worked out again; a clean
stop removes our routes.
Only routes marked proto bgp with the configured table and metric are
ever touched. A best route is installed if its next hop is a neighbor;
next hops further away are not resolved through other routes yet, and
such prefixes are counted and shown instead.
Configured with `kernel { table metric }`; off by default. The daemon
needs CAP_NET_ADMIN and stops if the kernel refuses it. On shutdown it
closes sessions and then removes its routes.
/api/kernel and the status page show installed, unresolved and refused
counts, the last error and the interfaces; five bgp_kernel_* metrics.
Testing: scripts/kernel-tests.sh runs, in network namespaces of their
own and with only the capabilities the daemon needs, the installer
against `ip route`, the daemon with a peer, and two daemons that route
for each other with pings across over IPv4 and IPv6.
scripts/linux-vm.sh runs that, or anything else, in a Lima VM from
macOS. CI runs the kernel tests and fuzzes the netlink decoder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
214 lines
7.0 KiB
Rust
214 lines
7.0 KiB
Rust
//! Putting the best routes into the kernel's routing table, so that the
|
|
//! machine forwards packets by them (Linux, over rtnetlink).
|
|
//!
|
|
//! The routing table chooses a best route for each prefix. This crate
|
|
//! follows those choices and keeps the kernel's table the same: one route
|
|
//! for each prefix, to the best route's next hop. It also learns from the
|
|
//! kernel what interfaces and addresses the machine has, which is what
|
|
//! says whether a next hop can be reached at all.
|
|
//!
|
|
//! - [`netlink`] is the kernel's messages, as bytes and back.
|
|
//! - [`interfaces`] is the machine's interfaces and what is reachable on
|
|
//! them.
|
|
//! - [`plan`] works out what to ask the kernel for.
|
|
//! - The rest, which only Linux has, asks it.
|
|
//!
|
|
//! # What is and is not touched
|
|
//!
|
|
//! Our routes are marked as BGP's ([`netlink::PROTOCOL_BGP`]) and carry
|
|
//! the configured metric, in the configured table. Only routes with that
|
|
//! mark, metric and table are ever replaced or removed; and all such
|
|
//! routes are taken to be ours, so two daemons must not share them.
|
|
//! Routes left behind by a run that ended badly are found at the next
|
|
//! start, and removed unless they are still wanted. A run that ends in
|
|
//! good order removes its routes.
|
|
//!
|
|
//! # What is installed
|
|
//!
|
|
//! A best route whose next hop is a neighbor: an address in the network
|
|
//! of one of the machine's interfaces. A route whose next hop is further
|
|
//! away than that, as with routes from a peer in our own AS that did not
|
|
//! set itself as next hop, would need the way to the next hop looked up in
|
|
//! turn, which is not done: the prefix then has no route in the kernel.
|
|
|
|
pub mod interfaces;
|
|
#[cfg(target_os = "linux")]
|
|
mod kernel;
|
|
pub mod netlink;
|
|
pub mod plan;
|
|
|
|
use std::net::IpAddr;
|
|
use std::sync::{Arc, Mutex, PoisonError};
|
|
|
|
use bgp_rib::table::RibTable;
|
|
use tokio::sync::watch;
|
|
use tokio::task::JoinHandle;
|
|
|
|
/// The metric our routes get unless configured otherwise: the one other
|
|
/// routing daemons give routes from external BGP peers, so that ours rank
|
|
/// against static and connected routes as an operator expects.
|
|
pub const DEFAULT_METRIC: u32 = 20;
|
|
|
|
/// Where our routes go in the kernel.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct FibConfig {
|
|
/// The routing table: [`netlink::TABLE_MAIN`] to forward by them.
|
|
pub table: u32,
|
|
/// The metric, which with the table says which routes are ours.
|
|
pub metric: u32,
|
|
}
|
|
|
|
impl Default for FibConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
table: netlink::TABLE_MAIN,
|
|
metric: DEFAULT_METRIC,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why routes cannot be kept in the kernel.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum FibError {
|
|
#[error("installing routes in the kernel is only supported on Linux")]
|
|
Unsupported,
|
|
#[error("the kernel's routing socket failed: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("the kernel sent something that is not netlink: {0}")]
|
|
Malformed(#[from] netlink::NetlinkError),
|
|
#[error("the kernel refused to list {what}: error {errno}")]
|
|
Dump { what: &'static str, errno: i32 },
|
|
#[error(
|
|
"not permitted to change the kernel's routes: the daemon needs the CAP_NET_ADMIN \
|
|
capability (or root)"
|
|
)]
|
|
NotPermitted,
|
|
}
|
|
|
|
/// One of the machine's interfaces, for showing.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InterfaceStatus {
|
|
pub index: u32,
|
|
pub name: String,
|
|
pub up: bool,
|
|
/// Its addresses, each with the length of the network it is in.
|
|
pub addresses: Vec<(IpAddr, u8)>,
|
|
}
|
|
|
|
/// How things stand with the kernel, for showing.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Status {
|
|
/// Whether the kernel has been brought in step with the routing table
|
|
/// at least once since the start.
|
|
pub synced: bool,
|
|
/// How many routes of ours the kernel has.
|
|
pub installed: usize,
|
|
/// Prefixes whose best route is not in the kernel because its next
|
|
/// hop is not usable, by why.
|
|
pub unresolved: Vec<(&'static str, usize)>,
|
|
/// Prefixes whose best route the kernel refused.
|
|
pub refused: usize,
|
|
/// How many requests the kernel has refused in all.
|
|
pub errors: u64,
|
|
/// The last such refusal.
|
|
pub last_error: Option<String>,
|
|
pub interfaces: Vec<InterfaceStatus>,
|
|
}
|
|
|
|
struct Shared {
|
|
config: FibConfig,
|
|
status: Mutex<Status>,
|
|
stop: watch::Sender<bool>,
|
|
/// Whether the task has ended.
|
|
ended: watch::Sender<bool>,
|
|
}
|
|
|
|
/// Handle to the task that keeps the kernel's routes. Cheap to clone.
|
|
#[derive(Clone)]
|
|
pub struct Fib {
|
|
shared: Arc<Shared>,
|
|
}
|
|
|
|
impl Fib {
|
|
/// Starts keeping the kernel's routes in step with the best routes of
|
|
/// `rib`, and returns a handle with the task that does it. The task
|
|
/// ends only by failing, or when [`Fib::stop`] is called.
|
|
///
|
|
/// Must be called from within a Tokio runtime. Fails at once where
|
|
/// there is no kernel to talk to in this way.
|
|
pub fn spawn(
|
|
rib: Arc<RibTable>,
|
|
config: FibConfig,
|
|
) -> Result<(Self, JoinHandle<Result<(), FibError>>), FibError> {
|
|
let (stop, stopping) = watch::channel(false);
|
|
let fib = Self {
|
|
shared: Arc::new(Shared {
|
|
config,
|
|
status: Mutex::default(),
|
|
stop,
|
|
ended: watch::channel(false).0,
|
|
}),
|
|
};
|
|
let task = start(rib, fib.clone(), stopping)?;
|
|
Ok((fib, task))
|
|
}
|
|
|
|
pub fn config(&self) -> FibConfig {
|
|
self.shared.config
|
|
}
|
|
|
|
pub fn status(&self) -> Status {
|
|
self.shared
|
|
.status
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner)
|
|
.clone()
|
|
}
|
|
|
|
/// Has the task take our routes out of the kernel and end, and waits
|
|
/// until it has. Returns at once if it has ended already, for whatever
|
|
/// reason; the task's own result says how.
|
|
pub async fn stop(&self) {
|
|
let mut ended = self.shared.ended.subscribe();
|
|
self.shared.stop.send_replace(true);
|
|
// Fails only if the handle that holds the sender is gone, and this
|
|
// is one.
|
|
let _ = ended.wait_for(|ended| *ended).await;
|
|
}
|
|
|
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
fn update(&self, change: impl FnOnce(&mut Status)) {
|
|
change(
|
|
&mut self
|
|
.shared
|
|
.status
|
|
.lock()
|
|
.unwrap_or_else(PoisonError::into_inner),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn start(
|
|
rib: Arc<RibTable>,
|
|
fib: Fib,
|
|
stopping: watch::Receiver<bool>,
|
|
) -> Result<JoinHandle<Result<(), FibError>>, FibError> {
|
|
// Opened here, so that a machine that cannot is known of at once.
|
|
let sockets = kernel::Sockets::open()?;
|
|
Ok(tokio::spawn(async move {
|
|
let result = kernel::run(rib, fib.clone(), sockets, stopping).await;
|
|
fib.shared.ended.send_replace(true);
|
|
result
|
|
}))
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn start(
|
|
_rib: Arc<RibTable>,
|
|
_fib: Fib,
|
|
_stopping: watch::Receiver<bool>,
|
|
) -> Result<JoinHandle<Result<(), FibError>>, FibError> {
|
|
Err(FibError::Unsupported)
|
|
}
|