Files
ferrix/crates/web/src/api.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 dd838eec9a feature: decoded hover for UPDATE bytes; opt-in auto-refresh
- Hovering (or focusing) a byte segment opens a popup that decodes it:
  header fields, the two length fields, and each path attribute with its
  flags spelled out and its value in readable form. AS_PATH shows segment
  type, ASNs, path length and ASN width; COMMUNITIES shows well-known
  names; LARGE_COMMUNITIES shows the SwissIX meaning of known values;
  MP_REACH/UNREACH show address family, next hop and prefixes; NLRI and
  withdrawn routes list prefixes. Decoding happens on hover from the bytes
  themselves. /api/routes/local/wire gains four_octet_asn so the AS_PATH
  width is known.
- The page is now a snapshot by default. ?refresh=<seconds> (1..3600)
  turns on reloading of all tables at that interval and connects the
  event log; the header offers 2s / 5s / 30s, "turn off" and "reload now".
  Events only feed the log, so the tables never change faster than asked.
  The reader's own announce / withdraw still shows its effect at once.
- Removed the "tables refresh on every event" footer text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 22:20:37 +02:00

464 lines
16 KiB
Rust

//! JSON handlers and their view types.
use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use bgp_packet::message::{Message, OriginPathAttribute, PathAttribute, UpdateMessage};
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::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
use bytes::BytesMut;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::AppState;
use crate::community;
const MAX_OWNER_LEN: usize = 64;
const MAX_PREPEND: u8 = 16;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self { status: StatusCode::BAD_REQUEST, message: message.into() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
impl From<LocalRibError> for ApiError {
fn from(e: LocalRibError) -> Self {
let status = match e {
LocalRibError::NotAllowed(_) => StatusCode::FORBIDDEN,
LocalRibError::OwnedByOther { .. } => StatusCode::CONFLICT,
LocalRibError::OwnerLimit { .. } => StatusCode::UNPROCESSABLE_ENTITY,
LocalRibError::Closed => StatusCode::SERVICE_UNAVAILABLE,
};
Self { status, message: e.to_string() }
}
}
// ---------------------------------------------------------------------------
// 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,
pub received_routes: 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>,
/// 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),
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,
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 owner: 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(),
owner: r.owner.clone(),
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(attrs: &[PathAttribute]) -> AttrSummary {
let mut s = AttrSummary::default();
for attr in attrs {
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::MpReachNlri(mp) => s.next_hop = Some(format!("{:?}", mp.next_hop)),
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,
pub received_at_unix: u64,
pub summary: AttrSummary,
/// The full decoded attribute list, for the curious.
pub attributes: Vec<PathAttribute>,
}
impl From<&SnapshotEntry> for ReceivedRouteView {
fn from(e: &SnapshotEntry) -> Self {
Self {
peer_addr: e.peer.addr.to_string(),
peer_asn: e.peer.asn,
prefix: e.prefix.to_string(),
received_at_unix: unix_secs(e.entry.received_at),
summary: summarize(&e.entry.path_attributes),
attributes: e.entry.path_attributes.clone(),
}
}
}
/// The exact UPDATE a route produces on one session.
#[derive(Debug, Clone, Serialize)]
pub struct WireView {
pub peer_addr: String,
pub peer_asn: u32,
pub next_hop: String,
/// Whether AS numbers in AS_PATH are 4 bytes wide on this session.
pub four_octet_asn: bool,
/// Complete BGP message including the 19-byte header, as lowercase hex.
pub hex: String,
pub length: usize,
pub summary: AttrSummary,
pub update: UpdateMessage,
}
fn origin_name(o: OriginPathAttribute) -> &'static str {
match o {
OriginPathAttribute::IGP => "IGP",
OriginPathAttribute::EGP => "EGP",
OriginPathAttribute::INCOMPLETE => "INCOMPLETE",
}
}
fn parse_origin(s: &str) -> Result<OriginPathAttribute, ApiError> {
match s.trim().to_ascii_uppercase().as_str() {
"IGP" => Ok(OriginPathAttribute::IGP),
"EGP" => Ok(OriginPathAttribute::EGP),
"INCOMPLETE" => Ok(OriginPathAttribute::INCOMPLETE),
other => Err(ApiError::bad_request(format!("origin {other:?} must be IGP, EGP or INCOMPLETE"))),
}
}
fn parse_prefix(s: &str) -> Result<IpPrefix, ApiError> {
IpPrefix::try_from(s.trim()).map_err(|e| ApiError::bad_request(format!("invalid prefix {s:?}: {e}")))
}
fn validate_owner(owner: &str) -> Result<String, ApiError> {
let owner = owner.trim();
if owner.is_empty() {
return Err(ApiError::bad_request("owner must not be empty"));
}
if owner.chars().count() > MAX_OWNER_LEN {
return Err(ApiError::bad_request(format!("owner must be at most {MAX_OWNER_LEN} characters")));
}
Ok(owner.to_string())
}
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Deserialize)]
pub struct AnnounceRequest {
pub prefix: String,
pub owner: String,
#[serde(default)]
pub origin: Option<String>,
#[serde(default)]
pub prepend: u8,
#[serde(default)]
pub med: Option<u32>,
#[serde(default)]
pub communities: Vec<String>,
#[serde(default)]
pub large_communities: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WithdrawRequest {
pub prefix: String,
/// Omit to withdraw regardless of owner.
#[serde(default)]
pub owner: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WireQuery {
pub prefix: String,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
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.snapshot().await.len(),
received_routes: state.adj_rib_in.snapshot().await.len(),
})
}
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>) -> Json<Vec<LocalRouteView>> {
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));
Json(routes)
}
pub async fn announce(
State(state): State<AppState>,
Json(req): Json<AnnounceRequest>,
) -> Result<Json<LocalRouteView>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = validate_owner(&req.owner)?;
if req.prepend > MAX_PREPEND {
return Err(ApiError::bad_request(format!("prepend must be at most {MAX_PREPEND}")));
}
let attrs = LocalRouteAttrs {
origin: req.origin.as_deref().map(parse_origin).transpose()?.unwrap_or(OriginPathAttribute::IGP),
prepend: req.prepend,
med: req.med,
communities: req
.communities
.iter()
.map(|c| community::parse_standard(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
large_communities: req
.large_communities
.iter()
.map(|c| community::parse_large(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
};
let route = LocalRoute::new(prefix, owner, attrs);
state.local_rib.announce(route.clone()).await?;
Ok(Json(LocalRouteView::from(&route)))
}
pub async fn withdraw(
State(state): State<AppState>,
Json(req): Json<WithdrawRequest>,
) -> Result<Json<Option<LocalRouteView>>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = req.owner.as_deref().map(validate_owner).transpose()?;
let removed = state.local_rib.withdraw(prefix, owner.as_deref()).await?;
Ok(Json(removed.as_ref().map(LocalRouteView::from)))
}
/// Shows the UPDATE bytes a local route produces on each established session.
pub async fn wire(
State(state): State<AppState>,
Query(q): Query<WireQuery>,
) -> Result<Json<Vec<WireView>>, ApiError> {
let prefix = parse_prefix(&q.prefix)?;
let route = state
.local_rib
.snapshot()
.await
.into_iter()
.find(|r| r.prefix == prefix)
.ok_or_else(|| ApiError { status: StatusCode::NOT_FOUND, message: format!("{prefix} is not announced") })?;
let mut views = vec![];
for session in state.registry.list() {
let Some(update) = build_announce(&route, state.info.local_asn, session.local_addr.ip()) else {
continue;
};
let ctx = ParserContext { four_octet_asn: Some(session.four_octet_asn), address_family: None };
let mut buf = BytesMut::new();
Message::Update(update.clone())
.to_wire(&ctx, &mut buf)
.map_err(|e| ApiError { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() })?;
views.push(WireView {
peer_addr: session.peer.addr.to_string(),
peer_asn: session.peer.asn,
next_hop: session.local_addr.ip().to_string(),
four_octet_asn: session.four_octet_asn,
hex: buf.iter().map(|b| format!("{b:02x}")).collect(),
length: buf.len(),
summary: summarize(&update.path_attributes),
update,
});
}
views.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
Ok(Json(views))
}
#[derive(Debug, Clone, Serialize)]
pub struct WellKnownCommunity {
pub name: &'static str,
pub value: String,
}
/// Names the API accepts in place of a numeric standard community.
pub async fn well_known_communities() -> Json<Vec<WellKnownCommunity>> {
Json(
community::WELL_KNOWN
.iter()
.map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) })
.collect(),
)
}
pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<ReceivedRouteView>> {
let mut routes: Vec<ReceivedRouteView> =
state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect();
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
Json(routes)
}