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>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 12:35:54 +02:00
1 parent 1ec60255ea
commit 630c175918
138 files changed
+21650 -2925

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# What a fuzzing run produces. Seeds worth keeping live in seeds/.
target/
corpus/
artifacts/
coverage/
Cargo.lock
+54
View File
@@ -0,0 +1,54 @@
# Coverage-guided fuzz targets, run with cargo-fuzz on a nightly toolchain.
# See README.md in this directory.
[package]
name = "bgp-fuzz"
version = "0.0.0"
edition = "2024"
license = "Apache-2.0"
publish = false
[package.metadata]
cargo-fuzz = true
# Not part of the main workspace: libfuzzer needs nightly, the workspace
# builds on stable.
[workspace]
[dependencies]
bgp-packet = { path = "../crates/packet" }
bgp-primitives = { path = "../crates/primitives" }
bgp-server = { path = "../crates/server" }
bytes = "1.10.1"
libfuzzer-sys = "0.4"
protobuf = "3.7.2"
[lib]
path = "src/lib.rs"
[[bin]]
name = "message"
path = "fuzz_targets/message.rs"
test = false
doc = false
bench = false
[[bin]]
name = "message_body"
path = "fuzz_targets/message_body.rs"
test = false
doc = false
bench = false
[[bin]]
name = "prefix_text"
path = "fuzz_targets/prefix_text.rs"
test = false
doc = false
bench = false
[[bin]]
name = "config"
path = "fuzz_targets/config.rs"
test = false
doc = false
bench = false
+47
View File
@@ -0,0 +1,47 @@
# Fuzz targets
Coverage-guided fuzzing of everything that reads input we do not control.
The property tests (`cargo test`, e.g. `crates/packet/tests/properties.rs`)
check the same things on generated input and run on every build; these
targets go further by letting libFuzzer follow code coverage for as long as
you leave them running.
| Target | Input |
|----------------|------------------------------------------------------------------|
| `message` | Raw bytes as they come off a TCP connection |
| `message_body` | A message body behind a correct header (reaches the body parsers) |
| `prefix_text` | A prefix as text |
| `config` | The configuration file |
For the two message targets a finding is either a panic, or a message the
decoder accepted that cannot be written and read back as itself.
## Running
libFuzzer needs a nightly toolchain and `cargo-fuzz`:
```bash
rustup toolchain install nightly
cargo install cargo-fuzz
```
Then, from this directory:
```bash
cargo +nightly fuzz run message_body corpus/message_body seeds/message_body -- -max_total_time=300
```
The first directory is the working corpus, which the fuzzer adds to and
which is not checked in. `seeds/` holds a few valid inputs to start from.
Leave out `-max_total_time` to run until interrupted.
## When it finds something
The input is saved under `artifacts/<target>/`. Reproduce it with
```bash
cargo +nightly fuzz run message_body artifacts/message_body/crash-...
```
Fix the bug, and add a unit test with the input next to the code it broke,
so that `cargo test` keeps it fixed without anyone having to run the fuzzer.
+13
View File
@@ -0,0 +1,13 @@
//! The configuration file. It is written by the operator and not by a peer,
//! but a typo in it should be an error message, never a panic at startup.
#![no_main]
use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|text: &str| {
if let Ok(proto) = protobuf::text_format::parse_from_str::<BgpConfig>(text) {
let _ = Config::from_proto(proto);
}
});
+12
View File
@@ -0,0 +1,12 @@
//! Raw bytes, as they come off a TCP connection. The first byte picks what the
//! session settled: see `bgp_fuzz::Session`.
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let Some((selector, wire)) = data.split_first() else {
return;
};
bgp_fuzz::decode_and_check(bgp_fuzz::Session::from_bits(*selector), wire);
});
+25
View File
@@ -0,0 +1,25 @@
//! A message body behind a correct header, so that the fuzzer spends its
//! time in the OPEN, UPDATE and NOTIFICATION parsers and not on guessing
//! the marker and length. The first byte picks the message type and what
//! the session settled: see `bgp_fuzz::Session`.
#![no_main]
use libfuzzer_sys::fuzz_target;
/// The longest body that fits a BGP message (4096 less the 19-byte header).
const MAX_BODY: usize = 4096 - 19;
fuzz_target!(|data: &[u8]| {
let Some((selector, body)) = data.split_first() else {
return;
};
let body = &body[..body.len().min(MAX_BODY)];
// Bits 1 to 3; the others pick the session.
let msg_type = 1 + ((selector >> 1) & 0x07) % 5;
let mut wire = vec![0xff; 16];
wire.extend_from_slice(&((19 + body.len()) as u16).to_be_bytes());
wire.push(msg_type);
wire.extend_from_slice(body);
bgp_fuzz::decode_and_check(bgp_fuzz::Session::from_bits(*selector), &wire);
});
+12
View File
@@ -0,0 +1,12 @@
//! Prefixes as text, as they appear in the configuration file.
#![no_main]
use bgp_primitives::ip_prefix::IpPrefix;
use libfuzzer_sys::fuzz_target;
fuzz_target!(|text: &str| {
if let Ok(prefix) = IpPrefix::try_from(text) {
let shown = prefix.to_string();
assert_eq!(IpPrefix::try_from(shown.as_str()), Ok(prefix));
}
});
+38
View File
@@ -0,0 +1,38 @@
# A worked example; the fields are documented in
# crates/server/proto/config.proto.
router_id: "10.0.0.1"
local_asn: 65000
# Where to accept BGP connections. Port 179 needs privileges; pick another
# port to run unprivileged against a peer that dials a custom port.
listen_endpoints { address: "127.0.0.1" port: 179 }
listen_endpoints { address: "::1" port: 179 }
# A peer we connect to (and accept connections from).
peers {
name: "r1"
endpoint { address: "127.0.0.1" port: 1791 }
remote_asn: 65001
hold_time_seconds: 30
# Offer only these families to the peer. Left out, all are offered.
address_families: IPV4_UNICAST
address_families: IPV6_UNICAST
}
connect_retry_seconds: 5
# Let a restarted peer replace a stale Established session immediately
# (RFC 4271 CollisionDetectEstablishedState). Default false.
collision_detect_established_state: false
# Routes this router originates, announced to every peer.
originate { prefix: "10.100.0.0/16" }
originate {
prefix: "2001:db8:100::/48"
med: 50
communities: "65000:100"
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 }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
bye
+1
View File
@@ -0,0 +1 @@
bye
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
10.0.0.0/8
+1
View File
@@ -0,0 +1 @@
2001:db8::/32
+1
View File
@@ -0,0 +1 @@
0.0.0.0/0
+1
View File
@@ -0,0 +1 @@
::/0
+1
View File
@@ -0,0 +1 @@
192.0.2.1/32
+1
View File
@@ -0,0 +1 @@
10.1.2.3/19
+72
View File
@@ -0,0 +1,72 @@
//! What the fuzz targets have in common.
use bgp_packet::message::Message;
use bgp_primitives::constants::AfiSafi;
use bgp_primitives::parser::{EncodeError, ParserContext};
use bytes::BytesMut;
/// What a session settled that decoding depends on.
#[derive(Debug, Clone, Copy)]
pub struct Session {
pub four_octet_asn: bool,
pub ibgp: bool,
/// Whether prefixes carry path identifiers (RFC 7911), for IPv4 and for
/// IPv6 unicast.
pub add_path: [bool; 2],
}
impl Session {
/// A session picked by bits of `selector`: bit 0 for the AS number
/// width, bits 5 and 6 for ADD-PATH, bit 7 for an internal peer. Bits 1
/// to 4 are left for the target.
pub fn from_bits(selector: u8) -> Self {
Self {
four_octet_asn: selector & 0x01 != 0,
add_path: [selector & 0x20 != 0, selector & 0x40 != 0],
ibgp: selector & 0x80 != 0,
}
}
fn context(self) -> ParserContext {
ParserContext {
four_octet_asn: self.four_octet_asn,
ibgp: self.ibgp,
add_path: [AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST]
.into_iter()
.zip(self.add_path)
.filter_map(|(family, in_use)| in_use.then_some(family))
.collect(),
}
}
}
/// Decodes `wire` as a peer's message on `session`. Whatever the bytes, this must return: a panic is a way for a peer
/// to take a session down.
///
/// A message that is accepted must also be one we can show, write, and read
/// back as itself. Anything else means the decoder built a value the rest
/// of the program cannot rely on.
pub fn decode_and_check(session: Session, wire: &[u8]) {
let ctx = session.context();
let Ok(decoded) = Message::decode_with_report(&ctx, wire) else {
return;
};
let (msg, repairs) = (decoded.message, decoded.update_errors);
let _ = format!("{msg:?} {repairs:?}");
let mut rewritten = BytesMut::new();
match msg.to_wire(&ctx, &mut rewritten) {
Ok(()) => {}
// Withdrawing what a full-size UPDATE announced can need more room
// than one message has. The result is acted on, never sent.
Err(EncodeError::TooLong { .. }) if !repairs.is_empty() => return,
Err(e) => panic!("an accepted message cannot be written: {e}"),
}
// An UPDATE the decoder had to repair (RFC 7606) is not what was on the
// wire, and need not read back as itself.
if repairs.is_empty() {
let again = Message::decode_with_report(&ctx, &rewritten).expect("and read back");
assert!(again.update_errors.is_empty(), "{:?}", again.update_errors);
assert_eq!(again.message, msg);
}
}