diff --git a/Cargo.lock b/Cargo.lock index ab238bf..cf8ca99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,16 +240,12 @@ version = "0.0.0" dependencies = [ "axum", "bgp-packet", - "bgp-primitives", "bgp-rib", "bgp-server", - "bytes", "eyre", "futures", "prometheus", - "reqwest", "serde", - "serde_json", "tokio", "tokio-stream", "tracing", diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 489a43b..e56028d 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -1,54 +1,48 @@ # BGP -This crate provides the `bgp` binary which runs the Border Gateway Protocol -daemon together with its HTTP API. +This crate provides the `bgp` binary, which runs the Border Gateway Protocol +daemon together with its read-only HTTP status interface. ``` cargo run -- path/to/config.textpb ``` The configuration format is defined in `crates/server/proto/config.proto`; -`testdata/server.textpb` is a worked example. When `http_endpoint` is set the -binary also serves: +`testdata/server.textpb` is a worked example. The daemon is configured +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 | |---|---| -| `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/peers` | Established sessions | -| `GET /api/routes/local` | Locally originated routes | -| `POST /api/routes/local` | Announce a route (JSON body, see below) | -| `DELETE /api/routes/local` | Withdraw a route (JSON body: `prefix`, optional `owner`) | -| `GET /api/routes/local/wire?prefix=…` | The exact UPDATE bytes sent to each peer | +| `GET /api/config` | The configuration file as read at startup | +| `GET /api/peers` | Configured peers and established sessions | +| `GET /api/routes/local` | Originated routes | | `GET /api/routes/received` | Adj-RIB-In contents | -| `GET /api/communities/well-known` | Names accepted in place of numeric communities | | `GET /api/events` | Server-sent events for session and route changes | | `GET /metricz` | Prometheus metrics | -Announce body: +Everything under it is read-only and unauthenticated. Bind it to loopback or +put it behind a reverse proxy. -```json -{ - "prefix": "10.100.1.0/24", - "owner": "alice", - "origin": "IGP", - "prepend": 2, - "med": 100, - "communities": ["65000:100", "NO_EXPORT"], - "large_communities": ["65000:1:2"] -} -``` +## What it does today -Only `prefix` and `owner` are required. Routes are subject to the -`local_route_policy` in the configuration. +It establishes sessions (both dialling configured peers and accepting +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 `fake_peer` is a minimal BGP speaker that connects to the daemon, announces a prefix, prints every UPDATE it receives and, with `--echo`, re-announces each received prefix back with its own ASN prepended (dropping NO_EXPORT routes). -That makes locally announced prefixes show up in the received table as if -they had travelled through a neighbouring AS: +That makes originated prefixes show up in the received table as if they had +travelled through a neighbouring AS: ``` cargo run -- testdata/server.textpb diff --git a/crates/integration-tests/examples/fake_peer.rs b/crates/integration-tests/examples/fake_peer.rs index 1ce2de7..b394803 100644 --- a/crates/integration-tests/examples/fake_peer.rs +++ b/crates/integration-tests/examples/fake_peer.rs @@ -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 -//! 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 -- \ //! --server 127.0.0.1:1790 --asn 65001 --announce 203.0.113.0/24 //! //! With `--echo`, every prefix received is re-announced back to the server -//! with this peer's ASN prepended, which lets a single ferrix instance show -//! its own routes "coming back" in the received table. +//! with this peer's ASN prepended, which lets a single daemon show its own +//! routes "coming back" in the received table. //! //! With `--listen ADDR` it instead waits for the server to dial it (one //! connection at a time), which exercises the server's active side. @@ -27,7 +28,7 @@ use tokio::net::TcpListener; #[derive(Parser, Debug)] 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")] server: String, /// Instead of connecting, listen here and accept connections from the diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index 90c05fe..c72d59a 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -1,6 +1,6 @@ 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::server::BgpServerBuilder; use bgp_server::session::SessionRegistry; @@ -32,20 +32,7 @@ impl TestServer { /// 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::start_inner(local_asn, hold_time_secs, LocalRibPolicy::default()).await - } - - /// Start a server with a policy restricting locally originated routes. - pub async fn start_with_policy(local_asn: u32, policy: LocalRibPolicy) -> Result { - Self::start_inner(local_asn, 90, policy).await - } - - async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result { - Self::start_with(local_asn, |c| { - c.hold_time_secs = hold_time_secs; - c.local_route_policy = policy; - }) - .await + Self::start_with(local_asn, |c| c.hold_time_secs = hold_time_secs).await } /// Start a server after letting `customize` edit the default test config @@ -59,7 +46,7 @@ impl TestServer { peers: vec![], connect_retry: std::time::Duration::from_secs(5), collision_detect_established_state: false, - local_route_policy: LocalRibPolicy::default(), + originate: vec![], http_endpoint: None, }; customize(&mut config); diff --git a/crates/integration-tests/tests/announce.rs b/crates/integration-tests/tests/announce.rs index 34d07ff..947de44 100644 --- a/crates/integration-tests/tests/announce.rs +++ b/crates/integration-tests/tests/announce.rs @@ -11,7 +11,7 @@ use bgp_packet::message::{ NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage, }; use bgp_primitives::ip_prefix::IpPrefix; -use bgp_rib::{LocalRibError, LocalRibPolicy, LocalRoute, LocalRouteAttrs}; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; use bgp_server::session::SessionEvent; use eyre::Result; @@ -24,7 +24,7 @@ fn prefix(s: &str) -> IpPrefix { } 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 { @@ -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?; 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?; assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]); 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] -async fn test_policy_rejects_prefix_outside_allowlist() -> Result<()> { - let server = TestServer::start_with_policy( - LOCAL_ASN, - LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 2 }, - ) - .await?; - let mut peer = established_peer(&server).await?; - wait_for_sessions(&server, 1).await?; +async fn test_configured_routes_are_announced_to_every_new_session() -> Result<()> { + let attrs = LocalRouteAttrs { med: Some(7), communities: vec![(65000, 100)], ..Default::default() }; + let configured = vec![route("10.100.1.0/24", attrs), route("10.100.2.0/24", LocalRouteAttrs::default())]; + let server = TestServer::start_with(LOCAL_ASN, |c| c.originate = configured).await?; + assert_eq!(server.local_rib.snapshot().await.len(), 2); - let err = server - .local_rib - .announce(route("192.0.2.0/24", LocalRouteAttrs::default())) - .await - .unwrap_err(); - assert_eq!(err, LocalRibError::NotAllowed(prefix("192.0.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")]); + for router_id in [Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 2)] { + let mut peer = established_peer_with_id(&server, router_id).await?; + let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]; + seen.sort_by_key(|u| u.announced_routes[0].to_string()); + assert_eq!(seen[0].announced_routes, vec![prefix("10.100.1.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")]); + } Ok(()) } diff --git a/crates/integration-tests/tests/collision.rs b/crates/integration-tests/tests/collision.rs index d19a975..b451b18 100644 --- a/crates/integration-tests/tests/collision.rs +++ b/crates/integration-tests/tests/collision.rs @@ -33,7 +33,7 @@ async fn server_dialing(listener: &TcpListener) -> Result { let endpoint: SocketAddr = listener.local_addr()?; TestServer::start_with(LOCAL_ASN, |c| { 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 } @@ -107,12 +107,12 @@ async fn test_simultaneous_open_peer_higher_keeps_inbound() -> Result<()> { wait_for_sessions(&server, 1).await?; let session = server.registry.list().remove(0); 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. server .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?; let update = tokio::time::timeout(TIMEOUT, inbound.recv_update()).await??; 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); server .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?; let update = tokio::time::timeout(TIMEOUT, first.recv_update()).await??; assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); diff --git a/crates/integration-tests/tests/dialer.rs b/crates/integration-tests/tests/dialer.rs index 18ffc96..b45d3ce 100644 --- a/crates/integration-tests/tests/dialer.rs +++ b/crates/integration-tests/tests/dialer.rs @@ -19,12 +19,12 @@ const LOCAL_ASN: u32 = 65000; const PEER_ASN: u32 = 65001; 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 { let endpoint: SocketAddr = listener.local_addr()?; TestServer::start_with(LOCAL_ASN, |c| { 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 } @@ -56,19 +56,19 @@ async fn test_server_dials_configured_peer_and_establishes() -> Result<()> { } }) .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.addr, listener.local_addr()?); // Configured view reflects it; the dialer is not in backoff. 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:?}"); // Local routes flow over the dialled session too. server .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?; let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; 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| { c.connect_retry = Duration::from_secs(60); c.peers = vec![PeerConfig { - name: "lab".into(), + name: "r1".into(), endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1), remote_asn: PEER_ASN, hold_time_secs: 90, @@ -173,7 +173,7 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> { } }) .await?; - assert_eq!(listed.name.as_deref(), Some("lab")); + assert_eq!(listed.name.as_deref(), Some("r1")); Ok(()) } @@ -187,7 +187,7 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> { let _server = TestServer::start_with(LOCAL_ASN, |c| { c.connect_retry = Duration::from_millis(200); c.peers = vec![PeerConfig { - name: "lab".into(), + name: "r1".into(), endpoint, remote_asn: PEER_ASN, 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| { c.connect_retry = Duration::from_millis(200); c.peers = vec![PeerConfig { - name: "lab".into(), + name: "r1".into(), endpoint, remote_asn: PEER_ASN, hold_time_secs: 90, diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs index 707c6aa..8953e5e 100644 --- a/crates/integration-tests/tests/http_api.rs +++ b/crates/integration-tests/tests/http_api.rs @@ -6,12 +6,11 @@ use std::time::Duration; use bgp_integration_tests::test_peer::TestPeer; use bgp_integration_tests::test_server::TestServer; use bgp_packet::message::{ - AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, + AsPathAttribute, CommunitiesPathAttribute, LargeCommunity, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage, }; use bgp_primitives::ip_prefix::IpPrefix; -use bgp_primitives::parser::ParserContext; -use bgp_rib::LocalRibPolicy; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; use eyre::Result; use serde_json::{Value, json}; @@ -69,135 +68,51 @@ async fn test_status_and_peers() -> Result<()> { Ok(()) } -/// The full loop: POST an announcement, the BGP peer receives the UPDATE, -/// the local table lists it, the wire view matches the bytes, DELETE -/// withdraws it. +/// Originated routes are listed with their attributes as text. #[tokio::test] -async fn test_announce_over_http_reaches_peer() -> Result<()> { - let server = TestServer::start(LOCAL_ASN).await?; - let (base, _http) = server.serve_http().await?; - let client = reqwest::Client::new(); - let mut peer = established_peer(&server).await?; - - let resp = client - .post(format!("{base}/api/routes/local")) - .json(&json!({ - "prefix": "10.100.1.0/24", - "owner": "alice", - "prepend": 1, - "med": 10, - "communities": ["65000:100", "NO_EXPORT"], - "large_communities": ["65000:1:2"], - })) - .send() - .await?; - assert_eq!(resp.status(), 200, "{}", resp.text().await?); - let body: Value = resp.json().await?; - assert_eq!(body["prefix"], "10.100.1.0/24"); - assert_eq!(body["origin"], "IGP"); - assert_eq!(body["communities"], json!(["65000:100", "65535:65281"])); - assert_eq!(body["large_communities"], json!(["65000:1:2"])); - - // The peer sees the UPDATE. - let update: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; - assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); - assert_eq!(update.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN, LOCAL_ASN])); - assert_eq!(update.path_attributes[2], PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST))); - assert!(update.path_attributes.contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![ - (65000, 100), - (65535, 65281) - ])))); - - // Local table lists it. - let local = get_json(&client, &format!("{base}/api/routes/local")).await?; - assert_eq!(local.as_array().unwrap().len(), 1); - assert_eq!(local[0]["owner"], "alice"); - - // Wire view reproduces exactly what went over the socket. - let wire = get_json(&client, &format!("{base}/api/routes/local/wire?prefix=10.100.1.0/24")).await?; - assert_eq!(wire.as_array().unwrap().len(), 1); - assert_eq!(wire[0]["peer_asn"], PEER_ASN); - assert_eq!(wire[0]["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 }, - ) +async fn test_originated_routes_are_listed() -> Result<()> { + let attrs = LocalRouteAttrs { + prepend: 1, + med: Some(10), + communities: vec![(65000, 100), (65535, 65281)], + large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }], + ..Default::default() + }; + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.originate = vec![LocalRoute::new(prefix("10.100.1.0/24"), attrs)]; + }) .await?; let (base, _http) = server.serve_http().await?; let client = reqwest::Client::new(); - let url = format!("{base}/api/routes/local"); - let post = |body: Value| client.post(&url).json(&body).send(); + 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. - let resp = post(json!({ "prefix": "not-a-prefix", "owner": "alice" })).await?; - assert_eq!(resp.status(), 400); - assert!(resp.json::().await?["error"].as_str().unwrap().contains("invalid prefix")); +/// The API only reports. Nothing reachable over HTTP changes what the +/// router announces. +#[tokio::test] +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?; - assert_eq!(resp.status(), 400); - - let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "communities": ["bogus"] })).await?; - assert_eq!(resp.status(), 400); - - let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "origin": "weird" })).await?; - assert_eq!(resp.status(), 400); - - // Outside allowlist → 403. - let resp = post(json!({ "prefix": "192.0.2.0/24", "owner": "alice" })).await?; - assert_eq!(resp.status(), 403); - - // First allowed route is fine. - assert_eq!(post(json!({ "prefix": "10.100.1.0/24", "owner": "alice" })).await?.status(), 200); - - // Per-owner limit → 422. - let resp = post(json!({ "prefix": "10.100.2.0/24", "owner": "alice" })).await?; - assert_eq!(resp.status(), 422); - - // Someone else's prefix → 409, for both replace and withdraw. - let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "bob" })).await?; - assert_eq!(resp.status(), 409); - let resp = client - .delete(&url) - .json(&json!({ "prefix": "10.100.1.0/24", "owner": "bob" })) - .send() - .await?; - assert_eq!(resp.status(), 409); - - // Withdrawing an unknown prefix is a 200 with null. - let resp = client.delete(&url).json(&json!({ "prefix": "10.100.7.0/24" })).send().await?; - assert_eq!(resp.status(), 200); - assert_eq!(resp.json::().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); + let body = json!({ "prefix": "10.100.1.0/24" }); + for path in ["/api/routes/local", "/api/routes/received", "/api/peers", "/api/status"] { + let post = client.post(format!("{base}{path}")).json(&body).send().await?; + assert_eq!(post.status(), 405, "POST {path}"); + let delete = client.delete(format!("{base}{path}")).json(&body).send().await?; + assert_eq!(delete.status(), 405, "DELETE {path}"); + } + assert!(server.local_rib.snapshot().await.is_empty()); Ok(()) } @@ -252,7 +167,7 @@ async fn test_event_stream() -> Result<()> { let _peer = established_peer(&server).await?; server .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?; // Collect SSE frames until we have seen both events. @@ -300,7 +215,7 @@ async fn test_statusz_page_is_served() -> Result<()> { } #[tokio::test] -async fn test_metricz_and_well_known() -> Result<()> { +async fn test_metricz() -> Result<()> { let server = TestServer::start(LOCAL_ASN).await?; let (base, _http) = server.serve_http().await?; 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_local_routes").is_some(), "{text}"); assert!(metric("bgp_adj_rib_in_routes").is_some(), "{text}"); - - let wk = get_json(&client, &format!("{base}/api/communities/well-known")).await?; - assert!(wk.as_array().unwrap().iter().any(|c| c["name"] == "NO_EXPORT" && c["value"] == "65535:65281")); Ok(()) } diff --git a/crates/integration-tests/tests/interop.rs b/crates/integration-tests/tests/interop.rs index 414d612..b6c3d59 100644 --- a/crates/integration-tests/tests/interop.rs +++ b/crates/integration-tests/tests/interop.rs @@ -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 /// 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] -async fn test_ipv6_lab_prefix_update_shape() -> Result<()> { - let route = LocalRoute::new(IpPrefix::try_from("2a0d:d741:1::/48")?, "student1", LocalRouteAttrs::default()); +async fn test_ipv6_prefix_update_shape() -> Result<()> { + let route = LocalRoute::new(IpPrefix::try_from("2001:db8:1::/48")?, LocalRouteAttrs::default()); let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?; let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into()).expect("ipv6 over ipv6"); 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::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; Ok(()) } diff --git a/crates/web/src/community.rs b/crates/packet/src/community.rs similarity index 94% rename from crates/web/src/community.rs rename to crates/packet/src/community.rs index c021772..a94f486 100644 --- a/crates/web/src/community.rs +++ b/crates/packet/src/community.rs @@ -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 //! of the well-known names from RFC 1997 / RFC 7999 / RFC 8326. Large //! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092). -use bgp_packet::message::LargeCommunity; +use crate::message::LargeCommunity; /// Well-known standard communities, by name. pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[ diff --git a/crates/packet/src/lib.rs b/crates/packet/src/lib.rs index 3fd7cf1..03b6a7b 100644 --- a/crates/packet/src/lib.rs +++ b/crates/packet/src/lib.rs @@ -1,3 +1,4 @@ +pub mod community; pub mod errors; pub mod message; pub mod open; diff --git a/crates/rib/src/lib.rs b/crates/rib/src/lib.rs index 20fb62d..bbdb277 100644 --- a/crates/rib/src/lib.rs +++ b/crates/rib/src/lib.rs @@ -6,4 +6,4 @@ mod local_rib; pub use adj_rib_in::AdjRibIn; pub use entry::{PeerId, RibEntry, SnapshotEntry}; pub use event::RibEvent; -pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRibPolicy, LocalRoute, LocalRouteAttrs}; +pub use local_rib::{LocalRib, LocalRibError, LocalRibEvent, LocalRoute, LocalRouteAttrs}; diff --git a/crates/rib/src/local_rib.rs b/crates/rib/src/local_rib.rs index 6575efb..58140e7 100644 --- a/crates/rib/src/local_rib.rs +++ b/crates/rib/src/local_rib.rs @@ -1,9 +1,9 @@ //! Locally originated routes (the "Loc-RIB" of routes this router announces //! on its own behalf, as opposed to routes learned from peers). //! -//! The actor stores one route per prefix together with the attributes chosen -//! by whoever announced it. It does not know about peers or sessions: the -//! server subscribes to [`LocalRibEvent`]s and turns them into UPDATEs. +//! The actor stores one route per prefix together with its attributes. It +//! does not know about peers or sessions: the server subscribes to +//! [`LocalRibEvent`]s and turns them into UPDATEs. use std::collections::HashMap; use std::time::SystemTime; @@ -17,7 +17,7 @@ use tracing::warn; const EVENT_CHANNEL_CAPACITY: usize = 1024; 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)] pub struct LocalRouteAttrs { pub origin: OriginPathAttribute, @@ -47,44 +47,18 @@ impl Default for LocalRouteAttrs { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalRoute { 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 announced_at: SystemTime, } impl LocalRoute { - pub fn new(prefix: IpPrefix, owner: impl Into, attrs: LocalRouteAttrs) -> Self { - Self { prefix, owner: owner.into(), 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, - /// 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)) + pub fn new(prefix: IpPrefix, attrs: LocalRouteAttrs) -> Self { + Self { prefix, attrs, announced_at: SystemTime::now() } } } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] 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")] Closed, } @@ -97,12 +71,8 @@ pub enum LocalRibEvent { } enum Command { - Announce { route: LocalRoute, reply: oneshot::Sender> }, - Withdraw { - prefix: IpPrefix, - requester: Option, - reply: oneshot::Sender, LocalRibError>>, - }, + Announce { route: LocalRoute, reply: oneshot::Sender<()> }, + Withdraw { prefix: IpPrefix, reply: oneshot::Sender> }, Snapshot { reply: oneshot::Sender> }, } @@ -114,38 +84,32 @@ pub struct LocalRib { } impl LocalRib { - pub fn spawn(policy: LocalRibPolicy) -> Self { + pub fn spawn() -> Self { let (tx, rx) = mpsc::channel(CMD_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 } } - /// Adds a route, or replaces the attributes of an existing route with the - /// same prefix and owner. + /// Adds a route, or replaces the existing route for the same prefix. pub async fn announce(&self, route: LocalRoute) -> Result<(), LocalRibError> { let (reply, rx) = oneshot::channel(); self.tx .send(Command::Announce { route, reply }) .await .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` - /// to withdraw regardless of owner (administrative override). Returns the - /// removed route, or `None` if the prefix was not present. - pub async fn withdraw( - &self, - prefix: IpPrefix, - requester: Option<&str>, - ) -> Result, LocalRibError> { + /// Removes a route. Returns the removed route, or `None` if the prefix + /// was not present. + pub async fn withdraw(&self, prefix: IpPrefix) -> Result, LocalRibError> { let (reply, rx) = oneshot::channel(); self.tx - .send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply }) + .send(Command::Withdraw { prefix, reply }) .await .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. @@ -162,39 +126,22 @@ impl LocalRib { } } -async fn run_actor( - mut rx: mpsc::Receiver, - events: broadcast::Sender, - policy: LocalRibPolicy, -) { +async fn run_actor(mut rx: mpsc::Receiver, events: broadcast::Sender) { let mut table: HashMap = HashMap::new(); while let Some(cmd) = rx.recv().await { match cmd { Command::Announce { route, reply } => { - let result = check_announce(&table, &policy, &route).map(|()| { - table.insert(route.prefix.clone(), route.clone()); - let _ = events.send(LocalRibEvent::Announced(route)); - }); - let _ = reply.send(result); + table.insert(route.prefix.clone(), route.clone()); + let _ = events.send(LocalRibEvent::Announced(route)); + let _ = reply.send(()); } - Command::Withdraw { prefix, requester, reply } => { - let result = match (table.get(&prefix), requester) { - (Some(existing), Some(req)) if existing.owner != req => { - Err(LocalRibError::OwnedByOther { - prefix: prefix.clone(), - owner: existing.owner.clone(), - }) - } - _ => { - let removed = table.remove(&prefix); - if removed.is_some() { - let _ = events.send(LocalRibEvent::Withdrawn { prefix }); - } - Ok(removed) - } - }; - let _ = reply.send(result); + Command::Withdraw { prefix, reply } => { + let removed = table.remove(&prefix); + if removed.is_some() { + let _ = events.send(LocalRibEvent::Withdrawn { prefix }); + } + let _ = reply.send(removed); } Command::Snapshot { reply } => { if reply.send(table.values().cloned().collect()).is_err() { @@ -205,36 +152,6 @@ async fn run_actor( } } -fn check_announce( - table: &HashMap, - 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)] mod tests { use super::*; @@ -243,63 +160,37 @@ mod tests { IpPrefix::try_from(s).unwrap() } - fn route(p: &str, owner: &str) -> LocalRoute { - LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default()) + fn route(p: &str) -> LocalRoute { + LocalRoute::new(prefix(p), LocalRouteAttrs::default()) } #[tokio::test] async fn announce_and_withdraw_emit_events() { - let rib = LocalRib::spawn(LocalRibPolicy::default()); + let rib = LocalRib::spawn(); 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_eq!(rib.snapshot().await.len(), 1); - let removed = rib.withdraw(prefix("10.0.0.0/24"), Some("alice")).await.unwrap(); - assert_eq!(removed.unwrap().owner, "alice"); + let removed = rib.withdraw(prefix("10.0.0.0/24")).await.unwrap(); + 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!(rib.snapshot().await.is_empty()); // 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] - async fn policy_allowlist_is_enforced() { - let rib = LocalRib::spawn(LocalRibPolicy { - allowed_prefixes: vec![prefix("10.100.0.0/16")], - max_routes_per_owner: 0, - }); - 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"))); - } + async fn announcing_a_prefix_again_replaces_its_attributes() { + let rib = LocalRib::spawn(); + rib.announce(route("10.0.0.0/24")).await.unwrap(); + let attrs = LocalRouteAttrs { med: Some(50), ..Default::default() }; + rib.announce(LocalRoute::new(prefix("10.0.0.0/24"), attrs.clone())).await.unwrap(); - #[tokio::test] - async fn owner_limit_ignores_replacements() { - let rib = LocalRib::spawn(LocalRibPolicy { allowed_prefixes: vec![], max_routes_per_owner: 1 }); - 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()); + let table = rib.snapshot().await; + assert_eq!(table.len(), 1); + assert_eq!(table[0].attrs, attrs); } } diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 4314e3b..c015a9e 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -18,8 +18,8 @@ message BgpConfig { // Peers configured on this server. repeated Peer peers = 4; - // Limits on routes originated locally (e.g. via the demo UI). - LocalRoutePolicy local_route_policy = 5; + // Routes this router originates and announces to every peer. + repeated OriginatedRoute originate = 5; // Where to serve the HTTP API, status page and metrics. The port is // 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, // instead of being refused until the hold timer expires. bool collision_detect_established_state = 8; - } -// Limits on locally originated routes. -message LocalRoutePolicy { - // A locally originated route is accepted only if it is covered by one of - // these prefixes, written in CIDR notation (e.g. "10.100.0.0/16"). - // Empty means any prefix may be announced. - repeated string allowed_prefixes = 1; +// A route originated by this router. It is announced with an AS_PATH of the +// local AS and, on each session, that session's local address as next hop. +message OriginatedRoute { + // The prefix in CIDR notation, e.g. "192.0.2.0/24" or "2001:db8::/32". + string prefix = 1; - // Maximum number of routes a single owner may announce. 0 means unlimited. - uint32 max_routes_per_owner = 2; + // ORIGIN attribute: "IGP" (the default), "EGP" or "INCOMPLETE". + 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. diff --git a/crates/server/src/advertise.rs b/crates/server/src/advertise.rs index 83f327c..94a3b27 100644 --- a/crates/server/src/advertise.rs +++ b/crates/server/src/advertise.rs @@ -201,7 +201,6 @@ mod tests { fn ipv4_announce_has_expected_attributes_and_roundtrips() { let route = LocalRoute::new( prefix("10.100.1.0/24"), - "alice", LocalRouteAttrs { origin: OriginPathAttribute::IGP, prepend: 2, @@ -240,7 +239,7 @@ mod tests { #[test] 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 update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap(); @@ -255,9 +254,9 @@ mod tests { #[test] 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()); - 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()); } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index e8bcfbe..a8e4bd1 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -3,14 +3,17 @@ use std::str::FromStr; use std::time::Duration; 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 crate::proto::config::BgpConfig; +use crate::proto::config::{BgpConfig, OriginatedRoute}; const DEFAULT_BGP_PORT: u16 = 179; const DEFAULT_HOLD_TIME_SECS: u16 = 90; 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. pub struct Config { @@ -27,8 +30,8 @@ pub struct Config { /// instead of always closing the new connection (RFC 4271 §8.1.1 /// `CollisionDetectEstablishedState`). pub collision_detect_established_state: bool, - /// Limits on locally originated routes. - pub local_route_policy: LocalRibPolicy, + /// Routes this router originates. + pub originate: Vec, /// Address for the HTTP API; `None` disables it. pub http_endpoint: Option, } @@ -105,17 +108,13 @@ impl Config { }) .collect::>()?; - let local_route_policy = match proto.local_route_policy.into_option() { - Some(policy) => LocalRibPolicy { - allowed_prefixes: policy - .allowed_prefixes - .iter() - .map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid allowed prefix {p:?}"))) - .collect::>()?, - max_routes_per_owner: policy.max_routes_per_owner as usize, - }, - None => LocalRibPolicy::default(), - }; + let originate: Vec = + proto.originate.iter().map(parse_originated_route).collect::>()?; + for (i, route) in originate.iter().enumerate() { + if originate[..i].iter().any(|earlier| earlier.prefix == route.prefix) { + bail!("originate lists {} more than once", route.prefix); + } + } let http_endpoint = match proto.http_endpoint.into_option() { Some(ep) => { @@ -141,7 +140,7 @@ impl Config { peers, connect_retry, collision_detect_established_state: proto.collision_detect_established_state, - local_route_policy, + originate, http_endpoint, }) } @@ -161,3 +160,96 @@ fn parse_endpoint(address: &str, port: u32) -> Result { Ok(SocketAddr::new(ip, port)) } + +fn parse_originated_route(route: &OriginatedRoute) -> Result { + 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::>() + .map_err(|e| eyre!("originate {prefix}: {e}"))?; + let large_communities = route + .large_communities + .iter() + .map(|c| community::parse_large(c)) + .collect::>() + .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 { + let proto = protobuf::text_format::parse_from_str::(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:#}"); + } +} diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 306b676..fb2522d 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -53,10 +53,16 @@ impl BgpServerBuilder { info!(%bound_addr, "Listening for BGP connections"); 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 { listeners, adj_rib_in: AdjRibIn::spawn(), - local_rib: LocalRib::spawn(self.config.local_route_policy.clone()), + local_rib, registry: SessionRegistry::with_configured( self.config .peers diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 75b8a77..573ecc6 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -6,18 +6,12 @@ edition.workspace = true [dependencies] axum.workspace = true bgp-packet.workspace = true -bgp-primitives.workspace = true bgp-rib.workspace = true bgp-server.workspace = true -bytes.workspace = true eyre.workspace = true futures.workspace = true prometheus.workspace = true serde.workspace = true -serde_json.workspace = true tokio.workspace = true tokio-stream.workspace = true tracing.workspace = true - -[dev-dependencies] -reqwest.workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index ad32d94..29a6d78 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -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 axum::Json; -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use bgp_packet::message::{Message, OriginPathAttribute, PathAttribute, UpdateMessage}; -use bgp_primitives::ip_prefix::IpPrefix; -use bgp_primitives::parser::ParserContext; -use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry}; -use bgp_server::advertise::build_announce; +use axum::extract::State; +use bgp_packet::community; +use bgp_packet::message::{OriginPathAttribute, PathAttribute}; +use bgp_rib::{LocalRoute, SnapshotEntry}; use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry}; -use bytes::BytesMut; -use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde::Serialize; 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) -> 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 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 @@ -165,7 +122,6 @@ pub fn peer_views(registry: &SessionRegistry) -> Vec { #[derive(Debug, Clone, Serialize)] pub struct LocalRouteView { pub prefix: String, - pub owner: String, pub origin: String, pub prepend: u8, pub med: Option, @@ -178,7 +134,6 @@ impl From<&LocalRoute> for LocalRouteView { fn from(r: &LocalRoute) -> Self { Self { prefix: r.prefix.to_string(), - owner: r.owner.clone(), origin: origin_name(r.attrs.origin).to_string(), prepend: r.attrs.prepend, med: r.attrs.med, @@ -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 { match o { OriginPathAttribute::IGP => "IGP", @@ -273,63 +213,6 @@ fn origin_name(o: OriginPathAttribute) -> &'static str { } } -fn parse_origin(s: &str) -> Result { - 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::try_from(s.trim()).map_err(|e| ApiError::bad_request(format!("invalid prefix {s:?}: {e}"))) -} - -fn validate_owner(owner: &str) -> Result { - 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, - #[serde(default)] - pub prepend: u8, - #[serde(default)] - pub med: Option, - #[serde(default)] - pub communities: Vec, - #[serde(default)] - pub large_communities: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct WithdrawRequest { - pub prefix: String, - /// Omit to withdraw regardless of owner. - #[serde(default)] - pub owner: Option, -} - -#[derive(Debug, Deserialize)] -pub struct WireQuery { - pub prefix: String, -} - // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -373,102 +256,6 @@ pub async fn local_routes(State(state): State) -> Json, - Json(req): Json, -) -> Result, 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::>() - .map_err(ApiError::bad_request)?, - large_communities: req - .large_communities - .iter() - .map(|c| community::parse_large(c)) - .collect::>() - .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, - Json(req): Json, -) -> Result>, 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, - Query(q): Query, -) -> Result>, 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> { - Json( - community::WELL_KNOWN - .iter() - .map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) }) - .collect(), - ) -} - pub async fn received_routes(State(state): State) -> Json> { let mut routes: Vec = state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect(); diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index c4dec55..5cbaa50 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -1,12 +1,11 @@ -//! HTTP surface of the daemon: a JSON API for originating routes and -//! inspecting state, a server-sent event stream, and Prometheus metrics. +//! HTTP surface of the daemon: a read-only JSON API for inspecting state, a +//! status page, a server-sent event stream, and Prometheus metrics. //! //! Everything here is a thin layer over handles owned by the BGP server //! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives //! in this crate. mod api; -mod community; mod events; mod metrics; @@ -17,17 +16,14 @@ use std::time::{Instant, SystemTime}; use axum::Router; use axum::http::header; use axum::response::{Html, IntoResponse, Redirect}; -use axum::routing::{delete, get, post}; +use axum::routing::get; use bgp_rib::{AdjRibIn, LocalRib}; use bgp_server::session::SessionRegistry; use eyre::{Result, WrapErr}; use tokio::net::TcpListener; use tracing::info; -pub use api::{ - AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView, - WellKnownCommunity, WireView, WithdrawRequest, -}; +pub use api::{AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView}; /// Static facts about the router shown in status output. #[derive(Debug, Clone)] @@ -101,12 +97,8 @@ pub fn router(state: AppState) -> Router { .route("/api/status", get(api::status)) .route("/api/config", get(api::config)) .route("/api/peers", get(api::peers)) - .route("/api/routes/local", get(api::local_routes).post(api::announce)) - .route("/api/routes/local", delete(api::withdraw)) - .route("/api/routes/local/withdraw", post(api::withdraw)) - .route("/api/routes/local/wire", get(api::wire)) + .route("/api/routes/local", get(api::local_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("/metricz", get(metrics::metricz)) .with_state(state) diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index d66dd97..ceec486 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -6,11 +6,10 @@ Status for ferrix_bgp