Files
ferrix/crates/integration-tests/tests/update_errors.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 630c175918 Rework the speaker: sans-I/O FSM, sharded table, RFC 7606, ADD-PATH
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>
2026-10-02 12:35:54 +02:00

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(())
}