Files
ferrix/crates/web/src/api.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 630c175918 Rework the speaker: sans-I/O FSM, sharded table, RFC 7606, ADD-PATH
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>
2026-10-02 12:35:54 +02:00

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))
}