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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3f7713965
commit
fb48fc0a85
7 files changed
+502
No files matched your search
Generated
+1
@@ -166,6 +166,7 @@ dependencies = [
|
||||
"bgp-server",
|
||||
"bgp-web",
|
||||
"bytes",
|
||||
"clap",
|
||||
"eyre",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<u32>, 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::<Vec<_>>());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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("<title>ferrix_bgp /statusz</title>"));
|
||||
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?;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ferrix_bgp /statusz</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%231a73e8'/%3E%3Ctext x='8' y='12' font-size='10' font-family='monospace' fill='white' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --fg: #202124; --muted: #5f6368; --line: #dadce0;
|
||||
--head: #f1f3f4; --accent: #1a73e8; --ok: #188038; --warn: #b06000; --err: #c5221f;
|
||||
--hi: #fef7e0; --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--hex-hdr:#e8f0fe; --hex-len:#fce8e6; --hex-attr:#e6f4ea; --hex-nlri:#fef7e0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 13px/1.45 var(--mono); color: var(--fg); background: var(--bg); }
|
||||
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
|
||||
header { padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--head); display: flex; flex-wrap: wrap; gap: 4px 24px; align-items: baseline; }
|
||||
header h1 { font-size: 15px; margin: 0 8px 0 0; font-weight: bold; }
|
||||
header .kv { color: var(--muted); } header .kv b { color: var(--fg); font-weight: normal; }
|
||||
nav { padding: 4px 16px; border-bottom: 1px solid var(--line); color: var(--muted); }
|
||||
nav a { margin-right: 14px; }
|
||||
main { padding: 0 16px 40px; max-width: 1400px; }
|
||||
section { margin-top: 22px; }
|
||||
h2 { font-size: 13px; margin: 0 0 6px; padding-bottom: 3px; border-bottom: 1px solid var(--line); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
||||
h2 .count { float: right; text-transform: none; letter-spacing: 0; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
|
||||
th { background: var(--head); font-weight: normal; color: var(--muted); }
|
||||
td.wrap { white-space: normal; }
|
||||
tr.mine { background: var(--hi); }
|
||||
tr.wire td { background: #fafafa; white-space: normal; }
|
||||
.empty { color: var(--muted); padding: 6px 8px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--err); margin-right: 4px; vertical-align: middle; }
|
||||
.dot.on { background: var(--ok); }
|
||||
.tag { display: inline-block; padding: 0 5px; border: 1px solid var(--line); border-radius: 3px; margin: 1px 2px 1px 0; background: #fff; }
|
||||
.tag.wk { border-color: var(--warn); color: var(--warn); }
|
||||
.muted { color: var(--muted); } .ok { color: var(--ok); } .err { color: var(--err); } .warn { color: var(--warn); }
|
||||
form.announce { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px 12px; align-items: end; padding: 10px; border: 1px solid var(--line); background: #fafafa; }
|
||||
form.announce label { display: flex; flex-direction: column; gap: 2px; color: var(--muted); }
|
||||
form.announce .span2 { grid-column: span 2; }
|
||||
input, select, button { font: inherit; padding: 3px 6px; border: 1px solid var(--line); border-radius: 3px; background: #fff; color: var(--fg); }
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
button { cursor: pointer; } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
button.small { padding: 0 6px; line-height: 18px; } button:disabled { opacity: .5; cursor: default; }
|
||||
.row { display: flex; gap: 6px; align-items: center; }
|
||||
#announce-msg { grid-column: 1 / -1; min-height: 1.4em; }
|
||||
.hex { font-family: var(--mono); word-break: break-all; line-height: 1.7; }
|
||||
.hex span { padding: 1px 0; } .hex .h { background: var(--hex-hdr); } .hex .l { background: var(--hex-len); } .hex .a { background: var(--hex-attr); } .hex .n { background: var(--hex-nlri); }
|
||||
.hex .a:nth-of-type(odd) { filter: brightness(.96); }
|
||||
.legend span { padding: 0 6px; margin-right: 8px; }
|
||||
.wirebox { margin: 4px 0 8px; padding: 6px 8px; border-left: 3px solid var(--line); }
|
||||
#event-log { max-height: 260px; overflow-y: auto; border: 1px solid var(--line); padding: 4px 8px; background: #fafafa; }
|
||||
#event-log div { white-space: nowrap; } #event-log .t { color: var(--muted); margin-right: 8px; }
|
||||
footer { padding: 10px 16px; color: var(--muted); border-top: 1px solid var(--line); margin-top: 30px; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg:#202124; --fg:#e8eaed; --muted:#9aa0a6; --line:#3c4043; --head:#292a2d; --accent:#8ab4f8; --ok:#81c995; --warn:#fdd663; --err:#f28b82; --hi:#3b3419;
|
||||
--hex-hdr:#28344b; --hex-len:#4a2a28; --hex-attr:#26382b; --hex-nlri:#3b3419; }
|
||||
input, select, button, .tag { background: #303134; color: var(--fg); }
|
||||
tr.wire td, form.announce, #event-log { background: #292a2d; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>ferrix_bgp</h1>
|
||||
<span class="kv">router-id <b id="s-router-id">…</b></span>
|
||||
<span class="kv">AS <b id="s-asn">…</b></span>
|
||||
<span class="kv">version <b id="s-version">…</b></span>
|
||||
<span class="kv">uptime <b id="s-uptime">…</b></span>
|
||||
<span class="kv">sessions <b id="s-sessions">…</b></span>
|
||||
<span class="kv">local <b id="s-local">…</b></span>
|
||||
<span class="kv">received <b id="s-received">…</b></span>
|
||||
<span class="kv"><span class="dot" id="live-dot"></span><span id="live-text">connecting</span></span>
|
||||
</header>
|
||||
<nav>
|
||||
<a href="#peers">peers</a><a href="#announce">announce</a><a href="#local">local routes</a><a href="#received">received routes</a><a href="#events">events</a>
|
||||
<a href="/metricz">/metricz</a><a href="/api/status">/api/status</a>
|
||||
</nav>
|
||||
<main>
|
||||
|
||||
<section id="peers">
|
||||
<h2>Peers <span class="count" id="peers-count"></span></h2>
|
||||
<table>
|
||||
<thead><tr><th>peer</th><th>ASN</th><th>state</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte ASN</th></tr></thead>
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="announce">
|
||||
<h2>Announce a prefix</h2>
|
||||
<form class="announce" id="announce-form" autocomplete="off">
|
||||
<label>owner (your name)<input name="owner" id="f-owner" required maxlength="64" placeholder="alice"></label>
|
||||
<label>prefix<input name="prefix" id="f-prefix" required placeholder="10.100.1.0/24"></label>
|
||||
<label>origin<select name="origin" id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></label>
|
||||
<label>AS-path prepends<input name="prepend" id="f-prepend" type="number" min="0" max="16" value="0"></label>
|
||||
<label>MED (optional)<input name="med" id="f-med" type="number" min="0" max="4294967295" placeholder="none"></label>
|
||||
<label class="span2">communities <span class="muted">(comma separated, ASN:VALUE or name)</span>
|
||||
<div class="row"><input name="communities" id="f-communities" placeholder="65000:100, NO_EXPORT" style="flex:1">
|
||||
<select id="f-wk"><option value="">+ well-known…</option></select></div>
|
||||
</label>
|
||||
<label class="span2">large communities <span class="muted">(GLOBAL:LOCAL1:LOCAL2)</span><input name="large_communities" id="f-large" placeholder="65000:1:2"></label>
|
||||
<div class="row"><button type="submit" class="primary">Announce</button><button type="button" id="f-clear">Clear</button></div>
|
||||
<div id="announce-msg"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="local">
|
||||
<h2>Local routes <span class="count" id="local-count"></span></h2>
|
||||
<table>
|
||||
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th></th></tr></thead>
|
||||
<tbody id="local-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="received">
|
||||
<h2>Received routes (Adj-RIB-In) <span class="count" id="received-count"></span></h2>
|
||||
<p class="muted" style="margin:0 0 6px">Rows highlighted in yellow carry a prefix that is also announced locally: a route that has travelled through the lab and come back.</p>
|
||||
<table>
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
|
||||
<tbody id="received-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="events">
|
||||
<h2>Event log</h2>
|
||||
<div id="event-log"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<footer>ferrix_bgp · Adj-RIB-In and local routes are live via <a href="/api/events">/api/events</a> · tables refresh on every event.</footer>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set() };
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
const fmtDur = s => {
|
||||
s = Math.max(0, Math.floor(s));
|
||||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
|
||||
return d ? `${d}d${h}h` : h ? `${h}h${m}m` : m ? `${m}m${sec}s` : `${sec}s`;
|
||||
};
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const age = unix => fmtDur(now() - unix);
|
||||
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
|
||||
const tags = (list, wk) => list.length ? list.map(c => {
|
||||
const name = wk && state.wellKnown[c];
|
||||
return `<span class="tag${name ? ' wk' : ''}" title="${esc(c)}">${esc(name ? name : c)}</span>`;
|
||||
}).join('') : '<span class="muted">—</span>';
|
||||
const asPath = p => p.length ? p.join(' ') : '<span class="muted">(empty)</span>';
|
||||
const myOwner = () => $('f-owner').value.trim();
|
||||
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${url}: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ---- renderers ---------------------------------------------------------
|
||||
function renderStatus() {
|
||||
const s = state.status; if (!s) return;
|
||||
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
|
||||
$('s-version').textContent = s.version; $('s-uptime').textContent = fmtDur(s.uptime_secs + (now() - s._at));
|
||||
$('s-sessions').textContent = state.peers.length; $('s-local').textContent = state.local.length;
|
||||
$('s-received').textContent = state.received.length;
|
||||
}
|
||||
|
||||
function renderPeers() {
|
||||
const b = $('peers-body'); $('peers-count').textContent = state.peers.length;
|
||||
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="7" class="empty">No established sessions. Peers must connect to this router.</td></tr>'; return; }
|
||||
b.innerHTML = state.peers.map(p => `<tr>
|
||||
<td>${esc(p.peer_addr)}</td><td>${p.peer_asn}</td><td class="ok">Established</td><td>${esc(p.local_addr)}</td>
|
||||
<td>${fmtTime(p.established_at_unix)}</td><td>${age(p.established_at_unix)}</td><td>${p.four_octet_asn ? 'yes' : 'no'}</td></tr>`).join('');
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
const b = $('local-body'); $('local-count').textContent = state.local.length;
|
||||
if (!state.local.length) { b.innerHTML = '<tr><td colspan="9" class="empty">Nothing announced yet.</td></tr>'; return; }
|
||||
const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
|
||||
b.innerHTML = state.local.map(r => {
|
||||
const mine = r.owner === me;
|
||||
const path = Array(1 + r.prepend).fill(asn).join(' ');
|
||||
const open = state.openWire.has(r.prefix);
|
||||
return `<tr class="${mine ? 'mine' : ''}" data-prefix="${esc(r.prefix)}">
|
||||
<td>${esc(r.prefix)}</td><td>${esc(r.owner)}</td><td>${esc(r.origin)}</td><td>${path}</td>
|
||||
<td>${r.med ?? '<span class="muted">—</span>'}</td><td class="wrap">${tags(r.communities, true)}</td><td class="wrap">${tags(r.large_communities)}</td>
|
||||
<td>${age(r.announced_at_unix)}</td>
|
||||
<td><button class="small" data-act="wire" data-prefix="${esc(r.prefix)}">${open ? 'hide bytes' : 'bytes'}</button>
|
||||
<button class="small" data-act="withdraw" data-prefix="${esc(r.prefix)}" ${mine ? '' : 'disabled title="only the owner can withdraw"'}>withdraw</button></td></tr>` +
|
||||
(open ? `<tr class="wire"><td colspan="9" id="wire-${esc(r.prefix)}"><span class="muted">loading…</span></td></tr>` : '');
|
||||
}).join('');
|
||||
state.openWire.forEach(loadWire);
|
||||
}
|
||||
|
||||
function renderReceived() {
|
||||
const b = $('received-body'); $('received-count').textContent = state.received.length;
|
||||
if (!state.received.length) { b.innerHTML = '<tr><td colspan="9" class="empty">No routes received from peers.</td></tr>'; return; }
|
||||
const localPrefixes = new Set(state.local.map(r => r.prefix));
|
||||
b.innerHTML = state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'mine' : ''}">
|
||||
<td>${esc(r.prefix)}</td><td>${esc(r.peer_addr)} <span class="muted">AS${r.peer_asn}</span></td><td>${asPath(s.as_path)}</td>
|
||||
<td>${esc(s.next_hop ?? '—')}</td><td>${esc(s.origin ?? '—')}</td><td>${s.med ?? '<span class="muted">—</span>'}</td>
|
||||
<td class="wrap">${tags(s.communities, true)}</td><td class="wrap">${tags(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join('');
|
||||
}
|
||||
|
||||
// ---- wire view: split the UPDATE bytes into labelled fields ------------
|
||||
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
|
||||
function segmentUpdate(hex) {
|
||||
const bytes = hex.match(/../g) || [], segs = [];
|
||||
const take = (n, cls, label) => { segs.push({ cls, label, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(16, 'h', 'marker'); take(2, 'h', 'length'); take(1, 'h', 'type=UPDATE');
|
||||
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'l', `withdrawn len=${wlen}`); if (wlen) take(wlen, 'n', 'withdrawn routes');
|
||||
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'l', `attr len=${alen}`);
|
||||
let rem = alen;
|
||||
while (rem > 0) {
|
||||
const flags = parseInt(bytes[0], 16), type = parseInt(bytes[1], 16), ext = flags & 0x10;
|
||||
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
|
||||
take(hdr + len, 'a', `${ATTR_NAMES[type] || 'attr ' + type} (flags 0x${flags.toString(16)}, len ${len})`); rem -= hdr + len;
|
||||
}
|
||||
if (bytes.length) take(bytes.length, 'n', 'NLRI');
|
||||
return segs;
|
||||
}
|
||||
async function loadWire(prefix) {
|
||||
const cell = $(`wire-${prefix}`); if (!cell) return;
|
||||
try {
|
||||
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(prefix)}`);
|
||||
if (!views.length) { cell.innerHTML = '<span class="muted">No established session to send this on.</span>'; return; }
|
||||
cell.innerHTML = `<div class="legend muted">UPDATE as sent: <span class="h">header</span><span class="l">lengths</span><span class="a">path attributes</span><span class="n">NLRI</span></div>` +
|
||||
views.map(v => `<div class="wirebox"><div>to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn} · ${v.length} bytes · next hop ${esc(v.next_hop)} · AS path ${asPath(v.summary.as_path)}</div>
|
||||
<div class="hex">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</div></div>`).join('');
|
||||
} catch (e) { cell.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
|
||||
}
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
async function loadStatus() { state.status = await getJSON('/api/status'); state.status._at = now(); renderStatus(); }
|
||||
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderStatus(); }
|
||||
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); renderStatus(); }
|
||||
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); renderStatus(); }
|
||||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
|
||||
|
||||
// ---- events ------------------------------------------------------------
|
||||
function logEvent(text, cls) {
|
||||
const box = $('event-log'), d = document.createElement('div');
|
||||
d.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span><span class="${cls || ''}">${text}</span>`;
|
||||
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
|
||||
}
|
||||
function describe(ev) {
|
||||
switch (ev.type) {
|
||||
case 'session_up': return [`session UP with ${esc(ev.peer.peer_addr)} AS${ev.peer.peer_asn}`, 'ok'];
|
||||
case 'session_down': return [`session DOWN with ${esc(ev.peer_addr)} AS${ev.peer_asn}`, 'err'];
|
||||
case 'local_announced': return [`announced ${esc(ev.route.prefix)} by ${esc(ev.route.owner)} [${ev.route.communities.map(c => state.wellKnown[c] || c).map(esc).join(' ')}]`, ''];
|
||||
case 'local_withdrawn': return [`withdrew ${esc(ev.prefix)}`, 'warn'];
|
||||
case 'received_announced': return [`received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`, ''];
|
||||
case 'received_withdrawn': return [`peer AS${ev.peer_asn} withdrew ${esc(ev.prefix)}`, 'warn'];
|
||||
case 'lagged': return [`missed ${ev.missed} events on ${ev.stream}; reloading`, 'err'];
|
||||
default: return [esc(JSON.stringify(ev)), ''];
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
const es = new EventSource('/api/events');
|
||||
es.onopen = () => { $('live-dot').classList.add('on'); $('live-text').textContent = 'live'; loadAll().catch(console.error); };
|
||||
es.onerror = () => { $('live-dot').classList.remove('on'); $('live-text').textContent = 'reconnecting'; };
|
||||
es.addEventListener('bgp', e => {
|
||||
const ev = JSON.parse(e.data); const [text, cls] = describe(ev); logEvent(text, cls);
|
||||
const t = ev.type;
|
||||
if (t.startsWith('session')) loadPeers().catch(console.error);
|
||||
else if (t.startsWith('local')) loadLocal().catch(console.error);
|
||||
else if (t.startsWith('received')) loadReceived().catch(console.error);
|
||||
else loadAll().catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- form --------------------------------------------------------------
|
||||
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
|
||||
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
|
||||
$('announce-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
|
||||
prepend: Number($('f-prepend').value || 0), communities: splitList($('f-communities').value),
|
||||
large_communities: splitList($('f-large').value),
|
||||
};
|
||||
if ($('f-med').value !== '') body.med = Number($('f-med').value);
|
||||
try { localStorage.setItem('owner', body.owner); } catch (_) {}
|
||||
setMsg('announcing…', 'muted');
|
||||
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.ok) { setMsg(`announced ${j.prefix} to ${state.peers.length} peer(s)`, 'ok'); loadLocal().catch(console.error); }
|
||||
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'err');
|
||||
});
|
||||
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-prepend').value = 0; $('f-origin').value = 'IGP'; setMsg(''); });
|
||||
$('f-wk').addEventListener('change', e => {
|
||||
if (!e.target.value) return;
|
||||
const cur = splitList($('f-communities').value); if (!cur.includes(e.target.value)) cur.push(e.target.value);
|
||||
$('f-communities').value = cur.join(', '); e.target.value = '';
|
||||
});
|
||||
$('f-owner').addEventListener('input', renderLocal);
|
||||
$('local-body').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('button[data-act]'); if (!btn) return;
|
||||
const prefix = btn.dataset.prefix;
|
||||
if (btn.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
|
||||
if (!confirm(`Withdraw ${prefix}?`)) return;
|
||||
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
|
||||
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'err'); }
|
||||
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); loadLocal().catch(console.error); }
|
||||
});
|
||||
|
||||
// ---- boot --------------------------------------------------------------
|
||||
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
|
||||
getJSON('/api/communities/well-known').then(list => {
|
||||
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
|
||||
renderLocal(); renderReceived();
|
||||
}).catch(console.error);
|
||||
loadAll().catch(e => logEvent(esc(e.message), 'err'));
|
||||
connect();
|
||||
setInterval(() => { renderStatus(); renderPeers(); renderLocal(); renderReceived(); }, 10000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user