The work of the speaker-cleanup branch as one commit; the branch keeps the step-by-step history. Sessions - The state machine is a pure function from events to actions, driven by one task per connection. Idle, Connect and Active belong to a dialer that keeps configured peers up with backoff. - Capabilities are negotiated: address families, four-octet AS numbers with AS_TRANS and AS4_PATH, route refresh, ADD-PATH per family and direction. - Collision detection per RFC 4271 section 6.8, by address and identifier. - Connections are taken only from configured peers, a few per address. - A panic in a session is contained and answered with a Cease; a core task that stops shuts the server down with a Cease to every peer. - Writes that make no progress are given up (RFC 9687). Codec - Malformed UPDATEs are handled as RFC 7606 says: treat-as-withdraw, attribute discard, or session reset, with the NOTIFICATION to send. - Address families are open types; any can be named and ignored. - Prefixes are fixed-size values, normalised, with path identifiers. - Lengths are filled in after writing; one definition of each type code. - Property tests and fuzz targets for every decoder. Routing table - Adj-RIBs-In and Loc-RIB in one sharded table that sessions write to directly. Routes are sixteen bytes; attribute sets are interned in wire form. Best-path selection per RFC 4271 section 9.1.2.2 with deterministic MED. - A journal per shard lets followers read changes at their own pace; one that falls behind is resynchronised. - On a RIPE RIS dump of 148 peers and 39.3M routes: loaded in 2.0 s on 12 cores, about 52 bytes a route. Announcing - Originated routes come from the configuration. Each session has its own exporter with an Adj-RIB-Out, so a slow peer delays only itself. - Routes to a peer in our own AS have an empty path and a LOCAL_PREF. Operations - HTTP status page, JSON API, event stream and Prometheus metrics. - Benchmarks on synthetic and real tables. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
407 lines
14 KiB
Rust
407 lines
14 KiB
Rust
//! JSON handlers and their view types. Everything here is read-only: the
|
|
//! daemon is configured through its configuration file.
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use axum::Json;
|
|
use std::collections::HashMap;
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use bgp_packet::community;
|
|
use bgp_packet::message::{NlriNextHop, OriginPathAttribute, PathAttribute};
|
|
use bgp_rib::table::{PeerIndex, Route};
|
|
use bgp_rib::{LocalRoute, RibStopped};
|
|
use bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::AppState;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Views
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn unix_secs(t: SystemTime) -> u64 {
|
|
t.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct StatusView {
|
|
pub router_id: String,
|
|
pub local_asn: u32,
|
|
pub version: String,
|
|
pub hostname: String,
|
|
pub started_at_unix: u64,
|
|
pub uptime_secs: u64,
|
|
pub sessions: usize,
|
|
pub local_routes: usize,
|
|
/// Routes learned from peers, counting each peer's separately.
|
|
pub received_routes: usize,
|
|
/// Prefixes with at least one such route, and so with a best route.
|
|
pub received_prefixes: usize,
|
|
}
|
|
|
|
#[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,
|
|
/// `Established`, `Connecting`, `Backoff` or `Idle`.
|
|
pub state: String,
|
|
/// `inbound` or `outbound`: which side opened the live TCP connection.
|
|
pub direction: Option<String>,
|
|
pub local_addr: Option<String>,
|
|
pub established_at_unix: Option<u64>,
|
|
pub uptime_secs: Option<u64>,
|
|
pub four_octet_asn: Option<bool>,
|
|
/// The address families negotiated with the peer, e.g. `IPv4 unicast`.
|
|
pub address_families: Option<Vec<String>>,
|
|
/// The families for which the peer sends us several routes for a
|
|
/// prefix (ADD-PATH, RFC 7911), and those for which we send it path
|
|
/// identifiers.
|
|
pub add_path_receive: Option<Vec<String>>,
|
|
pub add_path_send: Option<Vec<String>>,
|
|
/// 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,
|
|
state: "Established".into(),
|
|
direction: Some(s.direction.as_str().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),
|
|
address_families: Some(s.families.iter().map(|f| f.to_string()).collect()),
|
|
add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()),
|
|
add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()),
|
|
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(),
|
|
direction: None,
|
|
local_addr: None,
|
|
established_at_unix: None,
|
|
uptime_secs: None,
|
|
four_octet_asn: None,
|
|
address_families: None,
|
|
add_path_receive: None,
|
|
add_path_send: 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,
|
|
pub origin: String,
|
|
pub prepend: u8,
|
|
pub med: Option<u32>,
|
|
pub communities: Vec<String>,
|
|
pub large_communities: Vec<String>,
|
|
pub announced_at_unix: u64,
|
|
}
|
|
|
|
impl From<&LocalRoute> for LocalRouteView {
|
|
fn from(r: &LocalRoute) -> Self {
|
|
Self {
|
|
prefix: r.prefix.to_string(),
|
|
origin: origin_name(r.attrs.origin).to_string(),
|
|
prepend: r.attrs.prepend,
|
|
med: r.attrs.med,
|
|
communities: r
|
|
.attrs
|
|
.communities
|
|
.iter()
|
|
.copied()
|
|
.map(community::format_standard)
|
|
.collect(),
|
|
large_communities: r
|
|
.attrs
|
|
.large_communities
|
|
.iter()
|
|
.map(community::format_large)
|
|
.collect(),
|
|
announced_at_unix: unix_secs(r.announced_at),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Human-oriented digest of a path attribute list.
|
|
#[derive(Debug, Clone, Default, Serialize)]
|
|
pub struct AttrSummary {
|
|
pub origin: Option<String>,
|
|
/// AS_PATH flattened in order; AS_SETs are included in place.
|
|
pub as_path: Vec<u32>,
|
|
pub next_hop: Option<String>,
|
|
pub med: Option<u32>,
|
|
pub local_pref: Option<u32>,
|
|
pub communities: Vec<String>,
|
|
pub large_communities: Vec<String>,
|
|
}
|
|
|
|
pub fn summarize(attributes: &[PathAttribute], next_hop: Option<&NlriNextHop>) -> AttrSummary {
|
|
let mut s = AttrSummary {
|
|
// A multiprotocol route has its next hop here; an IPv4 route has it
|
|
// in the NEXT_HOP attribute below.
|
|
next_hop: next_hop.map(ToString::to_string),
|
|
..Default::default()
|
|
};
|
|
for attr in attributes {
|
|
match attr {
|
|
PathAttribute::Origin(o) => s.origin = Some(origin_name(*o).to_string()),
|
|
PathAttribute::AsPath(p) => {
|
|
s.as_path = p
|
|
.segments
|
|
.iter()
|
|
.flat_map(|seg| seg.path.iter().copied())
|
|
.collect()
|
|
}
|
|
PathAttribute::NextHop(nh) => s.next_hop = Some(nh.0.to_string()),
|
|
PathAttribute::MultiExitDisc(m) => s.med = Some(m.0),
|
|
PathAttribute::LocalPref(l) => s.local_pref = Some(l.0),
|
|
PathAttribute::Communities(c) => {
|
|
s.communities =
|
|
c.0.iter()
|
|
.copied()
|
|
.map(community::format_standard)
|
|
.collect()
|
|
}
|
|
PathAttribute::LargeCommunities(l) => {
|
|
s.large_communities = l.communities.iter().map(community::format_large).collect()
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
s
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ReceivedRouteView {
|
|
pub peer_addr: String,
|
|
pub peer_asn: u32,
|
|
pub prefix: String,
|
|
/// The peer's identifier for the route: zero unless the peer sends
|
|
/// several routes for a prefix (ADD-PATH, RFC 7911).
|
|
pub path_id: u32,
|
|
/// Whether this is the route chosen for its prefix.
|
|
pub best: bool,
|
|
pub summary: AttrSummary,
|
|
/// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop
|
|
/// is in the summary and its prefixes are routes of their own.
|
|
pub attributes: Vec<PathAttribute>,
|
|
}
|
|
|
|
/// Who the routes in the table are from: the table knows peers by number,
|
|
/// the sessions know which number is whose.
|
|
pub(crate) struct PeerNames(HashMap<PeerIndex, PeerId>);
|
|
|
|
impl PeerNames {
|
|
pub(crate) fn of(registry: &SessionRegistry) -> Self {
|
|
Self(
|
|
registry
|
|
.list()
|
|
.into_iter()
|
|
.map(|session| (session.rib_peer, session.peer))
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// A view of one route. `None` if the route's session has just ended:
|
|
/// the route is on its way out of the table.
|
|
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> {
|
|
let peer = self.0.get(&route.peer)?;
|
|
// What a session stored was accepted by the decoder, and parses.
|
|
let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default();
|
|
Some(ReceivedRouteView {
|
|
peer_addr: peer.addr.to_string(),
|
|
peer_asn: peer.asn,
|
|
prefix: route.prefix.to_string(),
|
|
path_id: route.path_id,
|
|
best: route.best,
|
|
summary: summarize(&attributes, next_hop.as_ref()),
|
|
attributes,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn origin_name(o: OriginPathAttribute) -> &'static str {
|
|
match o {
|
|
OriginPathAttribute::IGP => "IGP",
|
|
OriginPathAttribute::EGP => "EGP",
|
|
OriginPathAttribute::INCOMPLETE => "INCOMPLETE",
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A table could not be read because its actor has stopped. The daemon is
|
|
/// on its way down; saying so beats answering with an empty table.
|
|
pub struct Unavailable(RibStopped);
|
|
|
|
impl From<RibStopped> for Unavailable {
|
|
fn from(e: RibStopped) -> Self {
|
|
Self(e)
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for Unavailable {
|
|
fn into_response(self) -> Response {
|
|
(StatusCode::SERVICE_UNAVAILABLE, self.0.to_string()).into_response()
|
|
}
|
|
}
|
|
|
|
pub async fn status(State(state): State<AppState>) -> Result<Json<StatusView>, Unavailable> {
|
|
Ok(Json(StatusView {
|
|
router_id: state.info.router_id.to_string(),
|
|
local_asn: state.info.local_asn,
|
|
version: state.info.version.clone(),
|
|
hostname: state.info.hostname.clone(),
|
|
started_at_unix: unix_secs(state.info.started_at),
|
|
uptime_secs: state.info.started.elapsed().as_secs(),
|
|
sessions: state.registry.list().len(),
|
|
local_routes: state.local_rib.len().await?,
|
|
received_routes: state.rib.len(),
|
|
received_prefixes: state.rib.prefix_count(),
|
|
}))
|
|
}
|
|
|
|
/// The configuration file the daemon was started with.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ConfigView {
|
|
pub path: Option<String>,
|
|
/// Raw file contents as read at startup; later edits to the file are not
|
|
/// reflected until the daemon restarts.
|
|
pub text: Option<String>,
|
|
}
|
|
|
|
pub async fn config(State(state): State<AppState>) -> Json<ConfigView> {
|
|
let (path, text) = state.info.config.clone().unzip();
|
|
Json(ConfigView { path, text })
|
|
}
|
|
|
|
pub async fn peers(State(state): State<AppState>) -> Json<Vec<PeerView>> {
|
|
Json(peer_views(&state.registry))
|
|
}
|
|
|
|
pub async fn local_routes(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<LocalRouteView>>, Unavailable> {
|
|
let mut routes: Vec<LocalRouteView> = state
|
|
.local_rib
|
|
.snapshot()
|
|
.await?
|
|
.iter()
|
|
.map(LocalRouteView::from)
|
|
.collect();
|
|
routes.sort_by(|a, b| a.prefix.cmp(&b.prefix));
|
|
Ok(Json(routes))
|
|
}
|
|
|
|
/// How many routes [`received_routes`] returns unless asked for another
|
|
/// number. The table can hold hundreds of millions.
|
|
const DEFAULT_ROUTE_LIMIT: usize = 1000;
|
|
|
|
/// The most routes [`received_routes`] returns, whatever is asked for:
|
|
/// each is copied out of the table and written as JSON.
|
|
const MAX_ROUTE_LIMIT: usize = 100_000;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RouteQuery {
|
|
/// At most this many routes.
|
|
limit: Option<usize>,
|
|
}
|
|
|
|
/// Some of the routes learned from peers: all the routes of as many
|
|
/// prefixes as fit in the limit. `/api/status` has the totals.
|
|
pub async fn received_routes(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<RouteQuery>,
|
|
) -> Result<Json<Vec<ReceivedRouteView>>, StatusCode> {
|
|
let limit = query
|
|
.limit
|
|
.unwrap_or(DEFAULT_ROUTE_LIMIT)
|
|
.min(MAX_ROUTE_LIMIT);
|
|
let names = PeerNames::of(&state.registry);
|
|
// Copying, parsing and sorting many routes takes long enough to keep
|
|
// off the threads that sessions run on.
|
|
let routes = tokio::task::spawn_blocking(move || {
|
|
let mut routes: Vec<ReceivedRouteView> = state
|
|
.rib
|
|
.some_routes(limit)
|
|
.iter()
|
|
.filter_map(|route| names.view(route))
|
|
.collect();
|
|
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
|
|
routes
|
|
})
|
|
.await
|
|
// Only if making the views panicked.
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
Ok(Json(routes))
|
|
}
|