The work of the speaker-cleanup branch as one commit; the branch keeps the step-by-step history. Sessions - The state machine is a pure function from events to actions, driven by one task per connection. Idle, Connect and Active belong to a dialer that keeps configured peers up with backoff. - Capabilities are negotiated: address families, four-octet AS numbers with AS_TRANS and AS4_PATH, route refresh, ADD-PATH per family and direction. - Collision detection per RFC 4271 section 6.8, by address and identifier. - Connections are taken only from configured peers, a few per address. - A panic in a session is contained and answered with a Cease; a core task that stops shuts the server down with a Cease to every peer. - Writes that make no progress are given up (RFC 9687). Codec - Malformed UPDATEs are handled as RFC 7606 says: treat-as-withdraw, attribute discard, or session reset, with the NOTIFICATION to send. - Address families are open types; any can be named and ignored. - Prefixes are fixed-size values, normalised, with path identifiers. - Lengths are filled in after writing; one definition of each type code. - Property tests and fuzz targets for every decoder. Routing table - Adj-RIBs-In and Loc-RIB in one sharded table that sessions write to directly. Routes are sixteen bytes; attribute sets are interned in wire form. Best-path selection per RFC 4271 section 9.1.2.2 with deterministic MED. - A journal per shard lets followers read changes at their own pace; one that falls behind is resynchronised. - On a RIPE RIS dump of 148 peers and 39.3M routes: loaded in 2.0 s on 12 cores, about 52 bytes a route. Announcing - Originated routes come from the configuration. Each session has its own exporter with an Adj-RIB-Out, so a slow peer delays only itself. - Routes to a peer in our own AS have an empty path and a LOCAL_PREF. Operations - HTTP status page, JSON API, event stream and Prometheus metrics. - Benchmarks on synthetic and real tables. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
229 lines
7.8 KiB
Rust
229 lines
7.8 KiB
Rust
//! End-to-end tests for errors in UPDATE messages (RFC 7606): most of them
|
|
//! cost the routes in the UPDATE, not the session.
|
|
|
|
use std::net::Ipv4Addr;
|
|
use std::time::Duration;
|
|
|
|
use bgp_integration_tests::test_peer::TestPeer;
|
|
use bgp_integration_tests::test_server::{ReceivedRoute, TestServer};
|
|
use bgp_packet::errors::{BgpError, UpdateMsgSubcode};
|
|
use bgp_packet::message::{LocalPrefPathAttribute, Message, PathAttribute};
|
|
use bgp_primitives::ip_prefix::IpPrefix;
|
|
use bgp_server::metrics::UPDATE_ERRORS;
|
|
use eyre::{Result, bail};
|
|
|
|
const LOCAL_ASN: u32 = 65000;
|
|
const PEER_ASN: u32 = 65001;
|
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
const WELL_KNOWN: u8 = 0x40;
|
|
const OPTIONAL_TRANSITIVE: u8 = 0xc0;
|
|
|
|
fn attr(flags: u8, type_code: u8, value: &[u8]) -> Vec<u8> {
|
|
let mut wire = vec![flags, type_code, value.len() as u8];
|
|
wire.extend_from_slice(value);
|
|
wire
|
|
}
|
|
|
|
fn origin() -> Vec<u8> {
|
|
attr(WELL_KNOWN, 1, &[0])
|
|
}
|
|
|
|
/// AS_SEQUENCE of the peer's AS, four octets wide.
|
|
fn as_path() -> Vec<u8> {
|
|
attr(WELL_KNOWN, 2, &[2, 1, 0, 0, 0xfd, 0xe9])
|
|
}
|
|
|
|
fn next_hop() -> Vec<u8> {
|
|
attr(WELL_KNOWN, 3, &[192, 0, 2, 1])
|
|
}
|
|
|
|
/// A whole UPDATE message announcing `nlri` with `attributes`, as bytes, so
|
|
/// that it can be wrong in ways the encoder would not write.
|
|
fn update(attributes: &[Vec<u8>], nlri: &[u8]) -> Vec<u8> {
|
|
let attributes = attributes.concat();
|
|
let mut body = vec![0, 0];
|
|
body.extend_from_slice(&(attributes.len() as u16).to_be_bytes());
|
|
body.extend_from_slice(&attributes);
|
|
body.extend_from_slice(nlri);
|
|
|
|
let mut wire = vec![0xff; 16];
|
|
wire.extend_from_slice(&((19 + body.len()) as u16).to_be_bytes());
|
|
wire.push(2);
|
|
wire.extend_from_slice(&body);
|
|
wire
|
|
}
|
|
|
|
/// 10.1.0.0/16 and 10.2.0.0/16.
|
|
const ROUTE_A: [u8; 3] = [16, 10, 1];
|
|
const ROUTE_B: [u8; 3] = [16, 10, 2];
|
|
|
|
fn prefix(s: &str) -> IpPrefix {
|
|
IpPrefix::try_from(s).unwrap()
|
|
}
|
|
|
|
async fn established(server: &TestServer, asn: u32) -> Result<TestPeer> {
|
|
let mut peer = TestPeer::connect(server.addr).await?;
|
|
peer.handshake(asn, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
|
|
Ok(peer)
|
|
}
|
|
|
|
/// Waits until the server's Adj-RIB-In holds exactly `prefixes`.
|
|
async fn wait_for_routes(server: &TestServer, prefixes: &[&str]) -> Result<Vec<ReceivedRoute>> {
|
|
let mut expected: Vec<IpPrefix> = prefixes.iter().map(|p| prefix(p)).collect();
|
|
expected.sort_by_key(|p| p.to_string());
|
|
Ok(tokio::time::timeout(TIMEOUT, async {
|
|
loop {
|
|
let mut routes = server.received_routes();
|
|
routes.sort_by_key(|r| r.prefix.to_string());
|
|
if routes.iter().map(|r| &r.prefix).eq(expected.iter()) {
|
|
return routes;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await?)
|
|
}
|
|
|
|
/// The situation RFC 7606 exists for: a route is in the table, and then an
|
|
/// UPDATE for it arrives with a broken attribute. The route goes; the
|
|
/// session, and the other routes learned over it, stay.
|
|
#[tokio::test]
|
|
async fn test_a_malformed_attribute_withdraws_the_route_and_keeps_the_session() -> Result<()> {
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut peer = established(&server, PEER_ASN).await?;
|
|
let handled_before = UPDATE_ERRORS
|
|
.with_label_values(&["treat-as-withdraw"])
|
|
.get();
|
|
|
|
let good = [origin(), as_path(), next_hop()];
|
|
peer.send_raw(&update(&good, &[ROUTE_A, ROUTE_B].concat()))
|
|
.await?;
|
|
wait_for_routes(&server, &["10.1.0.0/16", "10.2.0.0/16"]).await?;
|
|
|
|
// ORIGIN with a value that is not defined.
|
|
let bad = [attr(WELL_KNOWN, 1, &[7]), as_path(), next_hop()];
|
|
peer.send_raw(&update(&bad, &ROUTE_A)).await?;
|
|
wait_for_routes(&server, &["10.2.0.0/16"]).await?;
|
|
|
|
// The session goes on: the route can be announced again.
|
|
peer.send_raw(&update(&good, &ROUTE_A)).await?;
|
|
wait_for_routes(&server, &["10.1.0.0/16", "10.2.0.0/16"]).await?;
|
|
assert_eq!(server.registry.list().len(), 1);
|
|
assert!(
|
|
UPDATE_ERRORS
|
|
.with_label_values(&["treat-as-withdraw"])
|
|
.get()
|
|
> handled_before
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// An announcement without a mandatory attribute is not a route we can use.
|
|
#[tokio::test]
|
|
async fn test_a_route_without_as_path_is_not_accepted() -> Result<()> {
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut peer = established(&server, PEER_ASN).await?;
|
|
|
|
peer.send_raw(&update(&[origin(), next_hop()], &ROUTE_A))
|
|
.await?;
|
|
// A well-formed one after it shows that the first was dealt with.
|
|
peer.send_raw(&update(&[origin(), as_path(), next_hop()], &ROUTE_B))
|
|
.await?;
|
|
|
|
wait_for_routes(&server, &["10.2.0.0/16"]).await?;
|
|
assert_eq!(server.registry.list().len(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
/// A broken attribute that plays no part in choosing a route is dropped,
|
|
/// and the route is kept.
|
|
#[tokio::test]
|
|
async fn test_a_malformed_aggregator_is_dropped_and_the_route_kept() -> Result<()> {
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut peer = established(&server, PEER_ASN).await?;
|
|
|
|
let attributes = [
|
|
origin(),
|
|
as_path(),
|
|
next_hop(),
|
|
// AGGREGATOR is eight octets on this session.
|
|
attr(OPTIONAL_TRANSITIVE, 7, &[0, 0, 0xfd, 0xe9, 192]),
|
|
];
|
|
peer.send_raw(&update(&attributes, &ROUTE_A)).await?;
|
|
|
|
let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?;
|
|
assert_eq!(routes[0].attributes.len(), 3);
|
|
assert!(
|
|
!routes[0]
|
|
.attributes
|
|
.iter()
|
|
.any(|a| matches!(a, PathAttribute::Aggregator(_)))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// LOCAL_PREF is a matter between routers of one AS. From an external peer
|
|
/// it is dropped; from an internal one it is kept.
|
|
#[tokio::test]
|
|
async fn test_local_pref_is_kept_only_from_an_internal_peer() -> Result<()> {
|
|
let local_pref = attr(WELL_KNOWN, 5, &[0, 0, 0, 200]);
|
|
let has_local_pref = |routes: &[ReceivedRoute]| {
|
|
routes[0]
|
|
.attributes
|
|
.contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(200)))
|
|
};
|
|
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut external = established(&server, PEER_ASN).await?;
|
|
let attributes = [origin(), as_path(), next_hop(), local_pref.clone()];
|
|
external.send_raw(&update(&attributes, &ROUTE_A)).await?;
|
|
let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?;
|
|
assert!(!has_local_pref(&routes));
|
|
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut internal = established(&server, LOCAL_ASN).await?;
|
|
// An internal peer has not added its AS to the path.
|
|
let attributes = [origin(), attr(WELL_KNOWN, 2, &[]), next_hop(), local_pref];
|
|
internal.send_raw(&update(&attributes, &ROUTE_A)).await?;
|
|
let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?;
|
|
assert!(has_local_pref(&routes));
|
|
Ok(())
|
|
}
|
|
|
|
/// When the prefixes themselves cannot be read there is nothing to
|
|
/// withdraw, and the session is reset as before.
|
|
#[tokio::test]
|
|
async fn test_an_unreadable_prefix_still_resets_the_session() -> Result<()> {
|
|
let server = TestServer::start(LOCAL_ASN).await?;
|
|
let mut peer = established(&server, PEER_ASN).await?;
|
|
|
|
// An IPv4 prefix of length 33.
|
|
peer.send_raw(&update(
|
|
&[origin(), as_path(), next_hop()],
|
|
&[33, 10, 1, 2, 3, 4],
|
|
))
|
|
.await?;
|
|
|
|
loop {
|
|
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
|
|
Message::KeepAlive => {}
|
|
Message::Notification(n) => {
|
|
assert_eq!(
|
|
n.error,
|
|
BgpError::UpdateMsg(UpdateMsgSubcode::InvalidNetworkField)
|
|
);
|
|
break;
|
|
}
|
|
other => bail!("expected NOTIFICATION, got {other:?}"),
|
|
}
|
|
}
|
|
tokio::time::timeout(TIMEOUT, async {
|
|
while !server.registry.list().is_empty() {
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|