Originate routes from the configuration; make the HTTP API read-only

Routes were announced and withdrawn through an HTTP form, each tagged with a
free-text owner and checked against an allow-list and a per-owner limit.
That was the lab's interface, not a router's.

Routes now come from 'originate' entries in the configuration file and are
in the local RIB before any session starts. The local RIB holds one route
per prefix with no notion of owners or policy. local_route_policy is gone
from the configuration.

The HTTP API keeps its GET endpoints (status, config, peers, originated and
received routes, events, metrics). The announce, withdraw, wire-bytes and
well-known-community endpoints are removed, and the status page loses the
announce form, the community presets and the byte decoder. Community text
parsing moves from the web crate to bgp-packet so the configuration can use
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-01 20:51:15 +02:00
1 parent c37cfd029a
commit b800e3a322
22 files changed
+369 -1078

No files matched your search

Generated
-4
View File
@@ -240,16 +240,12 @@ version = "0.0.0"
dependencies = [ dependencies = [
"axum", "axum",
"bgp-packet", "bgp-packet",
"bgp-primitives",
"bgp-rib", "bgp-rib",
"bgp-server", "bgp-server",
"bytes",
"eyre", "eyre",
"futures", "futures",
"prometheus", "prometheus",
"reqwest",
"serde", "serde",
"serde_json",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
+21 -27
View File
@@ -1,54 +1,48 @@
# BGP # BGP
This crate provides the `bgp` binary which runs the Border Gateway Protocol This crate provides the `bgp` binary, which runs the Border Gateway Protocol
daemon together with its HTTP API. daemon together with its read-only HTTP status interface.
``` ```
cargo run -- path/to/config.textpb cargo run -- path/to/config.textpb
``` ```
The configuration format is defined in `crates/server/proto/config.proto`; The configuration format is defined in `crates/server/proto/config.proto`;
`testdata/server.textpb` is a worked example. When `http_endpoint` is set the `testdata/server.textpb` is a worked example. The daemon is configured
binary also serves: entirely through that file: peers, and the routes it originates
(`originate { prefix: ... }`). There is no way to change either at runtime.
When `http_endpoint` is set the binary also serves:
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `GET /statusz` | Status page: peers, announce form, local and received routes, live event log | | `GET /statusz` | Status page: configuration, peers and session state, originated and received routes, live event log |
| `GET /api/status` | Router ID, ASN, uptime and table sizes | | `GET /api/status` | Router ID, ASN, uptime and table sizes |
| `GET /api/peers` | Established sessions | | `GET /api/config` | The configuration file as read at startup |
| `GET /api/routes/local` | Locally originated routes | | `GET /api/peers` | Configured peers and established sessions |
| `POST /api/routes/local` | Announce a route (JSON body, see below) | | `GET /api/routes/local` | Originated routes |
| `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/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 /api/events` | Server-sent events for session and route changes |
| `GET /metricz` | Prometheus metrics | | `GET /metricz` | Prometheus metrics |
Announce body: Everything under it is read-only and unauthenticated. Bind it to loopback or
put it behind a reverse proxy.
```json ## What it does today
{
"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 It establishes sessions (both dialling configured peers and accepting
`local_route_policy` in the configuration. connections, with RFC 4271 collision detection), announces its originated
routes to every peer with itself as next hop, and records what peers send in
an Adj-RIB-In. It does not select best paths, re-advertise received routes or
program a forwarding table, and it has no import or export policy.
## Trying it without a router ## Trying it without a router
`fake_peer` is a minimal BGP speaker that connects to the daemon, announces a `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 prefix, prints every UPDATE it receives and, with `--echo`, re-announces each
received prefix back with its own ASN prepended (dropping NO_EXPORT routes). 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 That makes originated prefixes show up in the received table as if they had
they had travelled through a neighbouring AS: travelled through a neighbouring AS:
``` ```
cargo run -- testdata/server.textpb cargo run -- testdata/server.textpb
@@ -1,13 +1,14 @@
//! A minimal stand-in for a lab router: connects to a ferrix_bgp instance, //! A minimal stand-in for a neighbouring router: connects to the daemon,
//! completes the handshake, optionally announces some prefixes, and prints //! completes the handshake, optionally announces some prefixes, and prints
//! every UPDATE it receives. Useful for demos when no real router is around. //! every UPDATE it receives. Useful for trying things out when no real
//! router is around.
//! //!
//! cargo run -p bgp-integration-tests --example fake_peer -- \ //! cargo run -p bgp-integration-tests --example fake_peer -- \
//! --server 127.0.0.1:1790 --asn 65001 --announce 203.0.113.0/24 //! --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 `--echo`, every prefix received is re-announced back to the server
//! with this peer's ASN prepended, which lets a single ferrix instance show //! with this peer's ASN prepended, which lets a single daemon show its own
//! its own routes "coming back" in the received table. //! routes "coming back" in the received table.
//! //!
//! With `--listen ADDR` it instead waits for the server to dial it (one //! With `--listen ADDR` it instead waits for the server to dial it (one
//! connection at a time), which exercises the server's active side. //! connection at a time), which exercises the server's active side.
@@ -27,7 +28,7 @@ use tokio::net::TcpListener;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct Args { struct Args {
/// Address of the ferrix_bgp listener to connect to. /// Address of the daemon's BGP listener to connect to.
#[arg(long, default_value = "127.0.0.1:1790", conflicts_with = "listen")] #[arg(long, default_value = "127.0.0.1:1790", conflicts_with = "listen")]
server: String, server: String,
/// Instead of connecting, listen here and accept connections from the /// Instead of connecting, listen here and accept connections from the
+3 -16
View File
@@ -1,6 +1,6 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy}; use bgp_rib::{AdjRibIn, LocalRib};
use bgp_server::config::Config; use bgp_server::config::Config;
use bgp_server::server::BgpServerBuilder; use bgp_server::server::BgpServerBuilder;
use bgp_server::session::SessionRegistry; use bgp_server::session::SessionRegistry;
@@ -32,20 +32,7 @@ impl TestServer {
/// Start a server with an explicit hold time (useful for timer tests). /// Start a server with an explicit hold time (useful for timer tests).
pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result<Self> { pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
Self::start_inner(local_asn, hold_time_secs, LocalRibPolicy::default()).await Self::start_with(local_asn, |c| c.hold_time_secs = hold_time_secs).await
}
/// Start a server with a policy restricting locally originated routes.
pub async fn start_with_policy(local_asn: u32, policy: LocalRibPolicy) -> Result<Self> {
Self::start_inner(local_asn, 90, policy).await
}
async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result<Self> {
Self::start_with(local_asn, |c| {
c.hold_time_secs = hold_time_secs;
c.local_route_policy = policy;
})
.await
} }
/// Start a server after letting `customize` edit the default test config /// Start a server after letting `customize` edit the default test config
@@ -59,7 +46,7 @@ impl TestServer {
peers: vec![], peers: vec![],
connect_retry: std::time::Duration::from_secs(5), connect_retry: std::time::Duration::from_secs(5),
collision_detect_established_state: false, collision_detect_established_state: false,
local_route_policy: LocalRibPolicy::default(), originate: vec![],
http_endpoint: None, http_endpoint: None,
}; };
customize(&mut config); customize(&mut config);
+19 -23
View File
@@ -11,7 +11,7 @@ use bgp_packet::message::{
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage, NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
}; };
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::{LocalRibError, LocalRibPolicy, LocalRoute, LocalRouteAttrs}; use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_server::session::SessionEvent; use bgp_server::session::SessionEvent;
use eyre::Result; use eyre::Result;
@@ -24,7 +24,7 @@ fn prefix(s: &str) -> IpPrefix {
} }
fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute { fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute {
LocalRoute::new(prefix(p), "alice", attrs) LocalRoute::new(prefix(p), attrs)
} }
async fn established_peer(server: &TestServer) -> Result<TestPeer> { async fn established_peer(server: &TestServer) -> Result<TestPeer> {
@@ -171,7 +171,7 @@ async fn test_withdraw_sends_withdrawn_route() -> Result<()> {
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?; server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
let _ = next_update(&mut peer).await?; let _ = next_update(&mut peer).await?;
server.local_rib.withdraw(prefix("10.100.1.0/24"), Some("alice")).await?; server.local_rib.withdraw(prefix("10.100.1.0/24")).await?;
let update = next_update(&mut peer).await?; let update = next_update(&mut peer).await?;
assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]); assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]);
assert!(update.announced_routes.is_empty()); assert!(update.announced_routes.is_empty());
@@ -352,29 +352,25 @@ async fn test_shutdown_sends_cease_to_every_peer() -> Result<()> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Policy // Routes from the configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Routes listed under `originate` are in the table before any peer
/// connects, and each session is sent them when it comes up.
#[tokio::test] #[tokio::test]
async fn test_policy_rejects_prefix_outside_allowlist() -> Result<()> { async fn test_configured_routes_are_announced_to_every_new_session() -> Result<()> {
let server = TestServer::start_with_policy( let attrs = LocalRouteAttrs { med: Some(7), communities: vec![(65000, 100)], ..Default::default() };
LOCAL_ASN, let configured = vec![route("10.100.1.0/24", attrs), route("10.100.2.0/24", LocalRouteAttrs::default())];
LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 2 }, let server = TestServer::start_with(LOCAL_ASN, |c| c.originate = configured).await?;
) assert_eq!(server.local_rib.snapshot().await.len(), 2);
.await?;
let mut peer = established_peer(&server).await?;
wait_for_sessions(&server, 1).await?;
let err = server for router_id in [Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 2)] {
.local_rib let mut peer = established_peer_with_id(&server, router_id).await?;
.announce(route("192.0.2.0/24", LocalRouteAttrs::default())) let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?];
.await seen.sort_by_key(|u| u.announced_routes[0].to_string());
.unwrap_err(); assert_eq!(seen[0].announced_routes, vec![prefix("10.100.1.0/24")]);
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24"))); assert!(seen[0].path_attributes.contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100)]))));
assert_eq!(seen[1].announced_routes, vec![prefix("10.100.2.0/24")]);
// An allowed prefix still goes through on the same session. }
server.local_rib.announce(route("10.100.9.0/24", LocalRouteAttrs::default())).await?;
let update = next_update(&mut peer).await?;
assert_eq!(update.announced_routes, vec![prefix("10.100.9.0/24")]);
Ok(()) Ok(())
} }
+4 -4
View File
@@ -33,7 +33,7 @@ async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
let endpoint: SocketAddr = listener.local_addr()?; let endpoint: SocketAddr = listener.local_addr()?;
TestServer::start_with(LOCAL_ASN, |c| { TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200); c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn: PEER_ASN, hold_time_secs: 90, local_address: None }]; c.peers = vec![PeerConfig { name: "r1".into(), endpoint, remote_asn: PEER_ASN, hold_time_secs: 90, local_address: None }];
}) })
.await .await
} }
@@ -107,12 +107,12 @@ async fn test_simultaneous_open_peer_higher_keeps_inbound() -> Result<()> {
wait_for_sessions(&server, 1).await?; wait_for_sessions(&server, 1).await?;
let session = server.registry.list().remove(0); let session = server.registry.list().remove(0);
assert_eq!(session.direction, Direction::Inbound); assert_eq!(session.direction, Direction::Inbound);
assert_eq!(session.name.as_deref(), Some("lab")); assert_eq!(session.name.as_deref(), Some("r1"));
// The survivor is fully functional. // The survivor is fully functional.
server server
.local_rib .local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default())) .announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, LocalRouteAttrs::default()))
.await?; .await?;
let update = tokio::time::timeout(TIMEOUT, inbound.recv_update()).await??; let update = tokio::time::timeout(TIMEOUT, inbound.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
@@ -190,7 +190,7 @@ async fn test_established_session_wins_by_default() -> Result<()> {
assert_eq!(server.registry.list().len(), 1); assert_eq!(server.registry.list().len(), 1);
server server
.local_rib .local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default())) .announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, LocalRouteAttrs::default()))
.await?; .await?;
let update = tokio::time::timeout(TIMEOUT, first.recv_update()).await??; let update = tokio::time::timeout(TIMEOUT, first.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
+9 -9
View File
@@ -19,12 +19,12 @@ const LOCAL_ASN: u32 = 65000;
const PEER_ASN: u32 = 65001; const PEER_ASN: u32 = 65001;
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
/// Starts a server configured to dial `listener`'s address as peer "lab". /// Starts a server configured to dial `listener`'s address as peer "r1".
async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> { async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> {
let endpoint: SocketAddr = listener.local_addr()?; let endpoint: SocketAddr = listener.local_addr()?;
TestServer::start_with(LOCAL_ASN, |c| { TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200); c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { name: "lab".into(), endpoint, remote_asn, hold_time_secs: 90, local_address: None }]; c.peers = vec![PeerConfig { name: "r1".into(), endpoint, remote_asn, hold_time_secs: 90, local_address: None }];
}) })
.await .await
} }
@@ -56,19 +56,19 @@ async fn test_server_dials_configured_peer_and_establishes() -> Result<()> {
} }
}) })
.await?; .await?;
assert_eq!(listed.name.as_deref(), Some("lab")); assert_eq!(listed.name.as_deref(), Some("r1"));
assert_eq!(listed.peer.asn, PEER_ASN); assert_eq!(listed.peer.asn, PEER_ASN);
assert_eq!(listed.peer.addr, listener.local_addr()?); assert_eq!(listed.peer.addr, listener.local_addr()?);
// Configured view reflects it; the dialer is not in backoff. // Configured view reflects it; the dialer is not in backoff.
let (cfg, state) = server.registry.configured_peers().into_iter().next().unwrap(); let (cfg, state) = server.registry.configured_peers().into_iter().next().unwrap();
assert_eq!(cfg.name, "lab"); assert_eq!(cfg.name, "r1");
assert!(matches!(state, DialState::Connecting | DialState::Idle), "{state:?}"); assert!(matches!(state, DialState::Connecting | DialState::Idle), "{state:?}");
// Local routes flow over the dialled session too. // Local routes flow over the dialled session too.
server server
.local_rib .local_rib
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", LocalRouteAttrs::default())) .announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, LocalRouteAttrs::default()))
.await?; .await?;
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
@@ -145,7 +145,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
let server = TestServer::start_with(LOCAL_ASN, |c| { let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_secs(60); c.connect_retry = Duration::from_secs(60);
c.peers = vec![PeerConfig { c.peers = vec![PeerConfig {
name: "lab".into(), name: "r1".into(),
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
remote_asn: PEER_ASN, remote_asn: PEER_ASN,
hold_time_secs: 90, hold_time_secs: 90,
@@ -173,7 +173,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
} }
}) })
.await?; .await?;
assert_eq!(listed.name.as_deref(), Some("lab")); assert_eq!(listed.name.as_deref(), Some("r1"));
Ok(()) Ok(())
} }
@@ -187,7 +187,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
let _server = TestServer::start_with(LOCAL_ASN, |c| { let _server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200); c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { c.peers = vec![PeerConfig {
name: "lab".into(), name: "r1".into(),
endpoint, endpoint,
remote_asn: PEER_ASN, remote_asn: PEER_ASN,
hold_time_secs: 90, hold_time_secs: 90,
@@ -202,7 +202,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
let server = TestServer::start_with(LOCAL_ASN, |c| { let server = TestServer::start_with(LOCAL_ASN, |c| {
c.connect_retry = Duration::from_millis(200); c.connect_retry = Duration::from_millis(200);
c.peers = vec![PeerConfig { c.peers = vec![PeerConfig {
name: "lab".into(), name: "r1".into(),
endpoint, endpoint,
remote_asn: PEER_ASN, remote_asn: PEER_ASN,
hold_time_secs: 90, hold_time_secs: 90,
+42 -130
View File
@@ -6,12 +6,11 @@ use std::time::Duration;
use bgp_integration_tests::test_peer::TestPeer; use bgp_integration_tests::test_peer::TestPeer;
use bgp_integration_tests::test_server::TestServer; use bgp_integration_tests::test_server::TestServer;
use bgp_packet::message::{ use bgp_packet::message::{
AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, AsPathAttribute, CommunitiesPathAttribute, LargeCommunity, Message, NextHopPathAttribute,
OriginPathAttribute, PathAttribute, UpdateMessage, OriginPathAttribute, PathAttribute, UpdateMessage,
}; };
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext; use bgp_rib::{LocalRoute, LocalRouteAttrs};
use bgp_rib::LocalRibPolicy;
use eyre::Result; use eyre::Result;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -69,135 +68,51 @@ async fn test_status_and_peers() -> Result<()> {
Ok(()) Ok(())
} }
/// The full loop: POST an announcement, the BGP peer receives the UPDATE, /// Originated routes are listed with their attributes as text.
/// the local table lists it, the wire view matches the bytes, DELETE
/// withdraws it.
#[tokio::test] #[tokio::test]
async fn test_announce_over_http_reaches_peer() -> Result<()> { async fn test_originated_routes_are_listed() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?; let attrs = LocalRouteAttrs {
let (base, _http) = server.serve_http().await?; prepend: 1,
let client = reqwest::Client::new(); med: Some(10),
let mut peer = established_peer(&server).await?; communities: vec![(65000, 100), (65535, 65281)],
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
let resp = client ..Default::default()
.post(format!("{base}/api/routes/local")) };
.json(&json!({ let server = TestServer::start_with(LOCAL_ASN, |c| {
"prefix": "10.100.1.0/24", c.originate = vec![LocalRoute::new(prefix("10.100.1.0/24"), attrs)];
"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]["four_octet_asn"], true);
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?; .await?;
let (base, _http) = server.serve_http().await?; let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("{base}/api/routes/local");
let post = |body: Value| client.post(&url).json(&body).send(); let local = get_json(&client, &format!("{base}/api/routes/local")).await?;
assert_eq!(local.as_array().unwrap().len(), 1);
assert_eq!(local[0]["prefix"], "10.100.1.0/24");
assert_eq!(local[0]["origin"], "IGP");
assert_eq!(local[0]["prepend"], 1);
assert_eq!(local[0]["med"], 10);
assert_eq!(local[0]["communities"], json!(["65000:100", "65535:65281"]));
assert_eq!(local[0]["large_communities"], json!(["65000:1:2"]));
assert_eq!(get_json(&client, &format!("{base}/api/status")).await?["local_routes"], 1);
Ok(())
}
// Malformed input → 400 with an error message. /// The API only reports. Nothing reachable over HTTP changes what the
let resp = post(json!({ "prefix": "not-a-prefix", "owner": "alice" })).await?; /// router announces.
assert_eq!(resp.status(), 400); #[tokio::test]
assert!(resp.json::<Value>().await?["error"].as_str().unwrap().contains("invalid prefix")); async fn test_api_is_read_only() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new();
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "" })).await?; let body = json!({ "prefix": "10.100.1.0/24" });
assert_eq!(resp.status(), 400); for path in ["/api/routes/local", "/api/routes/received", "/api/peers", "/api/status"] {
let post = client.post(format!("{base}{path}")).json(&body).send().await?;
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "communities": ["bogus"] })).await?; assert_eq!(post.status(), 405, "POST {path}");
assert_eq!(resp.status(), 400); let delete = client.delete(format!("{base}{path}")).json(&body).send().await?;
assert_eq!(delete.status(), 405, "DELETE {path}");
let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "origin": "weird" })).await?; }
assert_eq!(resp.status(), 400); assert!(server.local_rib.snapshot().await.is_empty());
// 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(()) Ok(())
} }
@@ -252,7 +167,7 @@ async fn test_event_stream() -> Result<()> {
let _peer = established_peer(&server).await?; let _peer = established_peer(&server).await?;
server server
.local_rib .local_rib
.announce(bgp_rib::LocalRoute::new(prefix("10.100.1.0/24"), "alice", Default::default())) .announce(LocalRoute::new(prefix("10.100.1.0/24"), Default::default()))
.await?; .await?;
// Collect SSE frames until we have seen both events. // Collect SSE frames until we have seen both events.
@@ -300,7 +215,7 @@ async fn test_statusz_page_is_served() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
async fn test_metricz_and_well_known() -> Result<()> { async fn test_metricz() -> Result<()> {
let server = TestServer::start(LOCAL_ASN).await?; let server = TestServer::start(LOCAL_ASN).await?;
let (base, _http) = server.serve_http().await?; let (base, _http) = server.serve_http().await?;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -319,8 +234,5 @@ async fn test_metricz_and_well_known() -> Result<()> {
assert!(metric("bgp_messages_received_total{type=\"keepalive\"}").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_local_routes").is_some(), "{text}");
assert!(metric("bgp_adj_rib_in_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(()) Ok(())
} }
+4 -4
View File
@@ -104,16 +104,16 @@ async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> {
/// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next /// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next
/// hop), so this only checks the builder the advertiser uses: the UPDATE for /// hop), so this only checks the builder the advertiser uses: the UPDATE for
/// a lab /48 carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP. /// an IPv6 prefix carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP.
#[tokio::test] #[tokio::test]
async fn test_ipv6_lab_prefix_update_shape() -> Result<()> { async fn test_ipv6_prefix_update_shape() -> Result<()> {
let route = LocalRoute::new(IpPrefix::try_from("2a0d:d741:1::/48")?, "student1", LocalRouteAttrs::default()); let route = LocalRoute::new(IpPrefix::try_from("2001:db8:1::/48")?, LocalRouteAttrs::default());
let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?; let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?;
let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into()).expect("ipv6 over ipv6"); let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into()).expect("ipv6 over ipv6");
assert!(update.announced_routes.is_empty()); assert!(update.announced_routes.is_empty());
assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_)))); assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_))));
assert!(update.path_attributes.iter().any(|a| matches!(a, PathAttribute::MpReachNlri(m) assert!(update.path_attributes.iter().any(|a| matches!(a, PathAttribute::MpReachNlri(m)
if m.afi == AddressFamilyId::Ipv6 && m.prefixes[0].to_string() == "2a0d:d741:1::/48"))); if m.afi == AddressFamilyId::Ipv6 && m.prefixes[0].to_string() == "2001:db8:1::/48")));
let _ = Ipv4Addr::LOCALHOST; let _ = Ipv4Addr::LOCALHOST;
Ok(()) Ok(())
} }
@@ -1,10 +1,11 @@
//! Parsing and formatting of community values as they appear in the API. //! Parsing and formatting of community values as text, the way they are
//! written in configuration and shown in status output.
//! //!
//! Standard communities are written `ASN:VALUE` (e.g. `65000:100`) or as one //! 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 //! of the well-known names from RFC 1997 / RFC 7999 / RFC 8326. Large
//! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092). //! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092).
use bgp_packet::message::LargeCommunity; use crate::message::LargeCommunity;
/// Well-known standard communities, by name. /// Well-known standard communities, by name.
pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[ pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[
+1
View File
@@ -1,3 +1,4 @@
pub mod community;
pub mod errors; pub mod errors;
pub mod message; pub mod message;
pub mod open; pub mod open;
+1 -1
View File
@@ -6,4 +6,4 @@ mod local_rib;
pub use adj_rib_in::AdjRibIn; pub use adj_rib_in::AdjRibIn;
pub use entry::{PeerId, RibEntry, SnapshotEntry}; pub use entry::{PeerId, RibEntry, SnapshotEntry};
pub use event::RibEvent; pub use event::RibEvent;
pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRibPolicy, LocalRoute, LocalRouteAttrs}; pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRoute, LocalRouteAttrs};
+42 -151
View File
@@ -1,9 +1,9 @@
//! Locally originated routes (the "Loc-RIB" of routes this router announces //! Locally originated routes (the "Loc-RIB" of routes this router announces
//! on its own behalf, as opposed to routes learned from peers). //! on its own behalf, as opposed to routes learned from peers).
//! //!
//! The actor stores one route per prefix together with the attributes chosen //! The actor stores one route per prefix together with its attributes. It
//! by whoever announced it. It does not know about peers or sessions: the //! does not know about peers or sessions: the server subscribes to
//! server subscribes to [`LocalRibEvent`]s and turns them into UPDATEs. //! [`LocalRibEvent`]s and turns them into UPDATEs.
use std::collections::HashMap; use std::collections::HashMap;
use std::time::SystemTime; use std::time::SystemTime;
@@ -17,7 +17,7 @@ use tracing::warn;
const EVENT_CHANNEL_CAPACITY: usize = 1024; const EVENT_CHANNEL_CAPACITY: usize = 1024;
const CMD_CHANNEL_CAPACITY: usize = 256; const CMD_CHANNEL_CAPACITY: usize = 256;
/// User-selectable attributes for a locally originated route. /// Attributes of a locally originated route.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRouteAttrs { pub struct LocalRouteAttrs {
pub origin: OriginPathAttribute, pub origin: OriginPathAttribute,
@@ -47,44 +47,18 @@ impl Default for LocalRouteAttrs {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalRoute { pub struct LocalRoute {
pub prefix: IpPrefix, pub prefix: IpPrefix,
/// Free-form identity of whoever announced the route (e.g. a student
/// name). Used to stop one owner replacing or withdrawing another's route.
pub owner: String,
pub attrs: LocalRouteAttrs, pub attrs: LocalRouteAttrs,
pub announced_at: SystemTime, pub announced_at: SystemTime,
} }
impl LocalRoute { impl LocalRoute {
pub fn new(prefix: IpPrefix, owner: impl Into<String>, attrs: LocalRouteAttrs) -> Self { pub fn new(prefix: IpPrefix, attrs: LocalRouteAttrs) -> Self {
Self { prefix, owner: owner.into(), attrs, announced_at: SystemTime::now() } Self { prefix, attrs, announced_at: SystemTime::now() }
}
}
/// Limits on what may be announced. Enforced inside the actor so every caller
/// (HTTP API, tests, future CLI) goes through the same checks.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LocalRibPolicy {
/// A route is accepted only if it is covered by at least one of these
/// prefixes. Empty means no restriction.
pub allowed_prefixes: Vec<IpPrefix>,
/// Maximum number of routes a single owner may hold. `0` means unlimited.
pub max_routes_per_owner: usize,
}
impl LocalRibPolicy {
fn allows(&self, prefix: &IpPrefix) -> bool {
self.allowed_prefixes.is_empty() || self.allowed_prefixes.iter().any(|p| p.contains(prefix))
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LocalRibError { pub enum LocalRibError {
#[error("prefix {0} is not covered by the allowed prefixes")]
NotAllowed(IpPrefix),
#[error("owner {owner:?} already has {limit} routes")]
OwnerLimit { owner: String, limit: usize },
#[error("prefix {prefix} is owned by {owner:?}")]
OwnedByOther { prefix: IpPrefix, owner: String },
#[error("local RIB actor has stopped")] #[error("local RIB actor has stopped")]
Closed, Closed,
} }
@@ -97,12 +71,8 @@ pub enum LocalRibEvent {
} }
enum Command { enum Command {
Announce { route: LocalRoute, reply: oneshot::Sender<Result<(), LocalRibError>> }, Announce { route: LocalRoute, reply: oneshot::Sender<()> },
Withdraw { Withdraw { prefix: IpPrefix, reply: oneshot::Sender<Option<LocalRoute>> },
prefix: IpPrefix,
requester: Option<String>,
reply: oneshot::Sender<Result<Option<LocalRoute>, LocalRibError>>,
},
Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> }, Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> },
} }
@@ -114,38 +84,32 @@ pub struct LocalRib {
} }
impl LocalRib { impl LocalRib {
pub fn spawn(policy: LocalRibPolicy) -> Self { pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tokio::spawn(run_actor(rx, events.clone(), policy)); tokio::spawn(run_actor(rx, events.clone()));
Self { tx, events } Self { tx, events }
} }
/// Adds a route, or replaces the attributes of an existing route with the /// Adds a route, or replaces the existing route for the same prefix.
/// same prefix and owner.
pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> { pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> {
let (reply, rx) = oneshot::channel(); let (reply, rx) = oneshot::channel();
self.tx self.tx
.send(Command::Announce { route, reply }) .send(Command::Announce { route, reply })
.await .await
.map_err(|_| LocalRibError::Closed)?; .map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)? rx.await.map_err(|_| LocalRibError::Closed)
} }
/// Removes a route. `requester` must match the route's owner; pass `None` /// Removes a route. Returns the removed route, or `None` if the prefix
/// to withdraw regardless of owner (administrative override). Returns the /// was not present.
/// removed route, or `None` if the prefix was not present. pub async fn withdraw(&self, prefix: IpPrefix) -> Result<Option<LocalRoute>, LocalRibError> {
pub async fn withdraw(
&self,
prefix: IpPrefix,
requester: Option<&str>,
) -> Result<Option<LocalRoute>, LocalRibError> {
let (reply, rx) = oneshot::channel(); let (reply, rx) = oneshot::channel();
self.tx self.tx
.send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply }) .send(Command::Withdraw { prefix, reply })
.await .await
.map_err(|_| LocalRibError::Closed)?; .map_err(|_| LocalRibError::Closed)?;
rx.await.map_err(|_| LocalRibError::Closed)? rx.await.map_err(|_| LocalRibError::Closed)
} }
/// Point-in-time copy of every route, in no particular order. /// Point-in-time copy of every route, in no particular order.
@@ -162,39 +126,22 @@ impl LocalRib {
} }
} }
async fn run_actor( async fn run_actor(mut rx: mpsc::Receiver<Command>, events: broadcast::Sender<LocalRibEvent>) {
mut rx: mpsc::Receiver<Command>,
events: broadcast::Sender<LocalRibEvent>,
policy: LocalRibPolicy,
) {
let mut table: HashMap<IpPrefix, LocalRoute> = HashMap::new(); let mut table: HashMap<IpPrefix, LocalRoute> = HashMap::new();
while let Some(cmd) = rx.recv().await { while let Some(cmd) = rx.recv().await {
match cmd { match cmd {
Command::Announce { route, reply } => { Command::Announce { route, reply } => {
let result = check_announce(&table, &policy, &route).map(|()| { table.insert(route.prefix.clone(), route.clone());
table.insert(route.prefix.clone(), route.clone()); let _ = events.send(LocalRibEvent::Announced(route));
let _ = events.send(LocalRibEvent::Announced(route)); let _ = reply.send(());
});
let _ = reply.send(result);
} }
Command::Withdraw { prefix, requester, reply } => { Command::Withdraw { prefix, reply } => {
let result = match (table.get(&prefix), requester) { let removed = table.remove(&prefix);
(Some(existing), Some(req)) if existing.owner != req => { if removed.is_some() {
Err(LocalRibError::OwnedByOther { let _ = events.send(LocalRibEvent::Withdrawn { prefix });
prefix: prefix.clone(), }
owner: existing.owner.clone(), let _ = reply.send(removed);
})
}
_ => {
let removed = table.remove(&prefix);
if removed.is_some() {
let _ = events.send(LocalRibEvent::Withdrawn { prefix });
}
Ok(removed)
}
};
let _ = reply.send(result);
} }
Command::Snapshot { reply } => { Command::Snapshot { reply } => {
if reply.send(table.values().cloned().collect()).is_err() { if reply.send(table.values().cloned().collect()).is_err() {
@@ -205,36 +152,6 @@ async fn run_actor(
} }
} }
fn check_announce(
table: &HashMap<IpPrefix, LocalRoute>,
policy: &LocalRibPolicy,
route: &LocalRoute,
) -> Result<(), LocalRibError> {
if !policy.allows(&route.prefix) {
return Err(LocalRibError::NotAllowed(route.prefix.clone()));
}
if let Some(existing) = table.get(&route.prefix) {
if existing.owner != route.owner {
return Err(LocalRibError::OwnedByOther {
prefix: route.prefix.clone(),
owner: existing.owner.clone(),
});
}
// Replacing our own route never changes the per-owner count.
return Ok(());
}
if policy.max_routes_per_owner > 0 {
let held = table.values().filter(|r| r.owner == route.owner).count();
if held >= policy.max_routes_per_owner {
return Err(LocalRibError::OwnerLimit {
owner: route.owner.clone(),
limit: policy.max_routes_per_owner,
});
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -243,63 +160,37 @@ mod tests {
IpPrefix::try_from(s).unwrap() IpPrefix::try_from(s).unwrap()
} }
fn route(p: &str, owner: &str) -> LocalRoute { fn route(p: &str) -> LocalRoute {
LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default()) LocalRoute::new(prefix(p), LocalRouteAttrs::default())
} }
#[tokio::test] #[tokio::test]
async fn announce_and_withdraw_emit_events() { async fn announce_and_withdraw_emit_events() {
let rib = LocalRib::spawn(LocalRibPolicy::default()); let rib = LocalRib::spawn();
let mut events = rib.subscribe(); let mut events = rib.subscribe();
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap(); rib.announce(route("10.0.0.0/24")).await.unwrap();
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24"))); assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24")));
assert_eq!(rib.snapshot().await.len(), 1); assert_eq!(rib.snapshot().await.len(), 1);
let removed = rib.withdraw(prefix("10.0.0.0/24"), Some("alice")).await.unwrap(); let removed = rib.withdraw(prefix("10.0.0.0/24")).await.unwrap();
assert_eq!(removed.unwrap().owner, "alice"); assert_eq!(removed.unwrap().prefix, prefix("10.0.0.0/24"));
assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24"))); assert!(matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24")));
assert!(rib.snapshot().await.is_empty()); assert!(rib.snapshot().await.is_empty());
// Withdrawing an absent prefix is a no-op, not an error. // Withdrawing an absent prefix is a no-op, not an error.
assert_eq!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap(), None); assert_eq!(rib.withdraw(prefix("10.0.0.0/24")).await.unwrap(), None);
} }
#[tokio::test] #[tokio::test]
async fn policy_allowlist_is_enforced() { async fn announcing_a_prefix_again_replaces_its_attributes() {
let rib = LocalRib::spawn(LocalRibPolicy { let rib = LocalRib::spawn();
allowed_prefixes: vec![prefix("10.100.0.0/16")], rib.announce(route("10.0.0.0/24")).await.unwrap();
max_routes_per_owner: 0, let attrs = LocalRouteAttrs { med: Some(50), ..Default::default() };
}); rib.announce(LocalRoute::new(prefix("10.0.0.0/24"), attrs.clone())).await.unwrap();
rib.announce(route("10.100.3.0/24", "alice")).await.unwrap();
let err = rib.announce(route("192.0.2.0/24", "alice")).await.unwrap_err();
assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.2.0/24")));
}
#[tokio::test] let table = rib.snapshot().await;
async fn owner_limit_ignores_replacements() { assert_eq!(table.len(), 1);
let rib = LocalRib::spawn(LocalRibPolicy { allowed_prefixes: vec![], max_routes_per_owner: 1 }); assert_eq!(table[0].attrs, attrs);
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
// Replacing the same prefix is fine.
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.1.0/24", "alice")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnerLimit { limit: 1, .. }));
// Other owners are unaffected.
rib.announce(route("10.0.1.0/24", "bob")).await.unwrap();
}
#[tokio::test]
async fn other_owner_cannot_replace_or_withdraw() {
let rib = LocalRib::spawn(LocalRibPolicy::default());
rib.announce(route("10.0.0.0/24", "alice")).await.unwrap();
let err = rib.announce(route("10.0.0.0/24", "bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
let err = rib.withdraw(prefix("10.0.0.0/24"), Some("bob")).await.unwrap_err();
assert!(matches!(err, LocalRibError::OwnedByOther { .. }));
// Administrative override works.
assert!(rib.withdraw(prefix("10.0.0.0/24"), None).await.unwrap().is_some());
} }
} }
+22 -11
View File
@@ -18,8 +18,8 @@ message BgpConfig {
// Peers configured on this server. // Peers configured on this server.
repeated Peer peers = 4; repeated Peer peers = 4;
// Limits on routes originated locally (e.g. via the demo UI). // Routes this router originates and announces to every peer.
LocalRoutePolicy local_route_policy = 5; repeated OriginatedRoute originate = 5;
// Where to serve the HTTP API, status page and metrics. The port is // Where to serve the HTTP API, status page and metrics. The port is
// required. Unset disables the HTTP server. // required. Unset disables the HTTP server.
@@ -37,18 +37,29 @@ message BgpConfig {
// restarted take over from a session we have not yet noticed is dead, // restarted take over from a session we have not yet noticed is dead,
// instead of being refused until the hold timer expires. // instead of being refused until the hold timer expires.
bool collision_detect_established_state = 8; bool collision_detect_established_state = 8;
} }
// Limits on locally originated routes. // A route originated by this router. It is announced with an AS_PATH of the
message LocalRoutePolicy { // local AS and, on each session, that session's local address as next hop.
// A locally originated route is accepted only if it is covered by one of message OriginatedRoute {
// these prefixes, written in CIDR notation (e.g. "10.100.0.0/16"). // The prefix in CIDR notation, e.g. "192.0.2.0/24" or "2001:db8::/32".
// Empty means any prefix may be announced. string prefix = 1;
repeated string allowed_prefixes = 1;
// Maximum number of routes a single owner may announce. 0 means unlimited. // ORIGIN attribute: "IGP" (the default), "EGP" or "INCOMPLETE".
uint32 max_routes_per_owner = 2; string origin = 2;
// Extra copies of the local AS to prepend to the AS_PATH. At most 16.
uint32 as_path_prepend = 3;
// MULTI_EXIT_DISC. Left out of the announcement when unset.
optional uint32 med = 4;
// Standard communities (RFC 1997): "ASN:VALUE", or a well-known name such
// as "NO_EXPORT".
repeated string communities = 5;
// Large communities (RFC 8092): "GLOBAL:LOCAL1:LOCAL2".
repeated string large_communities = 6;
} }
// An IP address and port pair. // An IP address and port pair.
+3 -4
View File
@@ -201,7 +201,6 @@ mod tests {
fn ipv4_announce_has_expected_attributes_and_roundtrips() { fn ipv4_announce_has_expected_attributes_and_roundtrips() {
let route = LocalRoute::new( let route = LocalRoute::new(
prefix("10.100.1.0/24"), prefix("10.100.1.0/24"),
"alice",
LocalRouteAttrs { LocalRouteAttrs {
origin: OriginPathAttribute::IGP, origin: OriginPathAttribute::IGP,
prepend: 2, prepend: 2,
@@ -240,7 +239,7 @@ mod tests {
#[test] #[test]
fn ipv6_announce_uses_mp_reach() { fn ipv6_announce_uses_mp_reach() {
let route = LocalRoute::new(prefix("2001:db8:1::/48"), "alice", LocalRouteAttrs::default()); let route = LocalRoute::new(prefix("2001:db8:1::/48"), LocalRouteAttrs::default());
let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
let update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap(); let update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap();
@@ -255,9 +254,9 @@ mod tests {
#[test] #[test]
fn family_mismatch_is_skipped() { fn family_mismatch_is_skipped() {
let v6 = LocalRoute::new(prefix("2001:db8::/32"), "a", LocalRouteAttrs::default()); let v6 = LocalRoute::new(prefix("2001:db8::/32"), LocalRouteAttrs::default());
assert!(build_announce(&v6, 65000, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); assert!(build_announce(&v6, 65000, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none());
let v4 = LocalRoute::new(prefix("10.0.0.0/8"), "a", LocalRouteAttrs::default()); let v4 = LocalRoute::new(prefix("10.0.0.0/8"), LocalRouteAttrs::default());
assert!(build_announce(&v4, 65000, IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none()); assert!(build_announce(&v4, 65000, IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none());
} }
+108 -16
View File
@@ -3,14 +3,17 @@ use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRibPolicy; use bgp_packet::community;
use bgp_packet::message::OriginPathAttribute;
use bgp_rib::{LocalRoute, LocalRouteAttrs};
use eyre::{Result, WrapErr, bail, eyre}; use eyre::{Result, WrapErr, bail, eyre};
use crate::proto::config::BgpConfig; use crate::proto::config::{BgpConfig, OriginatedRoute};
const DEFAULT_BGP_PORT: u16 = 179; const DEFAULT_BGP_PORT: u16 = 179;
const DEFAULT_HOLD_TIME_SECS: u16 = 90; const DEFAULT_HOLD_TIME_SECS: u16 = 90;
const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5); const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5);
const MAX_AS_PATH_PREPEND: u32 = 16;
/// Validated, Rust-native server configuration derived from the proto config. /// Validated, Rust-native server configuration derived from the proto config.
pub struct Config { pub struct Config {
@@ -27,8 +30,8 @@ pub struct Config {
/// instead of always closing the new connection (RFC 4271 §8.1.1 /// instead of always closing the new connection (RFC 4271 §8.1.1
/// `CollisionDetectEstablishedState`). /// `CollisionDetectEstablishedState`).
pub collision_detect_established_state: bool, pub collision_detect_established_state: bool,
/// Limits on locally originated routes. /// Routes this router originates.
pub local_route_policy: LocalRibPolicy, pub originate: Vec<LocalRoute>,
/// Address for the HTTP API; `None` disables it. /// Address for the HTTP API; `None` disables it.
pub http_endpoint: Option<SocketAddr>, pub http_endpoint: Option<SocketAddr>,
} }
@@ -105,17 +108,13 @@ impl Config {
}) })
.collect::<Result<_>>()?; .collect::<Result<_>>()?;
let local_route_policy = match proto.local_route_policy.into_option() { let originate: Vec<LocalRoute> =
Some(policy) => LocalRibPolicy { proto.originate.iter().map(parse_originated_route).collect::<Result<_>>()?;
allowed_prefixes: policy for (i, route) in originate.iter().enumerate() {
.allowed_prefixes if originate[..i].iter().any(|earlier| earlier.prefix == route.prefix) {
.iter() bail!("originate lists {} more than once", route.prefix);
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid allowed prefix {p:?}"))) }
.collect::<Result<_>>()?, }
max_routes_per_owner: policy.max_routes_per_owner as usize,
},
None => LocalRibPolicy::default(),
};
let http_endpoint = match proto.http_endpoint.into_option() { let http_endpoint = match proto.http_endpoint.into_option() {
Some(ep) => { Some(ep) => {
@@ -141,7 +140,7 @@ impl Config {
peers, peers,
connect_retry, connect_retry,
collision_detect_established_state: proto.collision_detect_established_state, collision_detect_established_state: proto.collision_detect_established_state,
local_route_policy, originate,
http_endpoint, http_endpoint,
}) })
} }
@@ -161,3 +160,96 @@ fn parse_endpoint(address: &str, port: u32) -> Result<SocketAddr> {
Ok(SocketAddr::new(ip, port)) Ok(SocketAddr::new(ip, port))
} }
fn parse_originated_route(route: &OriginatedRoute) -> Result<LocalRoute> {
let prefix = IpPrefix::try_from(route.prefix.as_str())
.wrap_err_with(|| format!("Invalid originate prefix {:?}", route.prefix))?;
let origin = match route.origin.to_ascii_uppercase().as_str() {
"" | "IGP" => OriginPathAttribute::IGP,
"EGP" => OriginPathAttribute::EGP,
"INCOMPLETE" => OriginPathAttribute::INCOMPLETE,
other => bail!("originate {prefix}: origin {other:?} must be IGP, EGP or INCOMPLETE"),
};
if route.as_path_prepend > MAX_AS_PATH_PREPEND {
bail!("originate {prefix}: as_path_prepend must be at most {MAX_AS_PATH_PREPEND}");
}
let communities = route
.communities
.iter()
.map(|c| community::parse_standard(c))
.collect::<Result<_, _>>()
.map_err(|e| eyre!("originate {prefix}: {e}"))?;
let large_communities = route
.large_communities
.iter()
.map(|c| community::parse_large(c))
.collect::<Result<_, _>>()
.map_err(|e| eyre!("originate {prefix}: {e}"))?;
Ok(LocalRoute::new(
prefix,
LocalRouteAttrs {
origin,
prepend: route.as_path_prepend as u8,
med: route.med,
communities,
large_communities,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> Result<Config> {
let proto = protobuf::text_format::parse_from_str::<BgpConfig>(text).map_err(|e| eyre!("{e}"))?;
Config::from_proto(proto)
}
const BASE: &str = "router_id: \"192.0.2.1\" local_asn: 65000\n";
#[test]
fn originated_routes_are_parsed_with_their_attributes() {
let config = parse(&format!(
"{BASE}originate {{ prefix: \"192.0.2.0/24\" }}
originate {{
prefix: \"2001:db8::/32\" origin: \"incomplete\" as_path_prepend: 2 med: 0
communities: \"65000:100\" communities: \"NO_EXPORT\"
large_communities: \"65000:1:2\"
}}"
))
.unwrap();
assert_eq!(config.originate.len(), 2);
let plain = &config.originate[0];
assert_eq!(plain.prefix, IpPrefix::try_from("192.0.2.0/24").unwrap());
assert_eq!(plain.attrs, LocalRouteAttrs::default());
let full = &config.originate[1].attrs;
assert_eq!(full.origin, OriginPathAttribute::INCOMPLETE);
assert_eq!(full.prepend, 2);
assert_eq!(full.med, Some(0), "an explicit MED of 0 is not the same as no MED");
assert_eq!(full.communities, vec![(65000, 100), (65535, 65281)]);
assert_eq!(full.large_communities.len(), 1);
}
#[test]
fn bad_originated_routes_are_rejected_with_the_prefix_named() {
for (body, expected) in [
("prefix: \"not-a-prefix\"", "Invalid originate prefix"),
("prefix: \"192.0.2.0/24\" origin: \"static\"", "must be IGP, EGP or INCOMPLETE"),
("prefix: \"192.0.2.0/24\" as_path_prepend: 17", "at most 16"),
("prefix: \"192.0.2.0/24\" communities: \"70000:1\"", "originate 192.0.2.0/24"),
("prefix: \"192.0.2.0/24\" large_communities: \"1:2\"", "originate 192.0.2.0/24"),
] {
let err = parse(&format!("{BASE}originate {{ {body} }}")).err().expect(body);
assert!(format!("{err:#}").contains(expected), "{body}: {err:#}");
}
let err = parse(&format!(
"{BASE}originate {{ prefix: \"192.0.2.0/24\" }} originate {{ prefix: \"192.0.2.0/24\" med: 5 }}"
))
.err()
.unwrap();
assert!(format!("{err:#}").contains("more than once"), "{err:#}");
}
}
+7 -1
View File
@@ -53,10 +53,16 @@ impl BgpServerBuilder {
info!(%bound_addr, "Listening for BGP connections"); info!(%bound_addr, "Listening for BGP connections");
listeners.push((bound_addr, listener)); listeners.push((bound_addr, listener));
} }
// Routes from the configuration go in before any session exists;
// each session is sent the whole table when it comes up.
let local_rib = LocalRib::spawn();
for route in &self.config.originate {
local_rib.announce(route.clone()).await.wrap_err("Local RIB stopped during startup")?;
}
Ok(BgpServer { Ok(BgpServer {
listeners, listeners,
adj_rib_in: AdjRibIn::spawn(), adj_rib_in: AdjRibIn::spawn(),
local_rib: LocalRib::spawn(self.config.local_route_policy.clone()), local_rib,
registry: SessionRegistry::with_configured( registry: SessionRegistry::with_configured(
self.config self.config
.peers .peers
-6
View File
@@ -6,18 +6,12 @@ edition.workspace = true
[dependencies] [dependencies]
axum.workspace = true axum.workspace = true
bgp-packet.workspace = true bgp-packet.workspace = true
bgp-primitives.workspace = true
bgp-rib.workspace = true bgp-rib.workspace = true
bgp-server.workspace = true bgp-server.workspace = true
bytes.workspace = true
eyre.workspace = true eyre.workspace = true
futures.workspace = true futures.workspace = true
prometheus.workspace = true prometheus.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
tracing.workspace = true tracing.workspace = true
[dev-dependencies]
reqwest.workspace = true
+7 -220
View File
@@ -1,60 +1,17 @@
//! JSON handlers and their view types. //! JSON handlers and their view types. Everything here is read-only: the
//! daemon is configured through its configuration file.
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use axum::Json; use axum::Json;
use axum::extract::{Query, State}; use axum::extract::State;
use axum::http::StatusCode; use bgp_packet::community;
use axum::response::{IntoResponse, Response}; use bgp_packet::message::{OriginPathAttribute, PathAttribute};
use bgp_packet::message::{Message, OriginPathAttribute, PathAttribute, UpdateMessage}; use bgp_rib::{LocalRoute, SnapshotEntry};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext;
use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry};
use bgp_server::advertise::build_announce;
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry}; use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
use bytes::BytesMut; use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::AppState; 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 // Views
@@ -165,7 +122,6 @@ pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct LocalRouteView { pub struct LocalRouteView {
pub prefix: String, pub prefix: String,
pub owner: String,
pub origin: String, pub origin: String,
pub prepend: u8, pub prepend: u8,
pub med: Option<u32>, pub med: Option<u32>,
@@ -178,7 +134,6 @@ impl From<&LocalRoute> for LocalRouteView {
fn from(r: &LocalRoute) -> Self { fn from(r: &LocalRoute) -> Self {
Self { Self {
prefix: r.prefix.to_string(), prefix: r.prefix.to_string(),
owner: r.owner.clone(),
origin: origin_name(r.attrs.origin).to_string(), origin: origin_name(r.attrs.origin).to_string(),
prepend: r.attrs.prepend, prepend: r.attrs.prepend,
med: r.attrs.med, med: r.attrs.med,
@@ -250,21 +205,6 @@ impl From<&SnapshotEntry> for ReceivedRouteView {
} }
} }
/// The exact UPDATE a route produces on one session.
#[derive(Debug, Clone, Serialize)]
pub struct WireView {
pub peer_addr: String,
pub peer_asn: u32,
pub next_hop: String,
/// Whether AS numbers in AS_PATH are 4 bytes wide on this session.
pub four_octet_asn: bool,
/// Complete BGP message including the 19-byte header, as lowercase hex.
pub hex: String,
pub length: usize,
pub summary: AttrSummary,
pub update: UpdateMessage,
}
fn origin_name(o: OriginPathAttribute) -> &'static str { fn origin_name(o: OriginPathAttribute) -> &'static str {
match o { match o {
OriginPathAttribute::IGP => "IGP", OriginPathAttribute::IGP => "IGP",
@@ -273,63 +213,6 @@ fn origin_name(o: OriginPathAttribute) -> &'static str {
} }
} }
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 // Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -373,102 +256,6 @@ pub async fn local_routes(State(state): State<AppState>) -> Json<Vec<LocalRouteV
Json(routes) Json(routes)
} }
pub async fn announce(
State(state): State<AppState>,
Json(req): Json<AnnounceRequest>,
) -> Result<Json<LocalRouteView>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = validate_owner(&req.owner)?;
if req.prepend > MAX_PREPEND {
return Err(ApiError::bad_request(format!("prepend must be at most {MAX_PREPEND}")));
}
let attrs = LocalRouteAttrs {
origin: req.origin.as_deref().map(parse_origin).transpose()?.unwrap_or(OriginPathAttribute::IGP),
prepend: req.prepend,
med: req.med,
communities: req
.communities
.iter()
.map(|c| community::parse_standard(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
large_communities: req
.large_communities
.iter()
.map(|c| community::parse_large(c))
.collect::<Result<_, _>>()
.map_err(ApiError::bad_request)?,
};
let route = LocalRoute::new(prefix, owner, attrs);
state.local_rib.announce(route.clone()).await?;
Ok(Json(LocalRouteView::from(&route)))
}
pub async fn withdraw(
State(state): State<AppState>,
Json(req): Json<WithdrawRequest>,
) -> Result<Json<Option<LocalRouteView>>, ApiError> {
let prefix = parse_prefix(&req.prefix)?;
let owner = req.owner.as_deref().map(validate_owner).transpose()?;
let removed = state.local_rib.withdraw(prefix, owner.as_deref()).await?;
Ok(Json(removed.as_ref().map(LocalRouteView::from)))
}
/// Shows the UPDATE bytes a local route produces on each established session.
pub async fn wire(
State(state): State<AppState>,
Query(q): Query<WireQuery>,
) -> Result<Json<Vec<WireView>>, ApiError> {
let prefix = parse_prefix(&q.prefix)?;
let route = state
.local_rib
.snapshot()
.await
.into_iter()
.find(|r| r.prefix == prefix)
.ok_or_else(|| ApiError { status: StatusCode::NOT_FOUND, message: format!("{prefix} is not announced") })?;
let mut views = vec![];
for session in state.registry.list() {
let Some(update) = build_announce(&route, state.info.local_asn, session.local_addr.ip()) else {
continue;
};
let ctx = ParserContext { four_octet_asn: Some(session.four_octet_asn), address_family: None };
let mut buf = BytesMut::new();
Message::Update(update.clone())
.to_wire(&ctx, &mut buf)
.map_err(|e| ApiError { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() })?;
views.push(WireView {
peer_addr: session.peer.addr.to_string(),
peer_asn: session.peer.asn,
next_hop: session.local_addr.ip().to_string(),
four_octet_asn: session.four_octet_asn,
hex: buf.iter().map(|b| format!("{b:02x}")).collect(),
length: buf.len(),
summary: summarize(&update.path_attributes),
update,
});
}
views.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
Ok(Json(views))
}
#[derive(Debug, Clone, Serialize)]
pub struct WellKnownCommunity {
pub name: &'static str,
pub value: String,
}
/// Names the API accepts in place of a numeric standard community.
pub async fn well_known_communities() -> Json<Vec<WellKnownCommunity>> {
Json(
community::WELL_KNOWN
.iter()
.map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) })
.collect(),
)
}
pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<ReceivedRouteView>> { pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<ReceivedRouteView>> {
let mut routes: Vec<ReceivedRouteView> = let mut routes: Vec<ReceivedRouteView> =
state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect(); state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect();
+5 -13
View File
@@ -1,12 +1,11 @@
//! HTTP surface of the daemon: a JSON API for originating routes and //! HTTP surface of the daemon: a read-only JSON API for inspecting state, a
//! inspecting state, a server-sent event stream, and Prometheus metrics. //! status page, a server-sent event stream, and Prometheus metrics.
//! //!
//! Everything here is a thin layer over handles owned by the BGP server //! Everything here is a thin layer over handles owned by the BGP server
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives //! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives
//! in this crate. //! in this crate.
mod api; mod api;
mod community;
mod events; mod events;
mod metrics; mod metrics;
@@ -17,17 +16,14 @@ use std::time::{Instant, SystemTime};
use axum::Router; use axum::Router;
use axum::http::header; use axum::http::header;
use axum::response::{Html, IntoResponse, Redirect}; use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{delete, get, post}; use axum::routing::get;
use bgp_rib::{AdjRibIn, LocalRib}; use bgp_rib::{AdjRibIn, LocalRib};
use bgp_server::session::SessionRegistry; use bgp_server::session::SessionRegistry;
use eyre::{Result, WrapErr}; use eyre::{Result, WrapErr};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::info;
pub use api::{ pub use api::{AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView};
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
WellKnownCommunity, WireView, WithdrawRequest,
};
/// Static facts about the router shown in status output. /// Static facts about the router shown in status output.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -101,12 +97,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/status", get(api::status)) .route("/api/status", get(api::status))
.route("/api/config", get(api::config)) .route("/api/config", get(api::config))
.route("/api/peers", get(api::peers)) .route("/api/peers", get(api::peers))
.route("/api/routes/local", get(api::local_routes).post(api::announce)) .route("/api/routes/local", get(api::local_routes))
.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/routes/received", get(api::received_routes))
.route("/api/communities/well-known", get(api::well_known_communities))
.route("/api/events", get(events::stream)) .route("/api/events", get(events::stream))
.route("/metricz", get(metrics::metricz)) .route("/metricz", get(metrics::metricz))
.with_state(state) .with_state(state)
+39 -414
View File
@@ -6,11 +6,10 @@
<title>Status for ferrix_bgp</title> <title>Status for ferrix_bgp</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E"> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
<!-- <!--
Styled after the classic Google-internal /statusz pages as mirrored by A read-only status page in the manner of the classic /statusz pages: plain
Vitess (go/vt/servenv/status.go) and golang.org/x/net/trace: plain sans-serif HTML, default links, dense bordered tables, and one full-width
sans-serif HTML, default links and form controls, dense bordered tables, light blue (#eef) heading bar per section. Deliberately no dark theme, no
and one full-width light blue (#eef) heading bar per section. Deliberately framework, no external resources. Everything shown comes from /api/*.
no dark theme, no framework, no external resources.
--> -->
<style> <style>
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; } body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
@@ -23,25 +22,10 @@ table.data { border-collapse: collapse; margin: 4px 0; }
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; } table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
table.data th { background: #dde; font-weight: bold; } table.data th { background: #dde; font-weight: bold; }
.mono, table.data td.mono { font-family: monospace; } .mono, table.data td.mono { font-family: monospace; }
table.data tr.loop td { background: #ffc; }
table.data tr.mine td.owner { font-weight: bold; }
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; } .ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
.dim { color: #666; } .dim { color: #666; }
table.form td { padding: 2px 6px 2px 0; }
table.form td:first-child { width: 11em; }
select { max-width: 14em; }
pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px 0; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; } #event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; } #event-log div { white-space: pre-wrap; }
pre.bytes span[data-kind] { cursor: help; }
pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px solid #00c; }
#tip { position: absolute; z-index: 10; max-width: 46em; background: #fff; border: 1px solid #99a; font-size: 12px; }
#tip .tip-title { background: #eef; font-weight: bold; text-align: center; padding: 1px 6px; border-bottom: 1px solid #99a; }
#tip table { border-collapse: collapse; }
#tip td { padding: 1px 6px; vertical-align: top; }
#tip td:first-child { color: #666; white-space: nowrap; }
#tip td.mono { word-break: break-word; }
details { clear: both; margin: 8px 0 0; } details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: #00c; } summary { cursor: pointer; color: #00c; }
summary:hover { text-decoration: underline; } summary:hover { text-decoration: underline; }
@@ -61,7 +45,7 @@ Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
</div> </div>
<div class="righthand"> <div class="righthand">
Running on <span id="s-hostname">...</span><br> Running on <span id="s-hostname">...</span><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/pings">pings json</a><br> View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a><br>
<span id="refresh-ctl"></span><br> <span id="refresh-ctl"></span><br>
</div> </div>
</div> </div>
@@ -77,86 +61,36 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<tbody id="peers-body"></tbody> <tbody id="peers-body"></tbody>
</table> </table>
<h1 id="announce">Announce a prefix</h1> <h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
<form id="announce-form" autocomplete="off">
<table class="form">
<tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
<td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
<tr><td><label for="f-origin">Origin:</label></td><td><select id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></td>
<td><label for="f-med">MED:</label></td><td><input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
</table>
<table class="form">
<tr><td><label for="f-communities">Communities:</label></td><td><input id="f-communities" size="50" placeholder="65000:100, NO_EXPORT">
<select id="f-wk" aria-label="Add a well-known community"><option value="">+ well-known...</option></select>
<span class="dim">comma separated, ASN:VALUE or a name</span></td></tr>
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="210036:0:0, 210036:1:42476">
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ presets...</option></select>
<span class="dim">GLOBAL:LOCAL1:LOCAL2</span></td></tr>
<tr><td></td><td><input type="submit" value="Announce"> <input type="button" id="f-clear" value="Clear">
&nbsp; <span id="announce-msg"></span></td></tr>
</table>
</form>
<h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
<table class="data"> <table class="data">
<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>actions</th></tr></thead> <thead><tr><th>prefix</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>since</th></tr></thead>
<tbody id="local-body"></tbody> <tbody id="local-body"></tbody>
</table> </table>
<div id="pings-section" hidden>
<h1 id="pings">Pings to announced addresses (<span id="pings-count">...</span>)</h1>
<div class="dim" id="pings-note"></div>
<table class="data">
<thead><tr><th>destination</th><th>route</th><th>owner</th><th>from</th><th>arrived on</th><th>hop limit</th><th>pings</th><th>last sequence</th><th>first seen</th><th>last seen</th><th>actions</th></tr></thead>
<tbody id="pings-body"></tbody>
</table>
</div>
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1> <h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
<table class="data"> <table class="data">
<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> <thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
<tbody id="received-body"></tbody> <tbody id="received-body"></tbody>
</table> </table>
<h1 id="events">Event log</h1> <h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div> <div id="log-state" class="dim"></div>
<div id="event-log"></div> <div id="event-log"></div>
<div id="tip" hidden></div>
<address>ferrix_bgp &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address> <address>ferrix_bgp &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
<script> <script>
(() => { (() => {
// Large-community presets offered next to the field, grouped by who acts // Well-known standard communities (RFC 1997, RFC 7999, RFC 8326), shown by
// on them. "PEER" is replaced by an AS number the user is asked for. // name with the number as a tooltip.
// AS210036: docs/lab/prod/bird-ferrix.conf (lab_export_allowed). Every const WELL_KNOWN = {
// session there has a neighbour AS and a location; "never" '65535:0': 'GRACEFUL_SHUTDOWN', '65535:666': 'BLACKHOLE', '65535:65281': 'NO_EXPORT',
// (functions 0 and 2) beats "announce" (1 and 3) beats 0:0. '65535:65282': 'NO_ADVERTISE', '65535:65283': 'NO_EXPORT_SUBCONFED', '65535:65284': 'NOPEER',
// SwissIX: https://www.swissix.ch/resources/action-bgp-communities/ };
const LOCATIONS = { 1: 'SwissIX', 2: 'NL-ix', 3: 'France-IX', 4: 'DE-CIX Frankfurt', 5: 'Free-IX Switzerland', 6: 'Community-IX.ch', 100: 'transit', 200: 'downstream customers' };
const LARGE_PRESET_GROUPS = [
['AS210036', 'AS210036: by neighbour', [
['210036:0:0', 'announce nowhere (then add where to)'],
['210036:1:42476', 'announce to the SwissIX route servers'],
['210036:1:PEER', 'announce to neighbour AS'],
['210036:0:PEER', 'never announce to neighbour AS'],
]],
['AS210036', 'AS210036: by location', Object.entries(LOCATIONS).filter(([id]) => id < 100).map(([id, name]) => [`210036:3:${id}`, `announce at ${name}`])
.concat([['210036:2:100', 'never announce to transit']])],
['SwissIX', 'SwissIX route servers (AS42476)', [
['42476:0:0', 'send to nobody'],
['42476:1:559', 'send to SWITCH (AS559)'],
['42476:1:PEER', 'send to peer AS'],
['42476:0:PEER', 'do not send to peer AS'],
['42476:1:0', 'send to all (default)'],
['42476:901:PEER', 'add NO_EXPORT towards peer AS'],
['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'],
]],
];
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], pings: null, wellKnown: {}, openWire: new Set(), openPing: new Set(), wire: new Map() }; const state = { status: null, peers: [], local: [], received: [] };
const DASH = '<span class="dim">-</span>'; const DASH = '<span class="dim">-</span>';
// ---- helpers ----------------------------------------------------------- // ---- helpers -----------------------------------------------------------
@@ -167,9 +101,9 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
}; };
const now = () => Math.floor(Date.now() / 1000); const now = () => Math.floor(Date.now() / 1000);
// Tables are only rewritten when their markup actually changes, so a // Tables are only rewritten when their markup actually changes, so a
// refresh with unchanged data never touches the DOM (no flicker, and open // refresh with unchanged data never touches the DOM (no flicker, and
// rows, selections and hover state survive). Anything that changes with // selections survive). Anything that changes with the clock is a
// the clock is a placeholder that tick() fills in as plain text. // placeholder that tick() fills in as plain text.
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } } function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
const age = unix => `<span data-since="${unix}"></span>`; const age = unix => `<span data-since="${unix}"></span>`;
const until = unix => `<span data-until="${unix}"></span>`; const until = unix => `<span data-until="${unix}"></span>`;
@@ -180,12 +114,9 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix); if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
} }
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString(); const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const commList = (list, wk) => list.length ? list.map(c => { const commList = (list, named) => list.length ? list.map(c =>
const name = wk && state.wellKnown[c]; named && WELL_KNOWN[c] ? `<span title="${esc(c)}">${WELL_KNOWN[c]}</span>` : esc(c)).join(' ') : DASH;
return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
}).join(' ') : DASH;
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>'; const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
const myOwner = () => $('f-owner').value.trim();
async function getJSON(url) { async function getJSON(url) {
const r = await fetch(url); const r = await fetch(url);
@@ -220,260 +151,30 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
function renderLocal() { function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length; const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { setHTML(b, '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'); return; } if (!state.local.length) { setHTML(b, '<tr><td colspan="7" class="dim">This router originates no routes.</td></tr>'); return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?', stale = []; const asn = state.status ? state.status.local_asn : '?';
setHTML(b, state.local.map(r => { setHTML(b, state.local.map(r => `<tr>
const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix); <td class="mono">${esc(r.prefix)}</td><td>${esc(r.origin)}</td>
const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>` <td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
: '<span class="dim" title="only the owner can withdraw">withdraw</span>'; <td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td>
return `<tr class="${mine ? 'mine' : ''}"> <td>${age(r.announced_at_unix)}</td></tr>`).join(''));
<td class="mono">${p}</td><td class="owner">${esc(r.owner)}</td><td>${esc(r.origin)}</td>
<td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td><td>${age(r.announced_at_unix)}</td>
<td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
(open ? `<tr><td colspan="9">${wireHTML(r, stale)}</td></tr>` : '');
}).join(''));
stale.forEach(loadWire);
} }
function renderReceived() { function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length; const b = $('received-body'); $('received-count').textContent = state.received.length;
if (!state.received.length) { setHTML(b, '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'); return; } if (!state.received.length) { setHTML(b, '<tr><td colspan="10" class="dim">No routes received from peers.</td></tr>'); return; }
const localPrefixes = new Set(state.local.map(r => r.prefix)); setHTML(b, state.received.map(r => { const s = r.summary; return `<tr>
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</span></td><td class="mono">${asPath(s.as_path)}</td> <td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</span></td><td class="mono">${asPath(s.as_path)}</td>
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td> <td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td><td>${s.local_pref ?? DASH}</td>
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join('')); <td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''));
} }
// Pings are shown per source and destination pair. The router answers
// them itself; this table is a copy of what arrived.
function renderPings() {
const p = state.pings; if (!p || p.status === 'disabled') { $('pings-section').hidden = true; return; }
$('pings-section').hidden = false;
if (p.status === 'failed') {
$('pings-count').textContent = 'off';
$('pings-note').innerHTML = `<span class="bad">The ping monitor could not start:</span> ${esc(p.detail || 'unknown error')}`;
setHTML($('pings-body'), ''); return;
}
const more = p.total_flows > p.flows.length ? `, showing the ${p.flows.length} most recent` : '';
$('pings-count').textContent = `${p.total_flows} flow${p.total_flows === 1 ? '' : 's'}${more}`;
$('pings-note').innerHTML = '';
if (!p.flows.length) { setHTML($('pings-body'), '<tr><td colspan="11" class="dim">No pings seen yet.</td></tr>'); return; }
const me = myOwner();
setHTML($('pings-body'), p.flows.map(f => { const key = `${f.src}|${f.dst}`, open = state.openPing.has(key); return `<tr class="${f.owner && f.owner === me ? 'mine' : ''}">
<td class="mono">${esc(f.dst)}</td><td class="mono">${f.prefix ? esc(f.prefix) : '<span class="dim">not announced</span>'}</td><td class="owner">${f.owner ? esc(f.owner) : DASH}</td>
<td class="mono">${esc(f.src)}</td><td class="mono">${f.interface ? esc(f.interface) : DASH}</td>
<td>${f.hop_limit == null ? DASH : `${f.hop_limit} <span class="dim">(${f.hops} hop${f.hops === 1 ? '' : 's'} away)</span>`}</td>
<td>${f.count}</td><td>${f.seq}</td><td>${fmtTime(f.first_seen_unix)}</td><td>${age(f.last_seen_unix)} ago</td>
<td><a href="#" data-act="ping-bytes" data-key="${esc(key)}">${open ? 'hide bytes' : 'bytes'}</a></td></tr>` +
(open ? `<tr><td colspan="11">${pingBytesHTML(f)}</td></tr>` : ''); }).join(''));
}
// The last echo request of a flow, split into the fields of RFC 4443. It
// starts at the ICMPv6 header: the socket we listen on is never given the
// IPv6 header, whose interesting fields are the columns of the table.
function pingBytesHTML(f) {
const bytes = f.hex.match(/../g) || [], segs = [];
const take = (n, cls, kind) => { if (bytes.length) segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
take(1, 'hh', 'icmp-type'); take(1, 'hh', 'icmp-code'); take(2, 'hl', 'icmp-checksum');
take(2, 'ha', 'icmp-ident'); take(2, 'ha2', 'icmp-seq'); take(bytes.length, 'hn', 'icmp-payload');
const kept = f.hex.length / 2;
return `Last ICMPv6 message from <b>${esc(f.src)}</b>, ${f.bytes} bytes${kept < f.bytes ? ` (first ${kept} shown)` : ''}, IPv6 header not included &nbsp;
<span class="hh">&nbsp;type, code&nbsp;</span> <span class="hl">&nbsp;checksum&nbsp;</span> <span class="ha">&nbsp;identifier, sequence&nbsp;</span> <span class="hn">&nbsp;payload&nbsp;</span>
<span class="dim">(hover a segment to decode it)</span>
<pre class="bytes">${segs.map(x => `<span class="${x.cls}" tabindex="0" data-kind="${x.kind}" data-hex="${x.hex}">${x.hex}</span>`).join(' ')}</pre>`;
}
function decodePing(kind, b) {
switch (kind) {
case 'icmp-type': return { title: 'ICMPv6: type', rows: [['value', `${b[0]} = ${{128: 'Echo Request', 129: 'Echo Reply'}[b[0]] || 'other'}`], ['meaning', 'The router answers with type 129, Echo Reply, carrying the same identifier, sequence number and payload.']] };
case 'icmp-code': return { title: 'ICMPv6: code', rows: [['value', `${b[0]}`], ['meaning', 'Always 0 for echo messages.']] };
case 'icmp-checksum': return { title: 'ICMPv6: checksum', rows: [['value', `0x${u(b, 0, 2).toString(16).padStart(4, '0')}`], ['covers', 'This message and a pseudo-header holding the IPv6 source and destination addresses, so it differs between two otherwise equal pings to different addresses.']] };
case 'icmp-ident': return { title: 'Echo: identifier', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Chosen by the sender so that several ping programs on one host can tell their replies apart.']] };
case 'icmp-seq': return { title: 'Echo: sequence number', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Counts up with every request; a gap in the replies is a lost packet.']] };
default: {
const rows = [['length', `${b.length} bytes shown`], ['as text', b.map(x => x >= 32 && x < 127 ? String.fromCharCode(x) : '.').join('')]];
// ping puts the time of sending first so that the reply carries it
// back. Only claim that when the value is in fact close to now.
const near = secs => Math.abs(secs - now()) < 86400;
const le = b.length >= 16 && !b[4] && !b[5] && !b[6] && !b[7] ? b[0] + b[1] * 256 + b[2] * 65536 + b[3] * 16777216 : null;
const be = b.length >= 8 ? u(b, 0, 4) : null;
if (le != null && near(le)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(le * 1000).toLocaleTimeString()} (a 16-byte timeval, as Linux ping writes it)`]);
else if (be != null && near(be)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(be * 1000).toLocaleTimeString()} (an 8-byte timestamp, as BSD and macOS ping6 write it)`]);
rows.push(['meaning', 'Anything the sender likes; the reply must return it unchanged.']);
return { title: 'Echo: payload', rows };
}
}
}
// A ping event carries the whole row, so the table follows the stream
// without asking the server again.
function pingSeen(f) {
const p = state.pings; if (!p || p.status !== 'listening') return;
const at = p.flows.findIndex(x => x.src === f.src && x.dst === f.dst);
if (at >= 0) p.flows.splice(at, 1); else p.total_flows++;
p.flows.unshift(f); p.flows.length = Math.min(p.flows.length, 100);
renderPings();
}
// ---- 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, kind) => { segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', 'wlen'); if (wlen) take(wlen, 'hn', 'withdrawn');
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', 'alen');
let rem = alen, i = 0;
while (rem > 0) {
const ext = parseInt(bytes[0], 16) & 0x10;
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', 'attr'); rem -= hdr + len;
}
if (bytes.length) take(bytes.length, 'hn', 'nlri');
return segs;
}
// ---- decoding a hovered segment into something readable ----------------
const u = (b, o, n) => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[o + k]; return v; };
const fmtV4 = b => b.slice(0, 4).join('.');
function fmtV6(b) {
const g = []; for (let k = 0; k < 16; k += 2) g.push(((b[k] << 8) | b[k + 1]).toString(16));
let best = -1, bestLen = 0;
for (let k = 0; k < 8; k++) { let n = 0; while (k + n < 8 && g[k + n] === '0') n++; if (n > bestLen && n > 1) { best = k; bestLen = n; } }
if (best < 0) return g.join(':');
return g.slice(0, best).join(':') + '::' + g.slice(best + bestLen).join(':');
}
function prefixList(b, afi) {
const out = []; let o = 0;
while (o < b.length) {
const len = b[o++], n = Math.ceil(len / 8), raw = b.slice(o, o + n); o += n;
const full = raw.concat(Array((afi === 2 ? 16 : 4) - raw.length).fill(0));
out.push((afi === 2 ? fmtV6(full) : fmtV4(full)) + '/' + len);
}
return out;
}
function largeLabel(c) {
// Our own scheme is decoded from its structure, so any value reads well,
// not only the ones in the menu.
const own = c.match(/^210036:([0-3]):(\d+)$/);
if (own) {
const [f, t] = [Number(own[1]), Number(own[2])], where = LOCATIONS[t] || `location ${t}`;
if (f === 0 && t === 0) return 'AS210036: announce nowhere, unless another community says where';
if (f === 0) return `AS210036: never announce to AS${t}`;
if (f === 1) return `AS210036: announce to AS${t}${t === 42476 ? ' (the SwissIX route servers)' : ''}`;
return `AS210036: ${f === 2 ? 'never announce' : 'announce'} ${t >= 100 ? 'to' : 'at'} ${where}`;
}
// Exact presets first, so 42476:1:559 reads "SWITCH" rather than "AS559".
for (const pass of [false, true]) for (const [who, , presets] of LARGE_PRESET_GROUPS) for (const [pattern, label] of presets) {
if (pattern.includes('PEER') !== pass) continue;
const m = c.match(new RegExp('^' + pattern.replace('PEER', '(\\d+)') + '$'));
if (m) return `${who}: ${label.replace(/(peer|neighbour) AS/, 'AS' + (m[1] || ''))}`;
}
return null;
}
const AFI = { 1: 'IPv4', 2: 'IPv6' }, SAFI = { 1: 'unicast', 2: 'multicast' };
function decodeAttr(b, w4) {
const flags = b[0], type = b[1], ext = flags & 0x10, hdr = ext ? 4 : 3, len = ext ? u(b, 2, 2) : b[2], v = b.slice(hdr);
const name = ATTR_NAMES[type] || `attribute ${type}`;
const rows = [
['flags', `0x${flags.toString(16)}: ${flags & 0x80 ? 'optional' : 'well-known'}, ${flags & 0x40 ? 'transitive' : 'non-transitive'}${flags & 0x20 ? ', partial' : ''}${ext ? ', 2-byte length' : ''}`],
['type code', `${type}`], ['value length', `${len} bytes`],
];
switch (type) {
case 1: rows.push(['origin', ['IGP (originated inside the AS)', 'EGP', 'INCOMPLETE (learned some other way)'][v[0]] || `unknown ${v[0]}`]); break;
case 2: {
const w = w4 ? 4 : 2; let o = 0, hops = 0;
while (o + 2 <= v.length) {
const st = v[o], n = v[o + 1], asns = []; o += 2;
for (let k = 0; k < n; k++, o += w) asns.push(u(v, o, w));
rows.push([st === 2 ? 'AS_SEQUENCE' : st === 1 ? 'AS_SET' : `segment ${st}`, asns.join(' ')]);
hops += st === 2 ? n : 1;
}
rows.push(['path length', `${hops} (what best-path selection compares)`], ['AS number width', w4 ? '4 bytes (RFC 6793)' : '2 bytes']);
break;
}
case 3: rows.push(['next hop', fmtV4(v)]); break;
case 4: rows.push(['MED', `${u(v, 0, 4)} (lower is preferred, compared between routes from the same neighbour AS)`]); break;
case 5: rows.push(['local preference', `${u(v, 0, 4)} (higher is preferred)`]); break;
case 8: for (let o = 0; o + 4 <= v.length; o += 4) { const c = `${u(v, o, 2)}:${u(v, o + 2, 2)}`; rows.push(['community', c + (state.wellKnown[c] ? ` = ${state.wellKnown[c]}` : '')]); } break;
case 32: for (let o = 0; o + 12 <= v.length; o += 12) { const c = `${u(v, o, 4)}:${u(v, o + 4, 4)}:${u(v, o + 8, 4)}`, l = largeLabel(c); rows.push(['large community', c + (l ? ` = ${l}` : '')]); } break;
case 14: {
const afi = u(v, 0, 2), nhLen = v[3], nh = v.slice(4, 4 + nhLen);
rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]);
if (nhLen === 4) rows.push(['next hop', fmtV4(nh)]);
else if (nhLen === 16) rows.push(['next hop', fmtV6(nh)]);
else if (nhLen === 32) rows.push(['next hop', fmtV6(nh.slice(0, 16))], ['link-local next hop', fmtV6(nh.slice(16))]);
prefixList(v.slice(5 + nhLen), afi).forEach(p => rows.push(['announced prefix', p]));
break;
}
case 15: { const afi = u(v, 0, 2); rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]); prefixList(v.slice(3), afi).forEach(p => rows.push(['withdrawn prefix', p])); break; }
default: rows.push(['value', v.map(x => x.toString(16).padStart(2, '0')).join(' ')]);
}
return { title: `Path attribute: ${name}`, rows };
}
function decodeSegment(kind, b, w4) {
switch (kind) {
case 'marker': return { title: 'Header: marker', rows: [['value', '16 bytes of 0xff'], ['meaning', 'Fixed filler. It once carried authentication data; now it only helps a receiver find message boundaries.']] };
case 'length': return { title: 'Header: message length', rows: [['value', `${u(b, 0, 2)} bytes, including this 19-byte header`], ['limits', '19 to 4096 bytes']] };
case 'type': return { title: 'Header: message type', rows: [['value', `${b[0]} = ${['', 'OPEN', 'UPDATE', 'NOTIFICATION', 'KEEPALIVE', 'ROUTE-REFRESH'][b[0]] || 'unknown'}`]] };
case 'wlen': return { title: 'Withdrawn routes length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', u(b, 0, 2) ? 'IPv4 prefixes being withdrawn follow.' : 'No IPv4 prefixes are withdrawn by this message.']] };
case 'withdrawn': return { title: 'Withdrawn routes', rows: prefixList(b, 1).map(p => ['withdrawn prefix', p]) };
case 'alen': return { title: 'Total path attribute length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', 'The path attributes follow; whatever remains after them is IPv4 NLRI.']] };
case 'nlri': return { title: 'NLRI: announced IPv4 prefixes', rows: prefixList(b, 1).map(p => ['announced prefix', p]).concat([['encoding', 'one length byte, then only as many address bytes as the length needs']]) };
default: return decodeAttr(b, w4);
}
}
function showTip(el) {
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
const kind = el.dataset.kind, tip = $('tip');
const d = kind.startsWith('icmp-') ? decodePing(kind, b) : decodeSegment(kind, b, el.dataset.w4 === '1');
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
tip.hidden = false;
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
const left = Math.max(8, Math.min(first.left, window.innerWidth - tip.offsetWidth - 8));
tip.style.left = `${left + window.scrollX}px`; tip.style.top = `${last.bottom + window.scrollY + 4}px`;
}
const hideTip = () => { $('tip').hidden = true; };
const segOf = e => e.target.closest && e.target.closest('pre.bytes span[data-kind]');
document.addEventListener('mouseover', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('mouseout', e => { if (segOf(e)) hideTip(); });
document.addEventListener('focusin', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('focusout', e => { if (segOf(e)) hideTip(); });
// The bytes for a route depend only on the route itself and on which
// sessions are up, so they are fetched once per such combination and kept.
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
function wireHTML(route, stale) {
const cached = state.wire.get(route.prefix), sig = wireSig(route);
if (cached && cached.sig === sig) return cached.html;
stale.push(route);
// Keep showing the previous bytes while the new ones load.
return cached ? cached.html : '<span class="dim">loading...</span>';
}
async function loadWire(route) {
const sig = wireSig(route);
const pending = state.wire.get(route.prefix);
if (pending && pending.loading === sig) return;
state.wire.set(route.prefix, { ...(pending || { html: '<span class="dim">loading...</span>' }), loading: sig });
let html;
try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)} &nbsp;
<span class="hh">&nbsp;header&nbsp;</span> <span class="hl">&nbsp;lengths&nbsp;</span> <span class="ha">&nbsp;path attributes&nbsp;</span> <span class="hn">&nbsp;NLRI&nbsp;</span>
<span class="dim">(hover a segment to decode it)</span>
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" tabindex="0" data-kind="${s.kind}" data-w4="${v.four_octet_asn ? 1 : 0}" data-hex="${s.hex}">${s.hex}</span>`).join(' ')}</pre>`).join('');
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
state.wire.set(route.prefix, { sig, html });
renderLocal();
}
// ---- data loading ------------------------------------------------------ // ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); } async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); } async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); } async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); } async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
async function loadPings() { state.pings = await getJSON('/api/pings'); renderPings(); } async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadPings()]); }
// ---- events ------------------------------------------------------------ // ---- events ------------------------------------------------------------
function logEvent(html) { function logEvent(html) {
@@ -481,21 +182,19 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`; d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove(); box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
} }
const names = list => list.map(c => state.wellKnown[c] || c).map(esc).join(' '); const names = list => list.map(c => WELL_KNOWN[c] || c).map(esc).join(' ');
function describe(ev) { function describe(ev) {
switch (ev.type) { switch (ev.type) {
case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`; case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent all local routes`; case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent all originated routes`;
case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`; case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
case 'local_announced': { const r = ev.route; return `announced ${esc(r.prefix)} by ${esc(r.owner)}` + case 'local_announced': { const r = ev.route; return `originating ${esc(r.prefix)}` +
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') + (r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); } (r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
case 'local_withdrawn': return `<span class="warn">withdrew</span> ${esc(ev.prefix)}`; case 'local_withdrawn': return `<span class="warn">no longer originating</span> ${esc(ev.prefix)}`;
case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`; 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} <span class="warn">withdrew</span> ${esc(ev.prefix)}`; case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'ping_seen': { const f = ev.ping; return `ping to ${esc(f.dst)}${f.owner ? ` (${esc(f.owner)})` : ''} from ${esc(f.src)}` + case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}`;
(f.interface ? ` via ${esc(f.interface)}` : '') + (f.hop_limit != null ? `, hop limit ${f.hop_limit}` : ''); }
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
default: return esc(JSON.stringify(ev)); default: return esc(JSON.stringify(ev));
} }
} }
@@ -506,14 +205,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; }; es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
// Events only feed the log. How often the tables reload is decided by // Events only feed the log. How often the tables reload is decided by
// ?refresh=, so the page never changes faster than the reader asked for. // ?refresh=, so the page never changes faster than the reader asked for.
// Pings are the exception: the reader causes them from a terminal, not es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
// from this page, so their table follows the stream. A steady ping is
// logged once, when it starts.
es.addEventListener('bgp', e => {
const ev = JSON.parse(e.data);
if (ev.type === 'ping_seen') { pingSeen(ev.ping); if (!ev.new_burst) return; }
logEvent(describe(ev));
});
} }
// ---- auto-refresh, opt-in through ?refresh=<seconds> -------------------- // ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
@@ -532,74 +224,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); }); $('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
} }
// ---- form --------------------------------------------------------------
// The reader's own action is not "auto" refresh: show its effect at once,
// and again shortly after for whatever the network sends back.
function reloadAfterAction() { loadAll().catch(console.error); setTimeout(() => loadAll().catch(console.error), 1500); }
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; }
function appendTo(id, value) {
const cur = splitList($(id).value); if (!cur.includes(value)) cur.push(value);
$(id).value = cur.join(', ');
}
$('announce-form').addEventListener('submit', async e => {
e.preventDefault();
const body = {
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
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...', 'dim');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
const up = state.peers.filter(p => p.state === 'Established').length;
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); reloadAfterAction(); }
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
});
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-origin').value = 'IGP'; setMsg(''); });
$('f-wk').addEventListener('change', e => { if (e.target.value) appendTo('f-communities', e.target.value); e.target.value = ''; });
$('f-lc').addEventListener('change', e => {
let v = e.target.value; e.target.value = '';
if (!v) return;
if (v.includes('PEER')) {
const peer = (prompt('AS number of the peer or neighbour?') || '').trim().replace(/^AS/i, '');
if (!/^\d+$/.test(peer)) { if (peer) setMsg(`"${peer}" is not an AS number`, 'bad'); return; }
v = v.replace('PEER', peer);
}
appendTo('f-large', v);
});
$('f-owner').addEventListener('input', () => { renderLocal(); renderPings(); });
$('pings-body').addEventListener('click', e => {
const link = e.target.closest('a[data-act="ping-bytes"]'); if (!link) return;
e.preventDefault();
const key = link.dataset.key;
state.openPing.has(key) ? state.openPing.delete(key) : state.openPing.add(key);
renderPings();
});
$('local-body').addEventListener('click', async e => {
const link = e.target.closest('a[data-act]'); if (!link) return;
e.preventDefault();
const prefix = link.dataset.prefix;
if (link.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'}`, 'bad'); }
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); reloadAfterAction(); }
});
// ---- boot -------------------------------------------------------------- // ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
LARGE_PRESET_GROUPS.forEach(([, title, presets]) => {
const g = document.createElement('optgroup'); g.label = title;
presets.forEach(([value, label]) => { const o = document.createElement('option'); o.value = value; o.textContent = `${value.replace('PEER', '<as>')} ${label}`; g.appendChild(o); });
$('f-lc').appendChild(g);
});
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);
getJSON('/api/config').then(c => { getJSON('/api/config').then(c => {
if (!c.text) return; if (!c.text) return;
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : ''; $('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
+23 -17
View File
@@ -1,29 +1,35 @@
# A worked example; the fields are documented in
# crates/server/proto/config.proto.
router_id: "10.0.0.1" router_id: "10.0.0.1"
local_asn: 65000 local_asn: 65000
listen_endpoints {
address: "127.0.0.1" # Where to accept BGP connections. Port 179 needs privileges; pick another
port: 179 # port to run unprivileged against a peer that dials a custom port.
} listen_endpoints { address: "127.0.0.1" port: 179 }
listen_endpoints { listen_endpoints { address: "::1" port: 179 }
address: "::1"
port: 179 # A peer we connect to (and accept connections from).
}
peers { peers {
name: "lab-r1" name: "r1"
endpoint { address: "127.0.0.1" port: 1791 } endpoint { address: "127.0.0.1" port: 1791 }
remote_asn: 65001 remote_asn: 65001
hold_time_seconds: 30 hold_time_seconds: 30
} }
connect_retry_seconds: 5 connect_retry_seconds: 5
# Let a restarted peer replace a stale Established session immediately # Let a restarted peer replace a stale Established session immediately
# (RFC 4271 CollisionDetectEstablishedState). Default false. # (RFC 4271 CollisionDetectEstablishedState). Default false.
collision_detect_established_state: false collision_detect_established_state: false
local_route_policy {
allowed_prefixes: "10.100.0.0/16" # Routes this router originates, announced to every peer.
allowed_prefixes: "2001:db8:100::/48" originate { prefix: "10.100.0.0/16" }
max_routes_per_owner: 4 originate {
} prefix: "2001:db8:100::/48"
http_endpoint { med: 50
address: "127.0.0.1" communities: "65000:100"
port: 8080 large_communities: "65000:1:2"
} }
# Read-only status page, JSON API and metrics. It has no authentication:
# keep it on loopback or behind a reverse proxy.
http_endpoint { address: "127.0.0.1" port: 8080 }