The RIB handles swallowed every failure of their actor: an UPDATE applied to a stopped Adj-RIB-In vanished, and a snapshot of it came back empty. Sessions stayed up, peers' routes went nowhere, and the status page showed a healthy router with no routes. An actor stops only by panicking, so this is the state the server would be in after a bug. - Every operation on AdjRibIn and LocalRib returns Result<_, RibStopped>. LocalRibError is replaced by it. - The server waits on both actors, the advertiser and the dialers. If one stops it sends every peer Cease / Administrative Shutdown and returns an error, so the process exits and can be restarted. Before, the tasks' handles were held and never looked at. - A session that cannot store an UPDATE ends with the same Cease. - The HTTP API answers 503 with the reason for anything that needs a table it cannot read, including /metricz: a scrape that fails is noticed, last known sizes are not. Also: - The routes of a session are now removed even if its task is torn down without running its cleanup (a panic), instead of staying in the table as routes of a peer that is gone. - Table sizes for the status page and metrics are asked for as a count instead of copying the whole table to take its length. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
338 lines
11 KiB
Rust
338 lines
11 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 axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
use bgp_packet::community;
|
|
use bgp_packet::message::{OriginPathAttribute, PathAttribute};
|
|
use bgp_rib::{LocalRoute, RibEntry, RibStopped, SnapshotEntry};
|
|
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
|
|
use serde::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,
|
|
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>,
|
|
/// The address families negotiated with the peer, e.g. `IPv4 unicast`.
|
|
pub address_families: 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()),
|
|
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,
|
|
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(entry: &RibEntry) -> 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: entry.next_hop.as_ref().map(ToString::to_string),
|
|
..Default::default()
|
|
};
|
|
for attr in entry.path_attributes.iter() {
|
|
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,
|
|
pub received_at_unix: u64,
|
|
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>,
|
|
}
|
|
|
|
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),
|
|
attributes: e.entry.path_attributes.to_vec(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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.adj_rib_in.len().await?,
|
|
}))
|
|
}
|
|
|
|
/// 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))
|
|
}
|
|
|
|
pub async fn received_routes(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<ReceivedRouteView>>, Unavailable> {
|
|
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)));
|
|
Ok(Json(routes))
|
|
}
|