feature: HTTP API, event stream and Prometheus metrics (bgp-web)

Phase 2 of the teaching demo: everything the status page will need is now
reachable over HTTP.

- New crate crates/web (axum). JSON endpoints for status, peers, local
  routes (announce / withdraw), received routes, the exact UPDATE bytes a
  local route produces per session, and well-known community names.
  Communities are accepted as ASN:VALUE or by name (NO_EXPORT, BLACKHOLE,
  ...); large communities as A:B:C. Policy errors map to 403/409/422.
- GET /api/events is a server-sent event stream merging session up/down,
  local route and Adj-RIB-In changes, with lag notifications.
- GET /metricz serves Prometheus metrics; the server crate now counts
  messages sent/received by type and sessions established.
- The runnable main moves from crates/server to bin/bgp so it can depend
  on both server and web without a cycle. `cargo run -- config.textpb`.
- Config gains http_endpoint; unset disables the API.
- Integration tests drive the API against a live server with a real BGP
  peer, including verifying the wire view matches the bytes the peer got.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-16 23:10:16 +02:00
1 parent 73e9fbf0ad
commit c3f7713965
24 files changed
+2009 -129

No files matched your search

Generated
+782 -89
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -19,6 +19,7 @@ bgp-packet = { path = "crates/packet" }
bgp-primitives = { path = "crates/primitives" }
bgp-rib = { path = "crates/rib" }
bgp-server = { path = "crates/server" }
bgp-web = { path = "crates/web" }
bgp-integration-tests = { path = "crates/integration-tests" }
# --- General ---
@@ -39,3 +40,6 @@ protobuf = { package = "protobuf", version = "3.7.2" }
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
clap = { version = "4.5.53", features = ["derive"] }
futures = "0.3"
axum = "0.8.9"
tokio-stream = { version = "0.1.19", features = ["sync"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
+7 -2
View File
@@ -7,5 +7,10 @@ name = "bgp"
version.workspace = true
[dependencies]
eyre.workspace = true
tokio.workspace = true
bgp-server.workspace = true
bgp-web.workspace = true
clap.workspace = true
eyre.workspace = true
protobuf.workspace = true
tokio.workspace = true
tracing.workspace = true
+43 -4
View File
@@ -1,9 +1,48 @@
# BGP
This crate provides a binary which runs the Border Gateway Protocol (BGP).
This crate provides the `bgp` binary which runs the Border Gateway Protocol
daemon together with its HTTP API.
Please refer to the other implementation crates part of this repository where most of the actual implemntation lives.
```
cargo run -- path/to/config.textpb
```
Warning: This is still very much a work in progress and is not yet ready for use.
The configuration format is defined in `crates/server/proto/config.proto`;
`testdata/server.textpb` is a worked example. When `http_endpoint` is set the
binary also serves:
The main ongoing work is happening currently in the BGP packet crate.
| Path | Purpose |
|---|---|
| `GET /api/status` | Router ID, ASN, uptime and table sizes |
| `GET /api/peers` | Established sessions |
| `GET /api/routes/local` | Locally originated routes |
| `POST /api/routes/local` | Announce a route (JSON body, see below) |
| `DELETE /api/routes/local` | Withdraw a route (JSON body: `prefix`, optional `owner`) |
| `GET /api/routes/local/wire?prefix=…` | The exact UPDATE bytes sent to each peer |
| `GET /api/routes/received` | Adj-RIB-In contents |
| `GET /api/communities/well-known` | Names accepted in place of numeric communities |
| `GET /api/events` | Server-sent events for session and route changes |
| `GET /metricz` | Prometheus metrics |
Announce body:
```json
{
"prefix": "10.100.1.0/24",
"owner": "alice",
"origin": "IGP",
"prepend": 2,
"med": 100,
"communities": ["65000:100", "NO_EXPORT"],
"large_communities": ["65000:1:2"]
}
```
Only `prefix` and `owner` are required. Routes are subject to the
`local_route_policy` in the configuration.
Please refer to the other implementation crates in this repository where most
of the actual implementation lives.
Warning: This is still very much a work in progress and is not yet ready for
production use.
+52 -3
View File
@@ -1,6 +1,55 @@
use eyre::Result;
use std::sync::Arc;
use std::time::Instant;
use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs;
use bgp_web::{AppState, RouterInfo};
use clap::Parser;
use eyre::{Context, Result};
use tracing::error;
#[derive(Parser, Debug)]
struct ServerArgs {
/// Path to the textproto configuration file.
config: String,
#[command(flatten)]
tracing: TracingArgs,
}
#[tokio::main]
pub async fn main() -> Result<()> {
todo!("Implement the server");
async fn main() -> Result<()> {
let args = ServerArgs::parse();
args.tracing.install()?;
let config_string =
std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
let server = BgpServerBuilder::new(config).bind().await?;
if let Some(http_endpoint) = server.config().http_endpoint {
let state = AppState {
info: Arc::new(RouterInfo {
router_id: server.config().router_id,
local_asn: server.config().local_asn,
version: env!("CARGO_PKG_VERSION").to_string(),
started: Instant::now(),
}),
registry: server.registry.clone(),
local_rib: server.local_rib.clone(),
adj_rib_in: server.adj_rib_in.clone(),
};
tokio::spawn(async move {
if let Err(e) = bgp_web::serve(state, http_endpoint).await {
error!(err = %e, "HTTP server exited");
}
});
}
server.run().await
}
+3
View File
@@ -8,6 +8,9 @@ bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
bgp-web.workspace = true
bytes.workspace = true
eyre.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tokio.workspace = true
+39 -1
View File
@@ -4,7 +4,9 @@ use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
use bgp_server::config::Config;
use bgp_server::server::BgpServerBuilder;
use bgp_server::session::SessionRegistry;
use bgp_web::{AppState, RouterInfo};
use eyre::Result;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
/// A BGP server started on an ephemeral port for testing.
@@ -19,6 +21,7 @@ pub struct TestServer {
pub local_rib: LocalRib,
/// Live view of the server's established sessions.
pub registry: SessionRegistry,
pub local_asn: u32,
}
impl TestServer {
@@ -45,6 +48,7 @@ impl TestServer {
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
peers: vec![],
local_route_policy: policy,
http_endpoint: None,
};
let bound = BgpServerBuilder::new(config).bind().await?;
@@ -58,7 +62,41 @@ impl TestServer {
let registry = bound.registry.clone();
let handle = tokio::spawn(bound.run());
Ok(Self { handle, addr, adj_rib_in, local_rib, registry })
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn })
}
}
impl TestServer {
/// Starts the HTTP API on an ephemeral loopback port and returns its
/// base URL (e.g. `http://127.0.0.1:12345`). The API task is aborted
/// when the returned guard drops.
pub async fn serve_http(&self) -> Result<(String, HttpGuard)> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let base_url = format!("http://{}", listener.local_addr()?);
let state = AppState {
info: std::sync::Arc::new(RouterInfo {
router_id: Ipv4Addr::new(127, 0, 0, 1),
local_asn: self.local_asn,
version: "test".into(),
started: std::time::Instant::now(),
}),
registry: self.registry.clone(),
local_rib: self.local_rib.clone(),
adj_rib_in: self.adj_rib_in.clone(),
};
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
Ok((base_url, HttpGuard { handle }))
}
}
/// Aborts the HTTP API task when dropped.
pub struct HttpGuard {
handle: JoinHandle<Result<()>>,
}
impl Drop for HttpGuard {
fn drop(&mut self) {
self.handle.abort();
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Drives the HTTP API against a real server with a real BGP peer attached.
use std::net::Ipv4Addr;
use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute,
OriginPathAttribute, PathAttribute, UpdateMessage,
};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext;
use bgp_rib::LocalRibPolicy;
use eyre::Result;
use serde_json::{Value, json};
const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5);
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
let mut peer = TestPeer::connect(server.addr).await?;
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
tokio::time::timeout(TIMEOUT, async {
while server.registry.list().is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
Ok(peer)
}
async fn get_json(client: &reqwest::Client, url: &str) -> Result<Value> {
Ok(client.get(url).send().await?.error_for_status()?.json().await?)
}
#[tokio::test]
async fn test_status_and_peers() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["local_asn"], LOCAL_ASN);
assert_eq!(status["router_id"], "127.0.0.1");
assert_eq!(status["sessions"], 0);
let _peer = established_peer(&server).await?;
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["sessions"], 1);
let peers = get_json(&client, &format!("{base}/api/peers")).await?;
assert_eq!(peers.as_array().unwrap().len(), 1);
assert_eq!(peers[0]["peer_asn"], PEER_ASN);
assert_eq!(peers[0]["local_addr"], server.addr.to_string());
assert_eq!(peers[0]["four_octet_asn"], true);
Ok(())
}
/// The full loop: POST an announcement, the BGP peer receives the UPDATE,
/// the local table lists it, the wire view matches the bytes, DELETE
/// withdraws it.
#[tokio::test]
async fn test_announce_over_http_reaches_peer() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut peer = established_peer(&server).await?;
let resp = client
.post(format!("{base}/api/routes/local"))
.json(&json!({
"prefix": "10.100.1.0/24",
"owner": "alice",
"prepend": 1,
"med": 10,
"communities": ["65000:100", "NO_EXPORT"],
"large_communities": ["65000:1:2"],
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let body: Value = resp.json().await?;
assert_eq!(body["prefix"], "10.100.1.0/24");
assert_eq!(body["origin"], "IGP");
assert_eq!(body["communities"], json!(["65000:100", "65535:65281"]));
assert_eq!(body["large_communities"], json!(["65000:1:2"]));
// The peer sees the UPDATE.
let update: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
assert_eq!(update.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN, LOCAL_ASN]));
assert_eq!(update.path_attributes[2], PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)));
assert!(update.path_attributes.contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![
(65000, 100),
(65535, 65281)
]))));
// Local table lists it.
let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert_eq!(local.as_array().unwrap().len(), 1);
assert_eq!(local[0]["owner"], "alice");
// Wire view reproduces exactly what went over the socket.
let wire = get_json(&client, &format!("{base}/api/routes/local/wire?prefix=10.100.1.0/24")).await?;
assert_eq!(wire.as_array().unwrap().len(), 1);
assert_eq!(wire[0]["peer_asn"], PEER_ASN);
assert_eq!(wire[0]["summary"]["as_path"], json!([LOCAL_ASN, LOCAL_ASN]));
let hex = wire[0]["hex"].as_str().unwrap();
let ctx = ParserContext { four_octet_asn: peer.ctx.four_octet_asn, address_family: None };
let mut expected = bytes::BytesMut::new();
Message::Update(update).to_wire(&ctx, &mut expected)?;
let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, expected_hex);
assert!(hex.starts_with(&"ff".repeat(16)), "marker missing");
// Withdraw.
let resp = client
.delete(format!("{base}/api/routes/local"))
.json(&json!({ "prefix": "10.100.1.0/24", "owner": "alice" }))
.send()
.await?;
assert_eq!(resp.status(), 200);
let withdrawn: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(withdrawn.withdrawn_routes, vec![prefix("10.100.1.0/24")]);
let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert!(local.as_array().unwrap().is_empty());
Ok(())
}
#[tokio::test]
async fn test_validation_and_policy_status_codes() -> Result<()> {
let server = TestServer::start_with_policy(
LOCAL_ASN,
LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 1 },
)
.await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let url = format!("{base}/api/routes/local");
let post = |body: Value| client.post(&url).json(&body).send();
// Malformed input → 400 with an error message.
let resp = post(json!({ "prefix": "not-a-prefix", "owner": "alice" })).await?;
assert_eq!(resp.status(), 400);
assert!(resp.json::<Value>().await?["error"].as_str().unwrap().contains("invalid prefix"));
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "" })).await?;
assert_eq!(resp.status(), 400);
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "communities": ["bogus"] })).await?;
assert_eq!(resp.status(), 400);
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "origin": "weird" })).await?;
assert_eq!(resp.status(), 400);
// Outside allowlist → 403.
let resp = post(json!({ "prefix": "192.0.2.0/24", "owner": "alice" })).await?;
assert_eq!(resp.status(), 403);
// First allowed route is fine.
assert_eq!(post(json!({ "prefix": "10.100.1.0/24", "owner": "alice" })).await?.status(), 200);
// Per-owner limit → 422.
let resp = post(json!({ "prefix": "10.100.2.0/24", "owner": "alice" })).await?;
assert_eq!(resp.status(), 422);
// Someone else's prefix → 409, for both replace and withdraw.
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "bob" })).await?;
assert_eq!(resp.status(), 409);
let resp = client
.delete(&url)
.json(&json!({ "prefix": "10.100.1.0/24", "owner": "bob" }))
.send()
.await?;
assert_eq!(resp.status(), 409);
// Withdrawing an unknown prefix is a 200 with null.
let resp = client.delete(&url).json(&json!({ "prefix": "10.100.7.0/24" })).send().await?;
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<Value>().await?, Value::Null);
// Wire view for an unannounced prefix → 404.
let resp = client.get(format!("{base}/api/routes/local/wire?prefix=10.100.7.0/24")).send().await?;
assert_eq!(resp.status(), 404);
Ok(())
}
/// Routes the peer sends us show up in the received table with a summary.
#[tokio::test]
async fn test_received_routes_summary() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut peer = established_peer(&server).await?;
peer.send(&Message::Update(UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![PEER_ASN, 65002]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65001, 7)])),
],
announced_routes: vec![prefix("203.0.113.0/24")],
}))
.await?;
let received = tokio::time::timeout(TIMEOUT, async {
loop {
let v = get_json(&client, &format!("{base}/api/routes/received")).await?;
if !v.as_array().unwrap().is_empty() {
return Ok::<_, eyre::Error>(v);
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await??;
assert_eq!(received[0]["prefix"], "203.0.113.0/24");
assert_eq!(received[0]["peer_asn"], PEER_ASN);
assert_eq!(received[0]["summary"]["as_path"], json!([PEER_ASN, 65002]));
assert_eq!(received[0]["summary"]["next_hop"], "10.0.0.1");
assert_eq!(received[0]["summary"]["communities"], json!(["65001:7"]));
Ok(())
}
/// The SSE stream reports session and route changes as they happen.
#[tokio::test]
async fn test_event_stream() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let mut resp = client.get(format!("{base}/api/events")).send().await?.error_for_status()?;
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/event-stream"));
let _peer = established_peer(&server).await?;
server
.local_rib
.announce(bgp_rib::LocalRoute::new(prefix("10.100.1.0/24"), "alice", Default::default()))
.await?;
// Collect SSE frames until we have seen both events.
let mut buf = String::new();
let mut seen_up = false;
let mut seen_announce = false;
tokio::time::timeout(TIMEOUT, async {
while !(seen_up && seen_announce) {
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
buf.push_str(std::str::from_utf8(&chunk)?);
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
let ev: Value = serde_json::from_str(line.trim())?;
match ev["type"].as_str() {
Some("session_up") if ev["peer"]["peer_asn"] == PEER_ASN => seen_up = true,
Some("local_announced") if ev["route"]["prefix"] == "10.100.1.0/24" => seen_announce = true,
_ => {}
}
}
}
Ok::<_, eyre::Error>(())
})
.await??;
Ok(())
}
#[tokio::test]
async fn test_metricz_and_well_known() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let _peer = established_peer(&server).await?;
let text = client.get(format!("{base}/metricz")).send().await?.error_for_status()?.text().await?;
// Metrics are process-global, so other tests in this binary contribute
// too; assert on presence and a lower bound rather than exact values.
let metric = |name: &str| -> Option<i64> {
text.lines()
.find(|l| l.starts_with(name) && l[name.len()..].starts_with(' '))
.and_then(|l| l.rsplit(' ').next()?.parse().ok())
};
assert!(metric("bgp_sessions_established").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_messages_sent_total{type=\"open\"}").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_messages_received_total{type=\"keepalive\"}").unwrap_or(0) >= 1, "{text}");
assert!(metric("bgp_local_routes").is_some(), "{text}");
assert!(metric("bgp_adj_rib_in_routes").is_some(), "{text}");
let wk = get_json(&client, &format!("{base}/api/communities/well-known")).await?;
assert!(wk.as_array().unwrap().iter().any(|c| c["name"] == "NO_EXPORT" && c["value"] == "65535:65281"));
Ok(())
}
+4
View File
@@ -20,6 +20,10 @@ message BgpConfig {
// Limits on routes originated locally (e.g. via the demo UI).
LocalRoutePolicy local_route_policy = 5;
// Where to serve the HTTP API, status page and metrics. The port is
// required. Unset disables the HTTP server.
TcpEndpoint http_endpoint = 6;
}
// Limits on locally originated routes.
+13
View File
@@ -20,6 +20,8 @@ pub struct Config {
pub peers: Vec<PeerConfig>,
/// Limits on locally originated routes.
pub local_route_policy: LocalRibPolicy,
/// Address for the HTTP API; `None` disables it.
pub http_endpoint: Option<SocketAddr>,
}
pub struct PeerConfig {
@@ -90,6 +92,16 @@ impl Config {
None => LocalRibPolicy::default(),
};
let http_endpoint = match proto.http_endpoint.into_option() {
Some(ep) => {
if ep.port == 0 {
bail!("http_endpoint.port is required");
}
Some(parse_endpoint(&ep.address, ep.port).wrap_err("Invalid http_endpoint")?)
}
None => None,
};
Ok(Self {
router_id,
local_asn: proto.local_asn,
@@ -97,6 +109,7 @@ impl Config {
listen_endpoints,
peers,
local_route_policy,
http_endpoint,
})
}
}
+4
View File
@@ -18,6 +18,7 @@ use tracing::{info, warn};
use bgp_packet::message::{AS_TRANS, BGP4_VERSION};
use crate::metrics::{MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, message_type_name};
use crate::session::{SessionHandle, SessionRegistry};
/// Number of outbound messages that may be queued for a session before
@@ -141,6 +142,7 @@ pub async fn run_session(
}
info!(%peer_addr, peer_asn, "Session established");
SESSIONS_ESTABLISHED_TOTAL.inc();
let peer_id = PeerId { addr: peer_addr, asn: peer_asn };
@@ -266,6 +268,7 @@ async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result
let (_, msg) =
Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?;
MESSAGES_RECEIVED.with_label_values(&[message_type_name(&msg)]).inc();
Ok(msg)
}
@@ -287,6 +290,7 @@ async fn send_message(
let mut buf = BytesMut::new();
msg.to_wire(ctx, &mut buf)?;
writer.write_all(&buf).await?;
MESSAGES_SENT.with_label_values(&[message_type_name(msg)]).inc();
Ok(())
}
+1
View File
@@ -2,6 +2,7 @@ pub mod advertise;
pub mod config;
pub mod connection;
pub mod fsm;
pub mod metrics;
pub mod server;
pub mod session;
pub mod tracing;
-29
View File
@@ -1,29 +0,0 @@
use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs;
use clap::Parser;
use eyre::{Context, Result};
#[derive(Parser, Debug)]
struct ServerArgs {
/// Path to the textproto configuration file.
config: String,
#[command(flatten)]
tracing: TracingArgs,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = ServerArgs::parse();
args.tracing.install()?;
let config_string =
std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
BgpServerBuilder::new(config).run().await
}
+59
View File
@@ -0,0 +1,59 @@
//! Process-wide Prometheus metrics, registered on the default registry.
use std::sync::LazyLock;
use bgp_packet::message::Message;
use prometheus::{
IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec,
register_int_gauge,
};
pub static MESSAGES_SENT: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("bgp_messages_sent_total", "BGP messages sent, by type", &["type"])
.expect("metric registration")
});
pub static MESSAGES_RECEIVED: LazyLock<IntCounterVec> = LazyLock::new(|| {
register_int_counter_vec!("bgp_messages_received_total", "BGP messages received, by type", &["type"])
.expect("metric registration")
});
pub static SESSIONS_ESTABLISHED_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
register_int_counter!("bgp_sessions_established_total", "Sessions that reached Established")
.expect("metric registration")
});
pub static SESSIONS_ESTABLISHED: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_sessions_established", "Sessions currently in Established")
.expect("metric registration")
});
pub static LOCAL_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_local_routes", "Locally originated routes").expect("metric registration")
});
pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
register_int_gauge!("bgp_adj_rib_in_routes", "Routes held in Adj-RIB-In across all peers")
.expect("metric registration")
});
/// Forces registration of every metric so they appear in scrapes before
/// their first observation.
pub fn init() {
LazyLock::force(&MESSAGES_SENT);
LazyLock::force(&MESSAGES_RECEIVED);
LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL);
LazyLock::force(&SESSIONS_ESTABLISHED);
LazyLock::force(&LOCAL_ROUTES);
LazyLock::force(&ADJ_RIB_IN_ROUTES);
}
pub fn message_type_name(msg: &Message) -> &'static str {
match msg {
Message::Open(_) => "open",
Message::Update(_) => "update",
Message::Notification(_) => "notification",
Message::KeepAlive => "keepalive",
Message::RouteRefresh => "route_refresh",
}
}
+5
View File
@@ -67,12 +67,17 @@ impl BgpServerBuilder {
}
impl BgpServer {
pub fn config(&self) -> &Config {
&self.config
}
/// The addresses actually bound (useful when the config uses port 0).
pub fn local_addrs(&self) -> Vec<SocketAddr> {
self.listeners.iter().map(|(addr, _)| *addr).collect()
}
pub async fn run(self) -> Result<()> {
crate::metrics::init();
info!(
router_id = %self.config.router_id,
local_asn = self.config.local_asn,
+4
View File
@@ -14,6 +14,8 @@ use bgp_packet::message::Message;
use bgp_rib::PeerId;
use tokio::sync::{broadcast, mpsc};
use crate::metrics::SESSIONS_ESTABLISHED;
const EVENT_CHANNEL_CAPACITY: usize = 256;
/// Handle to one established session. Cloning is cheap.
@@ -80,6 +82,7 @@ impl SessionRegistry {
pub fn register(&self, handle: SessionHandle) -> SessionGuard {
let peer = handle.peer;
self.inner.write().expect("session registry poisoned").insert(peer, handle.clone());
SESSIONS_ESTABLISHED.inc();
let _ = self.events.send(SessionEvent::Up(handle));
SessionGuard { registry: self.clone(), peer }
}
@@ -100,6 +103,7 @@ impl SessionRegistry {
fn remove(&self, peer: &PeerId) {
let removed = self.inner.write().expect("session registry poisoned").remove(peer);
if removed.is_some() {
SESSIONS_ESTABLISHED.dec();
let _ = self.events.send(SessionEvent::Down(*peer));
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ use tracing::Level;
use tracing_subscriber::EnvFilter;
/// Default targets that we enable logging for.
const DEFAULT_TARGETS: [&str; 1] = ["bgp_server"];
const DEFAULT_TARGETS: [&str; 3] = ["bgp", "bgp_server", "bgp_web"];
#[derive(Debug, Args)]
pub struct TracingArgs {
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "bgp-web"
description = "HTTP API, event stream and metrics for the BGP daemon"
edition.workspace = true
[dependencies]
axum.workspace = true
bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true
bgp-server.workspace = true
bytes.workspace = true
eyre.workspace = true
futures.workspace = true
prometheus.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
[dev-dependencies]
reqwest.workspace = true
+396
View File
@@ -0,0 +1,396 @@
//! 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::SessionHandle;
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 uptime_secs: u64,
pub sessions: usize,
pub local_routes: usize,
pub received_routes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct PeerView {
pub peer_addr: String,
pub peer_asn: u32,
pub local_addr: String,
pub established_at_unix: u64,
pub uptime_secs: u64,
pub four_octet_asn: bool,
}
impl From<&SessionHandle> for PeerView {
fn from(s: &SessionHandle) -> Self {
Self {
peer_addr: s.peer.addr.to_string(),
peer_asn: s.peer.asn,
local_addr: s.local_addr.to_string(),
established_at_unix: unix_secs(s.established_at),
uptime_secs: s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0),
four_octet_asn: s.four_octet_asn,
}
}
}
#[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,
/// 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(),
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>> {
let mut peers: Vec<PeerView> = state.registry.list().iter().map(PeerView::from).collect();
peers.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
Json(peers)
}
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(),
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)
}
+80
View File
@@ -0,0 +1,80 @@
//! Parsing and formatting of community values as they appear in the API.
//!
//! Standard communities are written `ASN:VALUE` (e.g. `65000:100`) or as one
//! of the well-known names from RFC 1997 / RFC 7999 / RFC 8326. Large
//! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092).
use bgp_packet::message::LargeCommunity;
/// Well-known standard communities, by name.
pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[
("GRACEFUL_SHUTDOWN", (0xFFFF, 0x0000)),
("BLACKHOLE", (0xFFFF, 0x029A)),
("NO_EXPORT", (0xFFFF, 0xFF01)),
("NO_ADVERTISE", (0xFFFF, 0xFF02)),
("NO_EXPORT_SUBCONFED", (0xFFFF, 0xFF03)),
("NOPEER", (0xFFFF, 0xFF04)),
];
pub fn parse_standard(s: &str) -> Result<(u16, u16), String> {
let s = s.trim();
if let Some((_, value)) = WELL_KNOWN.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) {
return Ok(*value);
}
let (high, low) = s
.split_once(':')
.ok_or_else(|| format!("community {s:?} must be ASN:VALUE or a well-known name"))?;
let parse = |part: &str| {
part.trim()
.parse::<u16>()
.map_err(|_| format!("community {s:?}: {part:?} is not a 16-bit number"))
};
Ok((parse(high)?, parse(low)?))
}
pub fn format_standard((high, low): (u16, u16)) -> String {
format!("{high}:{low}")
}
pub fn parse_large(s: &str) -> Result<LargeCommunity, String> {
let s = s.trim();
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return Err(format!("large community {s:?} must be GLOBAL:LOCAL1:LOCAL2"));
}
let parse = |part: &str| {
part.trim()
.parse::<u32>()
.map_err(|_| format!("large community {s:?}: {part:?} is not a 32-bit number"))
};
Ok(LargeCommunity { global_admin: parse(parts[0])?, data_1: parse(parts[1])?, data_2: parse(parts[2])? })
}
pub fn format_large(c: &LargeCommunity) -> String {
format!("{}:{}:{}", c.global_admin, c.data_1, c.data_2)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_roundtrip_and_names() {
assert_eq!(parse_standard("65000:100").unwrap(), (65000, 100));
assert_eq!(parse_standard(" no_export ").unwrap(), (65535, 65281));
assert_eq!(parse_standard("BLACKHOLE").unwrap(), (65535, 666));
assert_eq!(format_standard((65000, 100)), "65000:100");
assert!(parse_standard("65000").is_err());
assert!(parse_standard("70000:1").is_err());
assert!(parse_standard("bogus").is_err());
}
#[test]
fn large_roundtrip() {
let c = parse_large("65000:1:2").unwrap();
assert_eq!(c, LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 });
assert_eq!(format_large(&c), "65000:1:2");
assert!(parse_large("65000:1").is_err());
assert!(parse_large("a:b:c").is_err());
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Server-sent event stream merging session, local-route and received-route
//! changes into one feed the UI can follow.
use std::convert::Infallible;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use bgp_rib::{LocalRibEvent, RibEvent};
use bgp_server::session::SessionEvent;
use futures::stream::{Stream, StreamExt};
use serde::Serialize;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::AppState;
use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize};
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WireEvent {
SessionUp { peer: PeerView },
SessionDown { peer_addr: String, peer_asn: u32 },
LocalAnnounced { route: LocalRouteView },
LocalWithdrawn { prefix: String },
ReceivedAnnounced { route: ReceivedRouteView },
ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String },
/// The client fell behind and missed `missed` events on `stream`; it
/// should refetch the corresponding table.
Lagged { stream: &'static str, missed: u64 },
}
impl WireEvent {
fn from_session(ev: Result<SessionEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(SessionEvent::Up(h)) => Self::SessionUp { peer: PeerView::from(&h) },
Ok(SessionEvent::Down(p)) => Self::SessionDown { peer_addr: p.addr.to_string(), peer_asn: p.asn },
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "sessions", missed: n },
}
}
fn from_local(ev: Result<LocalRibEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(LocalRibEvent::Announced(r)) => Self::LocalAnnounced { route: LocalRouteView::from(&r) },
Ok(LocalRibEvent::Withdrawn { prefix }) => Self::LocalWithdrawn { prefix: prefix.to_string() },
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "local_routes", missed: n },
}
}
fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
match ev {
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
route: ReceivedRouteView {
peer_addr: peer.addr.to_string(),
peer_asn: peer.asn,
prefix: prefix.to_string(),
received_at_unix: entry
.received_at
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
summary: summarize(&entry.path_attributes),
attributes: entry.path_attributes,
},
},
Ok(RibEvent::Withdrawn { peer, prefix }) => Self::ReceivedWithdrawn {
peer_addr: peer.addr.to_string(),
peer_asn: peer.asn,
prefix: prefix.to_string(),
},
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "received_routes", missed: n },
}
}
}
pub async fn stream(
State(state): State<AppState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let sessions = BroadcastStream::new(state.registry.subscribe()).map(WireEvent::from_session);
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local);
let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| {
Ok(Event::default()
.event("bgp")
.json_data(&ev)
.unwrap_or_else(|e| Event::default().event("error").data(e.to_string())))
});
Sse::new(merged).keep_alive(KeepAlive::default())
}
+76
View File
@@ -0,0 +1,76 @@
//! HTTP surface of the daemon: a JSON API for originating routes and
//! inspecting state, a server-sent event stream, and Prometheus metrics.
//!
//! Everything here is a thin layer over handles owned by the BGP server
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives
//! in this crate.
mod api;
mod community;
mod events;
mod metrics;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
use axum::Router;
use axum::routing::{delete, get, post};
use bgp_rib::{AdjRibIn, LocalRib};
use bgp_server::session::SessionRegistry;
use eyre::{Result, WrapErr};
use tokio::net::TcpListener;
use tracing::info;
pub use api::{
AnnounceRequest, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
WellKnownCommunity, WireView, WithdrawRequest,
};
/// Static facts about the router shown in status output.
#[derive(Debug, Clone)]
pub struct RouterInfo {
pub router_id: Ipv4Addr,
pub local_asn: u32,
pub version: String,
pub started: Instant,
}
#[derive(Clone)]
pub struct AppState {
pub info: Arc<RouterInfo>,
pub registry: SessionRegistry,
pub local_rib: LocalRib,
pub adj_rib_in: AdjRibIn,
}
/// Builds the router. Exposed so tests can drive it without a socket.
pub fn router(state: AppState) -> Router {
Router::new()
.route("/api/status", get(api::status))
.route("/api/peers", get(api::peers))
.route("/api/routes/local", get(api::local_routes).post(api::announce))
.route("/api/routes/local", delete(api::withdraw))
.route("/api/routes/local/withdraw", post(api::withdraw))
.route("/api/routes/local/wire", get(api::wire))
.route("/api/routes/received", get(api::received_routes))
.route("/api/communities/well-known", get(api::well_known_communities))
.route("/api/events", get(events::stream))
.route("/metricz", get(metrics::metricz))
.with_state(state)
}
/// Binds `listen` and serves the API until the task is dropped.
pub async fn serve(state: AppState, listen: SocketAddr) -> Result<()> {
let listener = TcpListener::bind(listen)
.await
.wrap_err_with(|| format!("Failed to bind HTTP listener on {listen}"))?;
serve_on(state, listener).await
}
/// Serves the API on an already-bound listener (lets tests use port 0).
pub async fn serve_on(state: AppState, listener: TcpListener) -> Result<()> {
info!(addr = %listener.local_addr()?, "HTTP API listening");
axum::serve(listener, router(state)).await?;
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
//! Prometheus scrape endpoint. Counters are updated by the server crate as
//! messages flow; table-size gauges are refreshed at scrape time.
use axum::extract::State;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use bgp_server::metrics::{ADJ_RIB_IN_ROUTES, LOCAL_ROUTES};
use prometheus::{Encoder, TextEncoder};
use crate::AppState;
pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
LOCAL_ROUTES.set(state.local_rib.snapshot().await.len() as i64);
ADJ_RIB_IN_ROUTES.set(state.adj_rib_in.snapshot().await.len() as i64);
let mut buf = Vec::new();
match TextEncoder::new().encode(&prometheus::gather(), &mut buf) {
Ok(()) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; version=0.0.4")], buf).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
+4
View File
@@ -13,3 +13,7 @@ local_route_policy {
allowed_prefixes: "2001:db8:100::/48"
max_routes_per_owner: 4
}
http_endpoint {
address: "127.0.0.1"
port: 8080
}