From fb48fc0a858194ab0f2f0e461db1550f0396b3a6 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Wed, 16 Sep 2026 23:17:27 +0200 Subject: [PATCH] feature: statusz page and fake_peer example Phase 3 of the teaching demo. - crates/web serves an embedded, dependency-free status page at /statusz (/ redirects there). Sections: router header with live indicator, peers, announce form (owner remembered locally, well-known community picker, prepend / MED / large communities), local routes with per-session UPDATE bytes segmented into header / lengths / attributes / NLRI, received routes with rows highlighted when a locally announced prefix has come back, and a live event log. Tables refresh on every SSE event. Light and dark themes. - fake_peer example in crates/integration-tests: connects, announces, prints received UPDATEs and with --echo re-announces everything back with its ASN prepended (dropping NO_EXPORT), so one daemon plus one process demonstrates the full loop without lab routers. - README for the binary documents the endpoints and the demo setup. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + bin/bgp/README.md | 15 + crates/integration-tests/Cargo.toml | 1 + .../integration-tests/examples/fake_peer.rs | 132 +++++++ crates/integration-tests/tests/http_api.rs | 21 ++ crates/web/src/lib.rs | 11 + crates/web/static/statusz.html | 321 ++++++++++++++++++ 7 files changed, 502 insertions(+) create mode 100644 crates/integration-tests/examples/fake_peer.rs create mode 100644 crates/web/static/statusz.html diff --git a/Cargo.lock b/Cargo.lock index 40f181c..ab238bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,6 +166,7 @@ dependencies = [ "bgp-server", "bgp-web", "bytes", + "clap", "eyre", "reqwest", "serde_json", diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 647074b..489a43b 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -13,6 +13,7 @@ binary also serves: | Path | Purpose | |---|---| +| `GET /statusz` | Status page: peers, announce form, local and received routes, live event log | | `GET /api/status` | Router ID, ASN, uptime and table sizes | | `GET /api/peers` | Established sessions | | `GET /api/routes/local` | Locally originated routes | @@ -41,6 +42,20 @@ Announce body: Only `prefix` and `owner` are required. Routes are subject to the `local_route_policy` in the configuration. +## Trying it without a router + +`fake_peer` is a minimal BGP speaker that connects to the daemon, announces a +prefix, prints every UPDATE it receives and, with `--echo`, re-announces each +received prefix back with its own ASN prepended (dropping NO_EXPORT routes). +That makes locally announced prefixes show up in the received table as if +they had travelled through a neighbouring AS: + +``` +cargo run -- testdata/server.textpb +cargo run -p bgp-integration-tests --example fake_peer -- \ + --server 127.0.0.1:179 --asn 65001 --announce 203.0.113.0/24 --echo +``` + Please refer to the other implementation crates in this repository where most of the actual implementation lives. diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 407fdb1..cdb8079 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,6 +10,7 @@ bgp-rib.workspace = true bgp-server.workspace = true bgp-web.workspace = true bytes.workspace = true +clap.workspace = true eyre.workspace = true reqwest.workspace = true serde_json.workspace = true diff --git a/crates/integration-tests/examples/fake_peer.rs b/crates/integration-tests/examples/fake_peer.rs new file mode 100644 index 0000000..cb42d6f --- /dev/null +++ b/crates/integration-tests/examples/fake_peer.rs @@ -0,0 +1,132 @@ +//! A minimal stand-in for a lab router: connects to a ferrix_bgp instance, +//! completes the handshake, optionally announces some prefixes, and prints +//! every UPDATE it receives. Useful for demos when no real router is around. +//! +//! cargo run -p bgp-integration-tests --example fake_peer -- \ +//! --server 127.0.0.1:1790 --asn 65001 --announce 203.0.113.0/24 +//! +//! With `--echo`, every prefix received is re-announced back to the server +//! with this peer's ASN prepended, which lets a single ferrix instance show +//! its own routes "coming back" in the received table. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_packet::message::{ + AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, + PathAttribute, UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use clap::Parser; +use eyre::Result; + +#[derive(Parser, Debug)] +struct Args { + /// Address of the ferrix_bgp listener. + #[arg(long, default_value = "127.0.0.1:1790")] + server: String, + /// ASN this peer speaks as. + #[arg(long, default_value_t = 65001)] + asn: u32, + /// Router ID to send in OPEN. + #[arg(long, default_value = "10.0.0.2")] + router_id: Ipv4Addr, + /// Hold time to negotiate. + #[arg(long, default_value_t = 30)] + hold_time: u16, + /// Prefixes to announce after the session is established. + #[arg(long)] + announce: Vec, + /// Re-announce every received prefix back to the server, with our ASN + /// prepended and NO_EXPORT-tagged routes dropped. + #[arg(long)] + echo: bool, +} + +const NO_EXPORT: (u16, u16) = (0xFFFF, 0xFF01); + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let addr = args.server.parse()?; + let mut peer = TestPeer::connect(addr).await?; + let open = peer.handshake(args.asn, args.router_id, args.hold_time).await?; + println!("established with {} (AS{} router-id {})", addr, open.asn, open.identifier); + + for p in &args.announce { + let prefix = IpPrefix::try_from(p.as_str())?; + let update = announce(prefix.clone(), vec![args.asn], vec![(args.asn as u16, 1)]); + peer.send(&Message::Update(update)).await?; + println!("announced {prefix}"); + } + + let keepalive = Duration::from_secs((args.hold_time / 3).max(1) as u64); + let mut ticker = tokio::time::interval(keepalive); + loop { + tokio::select! { + _ = ticker.tick() => peer.send(&Message::KeepAlive).await?, + msg = peer.recv() => match msg? { + Message::KeepAlive => {} + Message::Update(u) => { + for w in &u.withdrawn_routes { + println!("WITHDRAW {w}"); + } + for a in &u.announced_routes { + println!("UPDATE {a} attrs={:?}", u.path_attributes); + } + if args.echo { + echo_back(&mut peer, &u, args.asn).await?; + } + } + Message::Notification(n) => { + println!("NOTIFICATION {}; exiting", n.error); + return Ok(()); + } + other => println!("unexpected {other:?}"), + }, + } + } +} + +fn announce(prefix: IpPrefix, as_path: Vec, communities: Vec<(u16, u16)>) -> UpdateMessage { + let mut path_attributes = vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(as_path), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 2))), + ]; + if !communities.is_empty() { + path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(communities))); + } + UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes: vec![prefix] } +} + +async fn echo_back(peer: &mut TestPeer, u: &UpdateMessage, asn: u32) -> Result<()> { + let mut as_path = vec![asn]; + let mut communities = vec![]; + for attr in &u.path_attributes { + match attr { + PathAttribute::AsPath(p) => as_path.extend(p.segments.iter().flat_map(|s| s.path.iter().copied())), + PathAttribute::Communities(c) => communities = c.0.clone(), + _ => {} + } + } + if communities.contains(&NO_EXPORT) { + println!(" not echoing: NO_EXPORT"); + return Ok(()); + } + for prefix in &u.announced_routes { + peer.send(&Message::Update(announce(prefix.clone(), as_path.clone(), communities.clone()))).await?; + println!(" echoed {prefix} back with path {as_path:?}"); + } + if !u.withdrawn_routes.is_empty() { + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: u.withdrawn_routes.clone(), + path_attributes: vec![], + announced_routes: vec![], + })) + .await?; + println!(" echoed withdraw of {:?}", u.withdrawn_routes.iter().map(|p| p.to_string()).collect::>()); + } + Ok(()) +} diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs index 6f5d32b..6eeb15d 100644 --- a/crates/integration-tests/tests/http_api.rs +++ b/crates/integration-tests/tests/http_api.rs @@ -271,6 +271,27 @@ async fn test_event_stream() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_statusz_page_is_served() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let resp = client.get(format!("{base}/statusz")).send().await?; + assert_eq!(resp.status(), 200); + assert!(resp.headers()["content-type"].to_str()?.starts_with("text/html")); + let body = resp.text().await?; + assert!(body.contains("ferrix_bgp /statusz")); + assert!(body.contains("/api/events")); + + // The root redirects to the page. + let no_redirect = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build()?; + let resp = no_redirect.get(format!("{base}/")).send().await?; + assert_eq!(resp.status(), 307); + assert_eq!(resp.headers()["location"], "/statusz"); + Ok(()) +} + #[tokio::test] async fn test_metricz_and_well_known() -> Result<()> { let server = TestServer::start(LOCAL_ASN).await?; diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index f6b600b..1e190db 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -15,6 +15,8 @@ use std::sync::Arc; use std::time::Instant; use axum::Router; +use axum::http::header; +use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{delete, get, post}; use bgp_rib::{AdjRibIn, LocalRib}; use bgp_server::session::SessionRegistry; @@ -44,9 +46,18 @@ pub struct AppState { pub adj_rib_in: AdjRibIn, } +/// The status page, embedded so the binary is self-contained. +const STATUSZ_HTML: &str = include_str!("../static/statusz.html"); + +async fn statusz() -> impl IntoResponse { + ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], Html(STATUSZ_HTML)) +} + /// Builds the router. Exposed so tests can drive it without a socket. pub fn router(state: AppState) -> Router { Router::new() + .route("/", get(|| async { Redirect::temporary("/statusz") })) + .route("/statusz", get(statusz)) .route("/api/status", get(api::status)) .route("/api/peers", get(api::peers)) .route("/api/routes/local", get(api::local_routes).post(api::announce)) diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html new file mode 100644 index 0000000..f58ead1 --- /dev/null +++ b/crates/web/static/statusz.html @@ -0,0 +1,321 @@ + + + + + +ferrix_bgp /statusz + + + + +
+

ferrix_bgp

+ router-id … + AS … + version … + uptime … + sessions … + local … + received … + connecting +
+ +
+ +
+

Peers

+ + + +
peerASNstatelocal address (next hop)establisheduptime4-byte ASN
+
+ +
+

Announce a prefix

+
+ + + + + + + +
+
+
+
+ +
+

Local routes

+ + + +
prefixowneroriginAS pathMEDcommunitieslarge communitiesage
+
+ +
+

Received routes (Adj-RIB-In)

+

Rows highlighted in yellow carry a prefix that is also announced locally: a route that has travelled through the lab and come back.

+ + + +
prefixfrom peerAS pathnext hoporiginMEDcommunitieslarge communitiesage
+
+ +
+

Event log

+
+
+ +
+
ferrix_bgp · Adj-RIB-In and local routes are live via /api/events · tables refresh on every event.
+ + + +