- 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>
464 lines
16 KiB
Rust
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)
|
|
}
|