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

+17 -7
View File
@@ -1,11 +1,21 @@
[package]
description = "A Border Gateway Protocol implementation"
edition.workspace = true
name = "bgp"
description = "A Border Gateway Protocol implementation"
authors.workspace = true
edition.workspace = true
homepage.workspace = true
license.workspace = true
name = "bgp"
version.workspace = true
license.workspace = true
repository.workspace = true
version.workspace = true
[dependencies]
eyre.workspace = true
tokio.workspace = true
bgp-server.workspace = true
bgp-web.workspace = true
clap.workspace = true
eyre.workspace = true
protobuf.workspace = true
tokio.workspace = true
tracing.workspace = true
[lints]
workspace = true
+60 -4
View File
@@ -1,9 +1,65 @@
# BGP
This crate provides a binary which runs the Border Gateway Protocol (BGP).
This crate provides the `bgp` binary, which runs the Border Gateway Protocol
daemon together with its read-only HTTP status interface.
Please refer to the other implementation crates part of this repository where most of the actual implemntation lives.
```
cargo run -- path/to/config.textpb
```
Warning: This is still very much a work in progress and is not yet ready for use.
The configuration format is defined in `crates/server/proto/config.proto`;
`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.
The main ongoing work is happening currently in the BGP packet crate.
When `http_endpoint` is set the binary also serves:
| Path | Purpose |
|---|---|
| `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/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/events` | Server-sent events for session and route changes |
| `GET /metricz` | Prometheus metrics |
Everything under it is read-only and unauthenticated. Bind it to loopback or
put it behind a reverse proxy.
## What it does today
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.
## Who may connect
Connections are taken only from the addresses of configured peers, and from
each of those only a few at a time. Anything else is closed unread and
counted in `bgp_connections_refused_total`. `accept_unconfigured_peers: true`
lets any host open a session as any AS; it exists for tests on a closed
network.
## 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 originated prefixes show up in the received table as if they had
travelled through a neighbouring AS:
```
cargo run -- testdata/server.textpb
cargo run -p bgp-integration-tests --example fake_peer -- \
--server 127.0.0.1:179 --asn 65001 --announce 203.0.113.0/24 --echo
```
Please refer to the other implementation crates in this repository where most
of the actual implementation lives.
Warning: This is still very much a work in progress and is not yet ready for
production use.
+110 -3
View File
@@ -1,6 +1,113 @@
use eyre::Result;
use std::sync::Arc;
use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig;
use bgp_server::server::{BgpServer, close_sessions};
use bgp_server::tracing::TracingArgs;
use bgp_web::{AppState, RouterInfo};
use clap::Parser;
use eyre::{Context, Result};
use tracing::{error, info};
#[derive(Parser, Debug)]
struct ServerArgs {
/// Path to the textproto configuration file.
config: String,
#[command(flatten)]
tracing: TracingArgs,
}
#[tokio::main]
pub async fn main() -> Result<()> {
todo!("Implement the server");
async fn main() -> Result<()> {
let args = ServerArgs::parse();
args.tracing.install()?;
log_panics();
let config_string =
std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?;
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
.wrap_err("Failed to parse config file")?;
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
let server = BgpServer::bind(config).await?;
if let Some(http_endpoint) = server.config().http_endpoint {
// Bound here, so that an address that cannot be bound stops the
// daemon from starting, as it does for the BGP listeners.
let listener = tokio::net::TcpListener::bind(http_endpoint)
.await
.wrap_err_with(|| format!("Failed to bind HTTP listener on {http_endpoint}"))?;
let state = AppState {
info: Arc::new(
RouterInfo::new(
server.config().router_id,
server.config().local_asn,
env!("CARGO_PKG_VERSION"),
)
.with_config(&args.config, &config_string),
),
registry: server.registry.clone(),
local_rib: server.local_rib.clone(),
rib: Arc::clone(&server.rib),
};
tokio::spawn(async move {
if let Err(e) = bgp_web::serve_on(state, listener).await {
error!(err = %e, "HTTP server exited");
}
});
}
// Leave politely: tell every peer we are going (Cease / Administrative
// Shutdown) rather than letting them find a dead TCP connection, which
// makes them slower to take us back.
let registry = server.registry.clone();
tokio::select! {
result = server.run() => Ok(result?),
signal = shutdown_signal() => {
info!(signal, "Shutting down, closing BGP sessions");
close_sessions(&registry).await;
Ok(())
}
}
}
/// Sends panics to the log, with where they happened. A panic is a bug, and
/// the default report goes to stderr in a format of its own, apart from
/// everything else the daemon says. The task that panicked still unwinds;
/// the server deals with what it leaves behind.
fn log_panics() {
std::panic::set_hook(Box::new(|info| {
let location = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "an unknown location".into());
let message = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "no message".into());
// Captured only if RUST_BACKTRACE is set.
let backtrace = std::backtrace::Backtrace::capture();
error!(%location, %message, %backtrace, "Panic: this is a bug");
}));
}
/// Resolves with the name of the first termination signal received.
async fn shutdown_signal() -> &'static str {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => "SIGINT",
_ = term.recv() => "SIGTERM",
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
"ctrl-c"
}
}