Compare commits
38
Commits
main
...
netsec_2026
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "slides",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["slides/serve.py", "8765"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
target
|
||||
.git
|
||||
@@ -1 +1,5 @@
|
||||
/target
|
||||
__pycache__/
|
||||
slides/reveal.js/
|
||||
slides/dist/
|
||||
.DS_Store
|
||||
Generated
+787
-89
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ bgp-packet = { path = "crates/packet" }
|
||||
bgp-primitives = { path = "crates/primitives" }
|
||||
bgp-rib = { path = "crates/rib" }
|
||||
bgp-server = { path = "crates/server" }
|
||||
bgp-web = { path = "crates/web" }
|
||||
bgp-integration-tests = { path = "crates/integration-tests" }
|
||||
|
||||
# --- General ---
|
||||
@@ -39,3 +40,7 @@ protobuf = { package = "protobuf", version = "3.7.2" }
|
||||
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
axum = "0.8.9"
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Builds the ferrix_bgp daemon for the lab. See docs/lab/README.md.
|
||||
FROM rust:1.92-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN cargo build --release -p bgp
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /src/target/release/bgp /usr/local/bin/bgp
|
||||
ENTRYPOINT ["/usr/local/bin/bgp"]
|
||||
CMD ["/etc/ferrix/server.textpb", "--level", "info"]
|
||||
@@ -20,8 +20,10 @@ We aim for the 1.0 release to be functional and stable to operate peering sessio
|
||||
- ✅ Capabilities from RFC4271
|
||||
- ✅ Update message
|
||||
- ☐ Notification message
|
||||
- ☐ Implement scaffolding required to start and configure a server
|
||||
- ☐ Implement connection logic to establish sessions with peers
|
||||
- ✅ Implement scaffolding required to start and configure a server
|
||||
- ✅ Implement connection logic to establish sessions with peers (passive and active)
|
||||
- ✅ Locally originated routes with communities, announced to all peers
|
||||
- ✅ HTTP API, live status page and Prometheus metrics
|
||||
- ☐ Implement an efficient data structure for storing prefixes (e.g. Poptrie)
|
||||
- ☐ Filtering for peer RIB in / out
|
||||
- ☐ Main / auxillary RIBs for exchanging routes between peers
|
||||
@@ -7,5 +7,10 @@ name = "bgp"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
bgp-server.workspace = true
|
||||
bgp-web.workspace = true
|
||||
clap.workspace = true
|
||||
eyre.workspace = true
|
||||
protobuf.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
+58
-4
@@ -1,9 +1,63 @@
|
||||
# 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 HTTP API.
|
||||
|
||||
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. When `http_endpoint` is set the
|
||||
binary also serves:
|
||||
|
||||
The main ongoing work is happening currently in the BGP packet crate.
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `GET /statusz` | Status page: peers, announce form, local 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/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:
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
|
||||
Only `prefix` and `owner` are required. Routes are subject to the
|
||||
`local_route_policy` in the configuration.
|
||||
|
||||
## 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:
|
||||
|
||||
```
|
||||
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.
|
||||
+93
-3
@@ -1,6 +1,96 @@
|
||||
use eyre::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_server::config::Config;
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::proto::config::BgpConfig;
|
||||
use bgp_server::server::BgpServerBuilder;
|
||||
use bgp_server::tracing::TracingArgs;
|
||||
use bgp_web::{AppState, RouterInfo};
|
||||
use clap::Parser;
|
||||
use eyre::{Context, Result};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[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()?;
|
||||
|
||||
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 = BgpServerBuilder::new(config).bind().await?;
|
||||
|
||||
let pings = match &server.config().ping_monitor {
|
||||
Some(prefixes) => PingMonitor::start(prefixes.clone(), server.local_rib.clone()),
|
||||
None => PingMonitor::disabled(),
|
||||
};
|
||||
|
||||
if let Some(http_endpoint) = server.config().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(),
|
||||
adj_rib_in: server.adj_rib_in.clone(),
|
||||
pings,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bgp_web::serve(state, http_endpoint).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() => result,
|
||||
signal = shutdown_signal() => {
|
||||
info!(signal, "Shutting down, closing BGP sessions");
|
||||
let left = registry.shutdown(Duration::from_secs(2)).await;
|
||||
if left > 0 {
|
||||
warn!(sessions = left, "Some sessions did not close in time");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ bgp-packet.workspace = true
|
||||
bgp-primitives.workspace = true
|
||||
bgp-rib.workspace = true
|
||||
bgp-server.workspace = true
|
||||
bgp-web.workspace = true
|
||||
bytes.workspace = true
|
||||
clap.workspace = true
|
||||
eyre.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -0,0 +1,158 @@
|
||||
//! A minimal stand-in for a lab router: connects to a ferrix_bgp instance,
|
||||
//! completes the handshake, optionally announces some prefixes, and prints
|
||||
//! every UPDATE it receives. Useful for demos 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 `--listen ADDR` it instead waits for the server to dial it (one
|
||||
//! connection at a time), which exercises the server's active side.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute,
|
||||
PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use clap::Parser;
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
/// Address of the ferrix_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
|
||||
/// server, one at a time, forever.
|
||||
#[arg(long)]
|
||||
listen: Option<String>,
|
||||
/// ASN this peer speaks as.
|
||||
#[arg(long, default_value_t = 65001)]
|
||||
asn: u32,
|
||||
/// Router ID to send in OPEN.
|
||||
#[arg(long, default_value = "10.0.0.2")]
|
||||
router_id: Ipv4Addr,
|
||||
/// Hold time to negotiate.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
hold_time: u16,
|
||||
/// Prefixes to announce after the session is established.
|
||||
#[arg(long)]
|
||||
announce: Vec<String>,
|
||||
/// Re-announce every received prefix back to the server, with our ASN
|
||||
/// prepended and NO_EXPORT-tagged routes dropped.
|
||||
#[arg(long)]
|
||||
echo: bool,
|
||||
}
|
||||
|
||||
const NO_EXPORT: (u16, u16) = (0xFFFF, 0xFF01);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
match &args.listen {
|
||||
Some(listen) => {
|
||||
let listener = TcpListener::bind(listen).await?;
|
||||
println!("listening on {}", listener.local_addr()?);
|
||||
loop {
|
||||
let (stream, from) = listener.accept().await?;
|
||||
println!("accepted connection from {from}");
|
||||
if let Err(e) = run(&args, TestPeer::from_stream(stream), from).await {
|
||||
println!("session ended: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let addr = args.server.parse()?;
|
||||
run(&args, TestPeer::connect(addr).await?, addr).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(args: &Args, mut peer: TestPeer, addr: std::net::SocketAddr) -> Result<()> {
|
||||
let open = peer.handshake(args.asn, args.router_id, args.hold_time).await?;
|
||||
println!("established with {} (AS{} router-id {})", addr, open.asn, open.identifier);
|
||||
|
||||
for p in &args.announce {
|
||||
let prefix = IpPrefix::try_from(p.as_str())?;
|
||||
let update = announce(prefix.clone(), vec![args.asn], vec![(args.asn as u16, 1)]);
|
||||
peer.send(&Message::Update(update)).await?;
|
||||
println!("announced {prefix}");
|
||||
}
|
||||
|
||||
let keepalive = Duration::from_secs((args.hold_time / 3).max(1) as u64);
|
||||
let mut ticker = tokio::time::interval(keepalive);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => peer.send(&Message::KeepAlive).await?,
|
||||
msg = peer.recv() => match msg? {
|
||||
Message::KeepAlive => {}
|
||||
Message::Update(u) => {
|
||||
for w in &u.withdrawn_routes {
|
||||
println!("WITHDRAW {w}");
|
||||
}
|
||||
for a in &u.announced_routes {
|
||||
println!("UPDATE {a} attrs={:?}", u.path_attributes);
|
||||
}
|
||||
if args.echo {
|
||||
echo_back(&mut peer, &u, args.asn).await?;
|
||||
}
|
||||
}
|
||||
Message::Notification(n) => {
|
||||
println!("NOTIFICATION {}; session over", n.error);
|
||||
return Ok(());
|
||||
}
|
||||
other => println!("unexpected {other:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn announce(prefix: IpPrefix, as_path: Vec<u32>, communities: Vec<(u16, u16)>) -> UpdateMessage {
|
||||
let mut path_attributes = vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(as_path),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 2))),
|
||||
];
|
||||
if !communities.is_empty() {
|
||||
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(communities)));
|
||||
}
|
||||
UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes: vec![prefix] }
|
||||
}
|
||||
|
||||
async fn echo_back(peer: &mut TestPeer, u: &UpdateMessage, asn: u32) -> Result<()> {
|
||||
let mut as_path = vec![asn];
|
||||
let mut communities = vec![];
|
||||
for attr in &u.path_attributes {
|
||||
match attr {
|
||||
PathAttribute::AsPath(p) => as_path.extend(p.segments.iter().flat_map(|s| s.path.iter().copied())),
|
||||
PathAttribute::Communities(c) => communities = c.0.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if communities.contains(&NO_EXPORT) {
|
||||
println!(" not echoing: NO_EXPORT");
|
||||
return Ok(());
|
||||
}
|
||||
for prefix in &u.announced_routes {
|
||||
peer.send(&Message::Update(announce(prefix.clone(), as_path.clone(), communities.clone()))).await?;
|
||||
println!(" echoed {prefix} back with path {as_path:?}");
|
||||
}
|
||||
if !u.withdrawn_routes.is_empty() {
|
||||
peer.send(&Message::Update(UpdateMessage {
|
||||
withdrawn_routes: u.withdrawn_routes.clone(),
|
||||
path_attributes: vec![],
|
||||
announced_routes: vec![],
|
||||
}))
|
||||
.await?;
|
||||
println!(" echoed withdraw of {:?}", u.withdrawn_routes.iter().map(|p| p.to_string()).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use bgp_packet::message::{BGP_HEADER_LEN, Message};
|
||||
use bgp_packet::message::{BGP_HEADER_LEN, Message, UpdateMessage};
|
||||
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
|
||||
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
@@ -19,7 +19,13 @@ pub struct TestPeer {
|
||||
impl TestPeer {
|
||||
pub async fn connect(addr: SocketAddr) -> Result<Self> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
Ok(Self { stream, ctx: ParserContext::default() })
|
||||
Ok(Self::from_stream(stream))
|
||||
}
|
||||
|
||||
/// Wraps an already-connected stream, e.g. one accepted from a server
|
||||
/// that dialled us.
|
||||
pub fn from_stream(stream: TcpStream) -> Self {
|
||||
Self { stream, ctx: ParserContext::default() }
|
||||
}
|
||||
|
||||
/// Send a BGP message.
|
||||
@@ -49,6 +55,18 @@ impl TestPeer {
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Read messages until an UPDATE arrives, skipping KEEPALIVEs. Any other
|
||||
/// message type is an error.
|
||||
pub async fn recv_update(&mut self) -> Result<UpdateMessage> {
|
||||
loop {
|
||||
match self.recv().await? {
|
||||
Message::Update(u) => return Ok(u),
|
||||
Message::KeepAlive => {}
|
||||
other => bail!("Expected UPDATE, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive and unwrap as `OpenMessage`, failing if a different type arrives.
|
||||
pub async fn recv_open(&mut self) -> Result<OpenMessage> {
|
||||
match self.recv().await? {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use bgp_rib::AdjRibIn;
|
||||
use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_server::config::Config;
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::server::BgpServerBuilder;
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use bgp_web::{AppState, RouterInfo};
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// A BGP server started on an ephemeral port for testing.
|
||||
@@ -14,6 +19,14 @@ pub struct TestServer {
|
||||
pub addr: SocketAddr,
|
||||
/// Handle to the server's Adj-RIB-In for test introspection.
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
/// Handle to the server's locally originated routes.
|
||||
pub local_rib: LocalRib,
|
||||
/// Live view of the server's established sessions.
|
||||
pub registry: SessionRegistry,
|
||||
/// Ping monitor without a socket, watching 2001:db8::/32; tests feed it
|
||||
/// through `record`.
|
||||
pub pings: PingMonitor,
|
||||
pub local_asn: u32,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
@@ -24,17 +37,38 @@ 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> {
|
||||
Self::start_inner(local_asn, hold_time_secs).await
|
||||
Self::start_inner(local_asn, hold_time_secs, LocalRibPolicy::default()).await
|
||||
}
|
||||
|
||||
async fn start_inner(local_asn: u32, hold_time_secs: u16) -> Result<Self> {
|
||||
let config = Config {
|
||||
/// Start a server with a policy restricting locally originated routes.
|
||||
pub async fn start_with_policy(local_asn: u32, policy: LocalRibPolicy) -> Result<Self> {
|
||||
Self::start_inner(local_asn, 90, policy).await
|
||||
}
|
||||
|
||||
async fn start_inner(local_asn: u32, hold_time_secs: u16, policy: LocalRibPolicy) -> Result<Self> {
|
||||
Self::start_with(local_asn, |c| {
|
||||
c.hold_time_secs = hold_time_secs;
|
||||
c.local_route_policy = policy;
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Start a server after letting `customize` edit the default test config
|
||||
/// (e.g. to add configured peers).
|
||||
pub async fn start_with(local_asn: u32, customize: impl FnOnce(&mut Config)) -> Result<Self> {
|
||||
let mut config = Config {
|
||||
router_id: Ipv4Addr::new(127, 0, 0, 1),
|
||||
local_asn,
|
||||
hold_time_secs,
|
||||
hold_time_secs: 90,
|
||||
listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)],
|
||||
peers: vec![],
|
||||
connect_retry: std::time::Duration::from_secs(5),
|
||||
collision_detect_established_state: false,
|
||||
local_route_policy: LocalRibPolicy::default(),
|
||||
http_endpoint: None,
|
||||
ping_monitor: None,
|
||||
};
|
||||
customize(&mut config);
|
||||
|
||||
let bound = BgpServerBuilder::new(config).bind().await?;
|
||||
let addr = bound
|
||||
@@ -43,9 +77,49 @@ impl TestServer {
|
||||
.next()
|
||||
.expect("at least one listener");
|
||||
let adj_rib_in = bound.adj_rib_in.clone();
|
||||
let local_rib = bound.local_rib.clone();
|
||||
let registry = bound.registry.clone();
|
||||
|
||||
let pings = PingMonitor::without_socket(
|
||||
vec![IpPrefix::try_from("2001:db8::/32").expect("valid prefix")],
|
||||
Some(local_rib.clone()),
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(bound.run());
|
||||
Ok(Self { handle, addr, adj_rib_in })
|
||||
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn })
|
||||
}
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// Starts the HTTP API on an ephemeral loopback port and returns its
|
||||
/// base URL (e.g. `http://127.0.0.1:12345`). The API task is aborted
|
||||
/// when the returned guard drops.
|
||||
pub async fn serve_http(&self) -> Result<(String, HttpGuard)> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let base_url = format!("http://{}", listener.local_addr()?);
|
||||
let state = AppState {
|
||||
info: std::sync::Arc::new(
|
||||
RouterInfo::new(Ipv4Addr::new(127, 0, 0, 1), self.local_asn, "test")
|
||||
.with_config("test.textpb", format!("local_asn: {}\n", self.local_asn)),
|
||||
),
|
||||
registry: self.registry.clone(),
|
||||
local_rib: self.local_rib.clone(),
|
||||
adj_rib_in: self.adj_rib_in.clone(),
|
||||
pings: self.pings.clone(),
|
||||
};
|
||||
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
|
||||
Ok((base_url, HttpGuard { handle }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts the HTTP API task when dropped.
|
||||
pub struct HttpGuard {
|
||||
handle: JoinHandle<Result<()>>,
|
||||
}
|
||||
|
||||
impl Drop for HttpGuard {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! End-to-end tests for locally originated routes: announcing via the
|
||||
//! `LocalRib` handle must produce UPDATEs on every established session.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity,
|
||||
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRibError, LocalRibPolicy, LocalRoute, LocalRouteAttrs};
|
||||
use bgp_server::session::SessionEvent;
|
||||
use eyre::Result;
|
||||
|
||||
const LOCAL_ASN: u32 = 65000;
|
||||
const PEER_ASN: u32 = 65001;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute {
|
||||
LocalRoute::new(prefix(p), "alice", attrs)
|
||||
}
|
||||
|
||||
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
|
||||
established_peer_with_id(server, Ipv4Addr::new(10, 0, 0, 1)).await
|
||||
}
|
||||
|
||||
/// Distinct routers need distinct BGP Identifiers, or the second connection
|
||||
/// is (correctly) treated as a collision with the first.
|
||||
async fn established_peer_with_id(server: &TestServer, router_id: Ipv4Addr) -> Result<TestPeer> {
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
peer.handshake(PEER_ASN, router_id, 90).await?;
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
async fn next_update(peer: &mut TestPeer) -> Result<UpdateMessage> {
|
||||
tokio::time::timeout(TIMEOUT, peer.recv_update()).await?
|
||||
}
|
||||
|
||||
/// Waits until the registry reports `n` established sessions.
|
||||
async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().len() != n {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_registry_tracks_session_lifecycle() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut events = server.registry.subscribe();
|
||||
assert!(server.registry.list().is_empty());
|
||||
|
||||
let peer = established_peer(&server).await?;
|
||||
|
||||
let up = tokio::time::timeout(TIMEOUT, events.recv()).await??;
|
||||
let SessionEvent::Up(handle) = up else { panic!("expected Up, got {up:?}") };
|
||||
assert_eq!(handle.peer.asn, PEER_ASN);
|
||||
assert_eq!(handle.local_addr, server.addr);
|
||||
assert!(handle.four_octet_asn);
|
||||
assert_eq!(server.registry.list().len(), 1);
|
||||
|
||||
drop(peer);
|
||||
|
||||
let down = tokio::time::timeout(TIMEOUT, events.recv()).await??;
|
||||
assert!(matches!(down, SessionEvent::Down(p) if p == handle.peer), "got {down:?}");
|
||||
assert!(server.registry.list().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Announce / withdraw
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A route announced while the session is up is sent immediately, with the
|
||||
/// attributes the announcer chose and our address as the next hop.
|
||||
#[tokio::test]
|
||||
async fn test_announce_after_session_up() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut peer = established_peer(&server).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
server
|
||||
.local_rib
|
||||
.announce(route(
|
||||
"10.100.1.0/24",
|
||||
LocalRouteAttrs {
|
||||
origin: OriginPathAttribute::IGP,
|
||||
prepend: 0,
|
||||
med: None,
|
||||
communities: vec![(65000, 100), (65535, 65281)],
|
||||
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
|
||||
let update = next_update(&mut peer).await?;
|
||||
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
|
||||
assert!(update.withdrawn_routes.is_empty());
|
||||
assert_eq!(
|
||||
update.path_attributes,
|
||||
vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![LOCAL_ASN]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)),
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
|
||||
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||
communities: vec![LargeCommunity { global_admin: 65000, data_1: 7, data_2: 8 }],
|
||||
}),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Routes announced before any session exists are replayed when a session
|
||||
/// reaches Established.
|
||||
#[tokio::test]
|
||||
async fn test_routes_replayed_on_session_up() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
|
||||
server.local_rib.announce(route("10.100.2.0/24", LocalRouteAttrs::default())).await?;
|
||||
|
||||
let mut peer = established_peer(&server).await?;
|
||||
|
||||
let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]
|
||||
.into_iter()
|
||||
.flat_map(|u| u.announced_routes)
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
seen.sort();
|
||||
assert_eq!(seen, vec!["10.100.1.0/24", "10.100.2.0/24"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An announcement is fanned out to every established session.
|
||||
#[tokio::test]
|
||||
async fn test_announce_reaches_all_sessions() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?;
|
||||
let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?;
|
||||
wait_for_sessions(&server, 2).await?;
|
||||
|
||||
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
|
||||
|
||||
for peer in [&mut a, &mut b] {
|
||||
let update = next_update(peer).await?;
|
||||
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_withdraw_sends_withdrawn_route() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut peer = established_peer(&server).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
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?;
|
||||
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());
|
||||
assert!(update.path_attributes.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-announcing with new attributes sends a fresh UPDATE (implicit replace).
|
||||
#[tokio::test]
|
||||
async fn test_reannounce_with_prepend_and_med() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut peer = established_peer(&server).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
|
||||
let first = next_update(&mut peer).await?;
|
||||
assert_eq!(first.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN]));
|
||||
|
||||
server
|
||||
.local_rib
|
||||
.announce(route(
|
||||
"10.100.1.0/24",
|
||||
LocalRouteAttrs { prepend: 3, med: Some(42), ..LocalRouteAttrs::default() },
|
||||
))
|
||||
.await?;
|
||||
let second = next_update(&mut peer).await?;
|
||||
assert_eq!(second.announced_routes, vec![prefix("10.100.1.0/24")]);
|
||||
assert_eq!(second.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN; 4]));
|
||||
assert!(second
|
||||
.path_attributes
|
||||
.iter()
|
||||
.any(|a| matches!(a, PathAttribute::MultiExitDisc(m) if m.0 == 42)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The session stays healthy (keepalives keep flowing) while UPDATEs are sent.
|
||||
#[tokio::test]
|
||||
async fn test_keepalives_continue_alongside_updates() -> Result<()> {
|
||||
let server = TestServer::start_with_hold_time(LOCAL_ASN, 3).await?;
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 3).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
|
||||
|
||||
let mut saw_update = false;
|
||||
let mut saw_keepalive = false;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while !(saw_update && saw_keepalive) {
|
||||
match peer.recv().await? {
|
||||
bgp_packet::message::Message::Update(_) => saw_update = true,
|
||||
bgp_packet::message::Message::KeepAlive => saw_keepalive = true,
|
||||
other => eyre::bail!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
Ok::<_, eyre::Error>(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Routes learned from a peer disappear from the Adj-RIB-In when its
|
||||
/// session ends, with a Withdrawn event for each.
|
||||
#[tokio::test]
|
||||
async fn test_received_routes_removed_when_session_closes() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut rib_events = server.adj_rib_in.subscribe();
|
||||
let mut session_events = server.registry.subscribe();
|
||||
let mut peer = established_peer(&server).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
peer.send(&bgp_packet::message::Message::Update(bgp_packet::message::UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![PEER_ASN]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
|
||||
],
|
||||
announced_routes: vec![prefix("203.0.113.0/24"), prefix("198.51.100.0/24")],
|
||||
}))
|
||||
.await?;
|
||||
for _ in 0..2 {
|
||||
let ev = tokio::time::timeout(TIMEOUT, rib_events.recv()).await??;
|
||||
assert!(matches!(ev, bgp_rib::RibEvent::Announced { .. }), "{ev:?}");
|
||||
}
|
||||
assert_eq!(server.adj_rib_in.snapshot().await.len(), 2);
|
||||
|
||||
drop(peer);
|
||||
|
||||
let mut withdrawn = vec![];
|
||||
for _ in 0..2 {
|
||||
match tokio::time::timeout(TIMEOUT, rib_events.recv()).await?? {
|
||||
bgp_rib::RibEvent::Withdrawn { prefix, .. } => withdrawn.push(prefix.to_string()),
|
||||
other => panic!("expected Withdrawn, got {other:?}"),
|
||||
}
|
||||
}
|
||||
withdrawn.sort();
|
||||
assert_eq!(withdrawn, vec!["198.51.100.0/24", "203.0.113.0/24"]);
|
||||
assert!(server.adj_rib_in.snapshot().await.is_empty());
|
||||
|
||||
// Withdrawals precede the Down event.
|
||||
let _up = tokio::time::timeout(TIMEOUT, session_events.recv()).await??;
|
||||
let down = tokio::time::timeout(TIMEOUT, session_events.recv()).await??;
|
||||
assert!(matches!(down, SessionEvent::Down(_)), "{down:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route refresh and shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ROUTE-REFRESH for IPv4 unicast exactly as a router sends it (RFC 2918):
|
||||
/// 19-byte header plus AFI, reserved, SAFI.
|
||||
#[rustfmt::skip]
|
||||
const ROUTE_REFRESH_IPV4: &[u8] = &[
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0x00, 0x17, 0x05,
|
||||
0x00, 0x01, 0x00, 0x01,
|
||||
];
|
||||
|
||||
/// A peer whose import policy changed asks for our routes again; it must get
|
||||
/// every local route re-sent, and the session must stay up.
|
||||
#[tokio::test]
|
||||
async fn test_route_refresh_resends_local_routes() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut peer = established_peer(&server).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
server.local_rib.announce(route("10.100.1.0/24", LocalRouteAttrs::default())).await?;
|
||||
server.local_rib.announce(route("10.100.2.0/24", LocalRouteAttrs::default())).await?;
|
||||
let _ = next_update(&mut peer).await?;
|
||||
let _ = next_update(&mut peer).await?;
|
||||
|
||||
peer.send_raw(ROUTE_REFRESH_IPV4).await?;
|
||||
|
||||
let mut resent = vec![next_update(&mut peer).await?, next_update(&mut peer).await?]
|
||||
.into_iter()
|
||||
.flat_map(|u| u.announced_routes)
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
resent.sort();
|
||||
assert_eq!(resent, vec!["10.100.1.0/24", "10.100.2.0/24"]);
|
||||
assert_eq!(server.registry.list().len(), 1, "session must survive a route refresh");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shutting down tells each peer why (Cease / Administrative Shutdown) and
|
||||
/// closes the sessions, instead of leaving dead TCP connections behind.
|
||||
#[tokio::test]
|
||||
async fn test_shutdown_sends_cease_to_every_peer() -> Result<()> {
|
||||
use bgp_packet::errors::{BgpError, CeaseSubcode};
|
||||
use bgp_packet::message::Message;
|
||||
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?;
|
||||
let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?;
|
||||
wait_for_sessions(&server, 2).await?;
|
||||
|
||||
let left = server.registry.shutdown(Duration::from_secs(2)).await;
|
||||
assert_eq!(left, 0, "sessions still open after shutdown");
|
||||
assert!(server.registry.is_shutting_down());
|
||||
|
||||
for peer in [&mut a, &mut b] {
|
||||
let notif = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
match peer.recv().await? {
|
||||
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert!(matches!(notif.error, BgpError::Cease(CeaseSubcode::AdminShutdown)), "{notif:?}");
|
||||
assert!(peer.is_closed().await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[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?;
|
||||
|
||||
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")]);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Connection collision detection (RFC 4271 §6.8) against a live server.
|
||||
//!
|
||||
//! The test server's BGP Identifier is 127.0.0.1. A test peer picks an
|
||||
//! identifier above or below that to steer which connection must survive.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::errors::{BgpError, CeaseSubcode, OpenMsgSubcode};
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, Message, NextHopPathAttribute, NotificationMessage, OriginPathAttribute,
|
||||
PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use bgp_server::collision::Direction;
|
||||
use bgp_server::config::PeerConfig;
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const LOCAL_ASN: u32 = 65000;
|
||||
const PEER_ASN: u32 = 65001;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Above the server's 127.0.0.1.
|
||||
const HIGH_ID: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
|
||||
/// Below the server's 127.0.0.1.
|
||||
const LOW_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
|
||||
|
||||
async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
|
||||
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 }];
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Exchanges OPENs only, leaving the server's side in OpenConfirm (it has
|
||||
/// sent its KEEPALIVE and is waiting for ours).
|
||||
async fn open_only(peer: &mut TestPeer, router_id: Ipv4Addr) -> Result<()> {
|
||||
peer.send_open(PEER_ASN, router_id, 90).await?;
|
||||
peer.recv_open().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finishes the handshake begun by [`open_only`].
|
||||
async fn confirm(peer: &mut TestPeer) -> Result<()> {
|
||||
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("expected KEEPALIVE, got {other:?}"),
|
||||
}
|
||||
peer.send(&Message::KeepAlive).await
|
||||
}
|
||||
|
||||
/// Reads until a NOTIFICATION arrives, skipping KEEPALIVEs.
|
||||
async fn expect_notification(peer: &mut TestPeer) -> Result<NotificationMessage> {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
match peer.recv().await? {
|
||||
Message::Notification(n) => return Ok(n),
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("expected NOTIFICATION, got {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn is_collision_cease(n: &NotificationMessage) -> bool {
|
||||
matches!(n.error, BgpError::Cease(CeaseSubcode::ConnCollisionResolution))
|
||||
}
|
||||
|
||||
async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().len() != n {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Peer has the higher identifier, so the connection the *peer* initiated
|
||||
/// must survive: the server closes its own outbound connection.
|
||||
#[tokio::test]
|
||||
async fn test_simultaneous_open_peer_higher_keeps_inbound() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener).await?;
|
||||
|
||||
// Server's outbound connection reaches OpenConfirm.
|
||||
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
let mut dialled = TestPeer::from_stream(stream);
|
||||
open_only(&mut dialled, HIGH_ID).await?;
|
||||
|
||||
// The peer now also dials the server.
|
||||
let mut inbound = TestPeer::connect(server.addr).await?;
|
||||
open_only(&mut inbound, HIGH_ID).await?;
|
||||
|
||||
let notif = expect_notification(&mut dialled).await?;
|
||||
assert!(is_collision_cease(¬if), "{notif:?}");
|
||||
assert!(dialled.is_closed().await);
|
||||
|
||||
confirm(&mut inbound).await?;
|
||||
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"));
|
||||
|
||||
// The survivor is fully functional.
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", 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");
|
||||
|
||||
// The dialer notices the surviving session and does not open another.
|
||||
let extra = tokio::time::timeout(Duration::from_millis(700), listener.accept()).await;
|
||||
assert!(extra.is_err(), "dialer opened a second connection despite an established session");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Peer has the lower identifier, so the connection the *server* initiated
|
||||
/// must survive: the peer's inbound connection is refused.
|
||||
#[tokio::test]
|
||||
async fn test_simultaneous_open_peer_lower_keeps_outbound() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener).await?;
|
||||
|
||||
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
let mut dialled = TestPeer::from_stream(stream);
|
||||
open_only(&mut dialled, LOW_ID).await?;
|
||||
|
||||
let mut inbound = TestPeer::connect(server.addr).await?;
|
||||
open_only(&mut inbound, LOW_ID).await?;
|
||||
|
||||
let notif = expect_notification(&mut inbound).await?;
|
||||
assert!(is_collision_cease(¬if), "{notif:?}");
|
||||
assert!(inbound.is_closed().await);
|
||||
|
||||
confirm(&mut dialled).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
assert_eq!(server.registry.list()[0].direction, Direction::Outbound);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The order in which the two OPENs are processed must not change the
|
||||
/// outcome: here the inbound connection reaches OpenConfirm first.
|
||||
#[tokio::test]
|
||||
async fn test_outcome_is_independent_of_arrival_order() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener).await?;
|
||||
|
||||
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
let mut dialled = TestPeer::from_stream(stream);
|
||||
|
||||
// Inbound first this time; peer is lower so outbound must still win.
|
||||
let mut inbound = TestPeer::connect(server.addr).await?;
|
||||
open_only(&mut inbound, LOW_ID).await?;
|
||||
open_only(&mut dialled, LOW_ID).await?;
|
||||
|
||||
let notif = expect_notification(&mut inbound).await?;
|
||||
assert!(is_collision_cease(¬if), "{notif:?}");
|
||||
|
||||
confirm(&mut dialled).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
assert_eq!(server.registry.list()[0].direction, Direction::Outbound);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RFC default: a collision with an Established session closes the new
|
||||
/// connection, whatever the identifiers say.
|
||||
#[tokio::test]
|
||||
async fn test_established_session_wins_by_default() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
|
||||
let mut first = TestPeer::connect(server.addr).await?;
|
||||
first.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
|
||||
let mut second = TestPeer::connect(server.addr).await?;
|
||||
open_only(&mut second, HIGH_ID).await?;
|
||||
let notif = expect_notification(&mut second).await?;
|
||||
assert!(is_collision_cease(¬if), "{notif:?}");
|
||||
|
||||
// The original is untouched.
|
||||
assert_eq!(server.registry.list().len(), 1);
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("10.100.1.0/24")?, "alice", 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");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With collision_detect_established_state, a peer that restarts and dials
|
||||
/// again replaces the stale session at once, and the routes learned over the
|
||||
/// old session are flushed.
|
||||
#[tokio::test]
|
||||
async fn test_restarted_peer_replaces_stale_session_when_enabled() -> Result<()> {
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| c.collision_detect_established_state = true).await?;
|
||||
|
||||
let mut stale = TestPeer::connect(server.addr).await?;
|
||||
stale.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
stale
|
||||
.send(&Message::Update(UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![PEER_ASN]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 9))),
|
||||
],
|
||||
announced_routes: vec![IpPrefix::try_from("203.0.113.0/24")?],
|
||||
}))
|
||||
.await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.adj_rib_in.snapshot().await.is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let stale_addr = server.registry.list()[0].peer.addr;
|
||||
|
||||
// "Restart": a fresh connection from the same router.
|
||||
let mut fresh = TestPeer::connect(server.addr).await?;
|
||||
fresh.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
|
||||
let notif = expect_notification(&mut stale).await?;
|
||||
assert!(is_collision_cease(¬if), "{notif:?}");
|
||||
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let sessions = server.registry.list();
|
||||
if sessions.len() == 1 && sessions[0].peer.addr != stale_addr {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert!(server.adj_rib_in.snapshot().await.is_empty(), "stale routes were not flushed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Different routers (different identifiers) never collide.
|
||||
#[tokio::test]
|
||||
async fn test_distinct_identifiers_do_not_collide() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut a = TestPeer::connect(server.addr).await?;
|
||||
a.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
let mut b = TestPeer::connect(server.addr).await?;
|
||||
b.handshake(PEER_ASN, LOW_ID, 90).await?;
|
||||
wait_for_sessions(&server, 2).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A failed connection frees its slot: the same router can come back.
|
||||
#[tokio::test]
|
||||
async fn test_identifier_is_released_when_connection_ends() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut first = TestPeer::connect(server.addr).await?;
|
||||
first.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
drop(first);
|
||||
wait_for_sessions(&server, 0).await?;
|
||||
|
||||
let mut second = TestPeer::connect(server.addr).await?;
|
||||
second.handshake(PEER_ASN, HIGH_ID, 90).await?;
|
||||
wait_for_sessions(&server, 1).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A peer claiming our own identifier is refused outright.
|
||||
#[tokio::test]
|
||||
async fn test_peer_with_our_identifier_is_refused() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
open_only(&mut peer, Ipv4Addr::new(127, 0, 0, 1)).await?;
|
||||
let notif = expect_notification(&mut peer).await?;
|
||||
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadBgpId)), "{notif:?}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! The active side: the server dials its configured peers and keeps
|
||||
//! retrying.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::errors::{BgpError, OpenMsgSubcode};
|
||||
use bgp_packet::message::Message;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use bgp_server::config::PeerConfig;
|
||||
use bgp_server::session::DialState;
|
||||
use eyre::Result;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
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".
|
||||
async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestServer> {
|
||||
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 }];
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn accept(listener: &TcpListener) -> Result<TestPeer> {
|
||||
let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
Ok(TestPeer::from_stream(stream))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_dials_configured_peer_and_establishes() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut peer = accept(&listener).await?;
|
||||
// Both sides send OPEN on connect; the test peer's handshake order works
|
||||
// whichever side opened TCP.
|
||||
let open = peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
assert_eq!(open.asn, LOCAL_ASN as u16);
|
||||
|
||||
let mut events = server.registry.subscribe();
|
||||
// Either the Up event is still queued or the session is already listed.
|
||||
let listed = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
if let Some(s) = server.registry.list().first().cloned() {
|
||||
return s;
|
||||
}
|
||||
let _ = events.recv().await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(listed.name.as_deref(), Some("lab"));
|
||||
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!(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()))
|
||||
.await?;
|
||||
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
||||
assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_reconnects_after_peer_closes() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut first = accept(&listener).await?;
|
||||
first.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
drop(first);
|
||||
|
||||
// Session goes away, then the dialer comes back.
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while !server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let (_, state) = server.registry.configured_peers().into_iter().next().unwrap();
|
||||
assert!(matches!(state, DialState::Backoff { .. }), "{state:?}");
|
||||
|
||||
let mut second = accept(&listener).await?;
|
||||
second.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wrong_asn_is_refused_with_bad_peer_as() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let _server = server_dialing(&listener, PEER_ASN).await?;
|
||||
|
||||
let mut peer = accept(&listener).await?;
|
||||
peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let _ = peer.recv_open().await?;
|
||||
let notif = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
match peer.recv().await? {
|
||||
Message::Notification(n) => return Ok::<_, eyre::Error>(n),
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert!(matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), "{notif:?}");
|
||||
|
||||
// The dialer retries rather than giving up.
|
||||
let _again = accept(&listener).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An inbound connection from a configured peer's address is also checked
|
||||
/// against the configured ASN and gets the configured name.
|
||||
#[tokio::test]
|
||||
async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
|
||||
// Configure a peer at the loopback address on a port nobody listens on,
|
||||
// so the dialer never succeeds; then connect inbound from loopback.
|
||||
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||
c.connect_retry = Duration::from_secs(60);
|
||||
c.peers = vec![PeerConfig {
|
||||
name: "lab".into(),
|
||||
endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1),
|
||||
remote_asn: PEER_ASN,
|
||||
hold_time_secs: 90,
|
||||
local_address: None,
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Wrong ASN inbound → refused.
|
||||
let mut bad = TestPeer::connect(server.addr).await?;
|
||||
bad.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let _ = bad.recv_open().await?;
|
||||
let msg = tokio::time::timeout(TIMEOUT, bad.recv()).await??;
|
||||
assert!(matches!(&msg, Message::Notification(n) if matches!(n.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs))), "{msg:?}");
|
||||
|
||||
// Right ASN inbound → established, named.
|
||||
let mut good = TestPeer::connect(server.addr).await?;
|
||||
good.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90).await?;
|
||||
let listed = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
if let Some(s) = server.registry.list().first().cloned() {
|
||||
return s;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(listed.name.as_deref(), Some("lab"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With `local_address` set the connection must come from that address (the
|
||||
/// peer may identify us by it), and an address the host does not have is
|
||||
/// reported as the reason for backing off rather than silently ignored.
|
||||
#[tokio::test]
|
||||
async fn test_local_address_is_used_as_the_source() -> Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let endpoint: SocketAddr = listener.local_addr()?;
|
||||
let _server = 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: Some(Ipv4Addr::LOCALHOST.into()),
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
let (_stream, from) = tokio::time::timeout(TIMEOUT, listener.accept()).await??;
|
||||
assert_eq!(from.ip(), std::net::IpAddr::from(Ipv4Addr::LOCALHOST));
|
||||
|
||||
// 192.0.2.1 is documentation space; no host has it.
|
||||
let server = 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: Some(Ipv4Addr::new(192, 0, 2, 1).into()),
|
||||
}];
|
||||
})
|
||||
.await?;
|
||||
let reason = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
if let Some((_, DialState::Backoff { last_error, .. })) = server.registry.configured_peers().into_iter().next() {
|
||||
return last_error;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert!(reason.contains("local_address 192.0.2.1"), "{reason}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Drives the HTTP API against a real server with a real BGP peer attached.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
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,
|
||||
OriginPathAttribute, PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bgp_rib::LocalRibPolicy;
|
||||
use eyre::Result;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const LOCAL_ASN: u32 = 65000;
|
||||
const PEER_ASN: u32 = 65001;
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
async fn established_peer(server: &TestServer) -> Result<TestPeer> {
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90).await?;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
async fn get_json(client: &reqwest::Client, url: &str) -> Result<Value> {
|
||||
Ok(client.get(url).send().await?.error_for_status()?.json().await?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_status_and_peers() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let config = get_json(&client, &format!("{base}/api/config")).await?;
|
||||
assert_eq!(config["path"], "test.textpb");
|
||||
assert_eq!(config["text"], format!("local_asn: {LOCAL_ASN}\n"));
|
||||
|
||||
let status = get_json(&client, &format!("{base}/api/status")).await?;
|
||||
assert_eq!(status["local_asn"], LOCAL_ASN);
|
||||
assert_eq!(status["router_id"], "127.0.0.1");
|
||||
assert_eq!(status["sessions"], 0);
|
||||
assert!(status["started_at_unix"].as_u64().unwrap() > 1_700_000_000);
|
||||
assert!(!status["hostname"].as_str().unwrap().is_empty());
|
||||
|
||||
let _peer = established_peer(&server).await?;
|
||||
|
||||
let status = get_json(&client, &format!("{base}/api/status")).await?;
|
||||
assert_eq!(status["sessions"], 1);
|
||||
|
||||
let peers = get_json(&client, &format!("{base}/api/peers")).await?;
|
||||
assert_eq!(peers.as_array().unwrap().len(), 1);
|
||||
assert_eq!(peers[0]["peer_asn"], PEER_ASN);
|
||||
assert_eq!(peers[0]["local_addr"], server.addr.to_string());
|
||||
assert_eq!(peers[0]["four_octet_asn"], true);
|
||||
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.
|
||||
#[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 },
|
||||
)
|
||||
.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();
|
||||
|
||||
// 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::<Value>().await?["error"].as_str().unwrap().contains("invalid prefix"));
|
||||
|
||||
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::<Value>().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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Routes the peer sends us show up in the received table with a summary.
|
||||
#[tokio::test]
|
||||
async fn test_received_routes_summary() -> 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?;
|
||||
|
||||
peer.send(&Message::Update(UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![PEER_ASN, 65002]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))),
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65001, 7)])),
|
||||
],
|
||||
announced_routes: vec![prefix("203.0.113.0/24")],
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let received = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let v = get_json(&client, &format!("{base}/api/routes/received")).await?;
|
||||
if !v.as_array().unwrap().is_empty() {
|
||||
return Ok::<_, eyre::Error>(v);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
assert_eq!(received[0]["prefix"], "203.0.113.0/24");
|
||||
assert_eq!(received[0]["peer_asn"], PEER_ASN);
|
||||
assert_eq!(received[0]["summary"]["as_path"], json!([PEER_ASN, 65002]));
|
||||
assert_eq!(received[0]["summary"]["next_hop"], "10.0.0.1");
|
||||
assert_eq!(received[0]["summary"]["communities"], json!(["65001:7"]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The SSE stream reports session and route changes as they happen.
|
||||
#[tokio::test]
|
||||
async fn test_event_stream() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut resp = client.get(format!("{base}/api/events")).send().await?.error_for_status()?;
|
||||
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/event-stream"));
|
||||
|
||||
let _peer = established_peer(&server).await?;
|
||||
server
|
||||
.local_rib
|
||||
.announce(bgp_rib::LocalRoute::new(prefix("10.100.1.0/24"), "alice", Default::default()))
|
||||
.await?;
|
||||
|
||||
// Collect SSE frames until we have seen both events.
|
||||
let mut buf = String::new();
|
||||
let mut seen_up = false;
|
||||
let mut seen_announce = false;
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while !(seen_up && seen_announce) {
|
||||
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
|
||||
buf.push_str(std::str::from_utf8(&chunk)?);
|
||||
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
|
||||
let ev: Value = serde_json::from_str(line.trim())?;
|
||||
match ev["type"].as_str() {
|
||||
Some("session_up") if ev["peer"]["peer_asn"] == PEER_ASN => seen_up = true,
|
||||
Some("local_announced") if ev["route"]["prefix"] == "10.100.1.0/24" => seen_announce = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<_, eyre::Error>(())
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_statusz_page_is_served() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client.get(format!("{base}/statusz")).send().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/html"));
|
||||
let body = resp.text().await?;
|
||||
assert!(body.contains("<title>Status for ferrix_bgp</title>"));
|
||||
assert!(body.contains("/api/events"));
|
||||
|
||||
// The root redirects to the page.
|
||||
let no_redirect = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
|
||||
let resp = no_redirect.get(format!("{base}/")).send().await?;
|
||||
assert_eq!(resp.status(), 307);
|
||||
assert_eq!(resp.headers()["location"], "/statusz");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metricz_and_well_known() -> Result<()> {
|
||||
let server = TestServer::start(LOCAL_ASN).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
let _peer = established_peer(&server).await?;
|
||||
|
||||
let text = client.get(format!("{base}/metricz")).send().await?.error_for_status()?.text().await?;
|
||||
// Metrics are process-global, so other tests in this binary contribute
|
||||
// too; assert on presence and a lower bound rather than exact values.
|
||||
let metric = |name: &str| -> Option<i64> {
|
||||
text.lines()
|
||||
.find(|l| l.starts_with(name) && l[name.len()..].starts_with(' '))
|
||||
.and_then(|l| l.rsplit(' ').next()?.parse().ok())
|
||||
};
|
||||
assert!(metric("bgp_sessions_established").unwrap_or(0) >= 1, "{text}");
|
||||
assert!(metric("bgp_messages_sent_total{type=\"open\"}").unwrap_or(0) >= 1, "{text}");
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Interoperability with what real routers put on the wire.
|
||||
|
||||
use std::net::Ipv4Addr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_peer::TestPeer;
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_packet::message::{Message, PathAttribute};
|
||||
use bgp_packet::open::{Capability, OpenOption};
|
||||
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||
use eyre::Result;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The OPEN a BIRD 2 router sends for an IPv6-only session from AS210036:
|
||||
/// one optional parameter carrying MP(IPv6 unicast), route refresh,
|
||||
/// extended message, graceful restart (aware only), 4-octet AS, enhanced
|
||||
/// route refresh and long-lived graceful restart (aware only).
|
||||
#[rustfmt::skip]
|
||||
const BIRD_OPEN: &[u8] = &[
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0x00, 0x37, 0x01, // length 55, type OPEN
|
||||
0x04, // version
|
||||
0x5b, 0xa0, // AS_TRANS
|
||||
0x00, 0xf0, // hold time 240
|
||||
0x0a, 0xff, 0x00, 0x01, // router id 10.255.0.1
|
||||
0x1a, // optional parameters length 26
|
||||
0x02, 0x18, // capabilities, 24 bytes
|
||||
0x01, 0x04, 0x00, 0x02, 0x00, 0x01, // MP: IPv6 unicast
|
||||
0x02, 0x00, // route refresh
|
||||
0x06, 0x00, // extended message
|
||||
0x40, 0x02, 0x00, 0x00, // graceful restart, no address families
|
||||
0x41, 0x04, 0x00, 0x03, 0x34, 0x74, // 4-octet AS 210036
|
||||
0x46, 0x00, // enhanced route refresh
|
||||
0x47, 0x00, // long-lived graceful restart, none
|
||||
];
|
||||
|
||||
/// IPv6 End-of-RIB: an UPDATE whose only attribute is an empty
|
||||
/// MP_UNREACH_NLRI for IPv6 unicast (RFC 4724 §2).
|
||||
#[rustfmt::skip]
|
||||
const IPV6_END_OF_RIB: &[u8] = &[
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0x00, 0x1e, 0x02, // length 30, type UPDATE
|
||||
0x00, 0x00, // no withdrawn routes
|
||||
0x00, 0x07, // 7 bytes of path attributes
|
||||
0x90, 0x0f, 0x00, 0x03, // optional + extended length, MP_UNREACH_NLRI, len 3
|
||||
0x00, 0x02, 0x01, // IPv6 unicast, no prefixes
|
||||
];
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_advertises_ipv4_and_ipv6_unicast() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
let open = peer.recv_open().await?;
|
||||
let caps: Vec<&Capability> = open
|
||||
.options
|
||||
.iter()
|
||||
.flat_map(|o| match o {
|
||||
OpenOption::Capabilities(c) => c.iter(),
|
||||
})
|
||||
.collect();
|
||||
for afi in [AddressFamilyId::Ipv4, AddressFamilyId::Ipv6] {
|
||||
assert!(
|
||||
caps.iter().any(|c| matches!(c, Capability::MultiProtocol { afi: a, safi: SubsequentAfi::Unicast } if *a == afi)),
|
||||
"missing MP capability for {afi:?}: {caps:?}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> {
|
||||
let server = TestServer::start(4200000001).await?;
|
||||
let mut peer = TestPeer::connect(server.addr).await?;
|
||||
|
||||
peer.send_raw(BIRD_OPEN).await?;
|
||||
peer.recv_open().await?;
|
||||
match tokio::time::timeout(TIMEOUT, peer.recv()).await?? {
|
||||
Message::KeepAlive => {}
|
||||
other => eyre::bail!("server did not accept BIRD's OPEN: {other:?}"),
|
||||
}
|
||||
peer.send(&Message::KeepAlive).await?;
|
||||
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
while server.registry.list().is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let session = server.registry.list().remove(0);
|
||||
assert_eq!(session.peer.asn, 210036, "4-octet ASN must come from the capability, not AS_TRANS");
|
||||
assert!(session.four_octet_asn);
|
||||
|
||||
// End-of-RIB must neither break the session nor create a route.
|
||||
peer.send_raw(IPV6_END_OF_RIB).await?;
|
||||
peer.send(&Message::KeepAlive).await?;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(server.registry.list().len(), 1, "session dropped after End-of-RIB");
|
||||
assert!(server.adj_rib_in.snapshot().await.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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());
|
||||
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")));
|
||||
let _ = Ipv4Addr::LOCALHOST;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The ping monitor as seen through the HTTP API. The socket is left out
|
||||
//! (it needs privileges); observations are fed in directly.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRoute;
|
||||
use bgp_server::ping::PingObservation;
|
||||
use eyre::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn ping(src: &str, dst: &str, hop_limit: u8, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(hop_limit),
|
||||
ident: 1,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: [&[128, 0, 0xab, 0xcd, 0, 1][..], &seq.to_be_bytes(), b"hi"].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_are_listed_with_the_route_they_were_sent_to() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("2001:db8:1a::/48").unwrap(), "alice", Default::default()))
|
||||
.await?;
|
||||
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 0)).await;
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 1)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2001:db8:ff::1", 243, 0)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2a0d:d740::1", 243, 0)).await; // not a watched prefix
|
||||
|
||||
let view: Value = client.get(format!("{base}/api/pings")).send().await?.error_for_status()?.json().await?;
|
||||
assert_eq!(view["status"], "listening");
|
||||
assert_eq!(view["prefixes"][0], "2001:db8::/32");
|
||||
assert_eq!(view["total_flows"], 2);
|
||||
|
||||
let flows = view["flows"].as_array().unwrap();
|
||||
let alice = flows.iter().find(|f| f["dst"] == "2001:db8:1a::1").unwrap();
|
||||
assert_eq!(alice["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(alice["prefix"], "2001:db8:1a::/48");
|
||||
assert_eq!(alice["owner"], "alice");
|
||||
assert_eq!(alice["interface"], "swissix0");
|
||||
assert_eq!((alice["hop_limit"].as_u64(), alice["hops"].as_u64()), (Some(57), Some(7)));
|
||||
assert_eq!((alice["count"].as_u64(), alice["seq"].as_u64()), (Some(2), Some(1)));
|
||||
assert_eq!(alice["hex"], "8000abcd000100016869");
|
||||
assert!(alice["last_seen_unix"].as_u64().unwrap() > 1_700_000_000);
|
||||
|
||||
let stray = flows.iter().find(|f| f["dst"] == "2001:db8:ff::1").unwrap();
|
||||
assert!(stray["prefix"].is_null() && stray["owner"].is_null());
|
||||
assert_eq!(stray["hops"], 12);
|
||||
|
||||
let limited: Value = client.get(format!("{base}/api/pings?limit=1")).send().await?.json().await?;
|
||||
assert_eq!(limited["total_flows"], 2);
|
||||
assert_eq!(limited["flows"].as_array().unwrap().len(), 1);
|
||||
|
||||
let metrics = client.get(format!("{base}/metricz")).send().await?.text().await?;
|
||||
assert!(metrics.contains("ping_monitor_echo_requests_total"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_appear_on_the_event_stream() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let mut resp = reqwest::Client::new().get(format!("{base}/api/events")).send().await?.error_for_status()?;
|
||||
|
||||
// The subscription exists once the response has started; a ping recorded
|
||||
// before that would be missed, so keep pinging until one shows up.
|
||||
let pings = server.pings.clone();
|
||||
let pinger = tokio::spawn(async move {
|
||||
for seq in 0.. {
|
||||
pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, seq)).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut buf = String::new();
|
||||
let event = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
|
||||
buf.push_str(std::str::from_utf8(&chunk)?);
|
||||
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
|
||||
let ev: Value = serde_json::from_str(line.trim())?;
|
||||
if ev["type"] == "ping_seen" {
|
||||
return Ok::<_, eyre::Error>(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
pinger.abort();
|
||||
|
||||
assert_eq!(event["ping"]["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(event["ping"]["dst"], "2001:db8:1a::1");
|
||||
assert_eq!(event["ping"]["interface"], "swissix0");
|
||||
assert!(event["new_burst"].is_boolean());
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,7 +313,7 @@ pub enum PathAttribute {
|
||||
LocalPref(LocalPrefPathAttribute) = 5,
|
||||
AtomicAggregate(AtomicAggregatePathAttribute) = 6,
|
||||
Aggregator(AggregatorPathAttribute) = 7,
|
||||
Communitites(CommunitiesPathAttribute) = 8,
|
||||
Communities(CommunitiesPathAttribute) = 8,
|
||||
MpReachNlri(MpReachNlriPathAttribute) = 14,
|
||||
MpUnreachNlri(MpUnreachNlriPathAttribute) = 15,
|
||||
ExtendedCommunities(ExtendedCommunitiesPathAttribute) = 16,
|
||||
@@ -335,7 +335,7 @@ impl From<u8> for PathAttributeDiscriminants {
|
||||
v if v == Self::LocalPref as u8 => Self::LocalPref,
|
||||
v if v == Self::AtomicAggregate as u8 => Self::AtomicAggregate,
|
||||
v if v == Self::Aggregator as u8 => Self::Aggregator,
|
||||
v if v == Self::Communitites as u8 => Self::Communitites,
|
||||
v if v == Self::Communities as u8 => Self::Communities,
|
||||
v if v == Self::MpReachNlri as u8 => Self::MpReachNlri,
|
||||
v if v == Self::MpUnreachNlri as u8 => Self::MpUnreachNlri,
|
||||
v if v == Self::ExtendedCommunities as u8 => Self::ExtendedCommunities,
|
||||
@@ -355,7 +355,7 @@ impl PathAttribute {
|
||||
PathAttribute::LocalPref(_) => PathAttributeDiscriminants::LocalPref as u8,
|
||||
PathAttribute::AtomicAggregate(_) => PathAttributeDiscriminants::AtomicAggregate as u8,
|
||||
PathAttribute::Aggregator(_) => PathAttributeDiscriminants::Aggregator as u8,
|
||||
PathAttribute::Communitites(_) => PathAttributeDiscriminants::Communitites as u8,
|
||||
PathAttribute::Communities(_) => PathAttributeDiscriminants::Communities as u8,
|
||||
PathAttribute::MpReachNlri(_) => PathAttributeDiscriminants::MpReachNlri as u8,
|
||||
PathAttribute::MpUnreachNlri(_) => PathAttributeDiscriminants::MpUnreachNlri as u8,
|
||||
PathAttribute::ExtendedCommunities(_) => {
|
||||
@@ -377,7 +377,7 @@ impl PathAttribute {
|
||||
PathAttribute::LocalPref(_) => false,
|
||||
PathAttribute::AtomicAggregate(_) => true,
|
||||
PathAttribute::Aggregator(_) => true,
|
||||
PathAttribute::Communitites(_) => true,
|
||||
PathAttribute::Communities(_) => true,
|
||||
PathAttribute::MpReachNlri(_) => true,
|
||||
PathAttribute::MpUnreachNlri(_) => true,
|
||||
PathAttribute::ExtendedCommunities(_) => true,
|
||||
@@ -395,7 +395,7 @@ impl PathAttribute {
|
||||
PathAttribute::LocalPref(_) => true,
|
||||
PathAttribute::AtomicAggregate(_) => true,
|
||||
PathAttribute::Aggregator(_) => false,
|
||||
PathAttribute::Communitites(_) => true,
|
||||
PathAttribute::Communities(_) => true,
|
||||
PathAttribute::MpReachNlri(_) => false,
|
||||
PathAttribute::MpUnreachNlri(_) => false,
|
||||
PathAttribute::ExtendedCommunities(_) => true,
|
||||
@@ -467,8 +467,8 @@ impl PathAttribute {
|
||||
PathAttributeDiscriminants::Aggregator => {
|
||||
PathAttribute::Aggregator(AggregatorPathAttribute::from_wire(ctx, pa_buf)?.1)
|
||||
}
|
||||
PathAttributeDiscriminants::Communitites => {
|
||||
PathAttribute::Communitites(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1)
|
||||
PathAttributeDiscriminants::Communities => {
|
||||
PathAttribute::Communities(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1)
|
||||
}
|
||||
PathAttributeDiscriminants::MpReachNlri => {
|
||||
PathAttribute::MpReachNlri(MpReachNlriPathAttribute::from_wire(ctx, pa_buf)?.1)
|
||||
@@ -538,7 +538,7 @@ impl PathAttribute {
|
||||
PathAttribute::Aggregator(aggregator_path_attribute) => {
|
||||
write_path_attribute!(out, aggregator_path_attribute);
|
||||
}
|
||||
PathAttribute::Communitites(communities_path_attribute) => {
|
||||
PathAttribute::Communities(communities_path_attribute) => {
|
||||
write_path_attribute!(out, communities_path_attribute);
|
||||
}
|
||||
PathAttribute::MpReachNlri(mp_reach_nlri_path_attribute) => {
|
||||
@@ -583,7 +583,7 @@ impl PathAttribute {
|
||||
PathAttribute::LocalPref(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::AtomicAggregate(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::Aggregator(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::Communitites(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::Communities(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::MpReachNlri(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::MpUnreachNlri(a) => a.wire_len(ctx)?,
|
||||
PathAttribute::ExtendedCommunities(a) => a.wire_len(ctx)?,
|
||||
@@ -903,7 +903,7 @@ impl AggregatorPathAttribute {
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct CommunitiesPathAttribute(Vec<(u16, u16)>);
|
||||
pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>);
|
||||
|
||||
impl CommunitiesPathAttribute {
|
||||
pub fn from_wire<'a>(
|
||||
@@ -1624,7 +1624,7 @@ mod tests {
|
||||
test_path_attribute_roundtrip!(
|
||||
test_communities,
|
||||
&[0xc0, 0x08, 0x04, 0xca, 0xfe, 0xba, 0xbe],
|
||||
PathAttribute::Communitites(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)]))
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)]))
|
||||
);
|
||||
|
||||
// MP Reach NLRI Path Attribute.
|
||||
|
||||
@@ -311,6 +311,10 @@ impl Capability {
|
||||
) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> {
|
||||
let (buf, typ) = be_u8(buf)?;
|
||||
let (buf, len) = be_u8(buf)?;
|
||||
// `cap_buf` holds exactly this capability's value; `buf` is what
|
||||
// follows it. Every arm must return `buf`, or the caller's list
|
||||
// parser sees an empty remainder and silently drops all the
|
||||
// capabilities after this one.
|
||||
let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?;
|
||||
macro_rules! require_empty {
|
||||
($buf:expr, $cap_type:expr) => {
|
||||
@@ -390,11 +394,11 @@ impl Capability {
|
||||
let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?;
|
||||
require_empty!(cap_buf, "ExtendedNextHop");
|
||||
|
||||
Ok((cap_buf, Self::ExtendedNextHop { entries }))
|
||||
Ok((buf, Self::ExtendedNextHop { entries }))
|
||||
}
|
||||
v if v == CapabilityDiscriminants::ExtendedMessage as u8 => {
|
||||
require_empty!(cap_buf, "ExtendedMessage");
|
||||
Ok((cap_buf, Self::ExtendedMessage))
|
||||
Ok((buf, Self::ExtendedMessage))
|
||||
}
|
||||
v if v == CapabilityDiscriminants::BgpSec as u8 => {
|
||||
let (cap_buf, header) = be_u8(cap_buf)?;
|
||||
@@ -402,7 +406,7 @@ impl Capability {
|
||||
let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?;
|
||||
require_empty!(cap_buf, "BgpSec");
|
||||
Ok((
|
||||
cap_buf,
|
||||
buf,
|
||||
Self::BgpSec {
|
||||
header: BgpSecHeader(header),
|
||||
afi,
|
||||
@@ -416,7 +420,7 @@ impl Capability {
|
||||
let (cap_buf, role) = be_u8(cap_buf)?;
|
||||
let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?;
|
||||
require_empty!(cap_buf, "BgpRole");
|
||||
Ok((cap_buf, Self::BgpRole { role }))
|
||||
Ok((buf, Self::BgpRole { role }))
|
||||
}
|
||||
v if v == CapabilityDiscriminants::GracefulRestart as u8 => {
|
||||
// flags_restart_time is: (4 bits: restart flags, 12 bits: restart time (s)).
|
||||
@@ -445,7 +449,7 @@ impl Capability {
|
||||
let (cap_buf, entries) = nom::multi::many0(parse_entry).parse(cap_buf)?;
|
||||
require_empty!(cap_buf, "GracefulRestart");
|
||||
Ok((
|
||||
cap_buf,
|
||||
buf,
|
||||
Self::GracefulRestart {
|
||||
restart_state,
|
||||
restart_time,
|
||||
@@ -467,7 +471,7 @@ impl Capability {
|
||||
let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?;
|
||||
require_empty!(cap_buf, "AddPath");
|
||||
Ok((
|
||||
cap_buf,
|
||||
buf,
|
||||
Self::AddPath {
|
||||
afi,
|
||||
safi,
|
||||
@@ -477,7 +481,7 @@ impl Capability {
|
||||
}
|
||||
v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => {
|
||||
require_empty!(cap_buf, "EnhancedRouteRefresh");
|
||||
Ok((cap_buf, Self::EnhancedRouteRefresh))
|
||||
Ok((buf, Self::EnhancedRouteRefresh))
|
||||
}
|
||||
v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => {
|
||||
let parse_llgr = |buf: &'a [u8]| -> IResult<
|
||||
@@ -503,13 +507,13 @@ impl Capability {
|
||||
};
|
||||
let (cap_buf, entries) = nom::multi::many0(parse_llgr).parse(cap_buf)?;
|
||||
require_empty!(cap_buf, "LongLivedGracefulRestart");
|
||||
Ok((cap_buf, Self::LongLivedGracefulRestart(entries)))
|
||||
Ok((buf, Self::LongLivedGracefulRestart(entries)))
|
||||
}
|
||||
type_code => {
|
||||
// Handling an unknown capability, so just parse it out and store the value
|
||||
// as bytes.
|
||||
Ok((
|
||||
&[],
|
||||
buf,
|
||||
Self::UnknownCapability {
|
||||
type_code,
|
||||
length: cap_buf.len() as u8,
|
||||
@@ -978,4 +982,56 @@ mod tests {
|
||||
assert_eq!(buf.to_vec(), out.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
/// Each capability must leave the bytes that follow it for the caller.
|
||||
/// Returning the (empty) per-capability buffer instead makes the list
|
||||
/// parser stop early and silently lose every later capability, e.g. the
|
||||
/// 4-octet ASN that routers list after graceful restart.
|
||||
#[test]
|
||||
fn capability_parsers_leave_the_following_bytes() {
|
||||
let ctx = ParserContext::default();
|
||||
let trailer: &[u8] = &[0x41, 0x04, 0x00, 0x03, 0x34, 0x74];
|
||||
let samples: &[(&str, &[u8])] = &[
|
||||
("multiprotocol", &[0x01, 0x04, 0x00, 0x02, 0x00, 0x01]),
|
||||
("route refresh", &[0x02, 0x00]),
|
||||
("extended next hop", &[0x05, 0x06, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02]),
|
||||
("extended message", &[0x06, 0x00]),
|
||||
("role", &[0x09, 0x01, 0x00]),
|
||||
("graceful restart, aware only", &[0x40, 0x02, 0x00, 0x00]),
|
||||
("graceful restart, one family", &[0x40, 0x06, 0x00, 0x78, 0x00, 0x02, 0x01, 0x80]),
|
||||
("4-octet asn", &[0x41, 0x04, 0x00, 0x00, 0xfd, 0xe8]),
|
||||
("add-path", &[0x45, 0x04, 0x00, 0x02, 0x01, 0x03]),
|
||||
("enhanced route refresh", &[0x46, 0x00]),
|
||||
("long-lived graceful restart, none", &[0x47, 0x00]),
|
||||
("unknown (fqdn)", &[0x49, 0x02, b'a', b'b']),
|
||||
];
|
||||
for (name, bytes) in samples {
|
||||
let mut input = bytes.to_vec();
|
||||
input.extend_from_slice(trailer);
|
||||
let (rest, _) = Capability::from_wire(&ctx, &input)
|
||||
.unwrap_or_else(|e| panic!("{name}: failed to parse: {e:?}"));
|
||||
assert_eq!(rest, trailer, "{name}: did not return the bytes following the capability");
|
||||
}
|
||||
}
|
||||
|
||||
/// The capability list of a BIRD 2 OPEN, end to end.
|
||||
#[test]
|
||||
fn bird_capability_list_is_parsed_completely() {
|
||||
let ctx = ParserContext::default();
|
||||
#[rustfmt::skip]
|
||||
let param: &[u8] = &[
|
||||
0x02, 0x18,
|
||||
0x01, 0x04, 0x00, 0x02, 0x00, 0x01,
|
||||
0x02, 0x00,
|
||||
0x06, 0x00,
|
||||
0x40, 0x02, 0x00, 0x00,
|
||||
0x41, 0x04, 0x00, 0x03, 0x34, 0x74,
|
||||
0x46, 0x00,
|
||||
0x47, 0x00,
|
||||
];
|
||||
let (rest, OpenOption::Capabilities(caps)) = OpenOption::from_wire(&ctx, param).unwrap();
|
||||
assert!(rest.is_empty());
|
||||
assert_eq!(caps.len(), 7, "{caps:?}");
|
||||
assert!(caps.contains(&Capability::FourByteAsn { asn: 210036 }));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -34,6 +34,25 @@ impl IpPrefix {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if `other` is equal to or more specific than `self`, i.e.
|
||||
/// `self` covers `other`. Prefixes of different address families never
|
||||
/// contain each other.
|
||||
pub fn contains(&self, other: &IpPrefix) -> bool {
|
||||
if self.address_family != other.address_family || other.length < self.length {
|
||||
return false;
|
||||
}
|
||||
let full_bytes = (self.length / 8) as usize;
|
||||
if self.prefix[..full_bytes] != other.prefix[..full_bytes] {
|
||||
return false;
|
||||
}
|
||||
let rem_bits = self.length % 8;
|
||||
if rem_bits == 0 {
|
||||
return true;
|
||||
}
|
||||
let mask = u8::MAX << (8 - rem_bits);
|
||||
(self.prefix[full_bytes] & mask) == (other.prefix[full_bytes] & mask)
|
||||
}
|
||||
|
||||
pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<(), ToWireError> {
|
||||
// Verify that there is enough space to write the IpPrefix.
|
||||
if out.remaining() < (self.prefix.len() + 1) {
|
||||
@@ -205,6 +224,22 @@ mod tests {
|
||||
32
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn contains_covers_more_specifics() {
|
||||
let p = |s: &str| IpPrefix::try_from(s).unwrap();
|
||||
assert!(p("10.100.0.0/16").contains(&p("10.100.7.0/24")));
|
||||
assert!(p("10.100.0.0/16").contains(&p("10.100.0.0/16")));
|
||||
assert!(!p("10.100.0.0/16").contains(&p("10.101.0.0/24")));
|
||||
assert!(!p("10.100.7.0/24").contains(&p("10.100.0.0/16")));
|
||||
assert!(p("0.0.0.0/0").contains(&p("192.0.2.0/24")));
|
||||
// Non-byte-aligned boundary.
|
||||
assert!(p("10.100.0.0/19").contains(&p("10.100.31.0/24")));
|
||||
assert!(!p("10.100.0.0/19").contains(&p("10.100.32.0/24")));
|
||||
// Cross-family never matches.
|
||||
assert!(!p("::/0").contains(&p("10.0.0.0/8")));
|
||||
assert!(p("2001:db8::/32").contains(&p("2001:db8:cafe::/48")));
|
||||
}
|
||||
|
||||
verify_roundtrip!(
|
||||
verify_roundtrip_ipv6_0,
|
||||
"::/0",
|
||||
|
||||
@@ -5,5 +5,7 @@ edition.workspace = true
|
||||
[dependencies]
|
||||
bgp-packet.workspace = true
|
||||
bgp-primitives.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing.workspace = true
|
||||
@@ -13,6 +13,8 @@ const CMD_CHANNEL_CAPACITY: usize = 256;
|
||||
enum RibCommand {
|
||||
Announce { peer: PeerId, prefix: IpPrefix, entry: RibEntry },
|
||||
Withdraw { peer: PeerId, prefix: IpPrefix },
|
||||
/// Drop everything learned from `peer`, e.g. because its session ended.
|
||||
RemovePeer { peer: PeerId, reply: oneshot::Sender<usize> },
|
||||
Snapshot { reply: oneshot::Sender<Vec<SnapshotEntry>> },
|
||||
}
|
||||
|
||||
@@ -42,6 +44,15 @@ impl AdjRibIn {
|
||||
let _ = self.tx.send(RibCommand::Withdraw { peer, prefix }).await;
|
||||
}
|
||||
|
||||
/// Removes every route learned from `peer`, emitting a
|
||||
/// [`RibEvent::Withdrawn`] for each (RFC 4271 §8.2.2: routes from a
|
||||
/// terminated session are deleted). Returns how many were removed.
|
||||
pub async fn remove_peer(&self, peer: PeerId) -> usize {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
let _ = self.tx.send(RibCommand::RemovePeer { peer, reply: reply_tx }).await;
|
||||
reply_rx.await.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns a point-in-time snapshot of all routes in the RIB.
|
||||
pub async fn snapshot(&self) -> Vec<SnapshotEntry> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
@@ -64,19 +75,24 @@ async fn run_actor(mut rx: mpsc::Receiver<RibCommand>, events: broadcast::Sender
|
||||
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
// Send errors below mean there are no subscribers, which is fine.
|
||||
RibCommand::Announce { peer, prefix, entry } => {
|
||||
table.insert((peer, prefix.clone()), entry.clone());
|
||||
if let Err(e) = events.send(RibEvent::Announced { peer, prefix, entry }) {
|
||||
// No active subscribers — not an error.
|
||||
let _ = e;
|
||||
}
|
||||
let _ = events.send(RibEvent::Announced { peer, prefix, entry });
|
||||
}
|
||||
RibCommand::Withdraw { peer, prefix } => {
|
||||
if table.remove(&(peer, prefix.clone())).is_some() {
|
||||
if let Err(e) = events.send(RibEvent::Withdrawn { peer, prefix }) {
|
||||
let _ = e;
|
||||
let _ = events.send(RibEvent::Withdrawn { peer, prefix });
|
||||
}
|
||||
}
|
||||
RibCommand::RemovePeer { peer, reply } => {
|
||||
let prefixes: Vec<IpPrefix> =
|
||||
table.keys().filter(|(p, _)| *p == peer).map(|(_, prefix)| prefix.clone()).collect();
|
||||
for prefix in &prefixes {
|
||||
table.remove(&(peer, prefix.clone()));
|
||||
let _ = events.send(RibEvent::Withdrawn { peer, prefix: prefix.clone() });
|
||||
}
|
||||
let _ = reply.send(prefixes.len());
|
||||
}
|
||||
RibCommand::Snapshot { reply } => {
|
||||
let snap = table
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
mod adj_rib_in;
|
||||
mod entry;
|
||||
mod event;
|
||||
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};
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tracing::warn;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
const CMD_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// User-selectable attributes for a locally originated route.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalRouteAttrs {
|
||||
pub origin: OriginPathAttribute,
|
||||
/// Number of *additional* copies of the local ASN to prepend to the
|
||||
/// AS_PATH. `0` yields the plain `[local_asn]` path.
|
||||
pub prepend: u8,
|
||||
pub med: Option<u32>,
|
||||
/// RFC 1997 standard communities as `(high, low)` 16-bit halves.
|
||||
pub communities: Vec<(u16, u16)>,
|
||||
/// RFC 8092 large communities.
|
||||
pub large_communities: Vec<LargeCommunity>,
|
||||
}
|
||||
|
||||
impl Default for LocalRouteAttrs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
origin: OriginPathAttribute::IGP,
|
||||
prepend: 0,
|
||||
med: None,
|
||||
communities: vec![],
|
||||
large_communities: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A route originated by this router.
|
||||
#[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<String>, 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<IpPrefix>,
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LocalRibEvent {
|
||||
/// A route was added or its attributes replaced.
|
||||
Announced(LocalRoute),
|
||||
Withdrawn { prefix: IpPrefix },
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Announce { route: LocalRoute, reply: oneshot::Sender<Result<(), LocalRibError>> },
|
||||
Withdraw {
|
||||
prefix: IpPrefix,
|
||||
requester: Option<String>,
|
||||
reply: oneshot::Sender<Result<Option<LocalRoute>, LocalRibError>>,
|
||||
},
|
||||
Snapshot { reply: oneshot::Sender<Vec<LocalRoute>> },
|
||||
}
|
||||
|
||||
/// Handle to the local RIB actor. Cheap to clone; all clones share one table.
|
||||
#[derive(Clone)]
|
||||
pub struct LocalRib {
|
||||
tx: mpsc::Sender<Command>,
|
||||
events: broadcast::Sender<LocalRibEvent>,
|
||||
}
|
||||
|
||||
impl LocalRib {
|
||||
pub fn spawn(policy: LocalRibPolicy) -> Self {
|
||||
let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY);
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
tokio::spawn(run_actor(rx, events.clone(), policy));
|
||||
Self { tx, events }
|
||||
}
|
||||
|
||||
/// Adds a route, or replaces the attributes of an existing route with the
|
||||
/// same prefix and owner.
|
||||
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)?
|
||||
}
|
||||
|
||||
/// 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<Option<LocalRoute>, LocalRibError> {
|
||||
let (reply, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Command::Withdraw { prefix, requester: requester.map(str::to_owned), reply })
|
||||
.await
|
||||
.map_err(|_| LocalRibError::Closed)?;
|
||||
rx.await.map_err(|_| LocalRibError::Closed)?
|
||||
}
|
||||
|
||||
/// Point-in-time copy of every route, in no particular order.
|
||||
pub async fn snapshot(&self) -> Vec<LocalRoute> {
|
||||
let (reply, rx) = oneshot::channel();
|
||||
let _ = self.tx.send(Command::Snapshot { reply }).await;
|
||||
rx.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Subscribe to mutations. See [`crate::AdjRibIn::subscribe`] for lag
|
||||
/// semantics.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LocalRibEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_actor(
|
||||
mut rx: mpsc::Receiver<Command>,
|
||||
events: broadcast::Sender<LocalRibEvent>,
|
||||
policy: LocalRibPolicy,
|
||||
) {
|
||||
let mut table: HashMap<IpPrefix, LocalRoute> = 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);
|
||||
}
|
||||
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::Snapshot { reply } => {
|
||||
if reply.send(table.values().cloned().collect()).is_err() {
|
||||
warn!("Snapshot reply channel closed before send");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_announce(
|
||||
table: &HashMap<IpPrefix, LocalRoute>,
|
||||
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::*;
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
fn route(p: &str, owner: &str) -> LocalRoute {
|
||||
LocalRoute::new(prefix(p), owner, LocalRouteAttrs::default())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn announce_and_withdraw_emit_events() {
|
||||
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
||||
let mut events = rib.subscribe();
|
||||
|
||||
rib.announce(route("10.0.0.0/24", "alice")).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");
|
||||
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);
|
||||
}
|
||||
|
||||
#[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")));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ bgp-rib.workspace = true
|
||||
bytes.workspace = true
|
||||
clap.workspace = true
|
||||
futures.workspace = true
|
||||
libc.workspace = true
|
||||
eyre.workspace = true
|
||||
prometheus.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tracing.workspace = true
|
||||
protobuf.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
protobuf-codegen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -2,6 +2,9 @@ use protobuf_codegen::Codegen;
|
||||
|
||||
fn main() {
|
||||
Codegen::new()
|
||||
// Parse the .proto in pure Rust so building needs no `protoc` binary
|
||||
// on the machine (routers and minimal containers rarely have one).
|
||||
.pure()
|
||||
.input("proto/config.proto")
|
||||
.includes(&["proto"])
|
||||
.cargo_out_dir("protos")
|
||||
|
||||
@@ -17,6 +17,53 @@ 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;
|
||||
|
||||
// Where to serve the HTTP API, status page and metrics. The port is
|
||||
// required. Unset disables the HTTP server.
|
||||
TcpEndpoint http_endpoint = 6;
|
||||
|
||||
// Initial delay between attempts to connect to a configured peer, in
|
||||
// seconds. Doubles after each failure up to two minutes. Defaults to 5.
|
||||
uint32 connect_retry_seconds = 7;
|
||||
|
||||
// Connection collisions (RFC 4271 §6.8) against a session that is already
|
||||
// Established are, by default, resolved by closing the new connection.
|
||||
// When true they are resolved like any other collision: the connection
|
||||
// initiated by the higher BGP Identifier survives, and a second connection
|
||||
// opened by the same side replaces the first. This lets a peer that
|
||||
// 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;
|
||||
|
||||
// Watch for pings (ICMPv6 echo requests) sent to the lab prefixes and show
|
||||
// them on the status page. Unset disables it.
|
||||
PingMonitor ping_monitor = 9;
|
||||
}
|
||||
|
||||
// Shows who is pinging addresses that this host answers for.
|
||||
//
|
||||
// The host must already own the addresses, e.g. through an AnyIP route
|
||||
// ("ip -6 route add local 2001:db8::/40 dev lo"); the kernel keeps answering
|
||||
// the pings and we only receive a copy. Opening the raw ICMPv6 socket this
|
||||
// needs takes CAP_NET_RAW on Linux. IPv6 only.
|
||||
message PingMonitor {
|
||||
// Only pings to destinations inside these prefixes are shown. Defaults to
|
||||
// the IPv6 prefixes in local_route_policy.allowed_prefixes.
|
||||
repeated string prefixes = 1;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Maximum number of routes a single owner may announce. 0 means unlimited.
|
||||
uint32 max_routes_per_owner = 2;
|
||||
}
|
||||
|
||||
// An IP address and port pair.
|
||||
@@ -41,4 +88,11 @@ message Peer {
|
||||
// Hold time to advertise in the OPEN message, in seconds (RFC 4271 §4.2).
|
||||
// Must be 0 or >= 3. Defaults to 90 when unset.
|
||||
uint32 hold_time_seconds = 4;
|
||||
|
||||
// Source IP address for connections we open to this peer. It is also the
|
||||
// next hop of the routes we send it. Required when the peer identifies us
|
||||
// by address and the host has several, e.g. when both speakers run on the
|
||||
// same machine. Must be the same address family as the endpoint. Unset
|
||||
// lets the operating system choose.
|
||||
string local_address = 5;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Turns locally originated routes into UPDATE messages for every established
|
||||
//! session.
|
||||
//!
|
||||
//! The advertiser task listens for [`LocalRibEvent`]s and [`SessionEvent`]s.
|
||||
//! A route change is fanned out to all live sessions; a new session receives
|
||||
//! the full local table. Sending the same route twice is harmless (BGP treats
|
||||
//! a re-announcement as an implicit replace), so the two event streams need
|
||||
//! no coordination beyond being handled by one task.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use bgp_packet::message::{
|
||||
AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, Message,
|
||||
MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, MultiExitDiscPathAttribute,
|
||||
NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
|
||||
};
|
||||
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRib, LocalRibEvent, LocalRoute};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::session::{SessionEvent, SessionHandle, SessionRegistry};
|
||||
|
||||
pub fn spawn_advertiser(
|
||||
local_rib: LocalRib,
|
||||
registry: SessionRegistry,
|
||||
local_asn: u32,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(run(local_rib, registry, local_asn))
|
||||
}
|
||||
|
||||
async fn run(local_rib: LocalRib, registry: SessionRegistry, local_asn: u32) {
|
||||
let mut rib_events = local_rib.subscribe();
|
||||
let mut session_events = registry.subscribe();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rib_events.recv() => match ev {
|
||||
Ok(LocalRibEvent::Announced(route)) => {
|
||||
for session in registry.list() {
|
||||
send_route(&session, &route, local_asn).await;
|
||||
}
|
||||
}
|
||||
Ok(LocalRibEvent::Withdrawn { prefix }) => {
|
||||
for session in registry.list() {
|
||||
send_withdraw(&session, &prefix).await;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
warn!(missed = n, "Advertiser lagged on local RIB events, resyncing all sessions");
|
||||
resync_all(&local_rib, ®istry, local_asn).await;
|
||||
}
|
||||
Err(RecvError::Closed) => return,
|
||||
},
|
||||
ev = session_events.recv() => match ev {
|
||||
Ok(SessionEvent::Up(session)) => {
|
||||
let routes = local_rib.snapshot().await;
|
||||
info!(peer = %session.peer, routes = routes.len(), "Session up, sending local routes");
|
||||
for route in &routes {
|
||||
send_route(&session, route, local_asn).await;
|
||||
}
|
||||
}
|
||||
Ok(SessionEvent::RefreshRequested(session)) => {
|
||||
let routes = local_rib.snapshot().await;
|
||||
info!(peer = %session.peer, routes = routes.len(), "Route refresh requested, re-sending local routes");
|
||||
for route in &routes {
|
||||
send_route(&session, route, local_asn).await;
|
||||
}
|
||||
}
|
||||
Ok(SessionEvent::Down(_)) => {}
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
warn!(missed = n, "Advertiser lagged on session events, resyncing all sessions");
|
||||
resync_all(&local_rib, ®istry, local_asn).await;
|
||||
}
|
||||
Err(RecvError::Closed) => return,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resync_all(local_rib: &LocalRib, registry: &SessionRegistry, local_asn: u32) {
|
||||
let routes = local_rib.snapshot().await;
|
||||
for session in registry.list() {
|
||||
for route in &routes {
|
||||
send_route(&session, route, local_asn).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_route(session: &SessionHandle, route: &LocalRoute, local_asn: u32) {
|
||||
match build_announce(route, local_asn, session.local_addr.ip()) {
|
||||
Some(update) => {
|
||||
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
|
||||
if let Err(e) = session.send(Message::Update(update)).await {
|
||||
warn!(peer = %session.peer, err = %e, "Failed to queue announce");
|
||||
}
|
||||
}
|
||||
None => debug!(
|
||||
peer = %session.peer,
|
||||
prefix = %route.prefix,
|
||||
"Skipping route: address family does not match session transport"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_withdraw(session: &SessionHandle, prefix: &IpPrefix) {
|
||||
debug!(peer = %session.peer, %prefix, "Withdrawing local route");
|
||||
if let Err(e) = session.send(Message::Update(build_withdraw(prefix))).await {
|
||||
warn!(peer = %session.peer, err = %e, "Failed to queue withdraw");
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the UPDATE announcing `route` with `next_hop` as the next hop.
|
||||
///
|
||||
/// Returns `None` when the prefix family does not match the next-hop family:
|
||||
/// carrying IPv4 NLRI over an IPv6 next hop (RFC 8950) or vice versa is not
|
||||
/// supported yet.
|
||||
pub fn build_announce(route: &LocalRoute, local_asn: u32, next_hop: IpAddr) -> Option<UpdateMessage> {
|
||||
let attrs = &route.attrs;
|
||||
|
||||
// Path attributes in ascending type-code order, as most implementations
|
||||
// expect them.
|
||||
let mut path_attributes = vec![
|
||||
PathAttribute::Origin(attrs.origin),
|
||||
AsPathAttribute::from_asns(vec![local_asn; 1 + attrs.prepend as usize]),
|
||||
];
|
||||
|
||||
let mut announced_routes = vec![];
|
||||
match (route.prefix.address_family, next_hop) {
|
||||
(AddressFamilyId::Ipv4, IpAddr::V4(nh)) => {
|
||||
path_attributes.push(PathAttribute::NextHop(NextHopPathAttribute(nh)));
|
||||
announced_routes.push(route.prefix.clone());
|
||||
}
|
||||
(AddressFamilyId::Ipv6, IpAddr::V6(_)) => {}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
if let Some(med) = attrs.med {
|
||||
path_attributes.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(med)));
|
||||
}
|
||||
if !attrs.communities.is_empty() {
|
||||
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
|
||||
attrs.communities.clone(),
|
||||
)));
|
||||
}
|
||||
if let (AddressFamilyId::Ipv6, IpAddr::V6(nh)) = (route.prefix.address_family, next_hop) {
|
||||
path_attributes.push(PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
|
||||
afi: AddressFamilyId::Ipv6,
|
||||
safi: SubsequentAfi::Unicast,
|
||||
next_hop: NlriNextHop::Ipv6(nh),
|
||||
prefixes: vec![route.prefix.clone()],
|
||||
}));
|
||||
}
|
||||
if !attrs.large_communities.is_empty() {
|
||||
path_attributes.push(PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||
communities: attrs.large_communities.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
Some(UpdateMessage { withdrawn_routes: vec![], path_attributes, announced_routes })
|
||||
}
|
||||
|
||||
/// Builds the UPDATE withdrawing `prefix`.
|
||||
pub fn build_withdraw(prefix: &IpPrefix) -> UpdateMessage {
|
||||
match prefix.address_family {
|
||||
AddressFamilyId::Ipv4 => UpdateMessage {
|
||||
withdrawn_routes: vec![prefix.clone()],
|
||||
path_attributes: vec![],
|
||||
announced_routes: vec![],
|
||||
},
|
||||
AddressFamilyId::Ipv6 => UpdateMessage {
|
||||
withdrawn_routes: vec![],
|
||||
path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
|
||||
afi: AddressFamilyId::Ipv6,
|
||||
safi: SubsequentAfi::Unicast,
|
||||
prefixes: vec![prefix.clone()],
|
||||
})],
|
||||
announced_routes: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use bgp_packet::message::{LargeCommunity, OriginPathAttribute};
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bgp_rib::LocalRouteAttrs;
|
||||
use bytes::BytesMut;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn prefix(s: &str) -> IpPrefix {
|
||||
IpPrefix::try_from(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
med: Some(50),
|
||||
communities: vec![(65000, 100), (65535, 65281)],
|
||||
large_communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
|
||||
},
|
||||
);
|
||||
let update = build_announce(&route, 65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))).unwrap();
|
||||
|
||||
assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]);
|
||||
assert!(update.withdrawn_routes.is_empty());
|
||||
assert_eq!(
|
||||
update.path_attributes,
|
||||
vec![
|
||||
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||
AsPathAttribute::from_asns(vec![65000, 65000, 65000]),
|
||||
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
|
||||
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)),
|
||||
PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 100), (65535, 65281)])),
|
||||
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||
communities: vec![LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }],
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
// Serialise and parse back to make sure the wire encoding is sound.
|
||||
let ctx = ParserContext { four_octet_asn: Some(true), address_family: None };
|
||||
let msg = Message::Update(update.clone());
|
||||
let mut buf = BytesMut::new();
|
||||
msg.to_wire(&ctx, &mut buf).unwrap();
|
||||
let (_, parsed) = Message::from_wire(&ctx, &buf).unwrap();
|
||||
let Message::Update(parsed) = parsed else { panic!("expected UPDATE") };
|
||||
assert_eq!(parsed, update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_announce_uses_mp_reach() {
|
||||
let route = LocalRoute::new(prefix("2001:db8:1::/48"), "alice", LocalRouteAttrs::default());
|
||||
let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1);
|
||||
let update = build_announce(&route, 65000, IpAddr::V6(nh)).unwrap();
|
||||
|
||||
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(MpReachNlriPathAttribute { next_hop: NlriNextHop::Ipv6(h), prefixes, .. })
|
||||
if *h == nh && *prefixes == vec![prefix("2001:db8:1::/48")]
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_mismatch_is_skipped() {
|
||||
let v6 = LocalRoute::new(prefix("2001:db8::/32"), "a", 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());
|
||||
assert!(build_announce(&v4, 65000, IpAddr::V6(Ipv6Addr::LOCALHOST)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withdraw_by_family() {
|
||||
let v4 = build_withdraw(&prefix("10.0.0.0/8"));
|
||||
assert_eq!(v4.withdrawn_routes, vec![prefix("10.0.0.0/8")]);
|
||||
assert!(v4.path_attributes.is_empty());
|
||||
|
||||
let v6 = build_withdraw(&prefix("2001:db8::/32"));
|
||||
assert!(v6.withdrawn_routes.is_empty());
|
||||
assert!(matches!(&v6.path_attributes[..], [PathAttribute::MpUnreachNlri(m)] if m.prefixes == vec![prefix("2001:db8::/32")]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Connection collision detection (RFC 4271 §6.8).
|
||||
//!
|
||||
//! Two speakers that dial each other can end up with two TCP connections.
|
||||
//! Exactly one must survive and both ends must pick the same one: the
|
||||
//! connection *initiated by the speaker with the higher BGP Identifier*.
|
||||
//! The other is closed with Cease / Connection Collision Resolution.
|
||||
//!
|
||||
//! This cannot be a check local to one connection: the rival connection is
|
||||
//! usually still mid-handshake (so it is not in the session registry yet),
|
||||
//! and sometimes it is the rival, running in another task, that must close.
|
||||
//! Hence one small shared map, keyed by the peer's BGP Identifier, holding
|
||||
//! the single connection currently preferred for that peer and a way to
|
||||
//! tell it to stop.
|
||||
//!
|
||||
//! A collision with an *Established* connection closes the new one, unless
|
||||
//! `detect_established` is set (the RFC's optional
|
||||
//! `CollisionDetectEstablishedState`), which lets a restarted peer replace a
|
||||
//! session we have not yet noticed is dead. If both connections were opened
|
||||
//! by the same side the newer wins: the initiator has abandoned the older.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::metrics::COLLISIONS;
|
||||
|
||||
/// Which side opened the TCP connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// The peer connected to us.
|
||||
Inbound,
|
||||
/// We connected to the peer.
|
||||
Outbound,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Direction::Inbound => "inbound",
|
||||
Direction::Outbound => "outbound",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
id: u64,
|
||||
direction: Direction,
|
||||
established: bool,
|
||||
kill: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
/// True if the new connection is the one to close.
|
||||
fn new_loses(
|
||||
local_id: Ipv4Addr,
|
||||
remote_id: Ipv4Addr,
|
||||
existing: &Entry,
|
||||
new: Direction,
|
||||
detect_established: bool,
|
||||
) -> bool {
|
||||
if existing.established && !detect_established {
|
||||
return true;
|
||||
}
|
||||
if existing.direction == new {
|
||||
return false;
|
||||
}
|
||||
// Identifiers compare as unsigned 32-bit integers.
|
||||
let keep = if u32::from(local_id) > u32::from(remote_id) { Direction::Outbound } else { Direction::Inbound };
|
||||
new != keep
|
||||
}
|
||||
|
||||
/// Shared by every connection of one server.
|
||||
#[derive(Clone)]
|
||||
pub struct CollisionTable {
|
||||
entries: Arc<Mutex<HashMap<Ipv4Addr, Entry>>>,
|
||||
next_id: Arc<AtomicU64>,
|
||||
detect_established: bool,
|
||||
}
|
||||
|
||||
impl CollisionTable {
|
||||
pub fn new(detect_established: bool) -> Self {
|
||||
Self { entries: Default::default(), next_id: Default::default(), detect_established }
|
||||
}
|
||||
|
||||
/// Call when a connection receives the peer's OPEN. `None` means this
|
||||
/// connection lost and must be closed with Cease / Connection Collision
|
||||
/// Resolution. Otherwise any rival has been told to close and this
|
||||
/// connection holds the slot until the guard drops.
|
||||
pub fn admit(&self, local_id: Ipv4Addr, remote_id: Ipv4Addr, direction: Direction) -> Option<ConnectionGuard> {
|
||||
let mut entries = self.entries.lock().expect("collision table poisoned");
|
||||
if let Some(existing) = entries.get_mut(&remote_id) {
|
||||
if new_loses(local_id, remote_id, existing, direction, self.detect_established) {
|
||||
COLLISIONS.with_label_values(&["new"]).inc();
|
||||
return None;
|
||||
}
|
||||
if let Some(kill) = existing.kill.take() {
|
||||
COLLISIONS.with_label_values(&["existing"]).inc();
|
||||
let _ = kill.send(());
|
||||
}
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (kill, killed) = oneshot::channel();
|
||||
entries.insert(remote_id, Entry { id, direction, established: false, kill: Some(kill) });
|
||||
Some(ConnectionGuard { table: self.clone(), remote_id, id, killed })
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds a connection's slot in the table; releases it on drop.
|
||||
pub struct ConnectionGuard {
|
||||
table: CollisionTable,
|
||||
remote_id: Ipv4Addr,
|
||||
id: u64,
|
||||
/// Completes when a later connection won against this one; the owner
|
||||
/// must then send Cease / Connection Collision Resolution and close.
|
||||
pub killed: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
impl ConnectionGuard {
|
||||
pub fn set_established(&self) {
|
||||
let mut entries = self.table.entries.lock().expect("collision table poisoned");
|
||||
if let Some(e) = entries.get_mut(&self.remote_id).filter(|e| e.id == self.id) {
|
||||
e.established = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ConnectionGuard {
|
||||
fn drop(&mut self) {
|
||||
// A connection that lost has already been replaced in the map; only
|
||||
// remove the slot if it is still ours.
|
||||
let mut entries = self.table.entries.lock().expect("collision table poisoned");
|
||||
if entries.get(&self.remote_id).is_some_and(|e| e.id == self.id) {
|
||||
entries.remove(&self.remote_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use Direction::*;
|
||||
|
||||
const LOW: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);
|
||||
const HIGH: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1);
|
||||
|
||||
fn loses(local: Ipv4Addr, remote: Ipv4Addr, existing: Direction, established: bool, new: Direction, detect: bool) -> bool {
|
||||
let e = Entry { id: 0, direction: existing, established, kill: None };
|
||||
new_loses(local, remote, &e, new, detect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_identifier_keeps_the_connection_it_initiated() {
|
||||
// We are HIGH: our outbound connection survives.
|
||||
assert!(loses(HIGH, LOW, Outbound, false, Inbound, false));
|
||||
assert!(!loses(HIGH, LOW, Inbound, false, Outbound, false));
|
||||
// We are LOW: the peer's connection (inbound to us) survives.
|
||||
assert!(!loses(LOW, HIGH, Outbound, false, Inbound, false));
|
||||
assert!(loses(LOW, HIGH, Inbound, false, Outbound, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_ends_agree() {
|
||||
// A and B dial each other. Each end holds its own dial as the
|
||||
// existing (Outbound) connection and sees the other's dial arrive as
|
||||
// new (Inbound). Exactly one of the two dials may be closed, so the
|
||||
// ends must give opposite answers to "close the other side's dial?".
|
||||
for (a, b) in [(LOW, HIGH), (HIGH, LOW)] {
|
||||
let a_closes_dial_from_b = loses(a, b, Outbound, false, Inbound, false);
|
||||
let b_closes_dial_from_a = loses(b, a, Outbound, false, Inbound, false);
|
||||
assert_ne!(a_closes_dial_from_b, b_closes_dial_from_a, "ends disagree for a={a} b={b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn established_wins_unless_enabled() {
|
||||
for new in [Inbound, Outbound] {
|
||||
for existing in [Inbound, Outbound] {
|
||||
assert!(loses(LOW, HIGH, existing, true, new, false));
|
||||
assert!(loses(HIGH, LOW, existing, true, new, false));
|
||||
}
|
||||
}
|
||||
assert!(!loses(LOW, HIGH, Outbound, true, Inbound, true));
|
||||
assert!(loses(HIGH, LOW, Outbound, true, Inbound, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_direction_newer_wins() {
|
||||
assert!(!loses(LOW, HIGH, Inbound, false, Inbound, false));
|
||||
assert!(!loses(HIGH, LOW, Inbound, false, Inbound, false));
|
||||
assert!(loses(LOW, HIGH, Inbound, true, Inbound, false));
|
||||
assert!(!loses(LOW, HIGH, Inbound, true, Inbound, true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_signals_loser_and_releases_slots() {
|
||||
let table = CollisionTable::new(false);
|
||||
// We are LOW: outbound first, then the peer's inbound wins.
|
||||
let mut out = table.admit(LOW, HIGH, Outbound).unwrap();
|
||||
let inb = table.admit(LOW, HIGH, Inbound).unwrap();
|
||||
assert!(out.killed.try_recv().is_ok());
|
||||
// The loser's guard dropping must not free the winner's slot.
|
||||
drop(out);
|
||||
inb.set_established();
|
||||
assert!(table.admit(LOW, HIGH, Outbound).is_none());
|
||||
|
||||
// A different identifier never collides.
|
||||
assert!(table.admit(LOW, Ipv4Addr::new(192, 0, 2, 2), Inbound).is_some());
|
||||
|
||||
// Once the survivor goes away the identifier is free again.
|
||||
drop(inb);
|
||||
assert!(table.admit(LOW, HIGH, Outbound).is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use eyre::{Result, bail, eyre};
|
||||
use bgp_primitives::constants::AddressFamilyId;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRibPolicy;
|
||||
use eyre::{Result, WrapErr, bail, eyre};
|
||||
|
||||
use crate::proto::config::BgpConfig;
|
||||
|
||||
const DEFAULT_BGP_PORT: u16 = 179;
|
||||
const DEFAULT_HOLD_TIME_SECS: u16 = 90;
|
||||
const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Validated, Rust-native server configuration derived from the proto config.
|
||||
pub struct Config {
|
||||
@@ -16,13 +21,31 @@ pub struct Config {
|
||||
pub hold_time_secs: u16,
|
||||
pub listen_endpoints: Vec<SocketAddr>,
|
||||
pub peers: Vec<PeerConfig>,
|
||||
/// Initial delay between attempts to connect to a configured peer;
|
||||
/// doubles on each failure.
|
||||
pub connect_retry: Duration,
|
||||
/// Resolve collisions against Established sessions by BGP Identifier
|
||||
/// 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,
|
||||
/// Address for the HTTP API; `None` disables it.
|
||||
pub http_endpoint: Option<SocketAddr>,
|
||||
/// IPv6 prefixes whose incoming pings are shown; `None` disables the
|
||||
/// ping monitor.
|
||||
pub ping_monitor: Option<Vec<IpPrefix>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerConfig {
|
||||
pub name: String,
|
||||
pub endpoint: SocketAddr,
|
||||
pub remote_asn: u32,
|
||||
pub hold_time_secs: u16,
|
||||
/// Source address for connections we open to this peer; `None` lets the
|
||||
/// operating system choose.
|
||||
pub local_address: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -65,16 +88,88 @@ impl Config {
|
||||
p.hold_time_seconds as u16
|
||||
};
|
||||
|
||||
let local_address = if p.local_address.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let ip = IpAddr::from_str(&p.local_address)
|
||||
.map_err(|_| eyre!("Peer {:?} has invalid local_address {:?}", p.name, p.local_address))?;
|
||||
if ip.is_ipv4() != endpoint.is_ipv4() {
|
||||
bail!("Peer {:?}: local_address {ip} and endpoint {endpoint} are different address families", p.name);
|
||||
}
|
||||
Some(ip)
|
||||
};
|
||||
|
||||
Ok(PeerConfig {
|
||||
name: p.name,
|
||||
endpoint,
|
||||
remote_asn: p.remote_asn,
|
||||
hold_time_secs,
|
||||
local_address,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
Ok(Self { router_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, peers })
|
||||
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::<Result<_>>()?,
|
||||
max_routes_per_owner: policy.max_routes_per_owner as usize,
|
||||
},
|
||||
None => LocalRibPolicy::default(),
|
||||
};
|
||||
|
||||
let http_endpoint = match proto.http_endpoint.into_option() {
|
||||
Some(ep) => {
|
||||
if ep.port == 0 {
|
||||
bail!("http_endpoint.port is required");
|
||||
}
|
||||
Some(parse_endpoint(&ep.address, ep.port).wrap_err("Invalid http_endpoint")?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let ping_monitor = match proto.ping_monitor.into_option() {
|
||||
Some(monitor) => {
|
||||
let prefixes: Vec<IpPrefix> = if monitor.prefixes.is_empty() {
|
||||
local_route_policy.allowed_prefixes.clone()
|
||||
} else {
|
||||
monitor
|
||||
.prefixes
|
||||
.iter()
|
||||
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid ping_monitor prefix {p:?}")))
|
||||
.collect::<Result<_>>()?
|
||||
};
|
||||
let prefixes: Vec<IpPrefix> =
|
||||
prefixes.into_iter().filter(|p| p.address_family == AddressFamilyId::Ipv6).collect();
|
||||
if prefixes.is_empty() {
|
||||
bail!("ping_monitor needs IPv6 prefixes, either its own or local_route_policy.allowed_prefixes");
|
||||
}
|
||||
Some(prefixes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let connect_retry = if proto.connect_retry_seconds == 0 {
|
||||
DEFAULT_CONNECT_RETRY
|
||||
} else {
|
||||
Duration::from_secs(proto.connect_retry_seconds as u64)
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
router_id,
|
||||
local_asn: proto.local_asn,
|
||||
hold_time_secs: DEFAULT_HOLD_TIME_SECS,
|
||||
listen_endpoints,
|
||||
peers,
|
||||
connect_retry,
|
||||
collision_detect_established_state: proto.collision_detect_established_state,
|
||||
local_route_policy,
|
||||
http_endpoint,
|
||||
ping_monitor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Active side of the FSM: dial each configured peer and keep the session up.
|
||||
//!
|
||||
//! One task per configured peer loops forever: connect, run the session,
|
||||
//! back off, repeat. While a session with the same remote IP and ASN is
|
||||
//! already established (for example because the peer dialled us first) the
|
||||
//! dialer stays idle rather than opening a second one. When both sides dial
|
||||
//! at once the FSM resolves the collision (see [`crate::collision`]); if our
|
||||
//! connection is the one closed, the next pass finds the surviving session
|
||||
//! and goes idle.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tokio::net::{TcpSocket, TcpStream};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::collision::Direction;
|
||||
use crate::config::PeerConfig;
|
||||
use crate::fsm::{SessionConfig, run_session};
|
||||
use crate::session::{DialState, SessionRegistry};
|
||||
|
||||
/// Upper bound for exponential backoff between connection attempts.
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(120);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn spawn_dialers(
|
||||
peers: &[PeerConfig],
|
||||
base: &SessionConfig,
|
||||
connect_retry: Duration,
|
||||
) -> Vec<JoinHandle<()>> {
|
||||
peers
|
||||
.iter()
|
||||
.map(|peer| {
|
||||
let config = SessionConfig {
|
||||
hold_time_secs: peer.hold_time_secs,
|
||||
direction: Direction::Outbound,
|
||||
peer_name: Some(peer.name.clone()),
|
||||
expected_asn: Some(peer.remote_asn),
|
||||
..base.clone()
|
||||
};
|
||||
tokio::spawn(dial_loop(Arc::new(peer.clone()), config, connect_retry))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens the TCP connection, from the configured source address if any.
|
||||
async fn connect(peer: &PeerConfig) -> std::io::Result<TcpStream> {
|
||||
let Some(local) = peer.local_address else {
|
||||
return TcpStream::connect(peer.endpoint).await;
|
||||
};
|
||||
let socket = if peer.endpoint.is_ipv4() { TcpSocket::new_v4()? } else { TcpSocket::new_v6()? };
|
||||
socket
|
||||
.bind(std::net::SocketAddr::new(local, 0))
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("cannot use local_address {local}: {e}")))?;
|
||||
socket.connect(peer.endpoint).await
|
||||
}
|
||||
|
||||
async fn dial_loop(peer: Arc<PeerConfig>, config: SessionConfig, connect_retry: Duration) {
|
||||
let registry: SessionRegistry = config.registry.clone();
|
||||
let mut backoff = connect_retry;
|
||||
|
||||
loop {
|
||||
if registry.is_shutting_down() {
|
||||
return;
|
||||
}
|
||||
if registry.find(peer.endpoint.ip(), peer.remote_asn).is_some() {
|
||||
// Peer dialled us; nothing to do until that session goes away.
|
||||
registry.set_dial_state(&peer.name, DialState::Idle);
|
||||
tokio::time::sleep(connect_retry).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
registry.set_dial_state(&peer.name, DialState::Connecting);
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer");
|
||||
|
||||
let outcome = match tokio::time::timeout(CONNECT_TIMEOUT, connect(&peer)).await {
|
||||
Ok(Ok(stream)) => {
|
||||
info!(peer = %peer.name, endpoint = %peer.endpoint, "TCP connected, starting session");
|
||||
backoff = connect_retry;
|
||||
run_session(stream, peer.endpoint, config.clone()).await.map(|()| "session closed by peer".to_string())
|
||||
}
|
||||
Ok(Err(e)) => Err(eyre::eyre!("connect failed: {e}")),
|
||||
Err(_) => Err(eyre::eyre!("connect timed out after {CONNECT_TIMEOUT:?}")),
|
||||
};
|
||||
|
||||
let reason = match outcome {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
warn!(peer = %peer.name, endpoint = %peer.endpoint, err = %e, "Session attempt ended");
|
||||
e.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
registry.set_dial_state(
|
||||
&peer.name,
|
||||
DialState::Backoff { until: SystemTime::now() + backoff, last_error: reason },
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
+143
-52
@@ -1,7 +1,7 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_packet::errors::{BgpError, FsmSubcode, OpenMsgSubcode};
|
||||
use bgp_packet::errors::{BgpError, CeaseSubcode, FsmSubcode, OpenMsgSubcode};
|
||||
use bgp_packet::message::{BGP_HEADER_LEN, Message, NotificationMessage, PathAttribute};
|
||||
use bgp_packet::open::{Capability, OpenMessage, OpenOption};
|
||||
use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi};
|
||||
@@ -12,11 +12,20 @@ use eyre::{Result, bail, eyre};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::{Instant, interval_at, sleep_until};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use bgp_packet::message::{AS_TRANS, BGP4_VERSION};
|
||||
|
||||
use crate::collision::{CollisionTable, Direction};
|
||||
use crate::metrics::{MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, message_type_name};
|
||||
use crate::session::{SessionHandle, SessionRegistry};
|
||||
|
||||
/// Number of outbound messages that may be queued for a session before
|
||||
/// senders are back-pressured.
|
||||
const OUTBOUND_QUEUE_CAPACITY: usize = 1024;
|
||||
|
||||
/// Minimal per-session config extracted from the server config.
|
||||
#[derive(Clone)]
|
||||
pub struct SessionConfig {
|
||||
@@ -24,6 +33,16 @@ pub struct SessionConfig {
|
||||
pub local_asn: u32,
|
||||
pub hold_time_secs: u16,
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
pub registry: SessionRegistry,
|
||||
/// Shared table used to resolve connection collisions (RFC 4271 §6.8).
|
||||
pub collisions: CollisionTable,
|
||||
/// Which side opened this TCP connection.
|
||||
pub direction: Direction,
|
||||
/// Configured name of the peer, if known.
|
||||
pub peer_name: Option<String>,
|
||||
/// If set, the peer's OPEN must carry this ASN or the session is
|
||||
/// refused with a Bad Peer AS notification.
|
||||
pub expected_asn: Option<u32>,
|
||||
}
|
||||
|
||||
/// Drive the BGP FSM for a single accepted TCP connection.
|
||||
@@ -34,6 +53,7 @@ pub async fn run_session(
|
||||
peer_addr: SocketAddr,
|
||||
config: SessionConfig,
|
||||
) -> Result<()> {
|
||||
let local_addr = stream.local_addr()?;
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// --- OpenSent -----------------------------------------------------------
|
||||
@@ -72,7 +92,7 @@ pub async fn run_session(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(bgp_err) = validate_open(&peer_open) {
|
||||
if let Err(bgp_err) = validate_open(&peer_open, config.router_id) {
|
||||
send_notification(&mut writer, bgp_err, &ParserContext::default()).await;
|
||||
bail!("Peer {} sent invalid OPEN", peer_addr);
|
||||
}
|
||||
@@ -102,19 +122,46 @@ pub async fn run_session(
|
||||
peer_open.asn as u32
|
||||
};
|
||||
|
||||
if let Some(expected) = config.expected_asn
|
||||
&& peer_asn != expected
|
||||
{
|
||||
send_notification(&mut writer, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), &ctx).await;
|
||||
bail!("Peer {peer_addr} announced AS{peer_asn} but AS{expected} was configured");
|
||||
}
|
||||
|
||||
// Connection collision detection (RFC 4271 §6.8). From here on the
|
||||
// guard keeps this connection visible to later ones, and `killed` fires
|
||||
// if one of them wins.
|
||||
let Some(mut collision_guard) =
|
||||
config.collisions.admit(config.router_id, peer_open.identifier, config.direction)
|
||||
else {
|
||||
return close_for_collision(&mut writer, &ctx, peer_addr).await;
|
||||
};
|
||||
|
||||
info!(
|
||||
%peer_addr,
|
||||
peer_asn,
|
||||
negotiated_hold_time,
|
||||
direction = config.direction.as_str(),
|
||||
"OPEN received, transitioning to OpenConfirm"
|
||||
);
|
||||
|
||||
// --- OpenConfirm --------------------------------------------------------
|
||||
send_message(&mut writer, &Message::KeepAlive, &ctx).await?;
|
||||
|
||||
let hold_duration = Duration::from_secs(negotiated_hold_time as u64);
|
||||
// With a negotiated hold time of zero the hold timer does not run
|
||||
// (RFC 4271 §4.2); fall back to the large pre-negotiation value so a
|
||||
// silent peer is still cleaned up eventually.
|
||||
let hold_duration = Duration::from_secs(if negotiated_hold_time == 0 { 240 } else { negotiated_hold_time as u64 });
|
||||
|
||||
match recv_with_timeout(&mut reader, &ctx, hold_duration).await {
|
||||
let confirm = tokio::select! {
|
||||
msg = recv_with_timeout(&mut reader, &ctx, hold_duration) => msg,
|
||||
_ = &mut collision_guard.killed => {
|
||||
return close_for_collision(&mut writer, &ctx, peer_addr).await;
|
||||
}
|
||||
};
|
||||
|
||||
match confirm {
|
||||
Ok(Message::KeepAlive) => {}
|
||||
Ok(other) => {
|
||||
send_notification(
|
||||
@@ -132,19 +179,51 @@ pub async fn run_session(
|
||||
}
|
||||
|
||||
info!(%peer_addr, peer_asn, "Session established");
|
||||
SESSIONS_ESTABLISHED_TOTAL.inc();
|
||||
|
||||
let peer_id = PeerId { addr: peer_addr, asn: peer_asn };
|
||||
|
||||
// --- Established --------------------------------------------------------
|
||||
established_loop(
|
||||
// Register so the rest of the server can see this session and queue
|
||||
// messages for it. The guard deregisters on any exit path.
|
||||
let (outbound_tx, mut outbound_rx) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY);
|
||||
collision_guard.set_established();
|
||||
let _guard = config.registry.register(SessionHandle::new(
|
||||
peer_id,
|
||||
config.peer_name.clone(),
|
||||
config.direction,
|
||||
local_addr,
|
||||
peer_four_byte_asn,
|
||||
outbound_tx,
|
||||
));
|
||||
|
||||
let result = established_loop(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&ctx,
|
||||
negotiated_hold_time,
|
||||
peer_id,
|
||||
&config.adj_rib_in,
|
||||
SessionInputs { outbound: &mut outbound_rx, killed: &mut collision_guard.killed, registry: &config.registry },
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
// Whatever ended the session, routes learned over it are gone
|
||||
// (RFC 4271 §8.2.2). Do this before the guard drops so subscribers see
|
||||
// the withdrawals before the Down event.
|
||||
let removed = config.adj_rib_in.remove_peer(peer_id).await;
|
||||
info!(peer = %peer_id, removed_routes = removed, "Session ended");
|
||||
result
|
||||
}
|
||||
|
||||
/// Signals from the rest of the server into an established session.
|
||||
struct SessionInputs<'a> {
|
||||
/// Messages queued for transmission to the peer.
|
||||
outbound: &'a mut mpsc::Receiver<Message>,
|
||||
/// Fires when a later connection won collision resolution.
|
||||
killed: &'a mut oneshot::Receiver<()>,
|
||||
/// Where to report a peer's route-refresh request.
|
||||
registry: &'a SessionRegistry,
|
||||
}
|
||||
|
||||
async fn established_loop(
|
||||
@@ -154,14 +233,16 @@ async fn established_loop(
|
||||
hold_time_secs: u16,
|
||||
peer_id: PeerId,
|
||||
adj_rib_in: &AdjRibIn,
|
||||
inputs: SessionInputs<'_>,
|
||||
) -> Result<()> {
|
||||
// When hold_time is 0, keepalives and the hold timer are disabled (RFC 4271 §4.4).
|
||||
if hold_time_secs == 0 {
|
||||
return no_timer_loop(reader, writer, ctx, peer_id, adj_rib_in).await;
|
||||
}
|
||||
|
||||
let hold_duration = Duration::from_secs(hold_time_secs as u64);
|
||||
let keepalive_interval = Duration::from_secs(hold_time_secs as u64 / 3);
|
||||
let SessionInputs { outbound, killed, registry } = inputs;
|
||||
// When hold_time is 0, keepalives and the hold timer are disabled
|
||||
// (RFC 4271 §4.4). The timer branches below are gated off in that case;
|
||||
// the placeholder durations are never observed.
|
||||
let timers_enabled = hold_time_secs != 0;
|
||||
let secs = if timers_enabled { hold_time_secs as u64 } else { 3 };
|
||||
let hold_duration = Duration::from_secs(secs);
|
||||
let keepalive_interval = Duration::from_secs(secs / 3);
|
||||
|
||||
// interval_at avoids firing immediately; first keepalive is sent after one interval.
|
||||
let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval);
|
||||
@@ -178,6 +259,7 @@ async fn established_loop(
|
||||
hold_timer.as_mut().reset(Instant::now() + hold_duration);
|
||||
}
|
||||
Message::Update(update) => {
|
||||
hold_timer.as_mut().reset(Instant::now() + hold_duration);
|
||||
info!(
|
||||
peer = %peer_id,
|
||||
withdrawn = update.withdrawn_routes.len(),
|
||||
@@ -195,56 +277,40 @@ async fn established_loop(
|
||||
send_notification(writer, BgpError::FsmError(FsmSubcode::UnexpectedEstablished), ctx).await;
|
||||
bail!("Received OPEN in Established state from {}", peer_id);
|
||||
}
|
||||
Message::RouteRefresh => {}
|
||||
}
|
||||
}
|
||||
_ = keepalive_timer.tick() => {
|
||||
send_message(writer, &Message::KeepAlive, ctx).await?;
|
||||
}
|
||||
_ = &mut hold_timer => {
|
||||
warn!(peer = %peer_id, "Hold timer expired");
|
||||
send_notification(writer, BgpError::HoldTimer, ctx).await;
|
||||
bail!("Hold timer expired for peer {}", peer_id);
|
||||
Message::RouteRefresh => {
|
||||
hold_timer.as_mut().reset(Instant::now() + hold_duration);
|
||||
info!(peer = %peer_id, "Received ROUTE-REFRESH, re-sending our routes");
|
||||
registry.request_refresh(&peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold-timer-disabled path for hold_time == 0.
|
||||
async fn no_timer_loop(
|
||||
reader: &mut OwnedReadHalf,
|
||||
writer: &mut OwnedWriteHalf,
|
||||
ctx: &ParserContext,
|
||||
peer_id: PeerId,
|
||||
adj_rib_in: &AdjRibIn,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
match read_message(reader, ctx).await? {
|
||||
Message::KeepAlive => {}
|
||||
Message::Update(update) => {
|
||||
Some(msg) = outbound.recv() => {
|
||||
if let Message::Update(update) = &msg {
|
||||
info!(
|
||||
peer = %peer_id,
|
||||
withdrawn = update.withdrawn_routes.len(),
|
||||
announced = update.announced_routes.len(),
|
||||
"Received UPDATE",
|
||||
"Sending UPDATE",
|
||||
);
|
||||
apply_update(adj_rib_in, peer_id, &update.path_attributes,
|
||||
&update.withdrawn_routes, &update.announced_routes).await;
|
||||
}
|
||||
Message::Notification(notif) => {
|
||||
info!(peer = %peer_id, error = %notif.error, "Received NOTIFICATION, closing session");
|
||||
send_message(writer, &msg, ctx).await?;
|
||||
// A NOTIFICATION from our side ends the session (RFC 4271 §6).
|
||||
if let Message::Notification(notif) = &msg {
|
||||
info!(peer = %peer_id, error = %notif.error, "Sent NOTIFICATION, closing session");
|
||||
return Ok(());
|
||||
}
|
||||
Message::Open(_) => {
|
||||
send_notification(
|
||||
writer,
|
||||
BgpError::FsmError(FsmSubcode::UnexpectedEstablished),
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
bail!("Received OPEN in Established state from {}", peer_id);
|
||||
}
|
||||
Message::RouteRefresh => {}
|
||||
_ = &mut *killed => {
|
||||
return close_for_collision(writer, ctx, peer_id.addr).await;
|
||||
}
|
||||
_ = keepalive_timer.tick(), if timers_enabled => {
|
||||
send_message(writer, &Message::KeepAlive, ctx).await?;
|
||||
}
|
||||
_ = &mut hold_timer, if timers_enabled => {
|
||||
warn!(peer = %peer_id, "Hold timer expired");
|
||||
send_notification(writer, BgpError::HoldTimer, ctx).await;
|
||||
bail!("Hold timer expired for peer {}", peer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +336,7 @@ async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result
|
||||
|
||||
let (_, msg) =
|
||||
Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?;
|
||||
MESSAGES_RECEIVED.with_label_values(&[message_type_name(&msg)]).inc();
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
@@ -291,6 +358,7 @@ async fn send_message(
|
||||
let mut buf = BytesMut::new();
|
||||
msg.to_wire(ctx, &mut buf)?;
|
||||
writer.write_all(&buf).await?;
|
||||
MESSAGES_SENT.with_label_values(&[message_type_name(msg)]).inc();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -306,6 +374,18 @@ async fn send_notification(writer: &mut OwnedWriteHalf, error: BgpError, ctx: &P
|
||||
}
|
||||
}
|
||||
|
||||
/// A later connection to the same BGP Identifier won collision resolution:
|
||||
/// tell the peer why and end this session.
|
||||
async fn close_for_collision(
|
||||
writer: &mut OwnedWriteHalf,
|
||||
ctx: &ParserContext,
|
||||
peer_addr: SocketAddr,
|
||||
) -> Result<()> {
|
||||
info!(%peer_addr, "Closing connection: lost collision resolution");
|
||||
send_notification(writer, BgpError::Cease(CeaseSubcode::ConnCollisionResolution), ctx).await;
|
||||
bail!("Connection to {peer_addr} closed by collision resolution");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RIB helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -373,15 +453,26 @@ fn build_open(config: &SessionConfig) -> Message {
|
||||
afi: AddressFamilyId::Ipv4,
|
||||
safi: SubsequentAfi::Unicast,
|
||||
},
|
||||
// Without this a peer will not accept the IPv6 routes we
|
||||
// originate via MP_REACH_NLRI (RFC 4760 §8).
|
||||
Capability::MultiProtocol {
|
||||
afi: AddressFamilyId::Ipv6,
|
||||
safi: SubsequentAfi::Unicast,
|
||||
},
|
||||
Capability::RouteRefresh,
|
||||
])],
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_open(open: &OpenMessage) -> Result<(), BgpError> {
|
||||
fn validate_open(open: &OpenMessage, local_id: Ipv4Addr) -> Result<(), BgpError> {
|
||||
if open.version != BGP4_VERSION {
|
||||
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnsupportedVersion));
|
||||
}
|
||||
// A peer cannot share our identifier or use the unspecified address;
|
||||
// collision resolution depends on identifiers being distinct.
|
||||
if open.identifier == local_id || open.identifier.is_unspecified() {
|
||||
return Err(BgpError::OpenMsg(OpenMsgSubcode::BadBgpId));
|
||||
}
|
||||
if open.hold_time != 0 && open.hold_time < 3 {
|
||||
return Err(BgpError::OpenMsg(OpenMsgSubcode::UnacceptableHoldTime));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
pub mod advertise;
|
||||
pub mod collision;
|
||||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod dialer;
|
||||
pub mod fsm;
|
||||
pub mod metrics;
|
||||
pub mod ping;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod tracing;
|
||||
|
||||
pub mod proto {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
use bgp_server::config::Config;
|
||||
use bgp_server::proto::config::BgpConfig;
|
||||
use bgp_server::server::BgpServerBuilder;
|
||||
use bgp_server::tracing::TracingArgs;
|
||||
use clap::Parser;
|
||||
use eyre::{Context, Result};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct ServerArgs {
|
||||
/// Path to the textproto configuration file.
|
||||
config: String,
|
||||
|
||||
#[command(flatten)]
|
||||
tracing: TracingArgs,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = ServerArgs::parse();
|
||||
args.tracing.install()?;
|
||||
|
||||
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")?;
|
||||
|
||||
BgpServerBuilder::new(config).run().await
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Process-wide Prometheus metrics, registered on the default registry.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use bgp_packet::message::Message;
|
||||
use prometheus::{
|
||||
IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec,
|
||||
register_int_gauge,
|
||||
};
|
||||
|
||||
pub static MESSAGES_SENT: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("bgp_messages_sent_total", "BGP messages sent, by type", &["type"])
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static MESSAGES_RECEIVED: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!("bgp_messages_received_total", "BGP messages received, by type", &["type"])
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static SESSIONS_ESTABLISHED_TOTAL: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!("bgp_sessions_established_total", "Sessions that reached Established")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static SESSIONS_ESTABLISHED: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("bgp_sessions_established", "Sessions currently in Established")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static COLLISIONS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"bgp_connection_collisions_total",
|
||||
"Connection collisions resolved, by which connection was closed",
|
||||
&["closed"]
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static LOCAL_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("bgp_local_routes", "Locally originated routes").expect("metric registration")
|
||||
});
|
||||
|
||||
pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
register_int_gauge!("bgp_adj_rib_in_routes", "Routes held in Adj-RIB-In across all peers")
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_ECHO_REQUESTS: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_echo_requests_total",
|
||||
"ICMPv6 echo requests seen for the watched prefixes"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_EVENTS_SUPPRESSED: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_events_suppressed_total",
|
||||
"Ping events not published because of the per-second limit"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
/// Forces registration of every metric so they appear in scrapes before
|
||||
/// their first observation.
|
||||
pub fn init() {
|
||||
LazyLock::force(&MESSAGES_SENT);
|
||||
LazyLock::force(&MESSAGES_RECEIVED);
|
||||
LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL);
|
||||
LazyLock::force(&SESSIONS_ESTABLISHED);
|
||||
LazyLock::force(&COLLISIONS);
|
||||
LazyLock::force(&LOCAL_ROUTES);
|
||||
LazyLock::force(&ADJ_RIB_IN_ROUTES);
|
||||
LazyLock::force(&PING_ECHO_REQUESTS);
|
||||
LazyLock::force(&PING_EVENTS_SUPPRESSED);
|
||||
}
|
||||
|
||||
pub fn message_type_name(msg: &Message) -> &'static str {
|
||||
match msg {
|
||||
Message::Open(_) => "open",
|
||||
Message::Update(_) => "update",
|
||||
Message::Notification(_) => "notification",
|
||||
Message::KeepAlive => "keepalive",
|
||||
Message::RouteRefresh => "route_refresh",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Shows who is pinging the prefixes this router originates.
|
||||
//!
|
||||
//! The host answers for the lab address space itself (an AnyIP route on the
|
||||
//! loopback interface), so the kernel already replies to every ping. A raw
|
||||
//! ICMPv6 socket gets a copy of each echo request as well; we never reply and
|
||||
//! never see forwarded traffic. For every packet we learn the source, the
|
||||
//! destination, the interface it arrived on and the hop limit it arrived
|
||||
//! with, which is enough to show a student that their route is in use and
|
||||
//! which way the traffic came in.
|
||||
//!
|
||||
//! Anyone on the Internet can send these packets, with any source address, so
|
||||
//! everything here is bounded: a fixed number of flows is remembered and the
|
||||
//! number of events published per second is capped.
|
||||
|
||||
#[cfg(unix)]
|
||||
mod sys;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bgp_primitives::constants::AddressFamilyId;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRib, LocalRoute};
|
||||
use tokio::sync::{Mutex, broadcast};
|
||||
use tokio::time::Instant;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::metrics::{PING_ECHO_REQUESTS, PING_EVENTS_SUPPRESSED};
|
||||
|
||||
/// Flows (source and destination pairs) remembered. When full, the flow that
|
||||
/// has been quiet the longest makes room.
|
||||
const MAX_FLOWS: usize = 2048;
|
||||
/// A flow that keeps pinging publishes an event this often, carrying the
|
||||
/// running count.
|
||||
const EVENT_INTERVAL: Duration = Duration::from_secs(2);
|
||||
/// A flow that was quiet for this long starts a new burst, which is what the
|
||||
/// event log reports.
|
||||
const BURST_GAP: Duration = Duration::from_secs(10);
|
||||
/// How much of each packet is kept for display.
|
||||
pub const MAX_PACKET_BYTES: usize = 256;
|
||||
/// Events published per second across all flows. Packets beyond this are
|
||||
/// still counted and show up in [`PingMonitor::flows`].
|
||||
const MAX_EVENTS_PER_SECOND: u32 = 50;
|
||||
|
||||
/// One echo request, as seen by the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingObservation {
|
||||
pub src: Ipv6Addr,
|
||||
pub dst: Ipv6Addr,
|
||||
/// Name of the interface the packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the packet arrived. Senders start at 64, 128 or
|
||||
/// 255, so this tells roughly how many routers the packet crossed.
|
||||
pub hop_limit: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the ICMPv6 message, header included.
|
||||
pub bytes: usize,
|
||||
/// The ICMPv6 message as received, from its type byte on, cut off after
|
||||
/// [`MAX_PACKET_BYTES`]. The IPv6 header is not part of it: a raw ICMPv6
|
||||
/// socket never sees one, which is why the fields above come separately.
|
||||
pub packet: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The local route a ping was addressed to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingedRoute {
|
||||
pub prefix: IpPrefix,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingFlow {
|
||||
/// The most recent packet.
|
||||
pub last: PingObservation,
|
||||
pub count: u64,
|
||||
pub first_seen: SystemTime,
|
||||
pub last_seen: SystemTime,
|
||||
/// The most specific local route covering the destination right now;
|
||||
/// `None` when nobody announces it.
|
||||
pub route: Option<PingedRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PingEvent {
|
||||
pub flow: PingFlow,
|
||||
/// True for the first event of a burst: a new flow, or one that had gone
|
||||
/// quiet. False for the periodic updates while it keeps pinging.
|
||||
pub new_burst: bool,
|
||||
}
|
||||
|
||||
/// Whether the monitor is watching, for display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PingMonitorStatus {
|
||||
/// Not configured.
|
||||
Disabled,
|
||||
Listening,
|
||||
/// Configured, but the socket could not be opened.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct FlowState {
|
||||
flow: PingFlow,
|
||||
last_packet: Instant,
|
||||
last_event: Option<Instant>,
|
||||
/// A burst started and its first event has not gone out yet.
|
||||
burst_pending: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Table {
|
||||
flows: HashMap<(Ipv6Addr, Ipv6Addr), FlowState>,
|
||||
window_start: Option<Instant>,
|
||||
window_events: u32,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn take_event_slot(&mut self, now: Instant) -> bool {
|
||||
if self.window_start.is_none_or(|start| now.duration_since(start) >= Duration::from_secs(1)) {
|
||||
self.window_start = Some(now);
|
||||
self.window_events = 0;
|
||||
}
|
||||
if self.window_events >= MAX_EVENTS_PER_SECOND {
|
||||
return false;
|
||||
}
|
||||
self.window_events += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn make_room(&mut self) {
|
||||
if self.flows.len() < MAX_FLOWS {
|
||||
return;
|
||||
}
|
||||
if let Some(key) = self.flows.iter().min_by_key(|(_, s)| s.last_packet).map(|(k, _)| *k) {
|
||||
self.flows.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
prefixes: Vec<IpPrefix>,
|
||||
local_rib: Option<LocalRib>,
|
||||
status: PingMonitorStatus,
|
||||
table: Mutex<Table>,
|
||||
events: broadcast::Sender<PingEvent>,
|
||||
}
|
||||
|
||||
/// Handle to the ping monitor. Cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub struct PingMonitor {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl PingMonitor {
|
||||
fn new(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>, status: PingMonitorStatus) -> Self {
|
||||
let (events, _) = broadcast::channel(256);
|
||||
Self { inner: Arc::new(Inner { prefixes, local_rib, status, table: Mutex::default(), events }) }
|
||||
}
|
||||
|
||||
/// A monitor that watches nothing.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(vec![], None, PingMonitorStatus::Disabled)
|
||||
}
|
||||
|
||||
/// A monitor without a socket, fed through [`PingMonitor::record`].
|
||||
pub fn without_socket(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>) -> Self {
|
||||
Self::new(prefixes, local_rib, PingMonitorStatus::Listening)
|
||||
}
|
||||
|
||||
/// Opens the socket and starts watching for pings to `prefixes`. Failing
|
||||
/// to open it (usually: no CAP_NET_RAW) is not fatal; the daemon runs on
|
||||
/// without the monitor and the status page says why.
|
||||
pub fn start(prefixes: Vec<IpPrefix>, local_rib: LocalRib) -> Self {
|
||||
#[cfg(unix)]
|
||||
let socket = sys::EchoSocket::open();
|
||||
#[cfg(not(unix))]
|
||||
let socket: std::io::Result<()> = Err(std::io::Error::other("not supported on this platform"));
|
||||
|
||||
match socket {
|
||||
Ok(socket) => {
|
||||
let shown: Vec<String> = prefixes.iter().map(ToString::to_string).collect();
|
||||
info!(prefixes = ?shown, "Ping monitor listening for ICMPv6 echo requests");
|
||||
let monitor = Self::new(prefixes, Some(local_rib), PingMonitorStatus::Listening);
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(monitor.clone().run(socket));
|
||||
monitor
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, "Ping monitor disabled: cannot open a raw ICMPv6 socket (needs CAP_NET_RAW)");
|
||||
Self::new(prefixes, None, PingMonitorStatus::Failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run(self, mut socket: sys::EchoSocket) {
|
||||
loop {
|
||||
match socket.recv().await {
|
||||
Ok(observation) => self.record(observation).await,
|
||||
Err(e) => {
|
||||
error!(err = %e, "Ping monitor socket failed");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &PingMonitorStatus {
|
||||
&self.inner.status
|
||||
}
|
||||
|
||||
/// The prefixes being watched.
|
||||
pub fn prefixes(&self) -> &[IpPrefix] {
|
||||
&self.inner.prefixes
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<PingEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// Accounts for one echo request. Packets to destinations outside the
|
||||
/// watched prefixes are ignored.
|
||||
pub async fn record(&self, observation: PingObservation) {
|
||||
let destination = host_prefix(observation.dst);
|
||||
if !self.inner.prefixes.iter().any(|p| p.contains(&destination)) {
|
||||
return;
|
||||
}
|
||||
PING_ECHO_REQUESTS.inc();
|
||||
|
||||
let now = Instant::now();
|
||||
let wall = SystemTime::now();
|
||||
let key = (observation.src, observation.dst);
|
||||
let mut guard = self.inner.table.lock().await;
|
||||
let table = &mut *guard;
|
||||
|
||||
let is_new = !table.flows.contains_key(&key);
|
||||
if is_new {
|
||||
table.make_room();
|
||||
}
|
||||
let state = table.flows.entry(key).or_insert_with(|| FlowState {
|
||||
flow: PingFlow { last: observation.clone(), count: 0, first_seen: wall, last_seen: wall, route: None },
|
||||
last_packet: now,
|
||||
last_event: None,
|
||||
burst_pending: false,
|
||||
});
|
||||
if is_new || now.duration_since(state.last_packet) >= BURST_GAP {
|
||||
state.burst_pending = true;
|
||||
}
|
||||
state.flow.last = observation;
|
||||
state.flow.count += 1;
|
||||
state.flow.last_seen = wall;
|
||||
state.last_packet = now;
|
||||
|
||||
let due = state.burst_pending || state.last_event.is_none_or(|t| now.duration_since(t) >= EVENT_INTERVAL);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let new_burst = state.burst_pending;
|
||||
let mut flow = state.flow.clone();
|
||||
if !table.take_event_slot(now) {
|
||||
PING_EVENTS_SUPPRESSED.inc();
|
||||
return;
|
||||
}
|
||||
if let Some(state) = table.flows.get_mut(&key) {
|
||||
state.last_event = Some(now);
|
||||
state.burst_pending = false;
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
flow.route = resolve(&self.local_routes().await, &destination);
|
||||
// No subscribers is fine.
|
||||
let _ = self.inner.events.send(PingEvent { flow, new_burst });
|
||||
}
|
||||
|
||||
/// Every remembered flow, most recently seen first.
|
||||
pub async fn flows(&self) -> Vec<PingFlow> {
|
||||
let mut flows: Vec<PingFlow> = self.inner.table.lock().await.flows.values().map(|s| s.flow.clone()).collect();
|
||||
let routes = self.local_routes().await;
|
||||
for flow in &mut flows {
|
||||
flow.route = resolve(&routes, &host_prefix(flow.last.dst));
|
||||
}
|
||||
flows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| a.last.dst.cmp(&b.last.dst)));
|
||||
flows
|
||||
}
|
||||
|
||||
async fn local_routes(&self) -> Vec<LocalRoute> {
|
||||
match &self.inner.local_rib {
|
||||
Some(rib) => rib.snapshot().await,
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_prefix(addr: Ipv6Addr) -> IpPrefix {
|
||||
IpPrefix { address_family: AddressFamilyId::Ipv6, prefix: addr.octets().to_vec(), length: 128 }
|
||||
}
|
||||
|
||||
fn resolve(routes: &[LocalRoute], destination: &IpPrefix) -> Option<PingedRoute> {
|
||||
routes
|
||||
.iter()
|
||||
.filter(|r| r.prefix.contains(destination))
|
||||
.max_by_key(|r| r.prefix.length)
|
||||
.map(|r| PingedRoute { prefix: r.prefix.clone(), owner: r.owner.clone() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bgp_rib::{LocalRibPolicy, LocalRouteAttrs};
|
||||
|
||||
fn lab() -> Vec<IpPrefix> {
|
||||
vec![IpPrefix::try_from("2a0d:d741::/40").unwrap()]
|
||||
}
|
||||
|
||||
fn ping(src: &str, dst: &str, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(57),
|
||||
ident: 7,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: vec![128, 0, 0, 0, 0, 7, (seq >> 8) as u8, seq as u8],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn counts_flows_and_names_the_route_owner() {
|
||||
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
||||
let prefix = IpPrefix::try_from("2a0d:d741:1a::/48").unwrap();
|
||||
rib.announce(LocalRoute::new(prefix.clone(), "alice", LocalRouteAttrs::default())).await.unwrap();
|
||||
let monitor = PingMonitor::without_socket(lab(), Some(rib.clone()));
|
||||
let mut events = monitor.subscribe();
|
||||
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 0)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:5::1", 0)).await;
|
||||
|
||||
let first = events.try_recv().unwrap();
|
||||
assert!(first.new_burst);
|
||||
assert_eq!(first.flow.route, Some(PingedRoute { prefix, owner: "alice".into() }));
|
||||
let other = events.try_recv().unwrap();
|
||||
assert_eq!(other.flow.route, None, "nobody announces 2a0d:d741:5::/48");
|
||||
assert!(events.try_recv().is_err(), "the second packet of a flow is not an event of its own");
|
||||
|
||||
let flows = monitor.flows().await;
|
||||
assert_eq!(flows.len(), 2);
|
||||
let alice = flows.iter().find(|f| f.route.is_some()).unwrap();
|
||||
assert_eq!((alice.count, alice.last.seq), (2, 1));
|
||||
|
||||
// The owner shown follows the routing table, not the first packet.
|
||||
rib.withdraw(alice.route.as_ref().unwrap().prefix.clone(), None).await.unwrap();
|
||||
assert!(monitor.flows().await.iter().all(|f| f.route.is_none()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn ignores_destinations_outside_the_watched_prefixes() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d740::1", 0)).await;
|
||||
monitor.record(ping("2001:db8::1", "2001:7f8:24::c9", 0)).await;
|
||||
assert!(monitor.flows().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_steady_ping_is_an_occasional_update_and_a_pause_starts_a_new_burst() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
let mut seen = vec![];
|
||||
for seq in 0..6 {
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", seq)).await;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
seen.push((ev.flow.count, ev.new_burst));
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(seen, vec![(1, true), (3, false), (5, false)]);
|
||||
|
||||
tokio::time::advance(BURST_GAP).await;
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", 6)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (7, true));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_flood_of_spoofed_sources_is_bounded() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
for n in 0..(MAX_FLOWS as u32 + 500) {
|
||||
let src = Ipv6Addr::from(0x2001_0db8_0000_0000_0000_0000_0000_0000u128 + n as u128);
|
||||
monitor.record(ping(&src.to_string(), "2a0d:d741:1::1", 0)).await;
|
||||
}
|
||||
assert_eq!(monitor.flows().await.len(), MAX_FLOWS);
|
||||
let mut published = 0;
|
||||
while events.try_recv().is_ok() {
|
||||
published += 1;
|
||||
}
|
||||
assert_eq!(published, MAX_EVENTS_PER_SECOND, "time stood still, so this was all one second");
|
||||
|
||||
// A real student still gets through once the flood's second is over,
|
||||
// even though the first attempt at announcing the burst was dropped.
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 0)).await;
|
||||
assert!(events.try_recv().is_err());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 1)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (2, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The raw ICMPv6 socket behind the ping monitor.
|
||||
//!
|
||||
//! All the `unsafe` in the daemon lives here: the socket options and the
|
||||
//! ancillary data we need (`ICMP6_FILTER`, `IPV6_PKTINFO`, `IPV6_HOPLIMIT`)
|
||||
//! have no safe wrapper in the standard library or tokio.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::CStr;
|
||||
use std::io;
|
||||
use std::mem::{size_of, size_of_val, zeroed};
|
||||
use std::net::Ipv6Addr;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
|
||||
use libc::{c_int, c_void};
|
||||
use tokio::io::Interest;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
use super::{MAX_PACKET_BYTES, PingObservation};
|
||||
|
||||
const ICMP6_ECHO_REQUEST: u8 = 128;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const ICMP6_FILTER: c_int = 1;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
const ICMP6_FILTER: c_int = 18;
|
||||
|
||||
/// `struct icmp6_filter`: one bit per ICMPv6 type.
|
||||
type Icmp6Filter = [u32; 8];
|
||||
|
||||
/// A filter that lets only echo requests through. Linux and the BSDs agree on
|
||||
/// the layout and disagree on the meaning: on Linux a set bit blocks the
|
||||
/// type, on the BSDs (and macOS) a set bit passes it.
|
||||
fn echo_request_only() -> Icmp6Filter {
|
||||
let (word, bit) = ((ICMP6_ECHO_REQUEST >> 5) as usize, 1u32 << (ICMP6_ECHO_REQUEST & 31));
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) {
|
||||
let mut filter = [u32::MAX; 8];
|
||||
filter[word] &= !bit;
|
||||
filter
|
||||
} else {
|
||||
let mut filter = [0u32; 8];
|
||||
filter[word] |= bit;
|
||||
filter
|
||||
}
|
||||
}
|
||||
|
||||
fn check(ret: c_int) -> io::Result<c_int> {
|
||||
if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret) }
|
||||
}
|
||||
|
||||
fn setsockopt<T>(fd: RawFd, level: c_int, name: c_int, value: &T) -> io::Result<()> {
|
||||
// SAFETY: `value` is a live reference and the length given is its size.
|
||||
check(unsafe {
|
||||
libc::setsockopt(fd, level, name, (value as *const T).cast::<c_void>(), size_of::<T>() as libc::socklen_t)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receives a copy of every ICMPv6 echo request the kernel delivers to this
|
||||
/// host. The kernel still answers them; we never send anything.
|
||||
pub struct EchoSocket {
|
||||
fd: AsyncFd<OwnedFd>,
|
||||
/// Interface names by index. Looked up once: interfaces on a router do
|
||||
/// not change name, and under a ping flood the lookup would be three
|
||||
/// system calls per packet.
|
||||
interfaces: HashMap<u32, Option<String>>,
|
||||
}
|
||||
|
||||
impl EchoSocket {
|
||||
/// Opens the socket. Needs CAP_NET_RAW on Linux. macOS also hands echo
|
||||
/// requests to unprivileged ICMPv6 datagram sockets, which is enough to
|
||||
/// try the monitor out on a laptop. (On Linux those only ever see the
|
||||
/// replies to their own pings, so there is no such fallback there.)
|
||||
pub fn open() -> io::Result<Self> {
|
||||
Self::open_as(libc::SOCK_RAW).or_else(|e| {
|
||||
if cfg!(target_os = "macos") { Self::open_as(libc::SOCK_DGRAM) } else { Err(e) }
|
||||
})
|
||||
}
|
||||
|
||||
fn open_as(socket_type: c_int) -> io::Result<Self> {
|
||||
// SAFETY: plain system call; the result is checked before use.
|
||||
let raw = check(unsafe { libc::socket(libc::AF_INET6, socket_type, libc::IPPROTO_ICMPV6) })?;
|
||||
// SAFETY: `raw` is a descriptor we just opened and nobody else owns.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
// SAFETY: fcntl on a descriptor we own, with integer arguments.
|
||||
unsafe {
|
||||
let flags = check(libc::fcntl(raw, libc::F_GETFL))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC))?;
|
||||
}
|
||||
|
||||
setsockopt(raw, libc::IPPROTO_ICMPV6, ICMP6_FILTER, &echo_request_only())?;
|
||||
let on: c_int = 1;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVPKTINFO, &on)?;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVHOPLIMIT, &on)?;
|
||||
|
||||
Ok(Self { fd: AsyncFd::with_interest(fd, Interest::READABLE)?, interfaces: HashMap::new() })
|
||||
}
|
||||
|
||||
/// Waits for the next echo request.
|
||||
pub async fn recv(&mut self) -> io::Result<PingObservation> {
|
||||
loop {
|
||||
let mut guard = self.fd.readable().await?;
|
||||
let packet = match guard.try_io(|fd| recv_packet(fd.as_raw_fd())) {
|
||||
Ok(result) => result?,
|
||||
Err(_would_block) => continue,
|
||||
};
|
||||
let Some(packet) = packet else { continue };
|
||||
let interface = self.interfaces.entry(packet.ifindex).or_insert_with(|| interface_name(packet.ifindex)).clone();
|
||||
return Ok(PingObservation {
|
||||
src: packet.src,
|
||||
dst: packet.dst,
|
||||
interface,
|
||||
hop_limit: packet.hop_limit,
|
||||
ident: packet.ident,
|
||||
seq: packet.seq,
|
||||
bytes: packet.bytes,
|
||||
packet: packet.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
src: Ipv6Addr,
|
||||
dst: Ipv6Addr,
|
||||
ifindex: u32,
|
||||
hop_limit: Option<u8>,
|
||||
ident: u16,
|
||||
seq: u16,
|
||||
bytes: usize,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Reads one packet. `Ok(None)` is a packet we cannot use (not an echo
|
||||
/// request, or the kernel did not say where it was going).
|
||||
fn recv_packet(fd: RawFd) -> io::Result<Option<Packet>> {
|
||||
// A raw ICMPv6 socket delivers the ICMPv6 message without the IPv6
|
||||
// header, so everything about the header comes from ancillary data.
|
||||
let mut data = [0u8; 2048];
|
||||
let mut control = [0u64; 16]; // u64 for the alignment cmsghdr needs
|
||||
// SAFETY: all-zero is a valid value for these plain C structs.
|
||||
let mut from: libc::sockaddr_in6 = unsafe { zeroed() };
|
||||
let mut iov = libc::iovec { iov_base: data.as_mut_ptr().cast(), iov_len: data.len() };
|
||||
// SAFETY: as above. msghdr has private padding on some targets, so it
|
||||
// cannot be built with a struct literal.
|
||||
let mut msg: libc::msghdr = unsafe { zeroed() };
|
||||
msg.msg_name = (&mut from as *mut libc::sockaddr_in6).cast();
|
||||
msg.msg_namelen = size_of::<libc::sockaddr_in6>() as _;
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1 as _;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = size_of_val(&control) as _;
|
||||
|
||||
// SAFETY: every pointer in `msg` refers to a local that outlives the call
|
||||
// and the lengths given match the buffers.
|
||||
let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
|
||||
if n < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let n = n as usize;
|
||||
if n < 8 || data[0] != ICMP6_ECHO_REQUEST {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut dst = None;
|
||||
let mut hop_limit = None;
|
||||
// SAFETY: the CMSG_* macros walk the control buffer the kernel just
|
||||
// filled in, bounded by msg_controllen. Payloads are read unaligned.
|
||||
unsafe {
|
||||
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
while !cmsg.is_null() {
|
||||
let header = &*cmsg;
|
||||
if header.cmsg_level == libc::IPPROTO_IPV6 {
|
||||
if header.cmsg_type == libc::IPV6_PKTINFO {
|
||||
let info: libc::in6_pktinfo = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
dst = Some((Ipv6Addr::from(info.ipi6_addr.s6_addr), info.ipi6_ifindex as u32));
|
||||
} else if header.cmsg_type == libc::IPV6_HOPLIMIT {
|
||||
let value: c_int = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
hop_limit = u8::try_from(value).ok();
|
||||
}
|
||||
}
|
||||
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
|
||||
}
|
||||
}
|
||||
let Some((dst, ifindex)) = dst else { return Ok(None) };
|
||||
|
||||
Ok(Some(Packet {
|
||||
src: Ipv6Addr::from(from.sin6_addr.s6_addr),
|
||||
dst,
|
||||
ifindex,
|
||||
hop_limit,
|
||||
ident: u16::from_be_bytes([data[4], data[5]]),
|
||||
seq: u16::from_be_bytes([data[6], data[7]]),
|
||||
bytes: n,
|
||||
data: data[..n.min(MAX_PACKET_BYTES)].to_vec(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn interface_name(index: u32) -> Option<String> {
|
||||
let mut name = [0 as libc::c_char; libc::IF_NAMESIZE];
|
||||
// SAFETY: the buffer is IF_NAMESIZE bytes, as if_indextoname requires,
|
||||
// and on success holds a NUL-terminated string.
|
||||
unsafe {
|
||||
if libc::if_indextoname(index, name.as_mut_ptr()).is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(CStr::from_ptr(name.as_ptr()).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_passes_only_echo_requests() {
|
||||
let filter = echo_request_only();
|
||||
let blocked = |t: u8| {
|
||||
let set = filter[(t >> 5) as usize] & (1 << (t & 31)) != 0;
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) { set } else { !set }
|
||||
};
|
||||
assert!(!blocked(128), "echo request passes");
|
||||
for t in [1u8, 3, 129, 133, 134, 135, 136] {
|
||||
assert!(blocked(t), "type {t} is dropped in the kernel");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pings ::1 and expects to see the request. Skipped where the socket
|
||||
/// cannot be opened, i.e. on Linux without CAP_NET_RAW.
|
||||
#[tokio::test]
|
||||
async fn sees_a_ping_to_loopback() {
|
||||
let mut socket = match EchoSocket::open() {
|
||||
Ok(socket) => socket,
|
||||
Err(e) => return eprintln!("skipped: cannot open an ICMPv6 socket: {e}"),
|
||||
};
|
||||
let ping = if cfg!(target_os = "macos") { "ping6" } else { "ping" };
|
||||
let Ok(mut child) = std::process::Command::new(ping)
|
||||
.args(["-c", "1", "::1"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return eprintln!("skipped: no {ping} command");
|
||||
};
|
||||
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let ping = socket.recv().await.unwrap();
|
||||
if ping.dst == Ipv6Addr::LOCALHOST {
|
||||
return ping;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let _ = child.wait();
|
||||
let seen = seen.expect("echo request to ::1 was not seen");
|
||||
assert_eq!(seen.src, Ipv6Addr::LOCALHOST);
|
||||
assert_eq!(seen.hop_limit, Some(64));
|
||||
assert!(seen.seq <= 1, "first sequence number is 0 (BSD ping6) or 1 (iputils)");
|
||||
assert!(seen.interface.as_deref().is_some_and(|name| name.starts_with("lo")), "{:?}", seen.interface);
|
||||
assert!(seen.bytes >= 8);
|
||||
assert_eq!(seen.packet[..2], [128, 0], "echo request, code 0");
|
||||
assert_eq!(seen.packet[4..8], [seen.ident.to_be_bytes(), seen.seq.to_be_bytes()].concat()[..]);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tracing::{error, info};
|
||||
|
||||
use bgp_rib::AdjRibIn;
|
||||
use bgp_rib::{AdjRibIn, LocalRib};
|
||||
|
||||
use crate::advertise::spawn_advertiser;
|
||||
use crate::collision::{CollisionTable, Direction};
|
||||
use crate::config::Config;
|
||||
use crate::connection::handle_connection;
|
||||
use crate::dialer::spawn_dialers;
|
||||
use crate::fsm::SessionConfig;
|
||||
use crate::session::{ConfiguredPeer, SessionRegistry};
|
||||
|
||||
pub struct BgpServerBuilder {
|
||||
config: Config,
|
||||
@@ -25,6 +29,10 @@ pub struct BgpServer {
|
||||
/// Shared RIB handle — created at bind time so callers can clone it before
|
||||
/// the accept loop starts (e.g. tests that want to introspect the RIB).
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
/// Routes this router originates; announced to every established peer.
|
||||
pub local_rib: LocalRib,
|
||||
/// Live view of established sessions.
|
||||
pub registry: SessionRegistry,
|
||||
}
|
||||
|
||||
impl BgpServerBuilder {
|
||||
@@ -46,9 +54,17 @@ impl BgpServerBuilder {
|
||||
listeners.push((bound_addr, listener));
|
||||
}
|
||||
Ok(BgpServer {
|
||||
config: self.config,
|
||||
listeners,
|
||||
adj_rib_in: AdjRibIn::spawn(),
|
||||
local_rib: LocalRib::spawn(self.config.local_route_policy.clone()),
|
||||
registry: SessionRegistry::with_configured(
|
||||
self.config
|
||||
.peers
|
||||
.iter()
|
||||
.map(|p| ConfiguredPeer { name: p.name.clone(), endpoint: p.endpoint, remote_asn: p.remote_asn })
|
||||
.collect(),
|
||||
),
|
||||
config: self.config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,12 +75,17 @@ impl BgpServerBuilder {
|
||||
}
|
||||
|
||||
impl BgpServer {
|
||||
pub fn config(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// The addresses actually bound (useful when the config uses port 0).
|
||||
pub fn local_addrs(&self) -> Vec<SocketAddr> {
|
||||
self.listeners.iter().map(|(addr, _)| *addr).collect()
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<()> {
|
||||
crate::metrics::init();
|
||||
info!(
|
||||
router_id = %self.config.router_id,
|
||||
local_asn = self.config.local_asn,
|
||||
@@ -72,12 +93,24 @@ impl BgpServer {
|
||||
"BGP server started"
|
||||
);
|
||||
|
||||
let session_config = Arc::new(SessionConfig {
|
||||
let session_config = SessionConfig {
|
||||
router_id: self.config.router_id,
|
||||
local_asn: self.config.local_asn,
|
||||
hold_time_secs: self.config.hold_time_secs,
|
||||
adj_rib_in: self.adj_rib_in.clone(),
|
||||
});
|
||||
registry: self.registry.clone(),
|
||||
collisions: CollisionTable::new(self.config.collision_detect_established_state),
|
||||
direction: Direction::Inbound,
|
||||
peer_name: None,
|
||||
expected_asn: None,
|
||||
};
|
||||
|
||||
let _advertiser = spawn_advertiser(
|
||||
self.local_rib.clone(),
|
||||
self.registry.clone(),
|
||||
self.config.local_asn,
|
||||
);
|
||||
let _dialers = spawn_dialers(&self.config.peers, &session_config, self.config.connect_retry);
|
||||
|
||||
let mut accepting: FuturesUnordered<_> = self
|
||||
.listeners
|
||||
@@ -88,11 +121,15 @@ impl BgpServer {
|
||||
while let Some((listen_addr, result, listener)) = accepting.next().await {
|
||||
match result {
|
||||
Ok((stream, peer_addr)) => {
|
||||
tokio::spawn(handle_connection(
|
||||
stream,
|
||||
peer_addr,
|
||||
Arc::clone(&session_config),
|
||||
));
|
||||
// Inbound connections from a configured peer inherit its
|
||||
// name, expected ASN and hold time.
|
||||
let mut config = session_config.clone();
|
||||
if let Some(peer) = self.config.peers.iter().find(|p| p.endpoint.ip() == peer_addr.ip()) {
|
||||
config.peer_name = Some(peer.name.clone());
|
||||
config.expected_asn = Some(peer.remote_asn);
|
||||
config.hold_time_secs = peer.hold_time_secs;
|
||||
}
|
||||
tokio::spawn(handle_connection(stream, peer_addr, Arc::new(config)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(%listen_addr, err = %e, "Accept error");
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Registry of established BGP sessions.
|
||||
//!
|
||||
//! A session registers itself when it reaches Established and is removed when
|
||||
//! its [`SessionGuard`] drops, so a session that errors out is always
|
||||
//! deregistered. Other parts of the server (the advertiser, the HTTP API) use
|
||||
//! the registry to enumerate live sessions and queue outbound messages.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bgp_packet::errors::{BgpError, CeaseSubcode};
|
||||
use bgp_packet::message::NotificationMessage;
|
||||
|
||||
use bgp_packet::message::Message;
|
||||
use bgp_rib::PeerId;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::collision::Direction;
|
||||
use crate::metrics::SESSIONS_ESTABLISHED;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// Where the dialer is in bringing up a configured peer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DialState {
|
||||
/// Not dialling: the peer connected to us, or the dialer has not started.
|
||||
Idle,
|
||||
Connecting,
|
||||
/// The last attempt failed or the session ended; waiting to retry.
|
||||
Backoff { until: SystemTime, last_error: String },
|
||||
}
|
||||
|
||||
/// A peer from the configuration file, whether or not it is currently up.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredPeer {
|
||||
pub name: String,
|
||||
pub endpoint: SocketAddr,
|
||||
pub remote_asn: u32,
|
||||
}
|
||||
|
||||
/// Handle to one established session. Cloning is cheap.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionHandle {
|
||||
pub peer: PeerId,
|
||||
/// Name from the configuration, if this peer is configured.
|
||||
pub name: Option<String>,
|
||||
/// Which side opened the TCP connection.
|
||||
pub direction: Direction,
|
||||
/// Our side of the TCP connection; used as the next hop for routes we
|
||||
/// originate to this peer.
|
||||
pub local_addr: SocketAddr,
|
||||
pub established_at: SystemTime,
|
||||
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
|
||||
/// AS_PATH is encoded on the wire for this session.
|
||||
pub four_octet_asn: bool,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("session with {0} has closed")]
|
||||
pub struct SessionClosed(pub PeerId);
|
||||
|
||||
impl SessionHandle {
|
||||
pub fn new(
|
||||
peer: PeerId,
|
||||
name: Option<String>,
|
||||
direction: Direction,
|
||||
local_addr: SocketAddr,
|
||||
four_octet_asn: bool,
|
||||
outbound: mpsc::Sender<Message>,
|
||||
) -> Self {
|
||||
Self { peer, name, direction, local_addr, established_at: SystemTime::now(), four_octet_asn, outbound }
|
||||
}
|
||||
|
||||
/// Queues a message for transmission on this session. Waits for queue
|
||||
/// space if the session's writer is behind.
|
||||
pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> {
|
||||
self.outbound.send(msg).await.map_err(|_| SessionClosed(self.peer))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
Up(SessionHandle),
|
||||
Down(PeerId),
|
||||
/// The peer sent ROUTE-REFRESH (RFC 2918): it wants everything we
|
||||
/// advertise to it sent again, typically because its import policy
|
||||
/// changed.
|
||||
RefreshRequested(SessionHandle),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionRegistry {
|
||||
inner: Arc<RwLock<HashMap<PeerId, SessionHandle>>>,
|
||||
configured: Arc<Vec<ConfiguredPeer>>,
|
||||
dial_states: Arc<RwLock<HashMap<String, DialState>>>,
|
||||
shutting_down: Arc<AtomicBool>,
|
||||
events: broadcast::Sender<SessionEvent>,
|
||||
}
|
||||
|
||||
impl Default for SessionRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::with_configured(vec![])
|
||||
}
|
||||
|
||||
pub fn with_configured(configured: Vec<ConfiguredPeer>) -> Self {
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
let dial_states = configured.iter().map(|p| (p.name.clone(), DialState::Idle)).collect();
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(HashMap::new())),
|
||||
configured: Arc::new(configured),
|
||||
dial_states: Arc::new(RwLock::new(dial_states)),
|
||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers from the configuration file, each with the dialer's current
|
||||
/// state for it.
|
||||
pub fn configured_peers(&self) -> Vec<(ConfiguredPeer, DialState)> {
|
||||
let states = self.dial_states.read().expect("dial states poisoned");
|
||||
self.configured
|
||||
.iter()
|
||||
.map(|p| (p.clone(), states.get(&p.name).cloned().unwrap_or(DialState::Idle)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The configured peer whose endpoint IP is `ip`, if any.
|
||||
pub fn configured_by_ip(&self, ip: IpAddr) -> Option<&ConfiguredPeer> {
|
||||
self.configured.iter().find(|p| p.endpoint.ip() == ip)
|
||||
}
|
||||
|
||||
pub fn set_dial_state(&self, name: &str, state: DialState) {
|
||||
self.dial_states.write().expect("dial states poisoned").insert(name.to_string(), state);
|
||||
}
|
||||
|
||||
/// An established session with this remote IP and ASN, regardless of
|
||||
/// which side opened the TCP connection.
|
||||
pub fn find(&self, ip: IpAddr, asn: u32) -> Option<SessionHandle> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("session registry poisoned")
|
||||
.values()
|
||||
.find(|h| h.peer.addr.ip() == ip && h.peer.asn == asn)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Adds a session and emits [`SessionEvent::Up`]. The returned guard
|
||||
/// removes the session again when dropped.
|
||||
pub fn register(&self, handle: SessionHandle) -> SessionGuard {
|
||||
let peer = handle.peer;
|
||||
self.inner.write().expect("session registry poisoned").insert(peer, handle.clone());
|
||||
SESSIONS_ESTABLISHED.inc();
|
||||
let _ = self.events.send(SessionEvent::Up(handle));
|
||||
SessionGuard { registry: self.clone(), peer }
|
||||
}
|
||||
|
||||
pub fn get(&self, peer: &PeerId) -> Option<SessionHandle> {
|
||||
self.inner.read().expect("session registry poisoned").get(peer).cloned()
|
||||
}
|
||||
|
||||
/// All currently established sessions, in no particular order.
|
||||
pub fn list(&self) -> Vec<SessionHandle> {
|
||||
self.inner.read().expect("session registry poisoned").values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Called by a session when its peer asks for a route refresh.
|
||||
pub fn request_refresh(&self, peer: &PeerId) {
|
||||
if let Some(handle) = self.get(peer) {
|
||||
let _ = self.events.send(SessionEvent::RefreshRequested(handle));
|
||||
}
|
||||
}
|
||||
|
||||
/// True once [`shutdown`](Self::shutdown) has begun; dialers stop
|
||||
/// reconnecting.
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Closes every session politely: each peer is sent Cease /
|
||||
/// Administrative Shutdown (RFC 4486) so it can drop the session at once
|
||||
/// instead of discovering a dead TCP connection. Waits up to `grace` for
|
||||
/// the sessions to finish; returns how many were still open.
|
||||
pub async fn shutdown(&self, grace: Duration) -> usize {
|
||||
self.shutting_down.store(true, Ordering::Relaxed);
|
||||
for session in self.list() {
|
||||
let _ = session
|
||||
.send(Message::Notification(NotificationMessage {
|
||||
error: BgpError::Cease(CeaseSubcode::AdminShutdown),
|
||||
data: vec![],
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + grace;
|
||||
while !self.list().is_empty() && tokio::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
self.list().len()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<SessionEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
fn remove(&self, peer: &PeerId) {
|
||||
let removed = self.inner.write().expect("session registry poisoned").remove(peer);
|
||||
if removed.is_some() {
|
||||
SESSIONS_ESTABLISHED.dec();
|
||||
let _ = self.events.send(SessionEvent::Down(*peer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the session from its registry on drop.
|
||||
pub struct SessionGuard {
|
||||
registry: SessionRegistry,
|
||||
peer: PeerId,
|
||||
}
|
||||
|
||||
impl Drop for SessionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.registry.remove(&self.peer);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use tracing::Level;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Default targets that we enable logging for.
|
||||
const DEFAULT_TARGETS: [&str; 1] = ["bgp_server"];
|
||||
const DEFAULT_TARGETS: [&str; 3] = ["bgp", "bgp_server", "bgp_web"];
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct TracingArgs {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "bgp-web"
|
||||
description = "HTTP API, event stream and metrics for the BGP daemon"
|
||||
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
|
||||
@@ -0,0 +1,587 @@
|
||||
//! JSON handlers and their view types.
|
||||
|
||||
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 bgp_server::ping::{PingFlow, PingMonitorStatus};
|
||||
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
|
||||
use bytes::BytesMut;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
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<String>) -> 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<LocalRibError> 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn unix_secs(t: SystemTime) -> u64 {
|
||||
t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StatusView {
|
||||
pub router_id: String,
|
||||
pub local_asn: u32,
|
||||
pub version: String,
|
||||
pub hostname: String,
|
||||
pub started_at_unix: u64,
|
||||
pub uptime_secs: u64,
|
||||
pub sessions: usize,
|
||||
pub local_routes: usize,
|
||||
pub received_routes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PeerView {
|
||||
/// Name from the configuration file; absent for unconfigured inbound peers.
|
||||
pub name: Option<String>,
|
||||
pub peer_addr: String,
|
||||
pub peer_asn: u32,
|
||||
/// `Established`, `Connecting`, `Backoff` or `Idle`.
|
||||
pub state: String,
|
||||
/// `inbound` or `outbound`: which side opened the live TCP connection.
|
||||
pub direction: Option<String>,
|
||||
pub local_addr: Option<String>,
|
||||
pub established_at_unix: Option<u64>,
|
||||
pub uptime_secs: Option<u64>,
|
||||
pub four_octet_asn: Option<bool>,
|
||||
/// Why the last connection attempt or session ended, while in Backoff.
|
||||
pub last_error: Option<String>,
|
||||
pub retry_at_unix: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<&SessionHandle> for PeerView {
|
||||
fn from(s: &SessionHandle) -> Self {
|
||||
Self {
|
||||
name: s.name.clone(),
|
||||
peer_addr: s.peer.addr.to_string(),
|
||||
peer_asn: s.peer.asn,
|
||||
state: "Established".into(),
|
||||
direction: Some(s.direction.as_str().into()),
|
||||
local_addr: Some(s.local_addr.to_string()),
|
||||
established_at_unix: Some(unix_secs(s.established_at)),
|
||||
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
|
||||
four_octet_asn: Some(s.four_octet_asn),
|
||||
last_error: None,
|
||||
retry_at_unix: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PeerView {
|
||||
fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self {
|
||||
let (state_name, last_error, retry_at_unix) = match state {
|
||||
DialState::Idle => ("Idle", None, None),
|
||||
DialState::Connecting => ("Connecting", None, None),
|
||||
DialState::Backoff { until, last_error } => ("Backoff", Some(last_error.clone()), Some(unix_secs(*until))),
|
||||
};
|
||||
Self {
|
||||
name: Some(peer.name.clone()),
|
||||
peer_addr: peer.endpoint.to_string(),
|
||||
peer_asn: peer.remote_asn,
|
||||
state: state_name.into(),
|
||||
direction: None,
|
||||
local_addr: None,
|
||||
established_at_unix: None,
|
||||
uptime_secs: None,
|
||||
four_octet_asn: None,
|
||||
last_error,
|
||||
retry_at_unix,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured peers first (with their live session when established), then
|
||||
/// any unconfigured inbound sessions.
|
||||
pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
|
||||
let sessions = registry.list();
|
||||
let mut claimed = std::collections::HashSet::new();
|
||||
let mut views: Vec<PeerView> = registry
|
||||
.configured_peers()
|
||||
.iter()
|
||||
.map(|(peer, state)| {
|
||||
match sessions.iter().find(|s| s.peer.addr.ip() == peer.endpoint.ip() && s.peer.asn == peer.remote_asn) {
|
||||
Some(session) => {
|
||||
claimed.insert(session.peer);
|
||||
PeerView { name: Some(peer.name.clone()), ..PeerView::from(session) }
|
||||
}
|
||||
None => PeerView::from_configured(peer, state),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut rest: Vec<PeerView> =
|
||||
sessions.iter().filter(|s| !claimed.contains(&s.peer)).map(PeerView::from).collect();
|
||||
rest.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr));
|
||||
views.extend(rest);
|
||||
views
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LocalRouteView {
|
||||
pub prefix: String,
|
||||
pub owner: String,
|
||||
pub origin: String,
|
||||
pub prepend: u8,
|
||||
pub med: Option<u32>,
|
||||
pub communities: Vec<String>,
|
||||
pub large_communities: Vec<String>,
|
||||
pub announced_at_unix: u64,
|
||||
}
|
||||
|
||||
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,
|
||||
communities: r.attrs.communities.iter().copied().map(community::format_standard).collect(),
|
||||
large_communities: r.attrs.large_communities.iter().map(community::format_large).collect(),
|
||||
announced_at_unix: unix_secs(r.announced_at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-oriented digest of a path attribute list.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct AttrSummary {
|
||||
pub origin: Option<String>,
|
||||
/// AS_PATH flattened in order; AS_SETs are included in place.
|
||||
pub as_path: Vec<u32>,
|
||||
pub next_hop: Option<String>,
|
||||
pub med: Option<u32>,
|
||||
pub local_pref: Option<u32>,
|
||||
pub communities: Vec<String>,
|
||||
pub large_communities: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn summarize(attrs: &[PathAttribute]) -> AttrSummary {
|
||||
let mut s = AttrSummary::default();
|
||||
for attr in attrs {
|
||||
match attr {
|
||||
PathAttribute::Origin(o) => s.origin = Some(origin_name(*o).to_string()),
|
||||
PathAttribute::AsPath(p) => {
|
||||
s.as_path = p.segments.iter().flat_map(|seg| seg.path.iter().copied()).collect()
|
||||
}
|
||||
PathAttribute::NextHop(nh) => s.next_hop = Some(nh.0.to_string()),
|
||||
PathAttribute::MpReachNlri(mp) => s.next_hop = Some(format!("{:?}", mp.next_hop)),
|
||||
PathAttribute::MultiExitDisc(m) => s.med = Some(m.0),
|
||||
PathAttribute::LocalPref(l) => s.local_pref = Some(l.0),
|
||||
PathAttribute::Communities(c) => {
|
||||
s.communities = c.0.iter().copied().map(community::format_standard).collect()
|
||||
}
|
||||
PathAttribute::LargeCommunities(l) => {
|
||||
s.large_communities = l.communities.iter().map(community::format_large).collect()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReceivedRouteView {
|
||||
pub peer_addr: String,
|
||||
pub peer_asn: u32,
|
||||
pub prefix: String,
|
||||
pub received_at_unix: u64,
|
||||
pub summary: AttrSummary,
|
||||
/// The full decoded attribute list, for the curious.
|
||||
pub attributes: Vec<PathAttribute>,
|
||||
}
|
||||
|
||||
impl From<&SnapshotEntry> for ReceivedRouteView {
|
||||
fn from(e: &SnapshotEntry) -> Self {
|
||||
Self {
|
||||
peer_addr: e.peer.addr.to_string(),
|
||||
peer_asn: e.peer.asn,
|
||||
prefix: e.prefix.to_string(),
|
||||
received_at_unix: unix_secs(e.entry.received_at),
|
||||
summary: summarize(&e.entry.path_attributes),
|
||||
attributes: e.entry.path_attributes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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",
|
||||
OriginPathAttribute::EGP => "EGP",
|
||||
OriginPathAttribute::INCOMPLETE => "INCOMPLETE",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_origin(s: &str) -> Result<OriginPathAttribute, ApiError> {
|
||||
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, ApiError> {
|
||||
IpPrefix::try_from(s.trim()).map_err(|e| ApiError::bad_request(format!("invalid prefix {s:?}: {e}")))
|
||||
}
|
||||
|
||||
fn validate_owner(owner: &str) -> Result<String, ApiError> {
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub prepend: u8,
|
||||
#[serde(default)]
|
||||
pub med: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub communities: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub large_communities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WithdrawRequest {
|
||||
pub prefix: String,
|
||||
/// Omit to withdraw regardless of owner.
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WireQuery {
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
|
||||
Json(StatusView {
|
||||
router_id: state.info.router_id.to_string(),
|
||||
local_asn: state.info.local_asn,
|
||||
version: state.info.version.clone(),
|
||||
hostname: state.info.hostname.clone(),
|
||||
started_at_unix: unix_secs(state.info.started_at),
|
||||
uptime_secs: state.info.started.elapsed().as_secs(),
|
||||
sessions: state.registry.list().len(),
|
||||
local_routes: state.local_rib.snapshot().await.len(),
|
||||
received_routes: state.adj_rib_in.snapshot().await.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The configuration file the daemon was started with.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConfigView {
|
||||
pub path: Option<String>,
|
||||
/// Raw file contents as read at startup; later edits to the file are not
|
||||
/// reflected until the daemon restarts.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn config(State(state): State<AppState>) -> Json<ConfigView> {
|
||||
let (path, text) = state.info.config.clone().unzip();
|
||||
Json(ConfigView { path, text })
|
||||
}
|
||||
|
||||
pub async fn peers(State(state): State<AppState>) -> Json<Vec<PeerView>> {
|
||||
Json(peer_views(&state.registry))
|
||||
}
|
||||
|
||||
pub async fn local_routes(State(state): State<AppState>) -> Json<Vec<LocalRouteView>> {
|
||||
let mut routes: Vec<LocalRouteView> =
|
||||
state.local_rib.snapshot().await.iter().map(LocalRouteView::from).collect();
|
||||
routes.sort_by(|a, b| a.prefix.cmp(&b.prefix));
|
||||
Json(routes)
|
||||
}
|
||||
|
||||
pub async fn announce(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AnnounceRequest>,
|
||||
) -> Result<Json<LocalRouteView>, 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::<Result<_, _>>()
|
||||
.map_err(ApiError::bad_request)?,
|
||||
large_communities: req
|
||||
.large_communities
|
||||
.iter()
|
||||
.map(|c| community::parse_large(c))
|
||||
.collect::<Result<_, _>>()
|
||||
.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<AppState>,
|
||||
Json(req): Json<WithdrawRequest>,
|
||||
) -> Result<Json<Option<LocalRouteView>>, 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<AppState>,
|
||||
Query(q): Query<WireQuery>,
|
||||
) -> Result<Json<Vec<WireView>>, 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<Vec<WellKnownCommunity>> {
|
||||
Json(
|
||||
community::WELL_KNOWN
|
||||
.iter()
|
||||
.map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<ReceivedRouteView>> {
|
||||
let mut routes: Vec<ReceivedRouteView> =
|
||||
state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect();
|
||||
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
|
||||
Json(routes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flows returned by `/api/pings` unless `?limit=` says otherwise.
|
||||
const DEFAULT_PING_FLOWS: usize = 100;
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingView {
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
/// The local route the destination belongs to and who announced it;
|
||||
/// `null` when nobody announces it at the moment.
|
||||
pub prefix: Option<String>,
|
||||
pub owner: Option<String>,
|
||||
/// Interface the most recent packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the most recent packet arrived.
|
||||
pub hop_limit: Option<u8>,
|
||||
/// Routers the packet crossed, assuming the sender started at the usual
|
||||
/// 64, 128 or 255.
|
||||
pub hops: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the most recent ICMPv6 message.
|
||||
pub bytes: usize,
|
||||
/// That message as lowercase hex, from the ICMPv6 type byte on (no IPv6
|
||||
/// header). Long packets are cut off, so this can be shorter than `bytes`.
|
||||
pub hex: String,
|
||||
pub count: u64,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
fn estimated_hops(hop_limit: u8) -> u8 {
|
||||
let initial = [64u8, 128, 255].into_iter().find(|&start| start >= hop_limit).unwrap_or(255);
|
||||
initial - hop_limit
|
||||
}
|
||||
|
||||
impl From<&PingFlow> for PingView {
|
||||
fn from(flow: &PingFlow) -> Self {
|
||||
Self {
|
||||
src: flow.last.src.to_string(),
|
||||
dst: flow.last.dst.to_string(),
|
||||
prefix: flow.route.as_ref().map(|r| r.prefix.to_string()),
|
||||
owner: flow.route.as_ref().map(|r| r.owner.clone()),
|
||||
interface: flow.last.interface.clone(),
|
||||
hop_limit: flow.last.hop_limit,
|
||||
hops: flow.last.hop_limit.map(estimated_hops),
|
||||
ident: flow.last.ident,
|
||||
seq: flow.last.seq,
|
||||
bytes: flow.last.bytes,
|
||||
hex: flow.last.packet.iter().map(|b| format!("{b:02x}")).collect(),
|
||||
count: flow.count,
|
||||
first_seen_unix: unix_secs(flow.first_seen),
|
||||
last_seen_unix: unix_secs(flow.last_seen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingMonitorView {
|
||||
/// `listening`, `disabled` (not configured) or `failed`.
|
||||
pub status: &'static str,
|
||||
/// Why the monitor failed to start.
|
||||
pub detail: Option<String>,
|
||||
/// Pings to destinations inside these prefixes are shown.
|
||||
pub prefixes: Vec<String>,
|
||||
/// Flows remembered; `flows` holds the most recently seen of them.
|
||||
pub total_flows: usize,
|
||||
pub flows: Vec<PingView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PingQuery {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn pings(State(state): State<AppState>, Query(query): Query<PingQuery>) -> Json<PingMonitorView> {
|
||||
let (status, detail) = match state.pings.status() {
|
||||
PingMonitorStatus::Listening => ("listening", None),
|
||||
PingMonitorStatus::Disabled => ("disabled", None),
|
||||
PingMonitorStatus::Failed(why) => ("failed", Some(why.clone())),
|
||||
};
|
||||
let flows = state.pings.flows().await;
|
||||
Json(PingMonitorView {
|
||||
status,
|
||||
detail,
|
||||
prefixes: state.pings.prefixes().iter().map(ToString::to_string).collect(),
|
||||
total_flows: flows.len(),
|
||||
flows: flows.iter().take(query.limit.unwrap_or(DEFAULT_PING_FLOWS)).map(PingView::from).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ping_tests {
|
||||
use super::estimated_hops;
|
||||
|
||||
#[test]
|
||||
fn hops_are_counted_from_the_nearest_usual_starting_value() {
|
||||
assert_eq!(estimated_hops(64), 0);
|
||||
assert_eq!(estimated_hops(57), 7);
|
||||
assert_eq!(estimated_hops(118), 10);
|
||||
assert_eq!(estimated_hops(243), 12);
|
||||
assert_eq!(estimated_hops(255), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Parsing and formatting of community values as they appear in the API.
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
/// Well-known standard communities, by name.
|
||||
pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[
|
||||
("GRACEFUL_SHUTDOWN", (0xFFFF, 0x0000)),
|
||||
("BLACKHOLE", (0xFFFF, 0x029A)),
|
||||
("NO_EXPORT", (0xFFFF, 0xFF01)),
|
||||
("NO_ADVERTISE", (0xFFFF, 0xFF02)),
|
||||
("NO_EXPORT_SUBCONFED", (0xFFFF, 0xFF03)),
|
||||
("NOPEER", (0xFFFF, 0xFF04)),
|
||||
];
|
||||
|
||||
pub fn parse_standard(s: &str) -> Result<(u16, u16), String> {
|
||||
let s = s.trim();
|
||||
if let Some((_, value)) = WELL_KNOWN.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) {
|
||||
return Ok(*value);
|
||||
}
|
||||
let (high, low) = s
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("community {s:?} must be ASN:VALUE or a well-known name"))?;
|
||||
let parse = |part: &str| {
|
||||
part.trim()
|
||||
.parse::<u16>()
|
||||
.map_err(|_| format!("community {s:?}: {part:?} is not a 16-bit number"))
|
||||
};
|
||||
Ok((parse(high)?, parse(low)?))
|
||||
}
|
||||
|
||||
pub fn format_standard((high, low): (u16, u16)) -> String {
|
||||
format!("{high}:{low}")
|
||||
}
|
||||
|
||||
pub fn parse_large(s: &str) -> Result<LargeCommunity, String> {
|
||||
let s = s.trim();
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(format!("large community {s:?} must be GLOBAL:LOCAL1:LOCAL2"));
|
||||
}
|
||||
let parse = |part: &str| {
|
||||
part.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("large community {s:?}: {part:?} is not a 32-bit number"))
|
||||
};
|
||||
Ok(LargeCommunity { global_admin: parse(parts[0])?, data_1: parse(parts[1])?, data_2: parse(parts[2])? })
|
||||
}
|
||||
|
||||
pub fn format_large(c: &LargeCommunity) -> String {
|
||||
format!("{}:{}:{}", c.global_admin, c.data_1, c.data_2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn standard_roundtrip_and_names() {
|
||||
assert_eq!(parse_standard("65000:100").unwrap(), (65000, 100));
|
||||
assert_eq!(parse_standard(" no_export ").unwrap(), (65535, 65281));
|
||||
assert_eq!(parse_standard("BLACKHOLE").unwrap(), (65535, 666));
|
||||
assert_eq!(format_standard((65000, 100)), "65000:100");
|
||||
assert!(parse_standard("65000").is_err());
|
||||
assert!(parse_standard("70000:1").is_err());
|
||||
assert!(parse_standard("bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_roundtrip() {
|
||||
let c = parse_large("65000:1:2").unwrap();
|
||||
assert_eq!(c, LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 });
|
||||
assert_eq!(format_large(&c), "65000:1:2");
|
||||
assert!(parse_large("65000:1").is_err());
|
||||
assert!(parse_large("a:b:c").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Server-sent event stream merging session, local-route, received-route and
|
||||
//! ping events into one feed the UI can follow.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use bgp_rib::{LocalRibEvent, RibEvent};
|
||||
use bgp_server::ping::PingEvent;
|
||||
use bgp_server::session::SessionEvent;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::Serialize;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum WireEvent {
|
||||
SessionUp { peer: PeerView },
|
||||
SessionDown { peer_addr: String, peer_asn: u32 },
|
||||
/// The peer asked us to send all our routes again (ROUTE-REFRESH).
|
||||
RouteRefreshRequested { peer: PeerView },
|
||||
LocalAnnounced { route: LocalRouteView },
|
||||
LocalWithdrawn { prefix: String },
|
||||
ReceivedAnnounced { route: ReceivedRouteView },
|
||||
ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String },
|
||||
/// Somebody is pinging an address in the watched prefixes. `new_burst`
|
||||
/// marks the start of a run of pings; the events that follow while it
|
||||
/// continues only update the count.
|
||||
PingSeen { ping: PingView, new_burst: bool },
|
||||
/// The client fell behind and missed `missed` events on `stream`; it
|
||||
/// should refetch the corresponding table.
|
||||
Lagged { stream: &'static str, missed: u64 },
|
||||
}
|
||||
|
||||
impl WireEvent {
|
||||
fn from_session(ev: Result<SessionEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(SessionEvent::Up(h)) => Self::SessionUp { peer: PeerView::from(&h) },
|
||||
Ok(SessionEvent::Down(p)) => Self::SessionDown { peer_addr: p.addr.to_string(), peer_asn: p.asn },
|
||||
Ok(SessionEvent::RefreshRequested(h)) => Self::RouteRefreshRequested { peer: PeerView::from(&h) },
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "sessions", missed: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn from_local(ev: Result<LocalRibEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(LocalRibEvent::Announced(r)) => Self::LocalAnnounced { route: LocalRouteView::from(&r) },
|
||||
Ok(LocalRibEvent::Withdrawn { prefix }) => Self::LocalWithdrawn { prefix: prefix.to_string() },
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "local_routes", missed: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn from_ping(ev: Result<PingEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(ev) => Self::PingSeen { ping: PingView::from(&ev.flow), new_burst: ev.new_burst },
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "pings", missed: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
|
||||
route: ReceivedRouteView {
|
||||
peer_addr: peer.addr.to_string(),
|
||||
peer_asn: peer.asn,
|
||||
prefix: prefix.to_string(),
|
||||
received_at_unix: entry
|
||||
.received_at
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
summary: summarize(&entry.path_attributes),
|
||||
attributes: entry.path_attributes,
|
||||
},
|
||||
},
|
||||
Ok(RibEvent::Withdrawn { peer, prefix }) => Self::ReceivedWithdrawn {
|
||||
peer_addr: peer.addr.to_string(),
|
||||
peer_asn: peer.asn,
|
||||
prefix: prefix.to_string(),
|
||||
},
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "received_routes", missed: n },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stream(
|
||||
State(state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||
let sessions = BroadcastStream::new(state.registry.subscribe()).map(WireEvent::from_session);
|
||||
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local);
|
||||
let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
|
||||
|
||||
let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping);
|
||||
|
||||
let routes = futures::stream::select(local, received);
|
||||
let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| {
|
||||
Ok(Event::default()
|
||||
.event("bgp")
|
||||
.json_data(&ev)
|
||||
.unwrap_or_else(|e| Event::default().event("error").data(e.to_string())))
|
||||
});
|
||||
|
||||
Sse::new(merged).keep_alive(KeepAlive::default())
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! HTTP surface of the daemon: a JSON API for originating routes and
|
||||
//! inspecting state, a server-sent event stream, and Prometheus metrics.
|
||||
//!
|
||||
//! Everything here is a thin layer over handles owned by the BGP server
|
||||
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives
|
||||
//! in this crate.
|
||||
|
||||
mod api;
|
||||
mod community;
|
||||
mod events;
|
||||
mod metrics;
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
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 bgp_rib::{AdjRibIn, LocalRib};
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use eyre::{Result, WrapErr};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
pub use api::{
|
||||
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView,
|
||||
ReceivedRouteView, StatusView,
|
||||
WellKnownCommunity, WireView, WithdrawRequest,
|
||||
};
|
||||
|
||||
/// Static facts about the router shown in status output.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouterInfo {
|
||||
pub router_id: Ipv4Addr,
|
||||
pub local_asn: u32,
|
||||
pub version: String,
|
||||
pub started: Instant,
|
||||
/// Wall-clock start time, for display.
|
||||
pub started_at: SystemTime,
|
||||
/// Name of the machine we run on, for display.
|
||||
pub hostname: String,
|
||||
/// The configuration file as read at startup (path and raw text), shown
|
||||
/// on the status page. `None` when the server was configured in code.
|
||||
pub config: Option<(String, String)>,
|
||||
}
|
||||
|
||||
impl RouterInfo {
|
||||
pub fn new(router_id: Ipv4Addr, local_asn: u32, version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
router_id,
|
||||
local_asn,
|
||||
version: version.into(),
|
||||
started: Instant::now(),
|
||||
started_at: SystemTime::now(),
|
||||
hostname: hostname(),
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the configuration file so the status page can show it.
|
||||
pub fn with_config(mut self, path: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
self.config = Some((path.into(), text.into()));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort host name without pulling in a dependency: ask the `hostname`
|
||||
/// utility, which exists on every platform we run on.
|
||||
fn hostname() -> String {
|
||||
std::process::Command::new("hostname")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "unknown host".into())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub info: Arc<RouterInfo>,
|
||||
pub registry: SessionRegistry,
|
||||
pub local_rib: LocalRib,
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
pub pings: PingMonitor,
|
||||
}
|
||||
|
||||
/// The status page, embedded so the binary is self-contained.
|
||||
const STATUSZ_HTML: &str = include_str!("../static/statusz.html");
|
||||
|
||||
async fn statusz() -> impl IntoResponse {
|
||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], Html(STATUSZ_HTML))
|
||||
}
|
||||
|
||||
/// Builds the router. Exposed so tests can drive it without a socket.
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(|| async { Redirect::temporary("/statusz") }))
|
||||
.route("/statusz", get(statusz))
|
||||
.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/received", get(api::received_routes))
|
||||
.route("/api/pings", get(api::pings))
|
||||
.route("/api/communities/well-known", get(api::well_known_communities))
|
||||
.route("/api/events", get(events::stream))
|
||||
.route("/metricz", get(metrics::metricz))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Binds `listen` and serves the API until the task is dropped.
|
||||
pub async fn serve(state: AppState, listen: SocketAddr) -> Result<()> {
|
||||
let listener = TcpListener::bind(listen)
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to bind HTTP listener on {listen}"))?;
|
||||
serve_on(state, listener).await
|
||||
}
|
||||
|
||||
/// Serves the API on an already-bound listener (lets tests use port 0).
|
||||
pub async fn serve_on(state: AppState, listener: TcpListener) -> Result<()> {
|
||||
info!(addr = %listener.local_addr()?, "HTTP API listening");
|
||||
axum::serve(listener, router(state)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Prometheus scrape endpoint. Counters are updated by the server crate as
|
||||
//! messages flow; table-size gauges are refreshed at scrape time.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
use bgp_server::metrics::{ADJ_RIB_IN_ROUTES, LOCAL_ROUTES};
|
||||
use prometheus::{Encoder, TextEncoder};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub async fn metricz(State(state): State<AppState>) -> impl IntoResponse {
|
||||
LOCAL_ROUTES.set(state.local_rib.snapshot().await.len() as i64);
|
||||
ADJ_RIB_IN_ROUTES.set(state.adj_rib_in.snapshot().await.len() as i64);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
match TextEncoder::new().encode(&prometheus::gather(), &mut buf) {
|
||||
Ok(()) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; version=0.0.4")], buf).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Status for ferrix_bgp</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
|
||||
<!--
|
||||
Styled after the classic Google-internal /statusz pages as mirrored by
|
||||
Vitess (go/vt/servenv/status.go) and golang.org/x/net/trace: plain
|
||||
sans-serif HTML, default links and form controls, dense bordered tables,
|
||||
and one full-width light blue (#eef) heading bar per section. Deliberately
|
||||
no dark theme, no framework, no external resources.
|
||||
-->
|
||||
<style>
|
||||
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
|
||||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
|
||||
h1.top { font-size: 150%; margin-top: 0; }
|
||||
.lefthand { float: left; width: 70%; }
|
||||
.righthand { text-align: right; }
|
||||
a { color: #00c; } a:visited { color: #551a8b; }
|
||||
table.data { border-collapse: collapse; margin: 4px 0; }
|
||||
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
|
||||
table.data th { background: #dde; font-weight: bold; }
|
||||
.mono, table.data td.mono { font-family: monospace; }
|
||||
table.data tr.loop td { background: #ffc; }
|
||||
table.data tr.mine td.owner { font-weight: bold; }
|
||||
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
|
||||
.dim { color: #666; }
|
||||
table.form td { padding: 2px 6px 2px 0; }
|
||||
table.form td:first-child { width: 11em; }
|
||||
select { max-width: 14em; }
|
||||
pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px 0; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
|
||||
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
|
||||
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
|
||||
#event-log div { white-space: pre-wrap; }
|
||||
pre.bytes span[data-kind] { cursor: help; }
|
||||
pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px solid #00c; }
|
||||
#tip { position: absolute; z-index: 10; max-width: 46em; background: #fff; border: 1px solid #99a; font-size: 12px; }
|
||||
#tip .tip-title { background: #eef; font-weight: bold; text-align: center; padding: 1px 6px; border-bottom: 1px solid #99a; }
|
||||
#tip table { border-collapse: collapse; }
|
||||
#tip td { padding: 1px 6px; vertical-align: top; }
|
||||
#tip td:first-child { color: #666; white-space: nowrap; }
|
||||
#tip td.mono { word-break: break-word; }
|
||||
details { clear: both; margin: 8px 0 0; }
|
||||
summary { cursor: pointer; color: #00c; }
|
||||
summary:hover { text-decoration: underline; }
|
||||
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||||
address { font-size: smaller; margin-top: 2em; color: #444; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="top">Status for ferrix_bgp</h1>
|
||||
|
||||
<div>
|
||||
<div class="lefthand">
|
||||
Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
|
||||
Version <span id="s-version">...</span><br>
|
||||
Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
|
||||
</div>
|
||||
<div class="righthand">
|
||||
Running on <span id="s-hostname">...</span><br>
|
||||
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/pings">pings json</a><br>
|
||||
<span id="refresh-ctl"></span><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="config-zippy" hidden>
|
||||
<summary>Configuration <span class="dim" id="config-path"></span></summary>
|
||||
<pre id="config-text"></pre>
|
||||
</details>
|
||||
|
||||
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
|
||||
<table class="data">
|
||||
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>detail</th></tr></thead>
|
||||
<tbody id="peers-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h1 id="announce">Announce a prefix</h1>
|
||||
<form id="announce-form" autocomplete="off">
|
||||
<table class="form">
|
||||
<tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
|
||||
<td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
|
||||
<tr><td><label for="f-origin">Origin:</label></td><td><select id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></td>
|
||||
<td><label for="f-med">MED:</label></td><td><input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
|
||||
</table>
|
||||
<table class="form">
|
||||
<tr><td><label for="f-communities">Communities:</label></td><td><input id="f-communities" size="50" placeholder="65000:100, NO_EXPORT">
|
||||
<select id="f-wk" aria-label="Add a well-known community"><option value="">+ well-known...</option></select>
|
||||
<span class="dim">comma separated, ASN:VALUE or a name</span></td></tr>
|
||||
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="210036:0:0, 210036:1:42476">
|
||||
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ presets...</option></select>
|
||||
<span class="dim">GLOBAL:LOCAL1:LOCAL2</span></td></tr>
|
||||
<tr><td></td><td><input type="submit" value="Announce"> <input type="button" id="f-clear" value="Clear">
|
||||
<span id="announce-msg"></span></td></tr>
|
||||
</table>
|
||||
</form>
|
||||
|
||||
<h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
|
||||
<table class="data">
|
||||
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th>actions</th></tr></thead>
|
||||
<tbody id="local-body"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pings-section" hidden>
|
||||
<h1 id="pings">Pings to announced addresses (<span id="pings-count">...</span>)</h1>
|
||||
<div class="dim" id="pings-note"></div>
|
||||
<table class="data">
|
||||
<thead><tr><th>destination</th><th>route</th><th>owner</th><th>from</th><th>arrived on</th><th>hop limit</th><th>pings</th><th>last sequence</th><th>first seen</th><th>last seen</th><th>actions</th></tr></thead>
|
||||
<tbody id="pings-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
|
||||
<table class="data">
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
|
||||
<tbody id="received-body"></tbody>
|
||||
</table>
|
||||
|
||||
<h1 id="events">Event log</h1>
|
||||
<div id="log-state" class="dim"></div>
|
||||
<div id="event-log"></div>
|
||||
<div id="tip" hidden></div>
|
||||
|
||||
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
// Large-community presets offered next to the field, grouped by who acts
|
||||
// on them. "PEER" is replaced by an AS number the user is asked for.
|
||||
// AS210036: docs/lab/prod/bird-ferrix.conf (lab_export_allowed). Every
|
||||
// session there has a neighbour AS and a location; "never"
|
||||
// (functions 0 and 2) beats "announce" (1 and 3) beats 0:0.
|
||||
// SwissIX: https://www.swissix.ch/resources/action-bgp-communities/
|
||||
const LOCATIONS = { 1: 'SwissIX', 2: 'NL-ix', 3: 'France-IX', 4: 'DE-CIX Frankfurt', 5: 'Free-IX Switzerland', 6: 'Community-IX.ch', 100: 'transit', 200: 'downstream customers' };
|
||||
const LARGE_PRESET_GROUPS = [
|
||||
['AS210036', 'AS210036: by neighbour', [
|
||||
['210036:0:0', 'announce nowhere (then add where to)'],
|
||||
['210036:1:42476', 'announce to the SwissIX route servers'],
|
||||
['210036:1:PEER', 'announce to neighbour AS'],
|
||||
['210036:0:PEER', 'never announce to neighbour AS'],
|
||||
]],
|
||||
['AS210036', 'AS210036: by location', Object.entries(LOCATIONS).filter(([id]) => id < 100).map(([id, name]) => [`210036:3:${id}`, `announce at ${name}`])
|
||||
.concat([['210036:2:100', 'never announce to transit']])],
|
||||
['SwissIX', 'SwissIX route servers (AS42476)', [
|
||||
['42476:0:0', 'send to nobody'],
|
||||
['42476:1:559', 'send to SWITCH (AS559)'],
|
||||
['42476:1:PEER', 'send to peer AS'],
|
||||
['42476:0:PEER', 'do not send to peer AS'],
|
||||
['42476:1:0', 'send to all (default)'],
|
||||
['42476:901:PEER', 'add NO_EXPORT towards peer AS'],
|
||||
['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'],
|
||||
]],
|
||||
];
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const state = { status: null, peers: [], local: [], received: [], pings: null, wellKnown: {}, openWire: new Set(), openPing: new Set(), wire: new Map() };
|
||||
const DASH = '<span class="dim">-</span>';
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
const fmtDur = s => {
|
||||
s = Math.max(0, Math.floor(s));
|
||||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`;
|
||||
};
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
// Tables are only rewritten when their markup actually changes, so a
|
||||
// refresh with unchanged data never touches the DOM (no flicker, and open
|
||||
// rows, selections and hover state survive). Anything that changes with
|
||||
// the clock is a placeholder that tick() fills in as plain text.
|
||||
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
|
||||
const age = unix => `<span data-since="${unix}"></span>`;
|
||||
const until = unix => `<span data-until="${unix}"></span>`;
|
||||
function tick() {
|
||||
const t = now();
|
||||
document.querySelectorAll('[data-since]').forEach(e => { const v = fmtDur(t - e.dataset.since); if (e.textContent !== v) e.textContent = v; });
|
||||
document.querySelectorAll('[data-until]').forEach(e => { const v = fmtDur(e.dataset.until - t); if (e.textContent !== v) e.textContent = v; });
|
||||
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
|
||||
}
|
||||
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
|
||||
const commList = (list, wk) => list.length ? list.map(c => {
|
||||
const name = wk && state.wellKnown[c];
|
||||
return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
|
||||
}).join(' ') : DASH;
|
||||
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
|
||||
const myOwner = () => $('f-owner').value.trim();
|
||||
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${url}: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ---- renderers ---------------------------------------------------------
|
||||
function renderStatus() {
|
||||
const s = state.status; if (!s) return;
|
||||
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
|
||||
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
|
||||
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
|
||||
tick();
|
||||
}
|
||||
|
||||
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' };
|
||||
function renderPeers() {
|
||||
const b = $('peers-body');
|
||||
$('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
|
||||
if (!state.peers.length) { setHTML(b, '<tr><td colspan="10" class="dim">No peers configured and none connected.</td></tr>'); return; }
|
||||
setHTML(b, state.peers.map(p => {
|
||||
const up = p.state === 'Established';
|
||||
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
||||
return `<tr>
|
||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
|
||||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
||||
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
|
||||
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td><td>${detail}</td></tr>`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
function renderLocal() {
|
||||
const b = $('local-body'); $('local-count').textContent = state.local.length;
|
||||
if (!state.local.length) { setHTML(b, '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'); return; }
|
||||
const me = myOwner(), asn = state.status ? state.status.local_asn : '?', stale = [];
|
||||
setHTML(b, state.local.map(r => {
|
||||
const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix);
|
||||
const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>`
|
||||
: '<span class="dim" title="only the owner can withdraw">withdraw</span>';
|
||||
return `<tr class="${mine ? 'mine' : ''}">
|
||||
<td class="mono">${p}</td><td class="owner">${esc(r.owner)}</td><td>${esc(r.origin)}</td>
|
||||
<td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
|
||||
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td><td>${age(r.announced_at_unix)}</td>
|
||||
<td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
|
||||
(open ? `<tr><td colspan="9">${wireHTML(r, stale)}</td></tr>` : '');
|
||||
}).join(''));
|
||||
stale.forEach(loadWire);
|
||||
}
|
||||
|
||||
function renderReceived() {
|
||||
const b = $('received-body'); $('received-count').textContent = state.received.length;
|
||||
if (!state.received.length) { setHTML(b, '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'); return; }
|
||||
const localPrefixes = new Set(state.local.map(r => r.prefix));
|
||||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
|
||||
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</span></td><td class="mono">${asPath(s.as_path)}</td>
|
||||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td>
|
||||
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''));
|
||||
}
|
||||
|
||||
// Pings are shown per source and destination pair. The router answers
|
||||
// them itself; this table is a copy of what arrived.
|
||||
function renderPings() {
|
||||
const p = state.pings; if (!p || p.status === 'disabled') { $('pings-section').hidden = true; return; }
|
||||
$('pings-section').hidden = false;
|
||||
if (p.status === 'failed') {
|
||||
$('pings-count').textContent = 'off';
|
||||
$('pings-note').innerHTML = `<span class="bad">The ping monitor could not start:</span> ${esc(p.detail || 'unknown error')}`;
|
||||
setHTML($('pings-body'), ''); return;
|
||||
}
|
||||
const more = p.total_flows > p.flows.length ? `, showing the ${p.flows.length} most recent` : '';
|
||||
$('pings-count').textContent = `${p.total_flows} flow${p.total_flows === 1 ? '' : 's'}${more}`;
|
||||
$('pings-note').innerHTML = '';
|
||||
if (!p.flows.length) { setHTML($('pings-body'), '<tr><td colspan="11" class="dim">No pings seen yet.</td></tr>'); return; }
|
||||
const me = myOwner();
|
||||
setHTML($('pings-body'), p.flows.map(f => { const key = `${f.src}|${f.dst}`, open = state.openPing.has(key); return `<tr class="${f.owner && f.owner === me ? 'mine' : ''}">
|
||||
<td class="mono">${esc(f.dst)}</td><td class="mono">${f.prefix ? esc(f.prefix) : '<span class="dim">not announced</span>'}</td><td class="owner">${f.owner ? esc(f.owner) : DASH}</td>
|
||||
<td class="mono">${esc(f.src)}</td><td class="mono">${f.interface ? esc(f.interface) : DASH}</td>
|
||||
<td>${f.hop_limit == null ? DASH : `${f.hop_limit} <span class="dim">(${f.hops} hop${f.hops === 1 ? '' : 's'} away)</span>`}</td>
|
||||
<td>${f.count}</td><td>${f.seq}</td><td>${fmtTime(f.first_seen_unix)}</td><td>${age(f.last_seen_unix)} ago</td>
|
||||
<td><a href="#" data-act="ping-bytes" data-key="${esc(key)}">${open ? 'hide bytes' : 'bytes'}</a></td></tr>` +
|
||||
(open ? `<tr><td colspan="11">${pingBytesHTML(f)}</td></tr>` : ''); }).join(''));
|
||||
}
|
||||
// The last echo request of a flow, split into the fields of RFC 4443. It
|
||||
// starts at the ICMPv6 header: the socket we listen on is never given the
|
||||
// IPv6 header, whose interesting fields are the columns of the table.
|
||||
function pingBytesHTML(f) {
|
||||
const bytes = f.hex.match(/../g) || [], segs = [];
|
||||
const take = (n, cls, kind) => { if (bytes.length) segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(1, 'hh', 'icmp-type'); take(1, 'hh', 'icmp-code'); take(2, 'hl', 'icmp-checksum');
|
||||
take(2, 'ha', 'icmp-ident'); take(2, 'ha2', 'icmp-seq'); take(bytes.length, 'hn', 'icmp-payload');
|
||||
const kept = f.hex.length / 2;
|
||||
return `Last ICMPv6 message from <b>${esc(f.src)}</b>, ${f.bytes} bytes${kept < f.bytes ? ` (first ${kept} shown)` : ''}, IPv6 header not included
|
||||
<span class="hh"> type, code </span> <span class="hl"> checksum </span> <span class="ha"> identifier, sequence </span> <span class="hn"> payload </span>
|
||||
<span class="dim">(hover a segment to decode it)</span>
|
||||
<pre class="bytes">${segs.map(x => `<span class="${x.cls}" tabindex="0" data-kind="${x.kind}" data-hex="${x.hex}">${x.hex}</span>`).join(' ')}</pre>`;
|
||||
}
|
||||
function decodePing(kind, b) {
|
||||
switch (kind) {
|
||||
case 'icmp-type': return { title: 'ICMPv6: type', rows: [['value', `${b[0]} = ${{128: 'Echo Request', 129: 'Echo Reply'}[b[0]] || 'other'}`], ['meaning', 'The router answers with type 129, Echo Reply, carrying the same identifier, sequence number and payload.']] };
|
||||
case 'icmp-code': return { title: 'ICMPv6: code', rows: [['value', `${b[0]}`], ['meaning', 'Always 0 for echo messages.']] };
|
||||
case 'icmp-checksum': return { title: 'ICMPv6: checksum', rows: [['value', `0x${u(b, 0, 2).toString(16).padStart(4, '0')}`], ['covers', 'This message and a pseudo-header holding the IPv6 source and destination addresses, so it differs between two otherwise equal pings to different addresses.']] };
|
||||
case 'icmp-ident': return { title: 'Echo: identifier', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Chosen by the sender so that several ping programs on one host can tell their replies apart.']] };
|
||||
case 'icmp-seq': return { title: 'Echo: sequence number', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Counts up with every request; a gap in the replies is a lost packet.']] };
|
||||
default: {
|
||||
const rows = [['length', `${b.length} bytes shown`], ['as text', b.map(x => x >= 32 && x < 127 ? String.fromCharCode(x) : '.').join('')]];
|
||||
// ping puts the time of sending first so that the reply carries it
|
||||
// back. Only claim that when the value is in fact close to now.
|
||||
const near = secs => Math.abs(secs - now()) < 86400;
|
||||
const le = b.length >= 16 && !b[4] && !b[5] && !b[6] && !b[7] ? b[0] + b[1] * 256 + b[2] * 65536 + b[3] * 16777216 : null;
|
||||
const be = b.length >= 8 ? u(b, 0, 4) : null;
|
||||
if (le != null && near(le)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(le * 1000).toLocaleTimeString()} (a 16-byte timeval, as Linux ping writes it)`]);
|
||||
else if (be != null && near(be)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(be * 1000).toLocaleTimeString()} (an 8-byte timestamp, as BSD and macOS ping6 write it)`]);
|
||||
rows.push(['meaning', 'Anything the sender likes; the reply must return it unchanged.']);
|
||||
return { title: 'Echo: payload', rows };
|
||||
}
|
||||
}
|
||||
}
|
||||
// A ping event carries the whole row, so the table follows the stream
|
||||
// without asking the server again.
|
||||
function pingSeen(f) {
|
||||
const p = state.pings; if (!p || p.status !== 'listening') return;
|
||||
const at = p.flows.findIndex(x => x.src === f.src && x.dst === f.dst);
|
||||
if (at >= 0) p.flows.splice(at, 1); else p.total_flows++;
|
||||
p.flows.unshift(f); p.flows.length = Math.min(p.flows.length, 100);
|
||||
renderPings();
|
||||
}
|
||||
|
||||
// ---- wire view: split the UPDATE bytes into labelled fields ------------
|
||||
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
|
||||
function segmentUpdate(hex) {
|
||||
const bytes = hex.match(/../g) || [], segs = [];
|
||||
const take = (n, cls, kind) => { segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type');
|
||||
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', 'wlen'); if (wlen) take(wlen, 'hn', 'withdrawn');
|
||||
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', 'alen');
|
||||
let rem = alen, i = 0;
|
||||
while (rem > 0) {
|
||||
const ext = parseInt(bytes[0], 16) & 0x10;
|
||||
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
|
||||
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', 'attr'); rem -= hdr + len;
|
||||
}
|
||||
if (bytes.length) take(bytes.length, 'hn', 'nlri');
|
||||
return segs;
|
||||
}
|
||||
|
||||
// ---- decoding a hovered segment into something readable ----------------
|
||||
const u = (b, o, n) => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[o + k]; return v; };
|
||||
const fmtV4 = b => b.slice(0, 4).join('.');
|
||||
function fmtV6(b) {
|
||||
const g = []; for (let k = 0; k < 16; k += 2) g.push(((b[k] << 8) | b[k + 1]).toString(16));
|
||||
let best = -1, bestLen = 0;
|
||||
for (let k = 0; k < 8; k++) { let n = 0; while (k + n < 8 && g[k + n] === '0') n++; if (n > bestLen && n > 1) { best = k; bestLen = n; } }
|
||||
if (best < 0) return g.join(':');
|
||||
return g.slice(0, best).join(':') + '::' + g.slice(best + bestLen).join(':');
|
||||
}
|
||||
function prefixList(b, afi) {
|
||||
const out = []; let o = 0;
|
||||
while (o < b.length) {
|
||||
const len = b[o++], n = Math.ceil(len / 8), raw = b.slice(o, o + n); o += n;
|
||||
const full = raw.concat(Array((afi === 2 ? 16 : 4) - raw.length).fill(0));
|
||||
out.push((afi === 2 ? fmtV6(full) : fmtV4(full)) + '/' + len);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function largeLabel(c) {
|
||||
// Our own scheme is decoded from its structure, so any value reads well,
|
||||
// not only the ones in the menu.
|
||||
const own = c.match(/^210036:([0-3]):(\d+)$/);
|
||||
if (own) {
|
||||
const [f, t] = [Number(own[1]), Number(own[2])], where = LOCATIONS[t] || `location ${t}`;
|
||||
if (f === 0 && t === 0) return 'AS210036: announce nowhere, unless another community says where';
|
||||
if (f === 0) return `AS210036: never announce to AS${t}`;
|
||||
if (f === 1) return `AS210036: announce to AS${t}${t === 42476 ? ' (the SwissIX route servers)' : ''}`;
|
||||
return `AS210036: ${f === 2 ? 'never announce' : 'announce'} ${t >= 100 ? 'to' : 'at'} ${where}`;
|
||||
}
|
||||
// Exact presets first, so 42476:1:559 reads "SWITCH" rather than "AS559".
|
||||
for (const pass of [false, true]) for (const [who, , presets] of LARGE_PRESET_GROUPS) for (const [pattern, label] of presets) {
|
||||
if (pattern.includes('PEER') !== pass) continue;
|
||||
const m = c.match(new RegExp('^' + pattern.replace('PEER', '(\\d+)') + '$'));
|
||||
if (m) return `${who}: ${label.replace(/(peer|neighbour) AS/, 'AS' + (m[1] || ''))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const AFI = { 1: 'IPv4', 2: 'IPv6' }, SAFI = { 1: 'unicast', 2: 'multicast' };
|
||||
function decodeAttr(b, w4) {
|
||||
const flags = b[0], type = b[1], ext = flags & 0x10, hdr = ext ? 4 : 3, len = ext ? u(b, 2, 2) : b[2], v = b.slice(hdr);
|
||||
const name = ATTR_NAMES[type] || `attribute ${type}`;
|
||||
const rows = [
|
||||
['flags', `0x${flags.toString(16)}: ${flags & 0x80 ? 'optional' : 'well-known'}, ${flags & 0x40 ? 'transitive' : 'non-transitive'}${flags & 0x20 ? ', partial' : ''}${ext ? ', 2-byte length' : ''}`],
|
||||
['type code', `${type}`], ['value length', `${len} bytes`],
|
||||
];
|
||||
switch (type) {
|
||||
case 1: rows.push(['origin', ['IGP (originated inside the AS)', 'EGP', 'INCOMPLETE (learned some other way)'][v[0]] || `unknown ${v[0]}`]); break;
|
||||
case 2: {
|
||||
const w = w4 ? 4 : 2; let o = 0, hops = 0;
|
||||
while (o + 2 <= v.length) {
|
||||
const st = v[o], n = v[o + 1], asns = []; o += 2;
|
||||
for (let k = 0; k < n; k++, o += w) asns.push(u(v, o, w));
|
||||
rows.push([st === 2 ? 'AS_SEQUENCE' : st === 1 ? 'AS_SET' : `segment ${st}`, asns.join(' ')]);
|
||||
hops += st === 2 ? n : 1;
|
||||
}
|
||||
rows.push(['path length', `${hops} (what best-path selection compares)`], ['AS number width', w4 ? '4 bytes (RFC 6793)' : '2 bytes']);
|
||||
break;
|
||||
}
|
||||
case 3: rows.push(['next hop', fmtV4(v)]); break;
|
||||
case 4: rows.push(['MED', `${u(v, 0, 4)} (lower is preferred, compared between routes from the same neighbour AS)`]); break;
|
||||
case 5: rows.push(['local preference', `${u(v, 0, 4)} (higher is preferred)`]); break;
|
||||
case 8: for (let o = 0; o + 4 <= v.length; o += 4) { const c = `${u(v, o, 2)}:${u(v, o + 2, 2)}`; rows.push(['community', c + (state.wellKnown[c] ? ` = ${state.wellKnown[c]}` : '')]); } break;
|
||||
case 32: for (let o = 0; o + 12 <= v.length; o += 12) { const c = `${u(v, o, 4)}:${u(v, o + 4, 4)}:${u(v, o + 8, 4)}`, l = largeLabel(c); rows.push(['large community', c + (l ? ` = ${l}` : '')]); } break;
|
||||
case 14: {
|
||||
const afi = u(v, 0, 2), nhLen = v[3], nh = v.slice(4, 4 + nhLen);
|
||||
rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]);
|
||||
if (nhLen === 4) rows.push(['next hop', fmtV4(nh)]);
|
||||
else if (nhLen === 16) rows.push(['next hop', fmtV6(nh)]);
|
||||
else if (nhLen === 32) rows.push(['next hop', fmtV6(nh.slice(0, 16))], ['link-local next hop', fmtV6(nh.slice(16))]);
|
||||
prefixList(v.slice(5 + nhLen), afi).forEach(p => rows.push(['announced prefix', p]));
|
||||
break;
|
||||
}
|
||||
case 15: { const afi = u(v, 0, 2); rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]); prefixList(v.slice(3), afi).forEach(p => rows.push(['withdrawn prefix', p])); break; }
|
||||
default: rows.push(['value', v.map(x => x.toString(16).padStart(2, '0')).join(' ')]);
|
||||
}
|
||||
return { title: `Path attribute: ${name}`, rows };
|
||||
}
|
||||
function decodeSegment(kind, b, w4) {
|
||||
switch (kind) {
|
||||
case 'marker': return { title: 'Header: marker', rows: [['value', '16 bytes of 0xff'], ['meaning', 'Fixed filler. It once carried authentication data; now it only helps a receiver find message boundaries.']] };
|
||||
case 'length': return { title: 'Header: message length', rows: [['value', `${u(b, 0, 2)} bytes, including this 19-byte header`], ['limits', '19 to 4096 bytes']] };
|
||||
case 'type': return { title: 'Header: message type', rows: [['value', `${b[0]} = ${['', 'OPEN', 'UPDATE', 'NOTIFICATION', 'KEEPALIVE', 'ROUTE-REFRESH'][b[0]] || 'unknown'}`]] };
|
||||
case 'wlen': return { title: 'Withdrawn routes length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', u(b, 0, 2) ? 'IPv4 prefixes being withdrawn follow.' : 'No IPv4 prefixes are withdrawn by this message.']] };
|
||||
case 'withdrawn': return { title: 'Withdrawn routes', rows: prefixList(b, 1).map(p => ['withdrawn prefix', p]) };
|
||||
case 'alen': return { title: 'Total path attribute length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', 'The path attributes follow; whatever remains after them is IPv4 NLRI.']] };
|
||||
case 'nlri': return { title: 'NLRI: announced IPv4 prefixes', rows: prefixList(b, 1).map(p => ['announced prefix', p]).concat([['encoding', 'one length byte, then only as many address bytes as the length needs']]) };
|
||||
default: return decodeAttr(b, w4);
|
||||
}
|
||||
}
|
||||
function showTip(el) {
|
||||
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
|
||||
const kind = el.dataset.kind, tip = $('tip');
|
||||
const d = kind.startsWith('icmp-') ? decodePing(kind, b) : decodeSegment(kind, b, el.dataset.w4 === '1');
|
||||
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
|
||||
tip.hidden = false;
|
||||
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
|
||||
const left = Math.max(8, Math.min(first.left, window.innerWidth - tip.offsetWidth - 8));
|
||||
tip.style.left = `${left + window.scrollX}px`; tip.style.top = `${last.bottom + window.scrollY + 4}px`;
|
||||
}
|
||||
const hideTip = () => { $('tip').hidden = true; };
|
||||
const segOf = e => e.target.closest && e.target.closest('pre.bytes span[data-kind]');
|
||||
document.addEventListener('mouseover', e => { const el = segOf(e); if (el) showTip(el); });
|
||||
document.addEventListener('mouseout', e => { if (segOf(e)) hideTip(); });
|
||||
document.addEventListener('focusin', e => { const el = segOf(e); if (el) showTip(el); });
|
||||
document.addEventListener('focusout', e => { if (segOf(e)) hideTip(); });
|
||||
|
||||
// The bytes for a route depend only on the route itself and on which
|
||||
// sessions are up, so they are fetched once per such combination and kept.
|
||||
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
|
||||
function wireHTML(route, stale) {
|
||||
const cached = state.wire.get(route.prefix), sig = wireSig(route);
|
||||
if (cached && cached.sig === sig) return cached.html;
|
||||
stale.push(route);
|
||||
// Keep showing the previous bytes while the new ones load.
|
||||
return cached ? cached.html : '<span class="dim">loading...</span>';
|
||||
}
|
||||
async function loadWire(route) {
|
||||
const sig = wireSig(route);
|
||||
const pending = state.wire.get(route.prefix);
|
||||
if (pending && pending.loading === sig) return;
|
||||
state.wire.set(route.prefix, { ...(pending || { html: '<span class="dim">loading...</span>' }), loading: sig });
|
||||
let html;
|
||||
try {
|
||||
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
|
||||
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
|
||||
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)}
|
||||
<span class="hh"> header </span> <span class="hl"> lengths </span> <span class="ha"> path attributes </span> <span class="hn"> NLRI </span>
|
||||
<span class="dim">(hover a segment to decode it)</span>
|
||||
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" tabindex="0" data-kind="${s.kind}" data-w4="${v.four_octet_asn ? 1 : 0}" data-hex="${s.hex}">${s.hex}</span>`).join(' ')}</pre>`).join('');
|
||||
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
|
||||
state.wire.set(route.prefix, { sig, html });
|
||||
renderLocal();
|
||||
}
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
|
||||
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); }
|
||||
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
|
||||
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
|
||||
async function loadPings() { state.pings = await getJSON('/api/pings'); renderPings(); }
|
||||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadPings()]); }
|
||||
|
||||
// ---- events ------------------------------------------------------------
|
||||
function logEvent(html) {
|
||||
const box = $('event-log'), d = document.createElement('div');
|
||||
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
|
||||
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
|
||||
}
|
||||
const names = list => list.map(c => state.wellKnown[c] || c).map(esc).join(' ');
|
||||
function describe(ev) {
|
||||
switch (ev.type) {
|
||||
case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
|
||||
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent all local routes`;
|
||||
case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
|
||||
case 'local_announced': { const r = ev.route; return `announced ${esc(r.prefix)} by ${esc(r.owner)}` +
|
||||
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
|
||||
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
|
||||
case 'local_withdrawn': return `<span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
||||
case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
|
||||
case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
||||
case 'ping_seen': { const f = ev.ping; return `ping to ${esc(f.dst)}${f.owner ? ` (${esc(f.owner)})` : ''} from ${esc(f.src)}` +
|
||||
(f.interface ? ` via ${esc(f.interface)}` : '') + (f.hop_limit != null ? `, hop limit ${f.hop_limit}` : ''); }
|
||||
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
|
||||
default: return esc(JSON.stringify(ev));
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
const st = $('log-state');
|
||||
const es = new EventSource('/api/events');
|
||||
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
|
||||
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
|
||||
// Events only feed the log. How often the tables reload is decided by
|
||||
// ?refresh=, so the page never changes faster than the reader asked for.
|
||||
// Pings are the exception: the reader causes them from a terminal, not
|
||||
// from this page, so their table follows the stream. A steady ping is
|
||||
// logged once, when it starts.
|
||||
es.addEventListener('bgp', e => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.type === 'ping_seen') { pingSeen(ev.ping); if (!ev.new_burst) return; }
|
||||
logEvent(describe(ev));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
|
||||
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
|
||||
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
|
||||
function refreshLink(seconds, text) {
|
||||
const url = new URL(location.href);
|
||||
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
|
||||
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
|
||||
}
|
||||
function renderRefreshControl() {
|
||||
$('refresh-ctl').innerHTML = (REFRESH
|
||||
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
|
||||
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
|
||||
` · <a href="#" id="reload-now">reload now</a>`;
|
||||
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
|
||||
}
|
||||
|
||||
// ---- form --------------------------------------------------------------
|
||||
// The reader's own action is not "auto" refresh: show its effect at once,
|
||||
// and again shortly after for whatever the network sends back.
|
||||
function reloadAfterAction() { loadAll().catch(console.error); setTimeout(() => loadAll().catch(console.error), 1500); }
|
||||
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
|
||||
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
|
||||
function appendTo(id, value) {
|
||||
const cur = splitList($(id).value); if (!cur.includes(value)) cur.push(value);
|
||||
$(id).value = cur.join(', ');
|
||||
}
|
||||
$('announce-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
|
||||
communities: splitList($('f-communities').value),
|
||||
large_communities: splitList($('f-large').value),
|
||||
};
|
||||
if ($('f-med').value !== '') body.med = Number($('f-med').value);
|
||||
try { localStorage.setItem('owner', body.owner); } catch (_) {}
|
||||
setMsg('announcing...', 'dim');
|
||||
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const up = state.peers.filter(p => p.state === 'Established').length;
|
||||
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); reloadAfterAction(); }
|
||||
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
|
||||
});
|
||||
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-origin').value = 'IGP'; setMsg(''); });
|
||||
$('f-wk').addEventListener('change', e => { if (e.target.value) appendTo('f-communities', e.target.value); e.target.value = ''; });
|
||||
$('f-lc').addEventListener('change', e => {
|
||||
let v = e.target.value; e.target.value = '';
|
||||
if (!v) return;
|
||||
if (v.includes('PEER')) {
|
||||
const peer = (prompt('AS number of the peer or neighbour?') || '').trim().replace(/^AS/i, '');
|
||||
if (!/^\d+$/.test(peer)) { if (peer) setMsg(`"${peer}" is not an AS number`, 'bad'); return; }
|
||||
v = v.replace('PEER', peer);
|
||||
}
|
||||
appendTo('f-large', v);
|
||||
});
|
||||
$('f-owner').addEventListener('input', () => { renderLocal(); renderPings(); });
|
||||
$('pings-body').addEventListener('click', e => {
|
||||
const link = e.target.closest('a[data-act="ping-bytes"]'); if (!link) return;
|
||||
e.preventDefault();
|
||||
const key = link.dataset.key;
|
||||
state.openPing.has(key) ? state.openPing.delete(key) : state.openPing.add(key);
|
||||
renderPings();
|
||||
});
|
||||
$('local-body').addEventListener('click', async e => {
|
||||
const link = e.target.closest('a[data-act]'); if (!link) return;
|
||||
e.preventDefault();
|
||||
const prefix = link.dataset.prefix;
|
||||
if (link.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
|
||||
if (!confirm(`Withdraw ${prefix}?`)) return;
|
||||
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
|
||||
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'bad'); }
|
||||
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); reloadAfterAction(); }
|
||||
});
|
||||
|
||||
// ---- boot --------------------------------------------------------------
|
||||
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
|
||||
LARGE_PRESET_GROUPS.forEach(([, title, presets]) => {
|
||||
const g = document.createElement('optgroup'); g.label = title;
|
||||
presets.forEach(([value, label]) => { const o = document.createElement('option'); o.value = value; o.textContent = `${value.replace('PEER', '<as>')} ${label}`; g.appendChild(o); });
|
||||
$('f-lc').appendChild(g);
|
||||
});
|
||||
getJSON('/api/communities/well-known').then(list => {
|
||||
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
|
||||
renderLocal(); renderReceived();
|
||||
}).catch(console.error);
|
||||
getJSON('/api/config').then(c => {
|
||||
if (!c.text) return;
|
||||
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
|
||||
$('config-text').textContent = c.text;
|
||||
$('config-zippy').hidden = false;
|
||||
}).catch(console.error);
|
||||
renderRefreshControl();
|
||||
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
|
||||
if (REFRESH) {
|
||||
connect();
|
||||
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
|
||||
setInterval(tick, 1000);
|
||||
} else {
|
||||
// A snapshot: nothing moves until the reader reloads or turns refresh on.
|
||||
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+14
-4
@@ -25,9 +25,17 @@ The base protocol specification.
|
||||
| §4.4 | KEEPALIVE message | ✅ |
|
||||
| §4.5 | NOTIFICATION message (all error codes and subcodes) | ✅ |
|
||||
| §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ |
|
||||
| §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ |
|
||||
| §8 | FSM — active (outbound) side: dial configured peers, exponential retry | ✅ |
|
||||
| §6.2 | Bad Peer AS check against configured ASN | ✅ |
|
||||
| §6.7 / RFC 4486 | Cease / Administrative Shutdown sent to every peer on SIGINT or SIGTERM | ✅ |
|
||||
| §6.8 | Connection collision detection (BGP Identifier comparison, Cease / Connection Collision Resolution) | ✅ |
|
||||
| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ |
|
||||
| §6.2 | Bad BGP Identifier check (peer using our identifier, or 0.0.0.0) | ✅ |
|
||||
| §9 | Decision process / best-path selection | ❌ |
|
||||
| §9 | RIB (Adj-RIB-In, Loc-RIB, Adj-RIB-Out) | ❌ |
|
||||
| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ |
|
||||
| §9 | Locally originated routes, advertised to all established peers | ✅ |
|
||||
| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ |
|
||||
| §9.2 | Hold timer reset on any received message (not only KEEPALIVE) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +67,7 @@ https://datatracker.ietf.org/doc/html/rfc2918
|
||||
| `RouteRefresh` capability advertisement | ✅ |
|
||||
| `ROUTE-REFRESH` message type (5) framing | ✅ |
|
||||
| AFI/SAFI payload parsing | ❌ |
|
||||
| Acting on received ROUTE-REFRESH (re-advertising routes) | ❌ |
|
||||
| Acting on received ROUTE-REFRESH (re-advertising all local routes, regardless of the AFI/SAFI asked for) | ✅ |
|
||||
|
||||
### RFC 7313 — Enhanced Route Refresh Capability for BGP-4
|
||||
https://datatracker.ietf.org/doc/html/rfc7313
|
||||
@@ -100,7 +108,7 @@ https://datatracker.ietf.org/doc/html/rfc2858
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ |
|
||||
| `MultiProtocol` capability advertisement (IPv4 and IPv6 unicast) | ✅ |
|
||||
| `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ |
|
||||
| `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ |
|
||||
| IPv4 and IPv6 next-hop encoding | ✅ |
|
||||
@@ -122,6 +130,7 @@ https://datatracker.ietf.org/doc/html/rfc1997
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `COMMUNITIES` path attribute (parse, serialise) | ✅ |
|
||||
| Attach to locally originated routes | ✅ |
|
||||
|
||||
### RFC 4360 — BGP Extended Communities Attribute
|
||||
https://datatracker.ietf.org/doc/html/rfc4360
|
||||
@@ -137,6 +146,7 @@ https://datatracker.ietf.org/doc/html/rfc8092
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ |
|
||||
| Attach to locally originated routes | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Announcing a prefix with BGP
|
||||
|
||||
You have a BGP router. It speaks to two neighbouring autonomous systems, and
|
||||
whatever you announce will travel around a loop and come back to you, so you
|
||||
can see exactly what the network did with it.
|
||||
|
||||
Open the router's status page (your instructor will give you the address,
|
||||
usually `http://<host>:8080/statusz`). Everything on the page is live.
|
||||
|
||||
**Your prefix:** `10.100.<N>.0/24` where `N` is the number you were given.
|
||||
Use your name as *owner*: only you can withdraw what you announce.
|
||||
|
||||
## The topology
|
||||
|
||||
```
|
||||
you ──announce──▶ AS65000 (ferrix) ──▶ AS65001 (r1) ──▶ AS65002 (r2) ──▶ back to AS65000
|
||||
```
|
||||
|
||||
Everything under **Received routes** came in from a neighbour. A yellow row
|
||||
means it is one of the prefixes announced locally: it went around the loop.
|
||||
|
||||
## Exercises
|
||||
|
||||
1. **Plain announcement.** Announce your prefix with no options. Watch the
|
||||
event log, then find your prefix in *Received routes*. What is its AS
|
||||
path, and why is it that long? Which ASN appears first, and what does that
|
||||
tell you about the direction the route travelled?
|
||||
|
||||
2. **Read the bytes.** Click *bytes* on your route under *Local routes*.
|
||||
Hover the coloured segments. Find the 16 bytes of marker, the length, and
|
||||
the AS_PATH attribute. How many bytes does your ASN take up in the path,
|
||||
and why?
|
||||
|
||||
3. **NO_EXPORT.** Announce with the community `NO_EXPORT` (use the
|
||||
well-known picker). Wait ten seconds. Does your prefix come back? Look at
|
||||
the topology and explain which router stopped it and why it still
|
||||
accepted it.
|
||||
|
||||
4. **Asking a neighbour for help.** Announce with the community
|
||||
`65001:100`. r1 has been configured to act on that. What changed in the AS
|
||||
path this time, and who changed it? This is how real operators ask an
|
||||
upstream to prepend on their behalf; look up your ISP's "BGP communities"
|
||||
page later.
|
||||
|
||||
5. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an
|
||||
operator want this?
|
||||
|
||||
6. **Withdraw.** Withdraw your prefix and watch the event log. How many
|
||||
messages did it take, and what did the withdrawal look like on the wire
|
||||
compared to the announcement? (Click *bytes* before withdrawing to compare.)
|
||||
|
||||
## Things to notice
|
||||
|
||||
- Your router never sent anyone else's routes onward. Only the two FRR
|
||||
routers re-advertise. What is a router that re-advertises called, and what
|
||||
would happen to the AS path if ferrix did it too?
|
||||
- The AS path is BGP's loop prevention. Your prefix came back with your own
|
||||
ASN in the path and the router accepted it anyway. What should it have
|
||||
done, and why is this lab allowed to cheat?
|
||||
- Communities are just 32-bit tags with no built-in meaning except a few
|
||||
well-known ones. Everything r1 did with `65001:100` was policy someone
|
||||
wrote. Whose job is it to document what a community means?
|
||||
@@ -0,0 +1,81 @@
|
||||
# BGP teaching lab
|
||||
|
||||
A small topology in which a prefix announced from the ferrix_bgp web UI is
|
||||
carried around a loop of two routers and arrives back, so students can watch
|
||||
what their path attributes and communities do.
|
||||
|
||||
```
|
||||
announce re-advertise
|
||||
┌─────────┐ ──────▶ ┌────────┐ ──────▶ ┌────────┐
|
||||
│ ferrix │ │ r1 │ │ r2 │
|
||||
│ AS65000 │ │ AS65001│ │ AS65002│
|
||||
│ web UI │ ◀──────────────────────────────────────┘
|
||||
└─────────┘ comes back with AS path 65002 65001 65000
|
||||
```
|
||||
|
||||
- **ferrix** (AS65000, `10.200.0.1`) dials r1 and r2 and announces whatever
|
||||
students enter on `http://localhost:8080/statusz`. It does not forward
|
||||
routes it learns, so the only routes it sends are the students'.
|
||||
- **r1** (AS65001, FRR) applies policy to routes from ferrix based on the
|
||||
communities they carry, then passes them to r2. It originates
|
||||
`192.0.2.0/24`.
|
||||
- **r2** (AS65002, FRR) refuses routes directly from ferrix, so it only learns
|
||||
student prefixes via r1, and re-advertises everything to ferrix. It
|
||||
originates `198.51.100.0/24`.
|
||||
|
||||
## Running it
|
||||
|
||||
From the repository root:
|
||||
|
||||
```
|
||||
docker compose -f docs/lab/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080/statusz>. Both peers should show
|
||||
`Established` within a few seconds and the received table should already
|
||||
list the two prefixes r1 and r2 originate.
|
||||
|
||||
Useful on the routers:
|
||||
|
||||
```
|
||||
docker exec r1 vtysh -c 'show bgp ipv4 unicast'
|
||||
docker exec r2 vtysh -c 'show bgp ipv4 unicast 10.100.1.0/24'
|
||||
docker exec r1 vtysh -c 'show bgp neighbors 10.200.0.1 advertised-routes'
|
||||
```
|
||||
|
||||
## Policy on r1
|
||||
|
||||
| Students set | r1 does | Visible effect in ferrix |
|
||||
|---------------------------|---------------------------------------------|-------------------------------------------------------|
|
||||
| nothing | forwards to r2 | prefix returns, AS path `65002 65001 65000` |
|
||||
| `NO_EXPORT` | keeps it, does not send to r2 (RFC 1997) | prefix does not return |
|
||||
| `65001:100` | prepends `65001` three times | returns with path `65002 65001 65001 65001 65001 65000` |
|
||||
| `BLACKHOLE` (65535:666) | drops it on import | prefix does not return |
|
||||
|
||||
## Without containers
|
||||
|
||||
`fake_peer` in `crates/integration-tests/examples` is enough for the core
|
||||
loop on one machine: it echoes every prefix back with its own ASN prepended
|
||||
and honours `NO_EXPORT`. See `bin/bgp/README.md`.
|
||||
|
||||
## Caveats
|
||||
|
||||
These container files were written without a container runtime to hand, so
|
||||
they have not been executed. Things most likely to need a touch:
|
||||
|
||||
- the FRR image tag, and FRR's route-map / community-list syntax if your
|
||||
version differs from 10.x;
|
||||
- `no bgp ebgp-requires-policy` is required on FRR 7.4+ or eBGP routes are
|
||||
silently dropped; it is present in both configs;
|
||||
- ferrix binds port 179 inside its container, which requires no special
|
||||
capability there. If you run it on the host instead, either run as root,
|
||||
grant `CAP_NET_BIND_SERVICE`, or change `listen_endpoints` and the FRR
|
||||
neighbor port.
|
||||
- Both ferrix and the FRR routers dial each other, so connection collisions
|
||||
(RFC 4271 §6.8) are normal at start-up: one of the two TCP connections is
|
||||
closed with a Cease / Connection Collision Resolution NOTIFICATION and the
|
||||
other carries the session. ferrix has the lowest BGP Identifier here, so
|
||||
the connection the FRR router opened is the one that survives; the
|
||||
"opened by" column on the status page shows which side that was.
|
||||
- ferrix does not check AS paths for its own ASN. That is what lets the
|
||||
looped route be accepted; a production BGP speaker would reject it.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Teaching lab: ferrix_bgp plus two FRR routers forming a loop, so a prefix
|
||||
# announced from the web UI travels ferrix -> r1 -> r2 -> ferrix and shows up
|
||||
# in the "received routes" table with the AS path it collected on the way.
|
||||
#
|
||||
# AS65000 ferrix 10.200.0.1 web UI on http://localhost:8080/statusz
|
||||
# AS65001 r1 10.200.0.2 applies community-driven policy
|
||||
# AS65002 r2 10.200.0.3 learns only via r1, re-advertises to ferrix
|
||||
#
|
||||
# Usage (from the repository root):
|
||||
# docker compose -f docs/lab/docker-compose.yml up --build
|
||||
#
|
||||
# NOTE: this topology has not been run by the author of this file on the
|
||||
# machine it was written on (no container runtime was available). Expect to
|
||||
# adjust image tags and FRR config syntax for the FRR version you pull.
|
||||
services:
|
||||
ferrix:
|
||||
build:
|
||||
context: ../..
|
||||
container_name: ferrix
|
||||
volumes:
|
||||
- ./ferrix.textpb:/etc/ferrix/server.textpb:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.1
|
||||
|
||||
r1:
|
||||
image: quay.io/frrouting/frr:10.2.1
|
||||
container_name: r1
|
||||
privileged: true
|
||||
volumes:
|
||||
- ./r1:/etc/frr:ro
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.2
|
||||
|
||||
r2:
|
||||
image: quay.io/frrouting/frr:10.2.1
|
||||
container_name: r2
|
||||
privileged: true
|
||||
volumes:
|
||||
- ./r2:/etc/frr:ro
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.3
|
||||
|
||||
networks:
|
||||
lab:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.200.0.0/24
|
||||
@@ -0,0 +1,30 @@
|
||||
router_id: "10.200.0.1"
|
||||
local_asn: 65000
|
||||
listen_endpoints { address: "0.0.0.0" port: 179 }
|
||||
|
||||
peers {
|
||||
name: "r1"
|
||||
endpoint { address: "10.200.0.2" port: 179 }
|
||||
remote_asn: 65001
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
peers {
|
||||
name: "r2"
|
||||
endpoint { address: "10.200.0.3" port: 179 }
|
||||
remote_asn: 65002
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
|
||||
# If r1 or r2 is restarted it will dial us again while we still believe the
|
||||
# old session is up. Let that new connection replace the stale one straight
|
||||
# away instead of refusing it until the hold timer (30 s here) expires.
|
||||
collision_detect_established_state: true
|
||||
|
||||
# Students may only announce out of 10.100.0.0/16; one /24 each is plenty.
|
||||
local_route_policy {
|
||||
allowed_prefixes: "10.100.0.0/16"
|
||||
max_routes_per_owner: 4
|
||||
}
|
||||
|
||||
http_endpoint { address: "0.0.0.0" port: 8080 }
|
||||
@@ -0,0 +1,8 @@
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
build.log
|
||||
class.pdf
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One exercise sheet per student, with their prefix filled in, in one PDF.
|
||||
|
||||
python3 build-class.py 100 # students 1..100 -> class.pdf
|
||||
python3 build-class.py 100 -o out.pdf
|
||||
python3 build-class.py 5 12 -o few.pdf # students 5..12
|
||||
|
||||
Student n gets 2a0d:d741:<n in hex>::/48; the lab /40 allows 1..255. Each
|
||||
sheet is compiled separately and must come out to exactly two pages, so
|
||||
the result prints double-sided with one student per leaf. Needs pdflatex
|
||||
and pdfunite (poppler).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
out = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, check=True).stdout
|
||||
return int(next(line.split()[-1] for line in out.splitlines() if line.startswith("Pages:")))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("first", type=int, help="first student number, or the count if it is the only number")
|
||||
ap.add_argument("last", type=int, nargs="?")
|
||||
ap.add_argument("-o", "--output", default=os.path.join(HERE, "class.pdf"))
|
||||
args = ap.parse_args()
|
||||
first, last = (1, args.first) if args.last is None else (args.first, args.last)
|
||||
if not 1 <= first <= last <= 255:
|
||||
sys.exit("student numbers must be within 1..255: the lab /40 has 255 usable /48s")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name in os.listdir(HERE):
|
||||
if name.endswith((".tex", ".cls", ".sty", ".bst", ".bib")):
|
||||
shutil.copy(os.path.join(HERE, name), tmp)
|
||||
parts = []
|
||||
for n in range(first, last + 1):
|
||||
job = f"student-{n:03d}"
|
||||
tex = f"\\def\\studentnum{{{n}}}\\def\\studenthex{{{n:x}}}\\input{{handout.tex}}"
|
||||
for _ in range(2): # twice, for the figure and table references
|
||||
r = subprocess.run(["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "-jobname", job, tex],
|
||||
cwd=tmp, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.exit(f"student {n}: pdflatex failed:\n{r.stdout[-2000:]}")
|
||||
pdf = os.path.join(tmp, job + ".pdf")
|
||||
pages = page_count(pdf)
|
||||
if pages != 2:
|
||||
sys.exit(f"student {n}: sheet is {pages} pages, not 2; double-sided printing would misalign")
|
||||
parts.append(pdf)
|
||||
print(f"\rstudent {n}: 2a0d:d741:{n:x}::/48", end="", flush=True)
|
||||
print()
|
||||
subprocess.run(["pdfunite", *parts, args.output], check=True)
|
||||
print(f"wrote {args.output}: students {first}..{last}, {page_count(args.output)} pages")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,195 @@
|
||||
% Exercise sheet for the "Hands-on BGP" lecture: two A4 pages, one sheet.
|
||||
% Springer LNCS class (llncs.cls ships with TeX Live). Build: latexmk -pdf handout.tex
|
||||
\documentclass[a4paper]{llncs}
|
||||
|
||||
\usepackage[a4paper,margin=1in]{geometry}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{microtype}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{listings}
|
||||
\usepackage{tikz}
|
||||
\usepackage[bitstream-charter]{mathdesign}
|
||||
\usetikzlibrary{positioning,arrows.meta}
|
||||
\PassOptionsToPackage{hyphens}{url}
|
||||
\usepackage[hidelinks]{hyperref}
|
||||
\renewcommand\UrlFont{\ttfamily\small}
|
||||
\pagestyle{empty}
|
||||
% Floats sit in the text here, so they need less air than in a paper.
|
||||
\setlength{\intextsep}{7pt}
|
||||
\setlength{\abovecaptionskip}{4pt}
|
||||
\setlength{\belowcaptionskip}{0pt}
|
||||
|
||||
% ---- things to change per class -------------------------------------------
|
||||
\newcommand{\laburl}{https://bgplab.rayhaan.net/}
|
||||
% build-class.py compiles one copy per student with \studentnum and
|
||||
% \studenthex defined (e.g. 26 and 1a); without them this is the blank sheet.
|
||||
\providecommand{\studentnum}{}
|
||||
\providecommand{\studenthex}{}
|
||||
\ifx\studentnum\empty
|
||||
\newcommand{\myprefix}{\texttt{2a0d:d741:}\underline{\texttt{\,n\,}}\texttt{::/48}}
|
||||
\newcommand{\nhex}{<n>}
|
||||
\else
|
||||
\newcommand{\myprefix}{\texttt{2a0d:d741:\studenthex::/48}}
|
||||
\newcommand{\nhex}{\studenthex}
|
||||
\fi
|
||||
|
||||
% ---- exercise machinery ------------------------------------------------------
|
||||
\lstset{basicstyle=\ttfamily\small,columns=fullflexible,keepspaces=true,
|
||||
aboveskip=0.5em,belowskip=0.3em}
|
||||
\newcounter{task}[section]
|
||||
\renewcommand{\thetask}{\thesection.\arabic{task}}
|
||||
\newcommand{\task}[1]{\refstepcounter{task}\smallskip\noindent
|
||||
\textbf{Task \thetask}\enspace\textit{#1}\enspace\ignorespaces}
|
||||
\newcommand{\answer}[1][1]{\par\nobreak
|
||||
\foreach \i in {1,...,#1}{\vspace{1.0em}\noindent\makebox[\linewidth]{\dotfill}\par}\smallskip}
|
||||
% A labelled answer box: the label on its own line, then #1 dotted lines to
|
||||
% write on (default 2). Used side by side by \fillintwo and \fillinthree.
|
||||
\newcommand{\writelines}[1]{\foreach \i in {1,...,#1}{\vspace{1.0em}\makebox[\linewidth]{\dotfill}\par}}
|
||||
\newcommand{\fillbox}[3][2]{\parbox[t]{#2}{#3\par\nopagebreak\writelines{#1}}}
|
||||
\newcommand{\fillintwo}[3][2]{\par\nopagebreak\smallskip\noindent
|
||||
\fillbox[#1]{0.485\linewidth}{#2}\hfill\fillbox[#1]{0.485\linewidth}{#3}\par\vspace{0.4em}}
|
||||
\newcommand{\fillinthree}[4][2]{\par\nopagebreak\smallskip\noindent
|
||||
\fillbox[#1]{0.32\linewidth}{#2}\hfill\fillbox[#1]{0.32\linewidth}{#3}\hfill\fillbox[#1]{0.32\linewidth}{#4}\par\vspace{0.4em}}
|
||||
\newcommand{\cbox}{$\square$\;}
|
||||
\newcommand{\reach}{\par\nopagebreak\smallskip\noindent
|
||||
Reachable from:\hspace{1.2em}\cbox laptop on the university network\hspace{2em}\cbox phone on mobile data\par\smallskip}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\title{Hands on BGP: Announcing and Steering an IPv6 Prefix}
|
||||
\author{Rayhaan Jaufeerally}
|
||||
\institute{Network Security, ETHZ\\ Lab page: \url{\laburl} (username ``netsec'', password ``perrig'')}
|
||||
\maketitle
|
||||
|
||||
\medskip\noindent
|
||||
\ifx\studentnum\empty
|
||||
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
|
||||
\parbox[t]{0.27\linewidth}{Prefix: \myprefix}
|
||||
|
||||
\smallskip\noindent
|
||||
Your prefix is \myprefix, where \texttt{n} is your number \emph{in
|
||||
hexadecimal}.\par\noindent
|
||||
Student 10 has \texttt{2a0d:d741:a::/48} and student 26 has
|
||||
\texttt{2a0d:d741:1a::/48}.
|
||||
\else
|
||||
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
|
||||
\parbox[t]{0.5\linewidth}{Student \studentnum\quad Prefix: \myprefix}
|
||||
|
||||
\smallskip\noindent
|
||||
Your prefix is \myprefix: student number \studentnum, written in
|
||||
hexadecimal. Make sure to use this prefix in the exercise.
|
||||
\fi
|
||||
|
||||
% ==============================================================================
|
||||
\section{Who Announces the Address You Are Using?}
|
||||
|
||||
\task{Find your IPv6 address.}
|
||||
Open \texttt{https://bgp.tools} in a browser: the top of the page shows the address
|
||||
you are connecting from, the prefix it belongs to, and the network that
|
||||
announces that prefix.
|
||||
\fillinthree{Your IPv6 address:}{its prefix:}{announced by AS:}
|
||||
|
||||
\task{Ask the registry.}
|
||||
The RIPE database records who holds address space
|
||||
(\texttt{inet6num}, or \texttt{inetnum} for IPv4) and who may announce it
|
||||
(\texttt{route6}, \texttt{route}). Look up your IPv6 address, then your lab prefix:
|
||||
\begin{lstlisting}[escapeinside={(*}{*)}]
|
||||
whois <your IPv6 address> whois 2a0d:d741:(*\nhex*)::/48
|
||||
\end{lstlisting}
|
||||
\fillintwo{Your IPv6 address: held by (\texttt{netname})}{\texttt{origin} AS:}
|
||||
\fillintwo{Your lab prefix: \texttt{origin} AS}{is it announced right now?}
|
||||
|
||||
% ==============================================================================
|
||||
\section{Announcing Your Prefix}
|
||||
|
||||
\begin{figure}[h]
|
||||
\centering
|
||||
\begin{tikzpicture}[font=\footnotesize, node distance=5mm and 11mm,
|
||||
box/.style={draw, rounded corners=1.5pt, minimum height=7.5mm, inner xsep=4pt, align=center},
|
||||
bgp/.style={-{Stealth[length=2mm]}, dashed}, web/.style={-{Stealth[length=2mm]}}]
|
||||
\node[box] (you) {you\\[-1pt]\scriptsize browser};
|
||||
\node[box, right=of you] (ferrix) {lab daemon\\[-1pt]\scriptsize AS4200000001};
|
||||
\node[box, right=of ferrix] (pr) {\texttt{pr01.rue}\\[-1pt]\scriptsize AS210036};
|
||||
\node[box, right=of pr] (rs) {SwissIX route servers\\[-1pt]\scriptsize AS42476};
|
||||
\node[box, right=of rs] (sw) {SWITCH (ETH)\\[-1pt]\scriptsize AS559};
|
||||
\node[box, below=4mm of rs] (other) {other exchanges, transit};
|
||||
\draw[web] (you) -- node[above, inner sep=1.5pt]{\tiny HTTPS} (ferrix);
|
||||
\draw[bgp] (ferrix) -- node[above, inner sep=1.5pt]{\tiny BGP} (pr);
|
||||
\draw[bgp] (pr) -- (rs);
|
||||
\draw[bgp] (rs) -- (sw);
|
||||
\draw[bgp] (pr) |- (other);
|
||||
\end{tikzpicture}
|
||||
\caption{The lab. Dashed arrows are BGP sessions; everything right of
|
||||
\texttt{pr01.rue} is the real Internet.}
|
||||
\label{fig:lab}
|
||||
\end{figure}
|
||||
|
||||
\task{Announce.}
|
||||
Open \url{\laburl} (username ``netsec'' password ``perrig''). Enter your name as
|
||||
\emph{owner} and your prefix, and press \emph{Announce}.
|
||||
|
||||
\task{Read what you sent.}
|
||||
Click \emph{bytes} next to your route and hover over the coloured segments.
|
||||
\fillintwo{AS number(s) in \texttt{AS\_PATH}:}{attribute carrying your prefix:}
|
||||
|
||||
\task{See it from outside.}
|
||||
Wait a moment, then look your prefix up on \texttt{https://bgp.tools/super-lg}.
|
||||
\fillintwo{Origin AS the world sees:}{an AS path you found:}
|
||||
\noindent The lab daemon's AS4200000001 is missing from that path. Where did it go, and why?
|
||||
\answer[2]
|
||||
|
||||
\task{Reach it.}
|
||||
The router answers for every address in the lab space, so your prefix is
|
||||
reachable exactly when your announcement got out:\quad
|
||||
\texttt{ping -6 2a0d:d741:\nhex::1}
|
||||
\reach
|
||||
|
||||
% ==============================================================================
|
||||
\section{Steering Your Prefix with Large Communities}
|
||||
|
||||
A Large Community is a tag added to a route. It can have any meaning that the network
|
||||
which issued it decides. In BGP large communities, the first part is the AS number
|
||||
and the subsequent two integers are up to the interpretation of that network.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
\begin{tabular}{@{}l@{\hspace{1.6em}}l@{\hspace{1.6em}}l@{}}
|
||||
\toprule
|
||||
Large community & Read by & Effect \\
|
||||
\midrule
|
||||
\texttt{210036:0:0} & \texttt{pr01.rue} & announce nowhere \ldots \\
|
||||
\texttt{210036:1:42476} & \texttt{pr01.rue} & \ldots except to AS42476, the SwissIX route servers \\
|
||||
\midrule
|
||||
\texttt{42476:0:0} & SwissIX & send to no member \ldots \\
|
||||
\texttt{42476:1:559} & SwissIX & \ldots except AS559, SWITCH, the network ETH is part of \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\end{table}
|
||||
|
||||
\task{One exchange only.}
|
||||
Announce your prefix again with \texttt{210036:0:0} and
|
||||
\texttt{210036:1:42476}; announcing a prefix you own replaces it. Before you
|
||||
look: who do you expect to still see it?
|
||||
\answer[2]
|
||||
\noindent Wait a moment, then check on \texttt{https://bgp.tools/super-lg} and with \texttt{ping}.
|
||||
\reach
|
||||
|
||||
\task{To SWITCH only.}
|
||||
Add \texttt{42476:0:0} and \texttt{42476:1:559} to the two you already have and
|
||||
announce again. Our router now tells only the route servers, and they tell
|
||||
only SWITCH.
|
||||
\reach
|
||||
\noindent Is your prefix still visible on \texttt{https://bgp.tools/super-lg}? Explain what you see.
|
||||
\answer[2]
|
||||
|
||||
\task{Use of large communities.}
|
||||
You steered routing in two networks you do not operate, using nothing but
|
||||
numbers attached to a route. What stops you from attaching a community that
|
||||
one of \emph{their} upstreams acts on?
|
||||
\answer[2]
|
||||
|
||||
\task{Clean up.} Withdraw your prefix and see how long it takes to go away.
|
||||
|
||||
\end{document}
|
||||
@@ -0,0 +1,172 @@
|
||||
# BIRD 2.x / 3.x: peer the production router (AS210036) with ferrix_bgp over
|
||||
# WireGuard. ferrix is announce-only: we accept the lab /48s from it and send
|
||||
# it nothing.
|
||||
#
|
||||
# Include from bird.conf: include "/etc/bird/bird-ferrix.conf";
|
||||
# Check and apply: birdc configure check && birdc configure
|
||||
# Inspect: birdc show protocols all ferrix
|
||||
# birdc show route protocol ferrix all
|
||||
|
||||
define MY_ASN = 210036;
|
||||
define FERRIX_ASN = 4200000001;
|
||||
define LAB_PREFIXES = [ 2a0d:d741::/40{48,48} ];
|
||||
|
||||
filter ferrix_import
|
||||
{
|
||||
# Only the students' /48s, nothing shorter or longer, nothing else.
|
||||
if !(net ~ LAB_PREFIXES) then reject;
|
||||
|
||||
# ferrix allows at most 16 prepends on top of its own ASN.
|
||||
if bgp_path.len > 17 then reject;
|
||||
if bgp_path.first != FERRIX_ASN then reject;
|
||||
|
||||
# Students type communities freely into a web form with no login, so
|
||||
# only namespaces we have chosen to expose get through.
|
||||
#
|
||||
# Standard: just the two well-known ones BIRD itself honours, so
|
||||
# NO_EXPORT / NO_ADVERTISE visibly work. Notably BLACKHOLE (65535:666),
|
||||
# which SwissIX acts on, stays scrubbed.
|
||||
bgp_community.filter([ (65535, 65281), (65535, 65282) ]);
|
||||
|
||||
# Large: the SwissIX route-server action communities, all of which live
|
||||
# under their route-server ASN 42476
|
||||
# (https://www.swissix.ch/resources/action-bgp-communities/):
|
||||
# 42476:0:<peer-as> do not redistribute to peer 42476:0:0 to nobody
|
||||
# 42476:1:<peer-as> redistribute to peer 42476:1:0 to all
|
||||
# 42476:101|102|103:<peer-as or 0> prepend 1x / 2x / 3x
|
||||
# 42476:901|902:<peer-as> add NO_EXPORT / NO_ADVERTISE
|
||||
# Everything else is dropped, including the rest of our own
|
||||
# (210036, *, *) space, so students cannot trigger anything else this
|
||||
# network acts on. Add further namespaces here deliberately.
|
||||
#
|
||||
# And our own export-control communities, functions 0 to 3, evaluated by
|
||||
# lab_export_allowed() below.
|
||||
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..3, *) ]);
|
||||
|
||||
bgp_ext_community.empty;
|
||||
|
||||
# Announce-only: there is nothing behind these prefixes. Do not forward
|
||||
# their traffic into the tunnel; answer with "unreachable" instead.
|
||||
dest = RTD_UNREACHABLE;
|
||||
|
||||
accept;
|
||||
}
|
||||
|
||||
protocol bgp ferrix
|
||||
{
|
||||
description "ferrix_bgp teaching lab (announce only)";
|
||||
local 2a0d:d747:1::1 as 210036;
|
||||
neighbor 2a0d:d747:1:: as FERRIX_ASN;
|
||||
direct;
|
||||
|
||||
# ferrix dials us. Staying passive avoids log noise from failed attempts
|
||||
# towards a laptop that is asleep or firewalled.
|
||||
passive on;
|
||||
|
||||
# ferrix supports none of these; leave them off.
|
||||
# password "..."; (TCP MD5)
|
||||
# ttl security on; (GTSM)
|
||||
# bfd on;
|
||||
|
||||
ipv6 {
|
||||
import filter ferrix_import;
|
||||
# 128 students, one /48 each. Extra routes are ignored, the session
|
||||
# stays up.
|
||||
import limit 130 action block;
|
||||
# Send nothing: ferrix keeps received routes in memory and renders
|
||||
# every one of them on its status page.
|
||||
export none;
|
||||
};
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export control for lab routes.
|
||||
#
|
||||
# BIRD export filters cannot see which neighbour they are exporting to, and
|
||||
# filters take no arguments; functions do. So every eBGP session states who it
|
||||
# talks to and where, by calling the shared export function with its neighbour
|
||||
# AS and a location:
|
||||
#
|
||||
# ipv6 { export where peers_out(42476, AT_SWISSIX); ... };
|
||||
#
|
||||
# Students steer their prefix with large communities, in this precedence:
|
||||
#
|
||||
# 1. 210036:0:<as> never announce to that neighbour AS
|
||||
# 210036:2:<location> never announce at that location
|
||||
# 2. 210036:1:<as> announce to that neighbour AS
|
||||
# 210036:3:<location> announce at that location
|
||||
# 3. 210036:0:0 announce nowhere (unless rule 2 says otherwise)
|
||||
# 4. nothing announce everywhere <- default
|
||||
#
|
||||
# A "never" always wins, so the two dimensions combine. The shape and
|
||||
# precedence match the SwissIX route-server communities on purpose, so one
|
||||
# idea is applied at two hops.
|
||||
#
|
||||
# only the SwissIX route servers: 210036:0:0 210036:1:42476
|
||||
# ...and there only SWITCH: add 42476:0:0 42476:1:559
|
||||
# every session at SwissIX: 210036:0:0 210036:3:1
|
||||
# Cloudflare, but not at DE-CIX: 210036:0:0 210036:1:13335 210036:2:4
|
||||
# ---------------------------------------------------------------------------
|
||||
define AT_SWISSIX = 1;
|
||||
define AT_NLIX = 2;
|
||||
define AT_FRANCEIX = 3;
|
||||
define AT_DECIX = 4;
|
||||
define AT_FREEIX = 5;
|
||||
define AT_COMMUNITYIX = 6;
|
||||
define AT_TRANSIT = 100;
|
||||
define AT_DOWNSTREAM = 200;
|
||||
|
||||
function lab_export_allowed(int peer_as; int location)
|
||||
{
|
||||
if (MY_ASN, 0, peer_as) ~ bgp_large_community then return false;
|
||||
if (MY_ASN, 2, location) ~ bgp_large_community then return false;
|
||||
if (MY_ASN, 1, peer_as) ~ bgp_large_community then return true;
|
||||
if (MY_ASN, 3, location) ~ bgp_large_community then return true;
|
||||
if (MY_ASN, 0, 0) ~ bgp_large_community then return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
# Decides whether a lab route goes out on a session and, if so, makes it
|
||||
# presentable: the origin must appear as AS210036 (matching the route6
|
||||
# objects) and our action communities must not leak.
|
||||
function lab_route_out(int peer_as; int location)
|
||||
{
|
||||
if !lab_export_allowed(peer_as, location) then return false;
|
||||
bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
|
||||
bgp_large_community.delete([ (MY_ASN, 0..3, *) ]);
|
||||
return true;
|
||||
}
|
||||
|
||||
# The shared export function then ends its list of accepted routes with
|
||||
#
|
||||
# if proto = "ferrix" then return lab_route_out(peer_as, location);
|
||||
# return false;
|
||||
#
|
||||
# and returns true for everything else it accepts. Sessions that export
|
||||
# everything (downstream customers) need the same gate, or they receive lab
|
||||
# routes regardless of what the student asked for:
|
||||
#
|
||||
# export filter {
|
||||
# if proto = "ferrix" then {
|
||||
# if lab_route_out(210400, AT_DOWNSTREAM) then accept;
|
||||
# reject;
|
||||
# }
|
||||
# accept;
|
||||
# };
|
||||
#
|
||||
# The ferrix branch only ever applies to routes learned from ferrix, so these
|
||||
# communities cannot be used by anyone else to steer other exports.
|
||||
#
|
||||
# If this BIRD rejects "int peer_as; int location", it wants a comma there.
|
||||
#
|
||||
# Check what a session would send, without waiting for a looking glass:
|
||||
# birdc show route 2a0d:d741:1::/48 export <protocol-name> all
|
||||
#
|
||||
# The status page no longer offers AS-path prepending or the SwissIX prepend
|
||||
# communities (42476:101..103:*), to keep the exercise focused. The import
|
||||
# filter still admits the whole 42476 namespace, so a student who types one
|
||||
# by hand can use it; narrow the filter if that is not wanted.
|
||||
#
|
||||
# The session towards the SwissIX route servers must send large communities
|
||||
# unmodified apart from the delete above; check nothing else clears them.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,20 @@
|
||||
router_id: "10.255.0.2" # must differ from the production router's
|
||||
local_asn: 4200000001 # private asn used for internal peering.
|
||||
|
||||
listen_endpoints { address: "2a0d:d747:1::" port: 9179 }
|
||||
|
||||
peers {
|
||||
name: "pr01.rue.rayhaan.net"
|
||||
endpoint { address: "2a0d:d747:1::1" port: 179 }
|
||||
remote_asn: 210036
|
||||
hold_time_seconds: 90
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
collision_detect_established_state: true
|
||||
|
||||
local_route_policy {
|
||||
allowed_prefixes: "2a0d:d741::/40"
|
||||
max_routes_per_owner: 1
|
||||
}
|
||||
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
@@ -0,0 +1,20 @@
|
||||
# /etc/systemd/network/05-lab-lo.network
|
||||
# If a .network file already matches "lo" (e.g. the AnyIP one), merge these
|
||||
# sections into it instead: networkd applies only the first match.
|
||||
[Match]
|
||||
Name=lo
|
||||
|
||||
# BIRD and ferrix peer over ::1, which needs no configuration. Should BIRD
|
||||
# object to identical local and neighbour addresses, give them one each
|
||||
# instead (and use those in bird.conf and ferrix.textpb):
|
||||
#
|
||||
# [Address]
|
||||
# Address=fd00:179::1/128
|
||||
#
|
||||
# [Address]
|
||||
# Address=fd00:179::2/128
|
||||
|
||||
# AnyIP: the router answers ping for every address in the lab space.
|
||||
[Route]
|
||||
Destination=2a0d:d741::/40
|
||||
Type=local
|
||||
@@ -0,0 +1,178 @@
|
||||
# Running ferrix_bgp on the production router
|
||||
|
||||
ferrix and BIRD run side by side on pr01 and peer over `::1`. Students reach the status page through a reverse proxy that adds
|
||||
TLS and a password. Nothing here has been run on the router; treat each step
|
||||
as something to check.
|
||||
|
||||
## 1. Build a static Linux binary (on the Mac)
|
||||
|
||||
The `x86_64-unknown-linux-musl` target and Homebrew's musl cross-linker are
|
||||
enough; the router needs no Rust toolchain and no `protoc`.
|
||||
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
||||
cargo build --release -p bgp --target x86_64-unknown-linux-musl
|
||||
|
||||
scp target/x86_64-unknown-linux-musl/release/bgp pr01:/tmp/ferrix-bgp
|
||||
|
||||
Check the router really is x86-64 first (`uname -m`).
|
||||
|
||||
## 2. Install on the router
|
||||
|
||||
install -m 0755 /tmp/ferrix-bgp /usr/local/bin/ferrix-bgp
|
||||
install -d /etc/ferrix-bgp
|
||||
install -m 0644 ferrix.textpb /etc/ferrix-bgp/ferrix.textpb
|
||||
install -m 0644 ferrix-bgp.service /etc/systemd/system/ferrix-bgp.service
|
||||
|
||||
## 3. Loopback
|
||||
|
||||
Peering over `::1` needs no address setup. `05-lab-lo.network` only carries
|
||||
the AnyIP route for the lab space; merge it into the networkd file that
|
||||
matches `lo` if it is not there already, then `networkctl reload`.
|
||||
|
||||
## 4. BIRD
|
||||
|
||||
Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
|
||||
`birdc configure check` and `birdc configure timeout 300` / `confirm`.
|
||||
|
||||
## 5. Start
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ferrix-bgp
|
||||
journalctl -u ferrix-bgp -f
|
||||
birdc show protocols all ferrix
|
||||
|
||||
The journal should say `Ping monitor listening for ICMPv6 echo requests`. If
|
||||
it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything
|
||||
else works without it and the status page says why the table is off.
|
||||
|
||||
To check the monitor before the class, announce a prefix on the page, turn on
|
||||
auto-refresh and ping an address inside it from somewhere else:
|
||||
|
||||
ping -c 3 2a0d:d741:1::1
|
||||
|
||||
The ping shows up under "Pings to announced addresses" with its source, the
|
||||
interface it arrived on and the hop limit it arrived with. Check that
|
||||
"arrived on" names the real ingress port (the exchange or transit interface)
|
||||
and not `lo`.
|
||||
|
||||
## 6. Let students in
|
||||
|
||||
The API has no authentication and announces into a real AS, so do not expose
|
||||
port 8080. Put a reverse proxy in front with TLS and a class password. The
|
||||
event stream is long-lived, so the proxy must not buffer it.
|
||||
|
||||
Apache (tested against a local instance: 401 without the password, pages,
|
||||
API and POSTs through the proxy, and events arriving within 50 ms):
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName lab.example.net
|
||||
Redirect permanent "/" "https://lab.example.net/"
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName lab.example.net
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/lab.example.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/lab.example.net/privkey.pem
|
||||
|
||||
# Everything behind one class password.
|
||||
<Location "/">
|
||||
AuthType Basic
|
||||
AuthName "BGP lab"
|
||||
AuthUserFile /etc/apache2/ferrix.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
|
||||
# Use Apache's built-in error messages in this vhost. Servers with the
|
||||
# multi-language error documents enabled fetch them from /error/,
|
||||
# which this vhost would proxy to ferrix; a 401 then comes back as
|
||||
# ferrix's empty 404: a blank page and no password prompt. With the
|
||||
# built-in ones nothing is fetched, so nothing needs exempting from
|
||||
# the login.
|
||||
ErrorDocument 400 default
|
||||
ErrorDocument 401 default
|
||||
ErrorDocument 403 default
|
||||
ErrorDocument 404 default
|
||||
ErrorDocument 500 default
|
||||
ErrorDocument 502 default
|
||||
ErrorDocument 503 default
|
||||
ErrorDocument 504 default
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||
|
||||
# ferrix has no use for the password; do not pass it on.
|
||||
RequestHeader unset Authorization
|
||||
|
||||
# The status page follows a long-lived event stream. If mod_deflate
|
||||
# compresses it, events sit in the compressor and never arrive.
|
||||
SetEnvIf Request_URI "^/api/events" no-gzip=1
|
||||
</VirtualHost>
|
||||
|
||||
Modules: `proxy proxy_http ssl headers setenvif auth_basic authn_file`.
|
||||
Create the password file with `htpasswd -c -B /etc/apache2/ferrix.htpasswd student`.
|
||||
To also limit it to known networks, replace `Require valid-user` with
|
||||
|
||||
<RequireAll>
|
||||
Require valid-user
|
||||
Require ip 192.0.2.0/24 2001:db8::/32
|
||||
</RequireAll>
|
||||
|
||||
Caddy:
|
||||
|
||||
lab.example.net {
|
||||
basic_auth {
|
||||
student <hash from `caddy hash-password`>
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
nginx, inside a TLS `server` block:
|
||||
|
||||
location / {
|
||||
auth_basic "BGP lab";
|
||||
auth_basic_user_file /etc/nginx/lab.htpasswd;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
Restricting the proxy to the university's address ranges as well is cheap
|
||||
and worth doing. Only the Apache example has been tested.
|
||||
|
||||
## 7. Load test before the class
|
||||
|
||||
`tools/loadtest.py` makes N simulated students announce their /48 at the same
|
||||
instant, then verifies the table, ownership under load, the event stream and
|
||||
the number of BGP UPDATEs actually sent, and withdraws everything again.
|
||||
Python 3 standard library only.
|
||||
|
||||
tools/loadtest.py 128 --url https://lab.example.net --user student
|
||||
|
||||
Every test route carries `210036:0:0`, so BIRD accepts it but exports it to no
|
||||
neighbour (apart from `home_v6`, which is outside the export control). While
|
||||
it runs, `birdc show route protocol ferrix count` should rise to the number of
|
||||
students and fall back to what it was. BIRD's `import limit 130` means more
|
||||
than 130 lab routes at once are blocked there, by design.
|
||||
|
||||
## Things to know
|
||||
|
||||
- The ping monitor only listens. The kernel answers the pings, with or
|
||||
without ferrix running, because of the AnyIP route. It sees echo requests
|
||||
to the lab space from anyone on the Internet, so source addresses of
|
||||
strangers will appear on the page next to the students' own. It remembers
|
||||
at most 2048 source and destination pairs and publishes at most 50 events a
|
||||
second, so a ping flood costs little; `ping_monitor_echo_requests_total` and
|
||||
`ping_monitor_events_suppressed_total` on `/metricz` show what it saw.
|
||||
|
||||
- Announced routes live in memory. A restart of the service forgets them and
|
||||
students have to announce again.
|
||||
- Stopping the service sends BIRD a Cease first, so restarts reconnect at
|
||||
once.
|
||||
- ferrix's own safety net is `local_route_policy`; BIRD's import filter and
|
||||
import limit are the second one. Keep both.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Replaces "protocol bgp ferrix" in bird.conf when ferrix runs on this host.
|
||||
# Only the addresses and "multihop" differ from the WireGuard version; the
|
||||
# import filter and everything about export control stay as they are.
|
||||
#
|
||||
# Without "multihop" BIRD looks for the neighbour on a directly connected
|
||||
# network, cannot find ::1 there, and disables the protocol with
|
||||
# "Error: Invalid next hop". After fixing that, `birdc enable ferrix` if a
|
||||
# reconfigure does not bring it back by itself.
|
||||
protocol bgp ferrix
|
||||
{
|
||||
description "ferrix_bgp teaching lab (announce only, same host)";
|
||||
local ::1 as 210036;
|
||||
neighbor ::1 as FERRIX_ASN;
|
||||
# Both ends are this machine, so there is no directly connected
|
||||
# neighbour for "direct" (the eBGP default) to find.
|
||||
multihop;
|
||||
passive on;
|
||||
ipv6 {
|
||||
import filter ferrix_import;
|
||||
import limit 130 action block;
|
||||
export none;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[Unit]
|
||||
Description=ferrix_bgp teaching BGP speaker
|
||||
After=network-online.target bird.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/ferrix-bgp /etc/ferrix-bgp/ferrix.textpb --level info
|
||||
# SIGTERM makes ferrix send Cease / Administrative Shutdown to BIRD first.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# It needs the network and nothing else: no root, no privileged port, no
|
||||
# write access anywhere. The one capability, CAP_NET_RAW, is for the ping
|
||||
# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not
|
||||
# configured.
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_RAW
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,32 @@
|
||||
# ferrix_bgp running on the production router itself, next to BIRD.
|
||||
router_id: "10.255.0.2"
|
||||
local_asn: 4200000001
|
||||
|
||||
# Unused in practice (BIRD is passive and we dial it), but a listener is
|
||||
# required. 179 belongs to BIRD, so take another port on our own address.
|
||||
listen_endpoints { address: "::1" port: 9179 }
|
||||
|
||||
peers {
|
||||
name: "pr01-bird"
|
||||
endpoint { address: "::1" port: 179 }
|
||||
# BIRD recognises its neighbour by source address, so pin ours.
|
||||
local_address: "::1"
|
||||
remote_asn: 210036
|
||||
hold_time_seconds: 90
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
collision_detect_established_state: true
|
||||
|
||||
local_route_policy {
|
||||
allowed_prefixes: "2a0d:d741::/40"
|
||||
max_routes_per_owner: 1
|
||||
}
|
||||
|
||||
# Loopback only. Students reach it through the reverse proxy, which adds TLS
|
||||
# and a password; the API itself has neither.
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
|
||||
# Show who pings the lab addresses. The kernel answers the pings (AnyIP route
|
||||
# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above.
|
||||
# Needs CAP_NET_RAW, which the systemd unit grants.
|
||||
ping_monitor {}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,4 @@
|
||||
bgpd=yes
|
||||
zebra=yes
|
||||
staticd=yes
|
||||
vtysh_enable=yes
|
||||
@@ -0,0 +1,44 @@
|
||||
! r1: AS65001. Peers with ferrix (AS65000) and r2 (AS65002).
|
||||
! Applies policy on routes from ferrix based on the communities students set:
|
||||
! NO_EXPORT (65535:65281) standard: not sent to r2 (FRR honours it)
|
||||
! 65001:100 r1 prepends its own ASN three times
|
||||
! BLACKHOLE (65535:666) dropped at r1
|
||||
frr defaults traditional
|
||||
hostname r1
|
||||
log stdout
|
||||
!
|
||||
ip route 192.0.2.0/24 Null0
|
||||
!
|
||||
bgp community-list standard PREPEND-ME permit 65001:100
|
||||
bgp community-list standard BLACKHOLE permit 65535:666
|
||||
!
|
||||
route-map FROM-FERRIX deny 10
|
||||
match community BLACKHOLE
|
||||
!
|
||||
route-map FROM-FERRIX permit 20
|
||||
match community PREPEND-ME
|
||||
set as-path prepend 65001 65001 65001
|
||||
!
|
||||
route-map FROM-FERRIX permit 30
|
||||
!
|
||||
route-map PERMIT-ALL permit 10
|
||||
!
|
||||
router bgp 65001
|
||||
bgp router-id 10.200.0.2
|
||||
no bgp ebgp-requires-policy
|
||||
no bgp network import-check
|
||||
neighbor 10.200.0.1 remote-as 65000
|
||||
neighbor 10.200.0.1 description ferrix
|
||||
neighbor 10.200.0.1 timers 10 30
|
||||
neighbor 10.200.0.3 remote-as 65002
|
||||
neighbor 10.200.0.3 description r2
|
||||
neighbor 10.200.0.3 timers 10 30
|
||||
!
|
||||
address-family ipv4 unicast
|
||||
network 192.0.2.0/24
|
||||
neighbor 10.200.0.1 route-map FROM-FERRIX in
|
||||
neighbor 10.200.0.1 route-map PERMIT-ALL out
|
||||
neighbor 10.200.0.3 route-map PERMIT-ALL in
|
||||
neighbor 10.200.0.3 route-map PERMIT-ALL out
|
||||
exit-address-family
|
||||
!
|
||||
@@ -0,0 +1 @@
|
||||
service integrated-vtysh-config
|
||||
@@ -0,0 +1,4 @@
|
||||
bgpd=yes
|
||||
zebra=yes
|
||||
staticd=yes
|
||||
vtysh_enable=yes
|
||||
@@ -0,0 +1,33 @@
|
||||
! r2: AS65002. Peers with r1 (AS65001) and ferrix (AS65000).
|
||||
! Accepts nothing directly from ferrix so every student prefix reaches it
|
||||
! only via r1, then re-advertises everything to ferrix. That closes the loop:
|
||||
! ferrix sees its own prefixes come back with AS path 65002 65001 65000.
|
||||
frr defaults traditional
|
||||
hostname r2
|
||||
log stdout
|
||||
!
|
||||
ip route 198.51.100.0/24 Null0
|
||||
!
|
||||
route-map PERMIT-ALL permit 10
|
||||
!
|
||||
route-map DENY-ALL deny 10
|
||||
!
|
||||
router bgp 65002
|
||||
bgp router-id 10.200.0.3
|
||||
no bgp ebgp-requires-policy
|
||||
no bgp network import-check
|
||||
neighbor 10.200.0.2 remote-as 65001
|
||||
neighbor 10.200.0.2 description r1
|
||||
neighbor 10.200.0.2 timers 10 30
|
||||
neighbor 10.200.0.1 remote-as 65000
|
||||
neighbor 10.200.0.1 description ferrix
|
||||
neighbor 10.200.0.1 timers 10 30
|
||||
!
|
||||
address-family ipv4 unicast
|
||||
network 198.51.100.0/24
|
||||
neighbor 10.200.0.2 route-map PERMIT-ALL in
|
||||
neighbor 10.200.0.2 route-map PERMIT-ALL out
|
||||
neighbor 10.200.0.1 route-map DENY-ALL in
|
||||
neighbor 10.200.0.1 route-map PERMIT-ALL out
|
||||
exit-address-family
|
||||
!
|
||||
@@ -0,0 +1 @@
|
||||
service integrated-vtysh-config
|
||||
@@ -0,0 +1,779 @@
|
||||
inet6num: 2a0d:d741::/40
|
||||
netname: CH-RAY-ETHZ-NETSEC-LAB-2026
|
||||
descr: ETHZ Netsec 2026 BGP hands-on lab, one /48 per student
|
||||
country: CH
|
||||
org: ORG-RAJ2-RIPE
|
||||
admin-c: RJ4890-RIPE
|
||||
tech-c: RJ4890-RIPE
|
||||
status: AGGREGATED-BY-LIR
|
||||
assignment-size: 48
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 1
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 2
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 3
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 4
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 5
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 6
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 7
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:8::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 8
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:9::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 9
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 10
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 11
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 12
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 13
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 14
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 15
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:10::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 16
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:11::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 17
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:12::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 18
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:13::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 19
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:14::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 20
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:15::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 21
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:16::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 22
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:17::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 23
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:18::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 24
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:19::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 25
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 26
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 27
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 28
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 29
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 30
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 31
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:20::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 32
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:21::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 33
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:22::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 34
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:23::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 35
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:24::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 36
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:25::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 37
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:26::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 38
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:27::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 39
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:28::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 40
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:29::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 41
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 42
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 43
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 44
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 45
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 46
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 47
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:30::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 48
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:31::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 49
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:32::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 50
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:33::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 51
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:34::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 52
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:35::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 53
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:36::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 54
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:37::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 55
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:38::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 56
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:39::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 57
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 58
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 59
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 60
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 61
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 62
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 63
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:40::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 64
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:41::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 65
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:42::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 66
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:43::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 67
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:44::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 68
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:45::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 69
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:46::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 70
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:47::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 71
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:48::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 72
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:49::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 73
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 74
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 75
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 76
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 77
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 78
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 79
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:50::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 80
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:51::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 81
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:52::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 82
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:53::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 83
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:54::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 84
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:55::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 85
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:56::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 86
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:57::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 87
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:58::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 88
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:59::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 89
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 90
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 91
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 92
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 93
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 94
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 95
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:60::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 96
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:61::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 97
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:62::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 98
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:63::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 99
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:64::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 100
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:65::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 101
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:66::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 102
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:67::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 103
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:68::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 104
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:69::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 105
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 106
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 107
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 108
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 109
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 110
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 111
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:70::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 112
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:71::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 113
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:72::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 114
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:73::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 115
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:74::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 116
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:75::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 117
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:76::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 118
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:77::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 119
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:78::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 120
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:79::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 121
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 122
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 123
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 124
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 125
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 126
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 127
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:80::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 128
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
@@ -0,0 +1,125 @@
|
||||
# Slides
|
||||
|
||||
A reveal.js deck structured like Matt Godbolt's newer talks
|
||||
([what-happens-beneath-accu-2026](https://github.com/mattgodbolt/what-happens-beneath-accu-2026)):
|
||||
no stock theme but a custom dark one, every slide on a rounded panel, IBM Plex
|
||||
Sans and Mono, monokai code with glowing stepped lines, 16:9 with fade
|
||||
transitions, and the content in one Markdown file per section.
|
||||
|
||||
What is ours: the palette, and the corner motif. His ring-and-thread became a
|
||||
chain of nodes and links, an AS path.
|
||||
|
||||
## The talk
|
||||
|
||||
| Section | File |
|
||||
|---|---|
|
||||
| About the speaker | `sections/00-intro.md` |
|
||||
| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` |
|
||||
| BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` |
|
||||
| IP transit and Internet exchanges | `sections/04-transit-and-ix.md` |
|
||||
| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` |
|
||||
|
||||
The UPDATE in section 3 is `2a0d:d740::/48` as AS210036 announces it to the
|
||||
SwissIX route servers: next hop `2001:7f8:24::c9` (pr01's port on the peering
|
||||
LAN), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts
|
||||
on routes to peers. It was produced with the lab daemon's own encoder and parsed back
|
||||
to the same message, not captured off the wire; a capture from pr01 may differ
|
||||
in detail (BIRD can append a link-local next hop). The byte slides step through
|
||||
it one part at a time (press down), and every number on them was checked
|
||||
against the bytes when the slides were generated. To change the example,
|
||||
regenerate rather than edit the hex by hand.
|
||||
|
||||
The deck deliberately has no fragments or stepped code: a slide shows
|
||||
everything at once. Two code blocks keep a static highlight on one line.
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `theme.css` | the look: palette, type, panels, motif, title slide |
|
||||
| `style.css` | components used by slides: columns, boxes, bytes, notes |
|
||||
| `index.html` | section list, reveal.js loader and options, `bird` highlighter |
|
||||
| `sections/*.md` | the slides |
|
||||
|
||||
## Make it yours
|
||||
|
||||
Everything brand-like is a variable at the top of `theme.css`. To compare
|
||||
accents without editing, open `?accent=green`, `?accent=amber` or
|
||||
`?accent=magenta` (blue is the default); copy the one you like into `:root`.
|
||||
The wordmark bottom-left is the `<div class="wordmark">` in `index.html`.
|
||||
For a full-bleed title image, empty the title `<section>` and give it
|
||||
`data-background="images/title.png"`.
|
||||
|
||||
## Show it
|
||||
|
||||
The sections are loaded over HTTP, so open the deck through a server, not by
|
||||
double-clicking `index.html`. Opened from disk, browsers block reveal.js and
|
||||
the section files, and you would see a title and empty panels; the page now
|
||||
says so instead:
|
||||
|
||||
python3 slides/serve.py 8000
|
||||
|
||||
(`python3 -m http.server` works too, but it lets the browser cache the
|
||||
section files and the stylesheet, so edits may not show until a hard reload,
|
||||
Cmd+Shift+R. `serve.py` is the same server with caching turned off.)
|
||||
|
||||
Then <http://localhost:8000>. Keys: arrows to move, `S` speaker view with
|
||||
notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen.
|
||||
|
||||
## Offline
|
||||
|
||||
slides/vendor-reveal.sh
|
||||
|
||||
copies reveal.js into `slides/reveal.js/` (ignored by git). The deck uses that
|
||||
copy when present and the CDN otherwise. Do this before presenting anywhere
|
||||
with uncertain Wi-Fi. IBM Plex still comes from Google Fonts; without a
|
||||
network the deck falls back to Helvetica/Arial and the system monospace, so
|
||||
install IBM Plex on the presenting machine if that matters.
|
||||
|
||||
## Write slides
|
||||
|
||||
Edit the files in `sections/`; add a section by adding a file and a
|
||||
`<section data-markdown=...>` line in `index.html`.
|
||||
|
||||
----- next slide
|
||||
--- next vertical slide (detail you can skip)
|
||||
Notes: speaker notes from here to the end of the slide
|
||||
|
||||
Apply a class to the element above with `<!-- .element: class="..." -->`.
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `big` | one large statement on the slide |
|
||||
| `note` | quiet italic aside |
|
||||
| `attribution` | small credit under an image or quote |
|
||||
| `borders` | table with visible borders |
|
||||
| `r-stretch` | image or iframe fills the remaining height |
|
||||
| `blur-edges` | soft edge for photos |
|
||||
|
||||
Blocks: `<div class="cols">` two columns, `<div class="boxes">` labelled cards
|
||||
(`us`, `ok`, `bad`), `<pre class="bytes">` BGP bytes in the colours of the lab
|
||||
page (add `focus`, and `on` to the spans to keep lit, to dim the rest),
|
||||
`<table class="decode">` for the explanation under them,
|
||||
`<svg class="diagram">` for diagrams drawn in the theme's colours (`hot`,
|
||||
`fabric`, `link`, `bgp`, `traffic`). Code fences take a language and optional line steps, e.g.
|
||||
` ```bird [1-3|5-9] `; `bird` is a small highlighter defined in `index.html`.
|
||||
|
||||
`<!-- .slide: class="bare" -->` drops the panel, the motif and the wordmark
|
||||
for one slide, e.g. a full-bleed image. The title slide does so by itself.
|
||||
|
||||
## Publish as one file
|
||||
|
||||
slides/vendor-reveal.sh # once, if slides/reveal.js is missing
|
||||
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
|
||||
|
||||
`build.py` inlines everything the page would otherwise fetch: reveal.js and
|
||||
its plugins, the stylesheets, the favicon, our scripts and the Markdown
|
||||
sections. The one file (about 1.3 MB) can be uploaded anywhere and also
|
||||
opens straight from disk. It still uses the network for two things: the IBM
|
||||
Plex fonts from Google Fonts (without them the browser's fallback fonts are
|
||||
used) and the live DNS and RIPE lookups in section 1. Speaker view (`S`)
|
||||
works from the bundle too. `slides/dist/` is not committed; rebuild after
|
||||
editing.
|
||||
|
||||
## PDF
|
||||
|
||||
Open <http://localhost:8000/?print-pdf> in Chrome and print to PDF with
|
||||
background graphics on.
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bundle the deck into one self-contained HTML file for uploading.
|
||||
|
||||
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
|
||||
|
||||
Everything the page fetches at runtime is inlined: reveal.js and its plugins
|
||||
(from the vendored copy, so run vendor-reveal.sh first), the stylesheets, the
|
||||
favicon, our scripts and the Markdown sections. The result opens from any web
|
||||
server and from disk. Two things still need a network: the IBM Plex fonts
|
||||
(Google Fonts) and the live DNS and RIPE lookups.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REVEAL = os.path.join(HERE, "reveal.js", "dist")
|
||||
OUT = os.path.join(HERE, "dist", "hands-on-bgp.html")
|
||||
|
||||
|
||||
def read(*parts):
|
||||
with open(os.path.join(HERE, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(REVEAL):
|
||||
sys.exit("reveal.js is not vendored; run slides/vendor-reveal.sh first")
|
||||
html = read("index.html")
|
||||
|
||||
# Stylesheets: reveal's first, then ours, as the loader orders them.
|
||||
css = "\n".join(read("reveal.js", "dist", p) for p in ["reset.css", "reveal.css", "plugin/highlight/monokai.css"])
|
||||
css += "\n" + read("theme.css") + "\n" + read("style.css")
|
||||
html = re.sub(r'\s*<link rel="stylesheet" href="theme\.css[^"]*" id="deck-style" />', "", html)
|
||||
html = re.sub(r'\s*<link rel="stylesheet" href="style\.css[^"]*" />', f"\n <style>\n{css}\n </style>", html, count=1)
|
||||
|
||||
icon = "data:image/svg+xml;base64," + base64.b64encode(read("images", "favicon.svg").encode()).decode()
|
||||
html = html.replace('href="images/favicon.svg"', f'href="{icon}"')
|
||||
|
||||
# Sections: the Markdown goes inline in a text/template script.
|
||||
def inline_section(m):
|
||||
attrs, path = m.group(1), m.group(2)
|
||||
text = read(path)
|
||||
if "</script" in text.lower():
|
||||
sys.exit(f"{path} contains '</script', which cannot be inlined")
|
||||
return f'<section data-markdown{attrs}>\n<script type="text/template">\n{text}\n</script>\n</section>'
|
||||
html, n = re.subn(r'<section\s+data-markdown="([^"]+)"((?:\s+data-[a-z-]+="[^"]*")*)\s*></section>',
|
||||
lambda m: inline_section(type("M", (), {"group": lambda self, i: (None, m.group(2), m.group(1))[i]})()), html)
|
||||
if n == 0:
|
||||
sys.exit("no data-markdown sections found in index.html")
|
||||
|
||||
# Our scripts.
|
||||
for name in ["dns-lookup.js", "prefix-explorer.js"]:
|
||||
html, k = re.subn(rf'<script src="{re.escape(name)}[^"]*"></script>', lambda m: f"<script>\n{read(name)}\n</script>", html)
|
||||
if k != 1:
|
||||
sys.exit(f"expected one <script src=\"{name}\"> tag")
|
||||
|
||||
# The help overlay and its watchdog exist to explain a page that could
|
||||
# not fetch its parts; this one fetches nothing.
|
||||
html = re.sub(r'\s*<div id="deck-help" hidden>.*?</div>', "", html, count=1, flags=re.S)
|
||||
html = re.sub(r'\s*<!-- A classic script: it still runs.*?</script>', "", html, count=1, flags=re.S)
|
||||
|
||||
# The loader: the module script that picks a reveal.js location and
|
||||
# imports it becomes reveal.js itself, its plugins, and the deck setup.
|
||||
m = re.search(r'<script type="module">(.*?)</script>', html, flags=re.S)
|
||||
if not m:
|
||||
sys.exit("loader script not found")
|
||||
loader = m.group(1)
|
||||
setup_start = loader.index("// BIRD configuration")
|
||||
setup = loader[setup_start:]
|
||||
setup = setup.replace("await deck.initialize().catch(fail(\"reveal.js failed to start\"));", "deck.initialize().then(() => {")
|
||||
setup = setup.replace("plugins: [Markdown, Highlight, Notes],", "plugins: [RevealMarkdown, RevealHighlight, RevealNotes],")
|
||||
setup = setup.rstrip() + "\n });\n"
|
||||
libs = "\n".join(read("reveal.js", "dist", p) for p in ["reveal.js", "plugin/markdown.js", "plugin/highlight.js", "plugin/notes.js"])
|
||||
html = html[: m.start()] + f"<script>\n{libs}\n</script>\n <script>\n{setup} </script>" + html[m.end():]
|
||||
if "await " in setup or "import(" in setup:
|
||||
sys.exit("loader still contains module-only code")
|
||||
|
||||
html = html.replace("reveal.js is loaded from ./reveal.js/dist if present", "Built by build.py from index.html: everything is inlined. reveal.js was loaded from ./reveal.js/dist if present")
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"wrote {os.path.relpath(OUT)} ({os.path.getsize(OUT) // 1024} KB, {n} sections)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
// Live lookups on slides.
|
||||
//
|
||||
// <form class="dns-lookup"> with <input name="domain"> and a .dns-result:
|
||||
// asks Google Public DNS over HTTPS for the name's AAAA records
|
||||
// (https://developers.google.com/speed/public-dns/docs/doh/json) and
|
||||
// prints the answer the way dig would.
|
||||
// <form class="ripe-lookup"> with <input name="query"> and a .dns-result:
|
||||
// asks the RIPE database (https://apps.db.ripe.net/docs/) what it knows
|
||||
// about an address, prefix or AS number, and prints the objects the way
|
||||
// whois would.
|
||||
(function () {
|
||||
const TYPES = { 1: "A", 5: "CNAME", 28: "AAAA" };
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
async function resolve(name, type) {
|
||||
const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, {
|
||||
headers: { accept: "application/dns-json" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`dns.google answered HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const line = (rr) =>
|
||||
`<span class="name">${esc(rr.name)}</span> <span class="ttl">${rr.TTL}</span> IN <span class="type">${esc(TYPES[rr.type] || rr.type)}</span> <span class="${rr.type === 28 ? "addr" : "data"}">${esc(rr.data)}</span>`;
|
||||
|
||||
async function lookup(form) {
|
||||
const out = form.querySelector(".dns-result");
|
||||
const name = form.elements.domain.value.trim().replace(/\.$/, "");
|
||||
if (!name) return;
|
||||
out.innerHTML = `<span class="dim">asking dns.google for ${esc(name)} AAAA ...</span>`;
|
||||
try {
|
||||
const v6 = await resolve(name, "AAAA");
|
||||
if (v6.Status === 3) {
|
||||
out.innerHTML = `<span class="bad">NXDOMAIN</span> <span class="dim">there is no such name</span>`;
|
||||
return;
|
||||
}
|
||||
if (v6.Status !== 0) {
|
||||
out.innerHTML = `<span class="bad">the resolver answered with status ${v6.Status}</span>`;
|
||||
return;
|
||||
}
|
||||
const answers = v6.Answer || [];
|
||||
const lines = answers.map(line);
|
||||
if (!answers.some((rr) => rr.type === 28)) {
|
||||
// No IPv6 address. Show that this is a real answer, not a failure,
|
||||
// and what the name does have.
|
||||
const v4 = await resolve(name, "A");
|
||||
lines.push(`<span class="dim">no AAAA record: this name has no IPv6 address</span>`);
|
||||
(v4.Answer || []).filter((rr) => rr.type === 1).forEach((rr) => lines.push(line(rr)));
|
||||
}
|
||||
out.innerHTML = lines.join("\n");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="bad">lookup failed:</span> ${esc(e.message)} <span class="dim">(is there a network?)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RIPE database ----------------------------------------------------
|
||||
|
||||
// Objects as apps.db.ripe.net shows them: one card per object, one row per
|
||||
// attribute, references in blue, rows the RIPE NCC manages shaded. The
|
||||
// API does not say which rows those are, so they are the ones the website
|
||||
// shades for an assignment: the key, org, status, source, sponsoring-org
|
||||
// and the RIPE NCC's own maintainers.
|
||||
const RIPE_MANAGED = new Set(["inetnum", "inet6num", "aut-num", "org", "status", "source", "sponsoring-org"]);
|
||||
const managed = (a) => RIPE_MANAGED.has(a.name) || (a.name === "mnt-by" && /^RIPE-NCC-/.test(a.value));
|
||||
|
||||
async function ripe(form) {
|
||||
const out = form.querySelector(".ripe-result");
|
||||
const query = form.elements.query.value.trim();
|
||||
if (!query) return;
|
||||
out.innerHTML = `<div class="ripe-msg">Searching the RIPE database for ${esc(query)} ...</div>`;
|
||||
try {
|
||||
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(query)}&flags=no-referenced&flags=no-irt&source=ripe`;
|
||||
const r = await fetch(url, { headers: { accept: "application/json" } });
|
||||
if (r.status === 404) {
|
||||
out.innerHTML = `<div class="ripe-msg">No results found for ${esc(query)}.</div>`;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`rest.db.ripe.net answered HTTP ${r.status}`);
|
||||
const objects = ((await r.json()).objects || {}).object || [];
|
||||
out.innerHTML = objects
|
||||
.map((o) => {
|
||||
const rows = o.attributes.attribute.map((a) => {
|
||||
const value = a.link || a.name === o.type ? `<span class="ref">${esc(a.value)}</span>` : esc(a.value);
|
||||
return `<tr class="${managed(a) ? "managed" : ""}"><th>${esc(a.name)}:</th><td>${value}${a.comment ? `<span class="comment"># ${esc(a.comment)}</span>` : ""}</td></tr>`;
|
||||
});
|
||||
return `<div class="ripe-card"><table>${rows.join("")}</table></div>`;
|
||||
})
|
||||
.join("");
|
||||
out.scrollTop = 0;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="ripe-msg">Lookup failed: ${esc(e.message)}. Is there a network?</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The answer changes the slide's height; have reveal centre it again.
|
||||
const relayout = () => window.deck && window.deck.layout();
|
||||
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.dns-lookup, form.ripe-lookup");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
await (form.classList.contains("ripe-lookup") ? ripe(form) : lookup(form));
|
||||
relayout();
|
||||
});
|
||||
})();
|
||||
|
||||
// Byte walks: hovering (or tabbing to) a field row lights its bytes in the
|
||||
// dump above it, and hovering bytes in the dump lights their row and scrolls
|
||||
// it into view, so the dump can be used as the index while presenting.
|
||||
(function () {
|
||||
const pair = (el) => {
|
||||
const walk = el.closest(".bytewalk");
|
||||
if (!walk) return [];
|
||||
const i = el.dataset.i;
|
||||
return [walk.querySelector(`.bw-dump span[data-i="${i}"]`), walk.querySelector(`.bw-row[data-i="${i}"]`)].filter(Boolean);
|
||||
};
|
||||
const target = (e) => e.target.closest && e.target.closest(".bw-row, .bw-dump span[data-i]");
|
||||
const on = (e) => {
|
||||
const el = target(e);
|
||||
if (!el) return;
|
||||
const both = pair(el);
|
||||
both.forEach((x) => x.classList.add("on"));
|
||||
if (el.matches(".bw-dump span")) {
|
||||
// Scroll the box itself, and instantly: scrollIntoView would also
|
||||
// move the slide, and Chrome drops smooth scrolls inside the
|
||||
// transformed slide that reveal positions.
|
||||
const row = both.find((x) => x.classList.contains("bw-row"));
|
||||
const box = row && row.closest(".bw-rows");
|
||||
if (box) box.scrollTop = row.offsetTop - box.clientHeight / 2 + row.offsetHeight / 2;
|
||||
}
|
||||
};
|
||||
const off = (e) => {
|
||||
const el = target(e);
|
||||
if (el) pair(el).forEach((x) => x.classList.remove("on"));
|
||||
};
|
||||
document.addEventListener("mouseover", on);
|
||||
document.addEventListener("focusin", on);
|
||||
document.addEventListener("mouseout", off);
|
||||
document.addEventListener("focusout", off);
|
||||
})();
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#11131f"/>
|
||||
<g fill="none" stroke="#5eb1ff" stroke-width="3">
|
||||
<line x1="9" y1="16" x2="23" y2="16"/>
|
||||
<circle cx="7" cy="16" r="4" fill="#5eb1ff"/>
|
||||
<circle cx="25" cy="16" r="4" fill="#11131f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
@@ -0,0 +1,230 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hands-on BGP</title>
|
||||
<!--
|
||||
Deck structure and look follow Matt Godbolt's newer talks (e.g.
|
||||
github.com/mattgodbolt/what-happens-beneath-accu-2026): reveal.js with
|
||||
no stock theme, a custom dark theme (theme.css), monokai code blocks,
|
||||
16:9, fade transitions, and one Markdown file per section.
|
||||
|
||||
reveal.js is loaded from ./reveal.js/dist if present (run
|
||||
./vendor-reveal.sh once; works offline), otherwise from the CDN. Its
|
||||
stylesheets are inserted BEFORE ours by the loader below, so theme.css
|
||||
and style.css always have the last word.
|
||||
-->
|
||||
<link rel="stylesheet" href="theme.css?v=3" id="deck-style" />
|
||||
<link rel="stylesheet" href="style.css?v=18" />
|
||||
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="shortcut icon" href="images/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
<!-- Title slide. For a full-bleed image instead, empty it and add
|
||||
data-background="images/title.png" like Matt does. -->
|
||||
<section class="title-slide">
|
||||
<h1>Hands-on BGP</h1>
|
||||
<h3>Insights into real world operations</h3>
|
||||
<p class="byline">
|
||||
Rayhaan Jaufeerally (rayhaan@rayhaan.ch) ·
|
||||
Network Security · ETH Zürich, 2026
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
One Markdown file per section. Inside a file:
|
||||
----- next horizontal slide
|
||||
--- next vertical slide (goes down, for detail you may skip)
|
||||
Notes: everything after this is speaker notes (press S)
|
||||
-->
|
||||
<section
|
||||
data-markdown="sections/00-intro.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/01-resources.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/03-bgp-update.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/04-transit-and-ix.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/05-lab.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits where Matt's decks carry the employer logo. -->
|
||||
<div class="wordmark"><b>AS210036</b> · netsec 2026</div>
|
||||
|
||||
<div id="deck-help" hidden>
|
||||
<h2>The slides did not load</h2>
|
||||
<p id="deck-help-why"></p>
|
||||
<pre>python3 -m http.server 8000 --directory slides</pre>
|
||||
<p>
|
||||
then open <code>http://localhost:8000</code>. Details in
|
||||
<code>slides/README.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A classic script: it still runs when the browser refuses to run the
|
||||
module below, which is exactly the case it has to explain. -->
|
||||
<script>
|
||||
(function () {
|
||||
function help(why) {
|
||||
document.getElementById("deck-help-why").textContent = why;
|
||||
document.getElementById("deck-help").hidden = false;
|
||||
}
|
||||
if (location.protocol === "file:") {
|
||||
help(
|
||||
"This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.addEventListener("deck-failed", function (e) {
|
||||
help(
|
||||
e.detail +
|
||||
" Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:",
|
||||
);
|
||||
});
|
||||
setTimeout(function () {
|
||||
if (!window.deck)
|
||||
help(
|
||||
"reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:",
|
||||
);
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="dns-lookup.js?v=7"></script>
|
||||
<script src="prefix-explorer.js?v=4"></script>
|
||||
|
||||
<script type="module">
|
||||
const VERSION = "6.0.2";
|
||||
const LOCAL = "./reveal.js/dist";
|
||||
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
|
||||
|
||||
// Prefer the vendored copy so the deck works without a network.
|
||||
async function pickBase() {
|
||||
try {
|
||||
const r = await fetch(`${LOCAL}/reveal.css`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (r.ok) return LOCAL;
|
||||
} catch (_) {}
|
||||
return CDN;
|
||||
}
|
||||
const base = await pickBase();
|
||||
const fail = (what) => (e) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("deck-failed", {
|
||||
detail: `${what} (${e && e.message ? e.message : e}).`,
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
};
|
||||
|
||||
const deckStyle = document.getElementById("deck-style");
|
||||
await Promise.all(
|
||||
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
|
||||
(href) =>
|
||||
new Promise((resolve) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${base}/${href}`;
|
||||
link.onload = link.onerror = resolve;
|
||||
deckStyle.before(link);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [
|
||||
{ default: Reveal },
|
||||
{ default: Markdown },
|
||||
{ default: Highlight },
|
||||
{ default: Notes },
|
||||
] = await Promise.all(
|
||||
[
|
||||
"reveal.mjs",
|
||||
"plugin/markdown.mjs",
|
||||
"plugin/highlight.mjs",
|
||||
"plugin/notes.mjs",
|
||||
].map((m) => import(/* @vite-ignore */ `${base}/${m}`)),
|
||||
).catch(fail(`Could not load reveal.js from ${base}`));
|
||||
|
||||
// BIRD configuration, so router config on slides is highlighted. Same
|
||||
// trick Matt uses for the assembly dialects in his talks.
|
||||
function bird() {
|
||||
return {
|
||||
name: "bird",
|
||||
keywords: {
|
||||
keyword:
|
||||
"protocol template filter function define table if then else case return accept reject " +
|
||||
"import export where none all limit action block neighbor local as direct multihop passive " +
|
||||
"description ipv4 ipv6 bgp kernel device static pipe include router id",
|
||||
built_in:
|
||||
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
|
||||
"bgp_med bgp_local_pref bgp_next_hop",
|
||||
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
|
||||
},
|
||||
contains: [
|
||||
{ className: "comment", begin: "#", end: "$" },
|
||||
{ className: "string", begin: '"', end: '"' },
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b",
|
||||
},
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b",
|
||||
},
|
||||
{ className: "number", begin: "\\b[0-9]+\\b" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ?accent=green|amber|magenta previews another accent; blue is the default.
|
||||
const accent = new URLSearchParams(location.search).get("accent");
|
||||
if (accent) document.documentElement.dataset.accent = accent;
|
||||
|
||||
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
|
||||
const scaleFactor = 0.65;
|
||||
const deck = new Reveal({
|
||||
hash: true,
|
||||
transition: "fade",
|
||||
slideNumber: false,
|
||||
margin: 0.15,
|
||||
width: 1920 * scaleFactor,
|
||||
height: 1080 * scaleFactor,
|
||||
markdown: { smartypants: true },
|
||||
highlight: {
|
||||
beforeHighlight: (hljs) =>
|
||||
hljs.registerLanguage("bird", bird),
|
||||
},
|
||||
plugins: [Markdown, Highlight, Notes],
|
||||
});
|
||||
|
||||
await deck.initialize().catch(fail("reveal.js failed to start"));
|
||||
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
|
||||
// The slides exist now: scripts that fill live boxes wait for this.
|
||||
window.dispatchEvent(new Event("deck-ready"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
// A live prefix calculator on a slide. Any <form class="prefix-explorer">
|
||||
// with <input name="prefix"> and <input type="range" name="len"> gets its
|
||||
// .px-bits, .px-range and .px-facts filled in as the reader types.
|
||||
(function () {
|
||||
const ALL = (1n << 128n) - 1n;
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// "2001:db8::1" -> BigInt, or null.
|
||||
function parse(text) {
|
||||
const s = text.trim().toLowerCase();
|
||||
if (!/^[0-9a-f:]+$/.test(s) || s.split("::").length > 2) return null;
|
||||
const [left, right = ""] = s.split("::");
|
||||
const l = left ? left.split(":") : [], r = right ? right.split(":") : [];
|
||||
if (s.includes("::") ? l.length + r.length > 7 : l.length !== 8) return null;
|
||||
const groups = [...l, ...Array(8 - l.length - r.length).fill("0"), ...r];
|
||||
let v = 0n;
|
||||
for (const g of groups) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
||||
v = (v << 16n) | BigInt(parseInt(g, 16));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function groupsOf(v) {
|
||||
const g = [];
|
||||
for (let i = 7; i >= 0; i--) g.push(Number((v >> BigInt(i * 16)) & 0xffffn));
|
||||
return g;
|
||||
}
|
||||
|
||||
// Shortest form, RFC 5952 style.
|
||||
function format(v) {
|
||||
const g = groupsOf(v).map((x) => x.toString(16));
|
||||
let best = -1, bestLen = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let n = 0;
|
||||
while (i + n < 8 && g[i + n] === "0") n++;
|
||||
if (n > bestLen && n > 1) { best = i; bestLen = n; }
|
||||
}
|
||||
if (best < 0) return g.join(":");
|
||||
return g.slice(0, best).join(":") + "::" + g.slice(best + bestLen).join(":");
|
||||
}
|
||||
|
||||
const sup = (k) => String(k).replace(/\d/g, (d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]);
|
||||
// 2^k as a number people can read: exact up to 2^50, else a power.
|
||||
const pow2 = (k) => (k <= 50 ? Number(1n << BigInt(k)).toLocaleString("en") : `2${sup(k)}`);
|
||||
|
||||
function render(form) {
|
||||
const input = form.elements.prefix, slider = form.elements.len;
|
||||
const bits = form.querySelector(".px-bits"), range = form.querySelector(".px-range"), facts = form.querySelector(".px-facts");
|
||||
let [addrText, lenText] = input.value.split("/");
|
||||
let len = lenText === undefined ? Number(slider.value) : parseInt(lenText, 10);
|
||||
const addr = parse(addrText || "");
|
||||
if (addr === null || !(len >= 0 && len <= 128)) {
|
||||
bits.innerHTML = '<span class="px-bad">not an IPv6 prefix: write address/length, e.g. 2001:db8::/32</span>';
|
||||
range.innerHTML = ""; facts.textContent = "";
|
||||
return;
|
||||
}
|
||||
slider.value = len;
|
||||
const host = 128 - len;
|
||||
const first = addr & ((ALL << BigInt(host)) & ALL);
|
||||
const last = first | ((1n << BigInt(host)) - 1n);
|
||||
|
||||
// The 32 hex digits with the fixed part in the accent colour; a nibble
|
||||
// that straddles the boundary is shown in binary.
|
||||
const hex = groupsOf(first).map((x) => x.toString(16).padStart(4, "0")).join("");
|
||||
const fullNibbles = Math.floor(len / 4), partBits = len % 4;
|
||||
const withColons = (s, offset) => s.replace(/./g, (c, i) => ((offset + i) % 4 === 3 && offset + i < 31 ? c + ":" : c));
|
||||
let html = `<span class="fixed">${withColons(hex.slice(0, fullNibbles), 0)}</span>`;
|
||||
let rest = fullNibbles;
|
||||
if (partBits) {
|
||||
const nib = parseInt(hex[fullNibbles], 16).toString(2).padStart(4, "0");
|
||||
// One hex digit, four bits, with the boundary inside it.
|
||||
html += `<span class="part" title="this hex digit straddles the boundary, shown as its four bits">${nib.slice(0, partBits)}<i>${"x".repeat(4 - partBits)}</i></span>`;
|
||||
rest++;
|
||||
}
|
||||
html += `<span class="free">${withColons("x".repeat(32 - rest), rest)}</span>`;
|
||||
bits.innerHTML = html;
|
||||
|
||||
// Every shorter prefix contains this one; show three of them, at byte
|
||||
// boundaries, with the range's position and share marked in each, so
|
||||
// that no single one looks like "the" parent. Then the range itself.
|
||||
const levels = [];
|
||||
for (let pl = len === 0 ? -1 : 8 * Math.floor((len - 1) / 8); pl >= 0 && levels.length < 3; pl -= 8) {
|
||||
const slots = 1n << BigInt(len - pl);
|
||||
const index = (first >> BigInt(host)) & (slots - 1n);
|
||||
const enclosing = first & ((ALL << BigInt(128 - pl)) & ALL);
|
||||
// Position and width as fractions; the slice is kept visible.
|
||||
const left = Number((index * 100000n) / slots) / 1000, width = Math.max(100 / Number(slots), 0.3);
|
||||
levels.push(`<div class="px-level"><span class="px-level-name">${esc(format(enclosing))}/${pl}</span>
|
||||
<span class="px-track"><i style="left:${left}%;width:${width}%"></i></span>
|
||||
<span class="px-level-count">1 of ${pow2(len - pl)}</span></div>`);
|
||||
}
|
||||
range.innerHTML = `
|
||||
<div class="px-levels">${levels.length ? `<div class="px-levels-title">every shorter prefix contains it; three of them:</div>${levels.join("")}` : '<div class="px-levels-title">nothing is shorter than /0: this is every address there is</div>'}</div>
|
||||
<div class="px-bar"><span class="px-end px-first">${esc(format(first))}</span><span class="px-mid">${pow2(host)} addresses</span><span class="px-end px-last">${esc(format(last))}</span></div>`;
|
||||
|
||||
const parts = [`The first <b>${len}</b> bits are fixed; the other ${host} are for use within the network.`];
|
||||
if (len < 64) parts.push(`That is <b>${pow2(64 - len)}</b> /64s, one per LAN.`);
|
||||
else if (len === 64) parts.push(`Exactly one LAN.`);
|
||||
else parts.push(`Smaller than a LAN: ${host === 0 ? "a single host" : `part of one /64`}.`);
|
||||
if (len < 48) parts.push(`Or ${pow2(48 - len)} /48s, one per site.`);
|
||||
facts.innerHTML = parts.join(" ");
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.prefix-explorer");
|
||||
if (!form) return;
|
||||
if (e.target.name === "len") {
|
||||
// The slider rewrites the length in the text field.
|
||||
const addr = form.elements.prefix.value.split("/")[0];
|
||||
form.elements.prefix.value = `${addr}/${e.target.value}`;
|
||||
}
|
||||
render(form);
|
||||
if (window.deck) window.deck.layout();
|
||||
});
|
||||
document.addEventListener("submit", (e) => {
|
||||
if (e.target.closest && e.target.closest("form.prefix-explorer")) e.preventDefault();
|
||||
});
|
||||
// Fill in the initial values once the slides exist.
|
||||
const init = () => document.querySelectorAll("form.prefix-explorer").forEach(render);
|
||||
window.addEventListener("deck-ready", init);
|
||||
if (document.readyState === "complete") setTimeout(init, 500);
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
## About the speaker
|
||||
|
||||
**Rayhaan Jaufeerally**
|
||||
|
||||
- Software engineer at a hyperscaler, working on security and reliability.
|
||||
- Studied at ETH from 2016 to 2019, and took the network security course.
|
||||
- Has been running **AS210036** since 2018, for personal use and research
|
||||
|
||||
<p class="note">Today we'll get to use some connectivity through this network.</p>
|
||||
|
||||
Notes:
|
||||
|
||||
- Working at a large company with some offices near the main train station
|
||||
- Former ETH masters student and took the network security course
|
||||
- After my degree I signed up to be a LIR at RIPE, and got my own Autonomous System and started peering, for the fun of running my own network.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Internet Numbering Resources
|
||||
|
||||
Notes:
|
||||
Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed.
|
||||
|
||||
---
|
||||
|
||||
## Packets are sent to IP addresses
|
||||
|
||||
<form class="dns-lookup" autocomplete="off">
|
||||
<input name="domain" value="ethz.ch" spellcheck="false" aria-label="domain name"><button type="submit">lookup AAAA</button>
|
||||
<div class="dns-result"><span class="dim">Ask a resolver which IPv6 address this name stands for.</span></div>
|
||||
</form>
|
||||
|
||||
IP packets are sent to IP addresses, not domain names. Every time you use a domain name, it is resolved into an IP address via DNS.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Live: type a few names the students use. ethz.ch has an AAAA record;
|
||||
github.com has none (only A), which shows that the two address families
|
||||
are separate. The answer comes from Google Public DNS over HTTPS
|
||||
(dns.google), so it needs the lecture-hall network.
|
||||
|
||||
---
|
||||
|
||||
## Who does the address belong to?
|
||||
|
||||
<form class="ripe-lookup" autocomplete="off">
|
||||
<input name="query" value="2001:67c:10ec:254::216" spellcheck="false" aria-label="address, prefix or AS number"><button type="submit">whois</button>
|
||||
<div class="ripe-result"><div class="ripe-msg">Paste the address from the last slide.</div></div>
|
||||
</form>
|
||||
|
||||
The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Paste the AAAA answer from the previous slide. ETH's /48 is ASSIGNED PI
|
||||
(provider independent, sponsored by SWITCH) and its route6 names AS559,
|
||||
SWITCH. Try 2a0d:d741:1::1 as well: the lab prefix, origin AS210036. The
|
||||
box takes AS numbers too (AS559). Same source as whois -h whois.ripe.net,
|
||||
over the database's REST API.
|
||||
|
||||
---
|
||||
|
||||
## An IPv6 address is 128 bits
|
||||
|
||||
```text
|
||||
2001:0db8:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
|
||||
2001:db8:1:0:0:0:0:1 drop leading zeros in a group
|
||||
2001:db8:1::1 one run of zero groups becomes ::
|
||||
```
|
||||
|
||||
`::` may appear once, or the address would be ambiguous. `2001:db8::/32` is reserved for documentation, so it never appears on the real Internet.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Ask: why only once? Because 2001::1::1 could put the zeros in several places.
|
||||
2001:db8::/32 is RFC 3849: set aside for examples, never routed.
|
||||
|
||||
---
|
||||
|
||||
## A prefix is a range of addresses
|
||||
|
||||
In BGP, the control plane for the internet, it's not viable to talk about single addresses, so we use prefix ranges.
|
||||
|
||||
<form class="prefix-explorer" autocomplete="off">
|
||||
<input name="prefix" value="2001:db8:1::/48" spellcheck="false" aria-label="IPv6 prefix"><input type="range" name="len" min="0" max="128" value="48" aria-label="prefix length">
|
||||
<p class="bits px-bits"></p>
|
||||
<div class="px-range"></div>
|
||||
<p class="note px-facts"></p>
|
||||
</form>
|
||||
|
||||
Notes:
|
||||
Live. Drag the slider: the fixed part grows and the range shrinks by half per
|
||||
bit. Try /47 or /50 to see a hex digit split in two. The three rows above the
|
||||
bar are three of the shorter prefixes that contain it, with its position and
|
||||
share in each: there is no such thing as "the" parent prefix, only whatever
|
||||
someone chose to allocate from.
|
||||
2^80 addresses in one /48, about 1.2 million billion billion. The /n is a
|
||||
count of leading bits, nothing more.
|
||||
|
||||
---
|
||||
|
||||
## The global IPv6 address space
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="Nested boxes: all of IPv6, inside it global unicast 2000::/3, inside that the documentation prefix 2001:db8::/32, inside that one /64 LAN with its hosts">
|
||||
<g class="nest l0"><rect x="8" y="8" width="984" height="454"/></g>
|
||||
<text x="28" y="42" class="left accent nest-label">::/0</text><text x="112" y="42" class="left nest-desc">every IPv6 address there is: 2¹²⁸ of them</text>
|
||||
<g class="nest l1"><rect x="80" y="70" width="880" height="366"/></g>
|
||||
<text x="100" y="104" class="left accent nest-label">2000::/3</text><text x="230" y="104" class="left nest-desc">global unicast: the eighth of the space that is routed on the Internet</text>
|
||||
<g class="nest l2"><rect x="160" y="134" width="760" height="278"/></g>
|
||||
<text x="180" y="168" class="left accent nest-label">2001:db8::/32</text><text x="376" y="168" class="left nest-desc">one provider's allocation (this one is reserved for documentation)</text>
|
||||
<g class="nest l3"><rect x="260" y="200" width="580" height="188"/></g>
|
||||
<text x="280" y="234" class="left accent nest-label">2001:db8:1:2::/64</text><text x="530" y="234" class="left nest-desc">one LAN: 2⁶⁴ addresses for its hosts</text>
|
||||
<circle class="host" cx="330" cy="310" r="12"/><text x="330" y="352" class="host-addr">::1</text>
|
||||
<circle class="host" cx="430" cy="310" r="12"/><text x="430" y="352" class="host-addr">::2</text>
|
||||
<circle class="host" cx="530" cy="310" r="12"/><text x="530" y="352" class="host-addr">::3</text>
|
||||
<text x="630" y="318" class="host-addr">…</text>
|
||||
<circle class="host" cx="740" cy="310" r="12"/><text x="740" y="352" class="host-addr">::ffff:ffff:ffff:ffff</text>
|
||||
<text x="976" y="452" class="small" style="text-anchor:end">not to scale</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Not to scale: a /64 is 2^-64 of the space. Only the first ring matters for
|
||||
routing; the others are how the space is handed down, which is next.
|
||||
|
||||
---
|
||||
|
||||
## Who hands out the addresses
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="IANA at the top hands blocks to five regional registries; RIPE NCC hands smaller blocks to local registries such as SWITCH and an ISP, which assign to end users such as ETH Zurich and their customers">
|
||||
<g class="hot"><rect x="400" y="14" width="200" height="56" rx="8"/></g><text x="500" y="48">IANA</text>
|
||||
<text x="30" y="48" class="left small">the root: one pool for the whole world</text>
|
||||
<line class="link" x1="500" y1="70" x2="96" y2="130"/><line class="link" x1="500" y1="70" x2="298" y2="130"/><line class="link" x1="500" y1="70" x2="500" y2="130"/><line class="link" x1="500" y1="70" x2="702" y2="130"/><line class="link" x1="500" y1="70" x2="904" y2="130"/>
|
||||
<text x="30" y="108" class="left small">a /12 to each region</text>
|
||||
<rect x="16" y="130" width="160" height="56" rx="8"/><text x="96" y="164">AFRINIC</text>
|
||||
<rect x="218" y="130" width="160" height="56" rx="8"/><text x="298" y="164">APNIC</text>
|
||||
<rect x="420" y="130" width="160" height="56" rx="8"/><text x="500" y="164">ARIN</text>
|
||||
<rect x="622" y="130" width="160" height="56" rx="8"/><text x="702" y="164">LACNIC</text>
|
||||
<g class="hot"><rect x="824" y="130" width="160" height="56" rx="8"/></g><text x="904" y="164" class="accent">RIPE NCC</text>
|
||||
<text x="30" y="222" class="left small">RIRs: five regional registries, one per part of the world</text>
|
||||
<line class="link" x1="904" y1="186" x2="690" y2="256"/><line class="link" x1="904" y1="186" x2="904" y2="256"/>
|
||||
<text x="700" y="238" class="left small">/29 and up to a member</text>
|
||||
<rect x="600" y="256" width="180" height="56" rx="8"/><text x="690" y="290">SWITCH</text>
|
||||
<rect x="814" y="256" width="180" height="56" rx="8"/><text x="904" y="290">an ISP</text>
|
||||
<text x="30" y="298" class="left small">LIRs: members of an RIR, usually providers</text>
|
||||
<line class="link" x1="690" y1="312" x2="690" y2="382"/><line class="link" x1="904" y1="312" x2="904" y2="382"/>
|
||||
<text x="700" y="360" class="left small">a /48 to a site</text>
|
||||
<rect x="600" y="382" width="180" height="56" rx="8"/><text x="690" y="416">ETH Zürich</text>
|
||||
<rect x="814" y="382" width="180" height="56" rx="8"/><text x="904" y="416">its customers</text>
|
||||
<text x="30" y="424" class="left small">end users: companies, universities, you</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
|
||||
- The Internet Assigned Numbers Authority comes up with allocations of IP
|
||||
addresses from the global space
|
||||
- Blocks are assigned to Regional Internet Registries (RIRs), which allocate
|
||||
them to Local Internet Registries (LIRs), who assign to end users
|
||||
- The RIPE lookup two slides back showed exactly this chain: ETH's /48 is
|
||||
ASSIGNED PI, sponsored by SWITCH (the LIR), registered at RIPE NCC (the
|
||||
RIR). Nobody owns addresses; they are registered to you.
|
||||
|
||||
---
|
||||
|
||||
## AS numbers
|
||||
|
||||
- Used to identify networks under a single oeprator, and required for exchanging BGP traffic
|
||||
- Managed by IANA and the RIRs
|
||||
- 16 bits at first: `1` to `65535`
|
||||
- 32 bits since RFC 6793: up to `4294967295`
|
||||
- In order to have a smooth transition, `AS23456` is used as a placeholder when the AS number is > 16 bits
|
||||
- 32 bit AS numbers are then exchanged using a BGP capability message to be backwards compatible
|
||||
- Private use: `64512–65534` and `4200000000–4294967294`
|
||||
|
||||
AS numbers come down the same chain: IANA to the RIRs, an RIR to whoever runs a network. The lab daemon is `AS4200000001`, a private one, stripped before the Internet sees your route.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Nobody "owns" numbers or addresses; they are registered to you. RFC 6996
|
||||
reserves the private ranges. The upstream router removes the private AS from
|
||||
the path, so the origin everybody else sees is AS210036.
|
||||
|
||||
---
|
||||
|
||||
## Handout Part 1
|
||||
@@ -0,0 +1,329 @@
|
||||
# BGP in detail
|
||||
|
||||
Notes:
|
||||
We're going to look at some implementation details of the protocol
|
||||
|
||||
---
|
||||
|
||||
##
|
||||
|
||||
- Runs over TCP, port `179`, and is exchanged typically between peering routers
|
||||
- Each side tells the other its best route per prefix
|
||||
- Only _changes_ are sent: no periodic refresh
|
||||
- If the session drops, after a timeout the routes learned over that session are dropped
|
||||
|
||||
Notes:
|
||||
BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants.
|
||||
Security and extensions have had to be bolted on, in perhaps strange ways.
|
||||
|
||||
---
|
||||
|
||||
## The four message types
|
||||
|
||||
| Type | Name | Says |
|
||||
| ---- | ------------ | ------------------------------------------------------- |
|
||||
| 1 | OPEN | AS number, router ID, capabilities |
|
||||
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
|
||||
| 3 | NOTIFICATION | errors and informational messages: e.g. rebooting |
|
||||
| 4 | KEEPALIVE | Heartbeat to keep the session up when there's no update |
|
||||
|
||||
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then a stream of UPDATEs.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed".
|
||||
|
||||
---
|
||||
|
||||
## OPEN: a handshake between peers.
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 340" role="img" aria-label="An OPEN message: the common header, then version, AS number, hold time, BGP identifier, and optional parameters that carry the capabilities">
|
||||
<text x="20" y="22" class="left small pkt-title">header, the same on every message: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">OPEN: the fixed part</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="169" class="pkt-name">version</text>
|
||||
<text x="95" y="191" class="pkt-size">1 byte: 4</text>
|
||||
<g class="pkt hl"><rect x="174" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="274" y="169" class="pkt-name">my AS</text>
|
||||
<text x="274" y="191" class="pkt-size">2 bytes, or 23456</text>
|
||||
<g class="pkt hl"><rect x="378" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="478" y="169" class="pkt-name">hold time</text>
|
||||
<text x="478" y="191" class="pkt-size">2 bytes, seconds</text>
|
||||
<g class="pkt hl"><rect x="582" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="702" y="169" class="pkt-name">BGP identifier</text>
|
||||
<text x="702" y="191" class="pkt-size">4 bytes, the router ID</text>
|
||||
<g class="pkt hl"><rect x="826" y="140" width="154" height="64" rx="6"/></g>
|
||||
<text x="903" y="169" class="pkt-name">opt. length</text>
|
||||
<text x="903" y="191" class="pkt-size">1 byte</text>
|
||||
<path class="pkt-expand" d="M 826 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 980 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">optional parameters: today always one, of type 2 = capabilities, each a (code, length, value)</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="120" height="64" rx="6"/></g>
|
||||
<text x="80" y="287" class="pkt-name">type 2</text>
|
||||
<text x="80" y="309" class="pkt-size">capabilities</text>
|
||||
<g class="pkt ha"><rect x="144" y="258" width="110" height="64" rx="6"/></g>
|
||||
<text x="199" y="287" class="pkt-name">length</text>
|
||||
<text x="199" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha2"><rect x="258" y="258" width="200" height="64" rx="6"/></g>
|
||||
<text x="358" y="287" class="pkt-name">IPv6 unicast</text>
|
||||
<text x="358" y="309" class="pkt-size">code 1: AFI 2, SAFI 1</text>
|
||||
<g class="pkt ha2"><rect x="462" y="258" width="190" height="64" rx="6"/></g>
|
||||
<text x="557" y="287" class="pkt-name">4-byte AS</text>
|
||||
<text x="557" y="309" class="pkt-size">code 65: 210036</text>
|
||||
<g class="pkt ha2"><rect x="656" y="258" width="170" height="64" rx="6"/></g>
|
||||
<text x="741" y="287" class="pkt-name">route refresh</text>
|
||||
<text x="741" y="309" class="pkt-size">code 2, empty</text>
|
||||
<g class="pkt ha2"><rect x="830" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="905" y="287" class="pkt-name">…</text>
|
||||
<text x="905" y="309" class="pkt-size">more capabilities</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Sent once, by each side, as soon as the TCP connection is up. The two sides
|
||||
must agree: the AS must be the one configured for the neighbour, the hold
|
||||
time becomes the smaller of the two, and only capabilities both announced are
|
||||
used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the
|
||||
real number rides in a capability, which is how our AS210036 arrives.
|
||||
|
||||
---
|
||||
|
||||
## BGP Open message
|
||||
|
||||
<div class="bytewalk">
|
||||
<pre class="bytes bw-dump"><span class="hh" data-i="0">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh" data-i="1">00 37</span> <span class="hh" data-i="2">01</span> <span class="hl" data-i="3">04</span> <span class="hl" data-i="4">5b a0</span> <span class="hl" data-i="5">00 f0</span> <span class="hl" data-i="6">0a ff 00 01</span> <span class="hl" data-i="7">1a</span> <span class="ha" data-i="8">02</span> <span class="ha" data-i="9">18</span> <span class="ha2" data-i="10">01</span> <span class="ha2" data-i="11">04</span> <span class="ha2" data-i="12">00 02</span> <span class="ha2" data-i="13">00</span> <span class="ha2" data-i="14">01</span> <span class="ha2" data-i="15">02</span> <span class="ha2" data-i="16">00</span> <span class="ha2" data-i="17">06</span> <span class="ha2" data-i="18">00</span> <span class="ha2" data-i="19">40</span> <span class="ha2" data-i="20">02</span> <span class="ha2" data-i="21">00 00</span> <span class="ha2" data-i="22">41</span> <span class="ha2" data-i="23">04</span> <span class="ha2" data-i="24">00 03 34 74</span> <span class="ha2" data-i="25">46</span> <span class="ha2" data-i="26">00</span> <span class="ha2" data-i="27">47</span> <span class="ha2" data-i="28">00</span></pre>
|
||||
<div class="bw-rows">
|
||||
<div class="bw-group">BGP header: 19 bytes, on every message</div>
|
||||
<div class="bw-row" data-i="0" tabindex="0"><code class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</code><b>marker</b><span>sixteen bytes of ff.</span></div>
|
||||
<div class="bw-row" data-i="1" tabindex="0"><code class="hh">00 37</code><b>length</b><span>0x0037 = 55 bytes, header included. At most 4096, or 65535 with the extended-message capability</span></div>
|
||||
<div class="bw-row" data-i="2" tabindex="0"><code class="hh">01</code><b>type</b><span>1 = OPEN (2 UPDATE, 3 NOTIFICATION, 4 KEEPALIVE, 5 ROUTE-REFRESH)</span></div>
|
||||
<div class="bw-group">OPEN: the fixed part, 10 bytes</div>
|
||||
<div class="bw-row" data-i="3" tabindex="0"><code class="hl">04</code><b>version</b><span>BGP version 4</span></div>
|
||||
<div class="bw-row" data-i="4" tabindex="0"><code class="hl">5b a0</code><b>my AS</b><span>0x5ba0 = 23456, AS_TRANS: the ASN is longer than 16 bits so is encoded in a capability instead</span></div>
|
||||
<div class="bw-row" data-i="5" tabindex="0"><code class="hl">00 f0</code><b>hold time</b><span>0x00f0 = 240 seconds. both peers use the minimum announced; a KEEPALIVE typically is sent every 1/3rd of this duration</span></div>
|
||||
<div class="bw-row" data-i="6" tabindex="0"><code class="hl">0a ff 00 01</code><b>BGP identifier</b><span>0a ff 00 01 = 10.255.0.1, the router ID. Looks like an IPv4 address, just needs to be a unique identifier.</span></div>
|
||||
<div class="bw-row" data-i="7" tabindex="0"><code class="hl">1a</code><b>optional parameters length</b><span>0x1a = 26 bytes follow</span></div>
|
||||
<div class="bw-group">Optional parameter: 2 + 24 bytes</div>
|
||||
<div class="bw-row" data-i="8" tabindex="0"><code class="ha">02</code><b>parameter type</b><span>2 = capabilities</span></div>
|
||||
<div class="bw-row" data-i="9" tabindex="0"><code class="ha">18</code><b>parameter length</b><span>0x18 = 24 bytes of capabilities follow, each as type, length, value</span></div>
|
||||
<div class="bw-group">Capability 1: multiprotocol (RFC 4760)</div>
|
||||
<div class="bw-row" data-i="10" tabindex="0"><code class="ha2">01</code><b>code</b><span>1 = multiprotocol extensions</span></div>
|
||||
<div class="bw-row" data-i="11" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
|
||||
<div class="bw-row" data-i="12" tabindex="0"><code class="ha2">00 02</code><b>AFI</b><span>0x0002 = IPv6. Address Family Identifier</span></div>
|
||||
<div class="bw-row" data-i="13" tabindex="0"><code class="ha2">00</code><b>reserved</b><span>0</span></div>
|
||||
<div class="bw-row" data-i="14" tabindex="0"><code class="ha2">01</code><b>SAFI</b><span>1 = unicast. Subsequent Address Family Identifier.</span></div>
|
||||
<div class="bw-group">Capability 2: route refresh (RFC 2918)</div>
|
||||
<div class="bw-row" data-i="15" tabindex="0"><code class="ha2">02</code><b>code</b><span>2 = route refresh: “you may ask me to resend everything”</span></div>
|
||||
<div class="bw-row" data-i="16" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 6: extended message (RFC 8654)</div>
|
||||
<div class="bw-row" data-i="17" tabindex="0"><code class="ha2">06</code><b>code</b><span>6 = messages up to 65535 bytes are supported</span></div>
|
||||
<div class="bw-row" data-i="18" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 64: graceful restart (RFC 4724)</div>
|
||||
<div class="bw-row" data-i="19" tabindex="0"><code class="ha2">40</code><b>code</b><span>0x40 = 64, graceful restart</span></div>
|
||||
<div class="bw-row" data-i="20" tabindex="0"><code class="ha2">02</code><b>length</b><span>2</span></div>
|
||||
<div class="bw-row" data-i="21" tabindex="0"><code class="ha2">00 00</code><b>flags and restart time</b><span>0x0000: not restarting, restart time 0, and no address families listed.</span></div>
|
||||
<div class="bw-group">Capability 65: four-octet AS (RFC 6793)</div>
|
||||
<div class="bw-row" data-i="22" tabindex="0"><code class="ha2">41</code><b>code</b><span>0x41 = 65, four-octet AS number</span></div>
|
||||
<div class="bw-row" data-i="23" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
|
||||
<div class="bw-row" data-i="24" tabindex="0"><code class="ha2">00 03 34 74</code><b>AS number</b><span>00 03 34 74 = 210036: the real AS. Included here since it doesn't fit in the header</span></div>
|
||||
<div class="bw-group">Capability 70: enhanced route refresh (RFC 7313)</div>
|
||||
<div class="bw-row" data-i="25" tabindex="0"><code class="ha2">46</code><b>code</b><span>0x46 = 70</span></div>
|
||||
<div class="bw-row" data-i="26" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 71: long-lived graceful restart</div>
|
||||
<div class="bw-row" data-i="27" tabindex="0"><code class="ha2">47</code><b>code</b><span>0x47 = 71</span></div>
|
||||
<div class="bw-row" data-i="28" tabindex="0"><code class="ha2">00</code><b>length</b><span>0, no address families listed</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Notes:
|
||||
What BIRD on our upstream router sends the lab daemon, byte for byte. Hold
|
||||
time 240 is BIRD's default; keepalives go every third of it.
|
||||
|
||||
---
|
||||
|
||||
## Update message format
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 460" role="img" aria-label="An UPDATE message: the common header, then withdrawn routes, path attributes and NLRI, with one attribute and one prefix expanded">
|
||||
<text x="20" y="22" class="left small pkt-title">header: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">UPDATE body: two lengths, three variable parts</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="105" y="169" class="pkt-name">withdrawn length</text>
|
||||
<text x="105" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hn"><rect x="194" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="294" y="169" class="pkt-name">withdrawn routes</text>
|
||||
<text x="294" y="191" class="pkt-size">IPv4 prefixes gone</text>
|
||||
<g class="pkt hl"><rect x="398" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="483" y="169" class="pkt-name">attributes length</text>
|
||||
<text x="483" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt ha"><rect x="572" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="692" y="169" class="pkt-name">path attributes</text>
|
||||
<text x="692" y="191" class="pkt-size">everything about the route</text>
|
||||
<g class="pkt hn"><rect x="816" y="140" width="172" height="64" rx="6"/></g>
|
||||
<text x="902" y="169" class="pkt-name">NLRI</text>
|
||||
<text x="902" y="191" class="pkt-size">IPv4 prefixes, new</text>
|
||||
<path class="pkt-expand" d="M 572 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 812 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">one path attribute; repeated for multiple</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="287" class="pkt-name">flags</text>
|
||||
<text x="95" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha"><rect x="174" y="258" width="330" height="64" rx="6"/></g>
|
||||
<text x="339" y="287" class="pkt-name">type</text>
|
||||
<text x="339" y="309" class="pkt-size">1 byte: ORIGIN = 1, AS_PATH = 2 …</text>
|
||||
<g class="pkt ha"><rect x="508" y="258" width="180" height="64" rx="6"/></g>
|
||||
<text x="598" y="287" class="pkt-name">length</text>
|
||||
<text x="598" y="309" class="pkt-size">1 or 2 bytes</text>
|
||||
<g class="pkt ha2"><rect x="692" y="258" width="292" height="64" rx="6"/></g>
|
||||
<text x="838" y="287" class="pkt-name">value</text>
|
||||
<text x="838" y="309" class="pkt-size">as many bytes as it said</text>
|
||||
<path class="pkt-expand" d="M 979 204 L 979 376"/>
|
||||
<text x="20" y="368" class="left small pkt-title">one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs</text>
|
||||
<g class="pkt hn"><rect x="20" y="376" width="300" height="64" rx="6"/></g>
|
||||
<text x="170" y="405" class="pkt-name">length</text>
|
||||
<text x="170" y="427" class="pkt-size">1 byte: 24 for a /24</text>
|
||||
<g class="pkt hn"><rect x="324" y="376" width="656" height="64" rx="6"/></g>
|
||||
<text x="652" y="405" class="pkt-name">prefix</text>
|
||||
<text x="652" y="427" class="pkt-size">3 bytes for a /24, 6 for a /48 …</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
One UPDATE carries one set of attributes, and every prefix that shares them.
|
||||
The withdrawn and NLRI fields can only hold IPv4; the next slides show where
|
||||
IPv6 goes. Lengths everywhere: a receiver can walk the message without
|
||||
understanding any of it, which is what lets unknown attributes pass through.
|
||||
|
||||
---
|
||||
|
||||
## An example BGP update
|
||||
|
||||
<div class="legend"><span class="hh">header</span><span class="hl">lengths</span><span class="ha">path attributes</span><span class="hn">NLRI</span></div>
|
||||
|
||||
<pre class="bytes"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
`2a0d:d740::/48`, as AS210036 announces it to the SwissIX route servers.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Built with the lab daemon's own encoder and checked by parsing it back, with
|
||||
the attributes pr01 puts on routes it exports to peers. Press down to walk
|
||||
through it part by part. Note there is nothing yellow: the NLRI field is
|
||||
empty. Hold that thought.
|
||||
|
||||
---
|
||||
|
||||
## Header
|
||||
|
||||
<pre class="bytes focus"><span class="hh on">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh on">00 59</span> <span class="hh on">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>ff × 16</td><td>Start of message marker.</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
|
||||
|
||||
---
|
||||
|
||||
## Two lengths
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl on">00 00</span> <span class="hl on">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>00 00</td><td>0 bytes of withdrawn routes: nothing is being taken back</td></tr><tr><td>00 42</td><td>66 bytes of path attributes follow</td></tr><tr><td></td><td>89 − 19 − 2 − 2 − 66 = 0 bytes left over for NLRI</td></tr></table>
|
||||
|
||||
---
|
||||
|
||||
## ORIGIN
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha on">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>40</td><td>flags: well-known, transitive</td></tr><tr><td>01</td><td>type 1 = ORIGIN</td></tr><tr><td>01</td><td>1 byte of value</td></tr><tr><td>00</td><td>IGP: the route was originated inside this AS</td></tr></table>
|
||||
|
||||
Every attribute is _flags, type, length, value_.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## AS_PATH
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2 on">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>40 02 06</td><td>well-known, transitive; type 2; 6 bytes</td></tr><tr><td>02</td><td>an AS_SEQUENCE, order matters</td></tr><tr><td>01</td><td>of 1 AS number</td></tr><tr><td>00 03 34 74</td><td>210036, the origin. Four bytes each (RFC 6793)</td></tr></table>
|
||||
|
||||
Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
One entry, because SwissIX is one hop from the origin and the route server
|
||||
does not add itself. Seen from further away it reads e.g. 6939 210036.
|
||||
|
||||
---
|
||||
|
||||
## MULTI_EXIT_DISC
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha on">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>80 04 04</td><td>optional, non-transitive; type 4; 4 bytes</td></tr><tr><td>00 00 00 00</td><td>MED 0. Lower is preferred, among routes from the same neighbour AS</td></tr></table>
|
||||
|
||||
A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2 on">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>80 0e 1c</td><td>optional, non-transitive; type 14; 28 bytes</td></tr><tr><td>00 02 · 01</td><td>address family IPv6, unicast</td></tr><tr><td>10 · 2001 07f8 0024 … 00c9</td><td>next hop, 16 bytes: 2001:7f8:24::c9, our port on the SwissIX peering LAN</td></tr><tr><td>00</td><td>reserved</td></tr><tr><td>30 · 2a 0d d7 40 00 00</td><td>48 bits of prefix: 2a0d:d740::/48</td></tr></table>
|
||||
|
||||
Notes:
|
||||
BGP-4 was built for IPv4: its NLRI field cannot carry anything else. RFC 4760
|
||||
bolts other address families on as an attribute, each with its own next hop.
|
||||
A prefix is sent as a bit count plus only as many bytes as the count needs.
|
||||
The next hop is an address on the exchange's shared LAN: whoever receives this
|
||||
route sends packets straight to that address across the switch. Remember ::c9
|
||||
for the exchange diagram in the next section.
|
||||
Routers may also append their link-local address here, making it 32 bytes.
|
||||
|
||||
---
|
||||
|
||||
## LARGE_COMMUNITIES
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha on">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>c0 20 0c</td><td>optional, transitive; type 32; 12 bytes = one community</td></tr><tr><td>00033474 · 0000000a · 00000002</td><td>210036:10:2, a tag our router puts on everything it exports to peers</td></tr></table>
|
||||
|
||||
Three 32-bit numbers. They mean whatever the network named in the first one says they mean.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## The flags byte
|
||||
|
||||
| Bit | Set means | Clear means |
|
||||
| ------ | ------------------------------------------------- | ------------------------------------------------ |
|
||||
| `0x80` | optional | well-known: every BGP speaker must understand it |
|
||||
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
|
||||
| `0x20` | partial: someone on the way did not understand it | |
|
||||
| `0x10` | length field is 2 bytes | 1 byte |
|
||||
|
||||
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
@@ -0,0 +1,105 @@
|
||||
# Transit and exchanges
|
||||
|
||||
Notes:
|
||||
Section 4. Two very different ways of getting routes, and what an Internet
|
||||
exchange physically is.
|
||||
|
||||
---
|
||||
|
||||
## Two ways to reach other networks
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
|
||||
### IP transit
|
||||
|
||||
- You **pay** a provider
|
||||
- They give you routes to _everyone_
|
||||
- They announce you to _everyone_
|
||||
|
||||
</div>
|
||||
<div>
|
||||
|
||||
### Peering
|
||||
|
||||
- Usually **free**, by mutual benefit
|
||||
- You exchange _your own_ routes, and your customers'
|
||||
- Traffic between the locally connected parties only
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Notes:
|
||||
Transit is a customer-provider relationship; peering is between equals. A peer
|
||||
does not carry your traffic to third parties. Most networks have both: transit
|
||||
as the fallback, peering to keep traffic cheap and close.
|
||||
|
||||
---
|
||||
|
||||
## An exchange is a big Ethernet switch
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 400" role="img" aria-label="Members attached to a shared Ethernet peering LAN, with a route server">
|
||||
<g class="fabric"><rect x="60" y="175" width="880" height="56" rx="10"/></g>
|
||||
<text x="500" y="200" class="accent">peering LAN: one Ethernet segment, one subnet</text>
|
||||
<text x="500" y="220" class="small">SwissIX 2001:7f8:24::/64</text>
|
||||
<g class="hot"><rect x="70" y="40" width="180" height="70" rx="8"/></g>
|
||||
<text x="160" y="70">AS210036</text><text x="160" y="92" class="small">::c9</text>
|
||||
<rect x="300" y="40" width="180" height="70" rx="8"/><text x="390" y="70">AS559 SWITCH</text><text x="390" y="92" class="small">::35</text>
|
||||
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS2906 Netflix</text><text x="620" y="92" class="small">::55</text>
|
||||
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335 Cloudflare</text><text x="850" y="92" class="small">::c0</text>
|
||||
<line class="link" x1="160" y1="110" x2="160" y2="175"/><line class="link" x1="390" y1="110" x2="390" y2="175"/>
|
||||
<line class="link" x1="620" y1="110" x2="620" y2="175"/><line class="link" x1="850" y1="110" x2="850" y2="175"/>
|
||||
<rect x="385" y="300" width="230" height="70" rx="8"/><text x="500" y="330">route servers</text><text x="500" y="352" class="small">AS42476 ::a5ec:a:1</text>
|
||||
<line class="link" x1="500" y1="231" x2="500" y2="300"/>
|
||||
<path class="bgp" d="M 140 110 C 140 290, 300 335, 385 335"/>
|
||||
<path class="bgp" d="M 410 110 C 410 250, 440 270, 470 300"/>
|
||||
<path class="traffic" d="M 180 110 C 180 165, 370 165, 370 110"/>
|
||||
<text x="275" y="138" class="small" style="fill:#4fd98a">traffic</text>
|
||||
<text x="215" y="300" class="small accent">BGP</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Every member plugs one router port into the same layer-2 network and gets one
|
||||
address in a shared subnet. No routing inside the exchange: just Ethernet.
|
||||
Dashed blue is a BGP session, green is where the packets actually go.
|
||||
|
||||
---
|
||||
|
||||
## What that implies
|
||||
|
||||
- Peering participants in an exchange are all one hop away, found by neighbour discovery
|
||||
- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
|
||||
- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost
|
||||
- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets)
|
||||
|
||||
Notes:
|
||||
|
||||
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
|
||||
- after which traffic can flow directly
|
||||
|
||||
---
|
||||
|
||||
## Route servers
|
||||
|
||||
Are a piece of shared infrastructure that simplifies peering.
|
||||
|
||||
- Most participants peer with the route server and annouce their prefixes there
|
||||
- The route server can then distribute these routes to every other participant
|
||||
- Simplifes from `n²` to `n` sessions
|
||||
- Not in the traffic path and does not add its own AS into the path
|
||||
- Allows controlling behaviour with communities, e.g. `42476:0:<as>` "not to `<as>`", `42476:1:<as>` "only to `<as>`"
|
||||
|
||||
Notes:
|
||||
A route server is a BGP speaker that only redistributes. Because it is
|
||||
transparent, the AS path looks exactly as if you peered with each member
|
||||
directly. We will use those communities in the lab.
|
||||
|
||||
---
|
||||
|
||||
## Looking glasses
|
||||
|
||||
Are a useful tool to inspect the state of another network or internet exchange
|
||||
|
||||
- SwissIX https://lg.swissix.ch/
|
||||
- NL-ix https://lg.nl-ix.net/
|
||||
- BGP tools https://bgp.tools/
|
||||
@@ -0,0 +1,90 @@
|
||||
# The lab
|
||||
|
||||
### <a href="https://bgplab.rayhaan.net">bgplab.rayhaan.net</a>
|
||||
|
||||
Notes:
|
||||
Section 5. What the students are about to touch, and what stands between a
|
||||
typo and the Internet.
|
||||
|
||||
---
|
||||
|
||||
## What you are connected to
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to the lab BGP speaker to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
|
||||
<rect x="20" y="170" width="150" height="80" rx="8"/><text x="95" y="205">you</text><text x="95" y="228" class="small">a browser</text>
|
||||
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">lab speaker</text><text x="330" y="226" class="small">AS4200000001</text>
|
||||
<rect x="490" y="170" width="200" height="80" rx="8"/><text x="590" y="202">pr01.rue (BIRD)</text><text x="590" y="226" class="small">AS210036</text>
|
||||
<line class="link" x1="170" y1="210" x2="230" y2="210"/><text x="200" y="160" class="small">HTTPS</text>
|
||||
<line class="bgp" x1="430" y1="210" x2="490" y2="210"/><text x="460" y="160" class="small accent">BGP</text>
|
||||
<text x="330" y="280" class="small">web page + BGP speaker</text><text x="590" y="280" class="small">filters, then decides where</text>
|
||||
<rect x="780" y="20" width="200" height="56" rx="8"/><text x="880" y="54">SwissIX</text>
|
||||
<rect x="780" y="98" width="200" height="56" rx="8"/><text x="880" y="132">NL-ix</text>
|
||||
<rect x="780" y="176" width="200" height="56" rx="8"/><text x="880" y="210">France-IX</text>
|
||||
<rect x="780" y="254" width="200" height="56" rx="8"/><text x="880" y="288">DE-CIX</text>
|
||||
<rect x="780" y="352" width="200" height="56" rx="8"/><text x="880" y="386">transit</text>
|
||||
<path class="bgp" d="M 690 195 C 740 195, 730 48, 780 48"/><path class="bgp" d="M 690 203 C 740 203, 735 126, 780 126"/>
|
||||
<path class="bgp" d="M 690 210 L 780 204"/><path class="bgp" d="M 690 217 C 740 217, 735 282, 780 282"/>
|
||||
<path class="bgp" d="M 690 225 C 740 225, 730 380, 780 380"/>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
The lab speaker is a small BGP daemon written for this course; it runs on
|
||||
the same machine as the router. Both dashed hops are real BGP sessions. Everything to
|
||||
the right of pr01 is the actual Internet.
|
||||
|
||||
---
|
||||
|
||||
## Your prefix
|
||||
|
||||
`2a0d:d741:<n>::/48`
|
||||
|
||||
- `<n>` is your number **in hex**: student 10 is `2a0d:d741:a::/48`
|
||||
- Put your name in _owner_: only the owner can withdraw a route
|
||||
- One prefix each
|
||||
|
||||
Notes:
|
||||
Put the URL and the class password on the board. The page is a snapshot by
|
||||
default; turn on auto-refresh at the top right to watch things change.
|
||||
|
||||
---
|
||||
|
||||
## What happens when you press _Announce_
|
||||
|
||||
1. The lab speaker builds an UPDATE. Click _bytes_ to see it, hover to decode it
|
||||
2. pr01 checks it against its import filter
|
||||
3. pr01 decides, per neighbour, whether to pass it on
|
||||
4. Your announcement propagates throughout the whole internet!
|
||||
5. `ping 2a0d:d741:<n>::1` answers, from anywhere
|
||||
|
||||
Notes:
|
||||
Step 5: pr01 answers for every address in the lab space, so reachability of
|
||||
your prefix depends only on whether your announcement got out.
|
||||
|
||||
---
|
||||
|
||||
## Steering your route
|
||||
|
||||
| You add | Who acts | Effect |
|
||||
| ----------------------------- | -------- | --------------------------------- |
|
||||
| nothing | | announced everywhere |
|
||||
| `210036:0:0` | pr01 | announced nowhere |
|
||||
| `210036:0:0` `210036:1:42476` | pr01 | only to the SwissIX route servers |
|
||||
| + `42476:0:0` `42476:1:559` | SwissIX | …and from there only to SWITCH |
|
||||
| `NO_EXPORT` | pr01 | accepted, never passed on |
|
||||
|
||||
Same idea at two hops: _never there_, _only there_, _nowhere_.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
210036:0:<as> never to that AS, 210036:1:<as> to that AS, 210036:2/3:<n> the
|
||||
same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always
|
||||
wins. The preset menu next to the field has all of these.
|
||||
|
||||
---
|
||||
|
||||
## Handout Part 2 & 3
|
||||
|
||||
Notes:
|
||||
Looking glasses: bgp.tools, RIPEstat, and the exchange's own. Global
|
||||
visibility takes a minute or two to settle after each change.
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve the deck for editing: like `python3 -m http.server`, but every
|
||||
response says Cache-Control: no-store, so a plain reload always shows the
|
||||
files as they are on disk. (The stock server sends Last-Modified and nothing
|
||||
else, and browsers then keep the section files and the CSS for a while.)
|
||||
|
||||
python3 slides/serve.py # http://localhost:8765
|
||||
python3 slides/serve.py 8000
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # quiet; errors still go through log_error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
print(f"serving {os.getcwd()} on http://localhost:{port}/")
|
||||
ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever()
|
||||
@@ -0,0 +1,556 @@
|
||||
/*
|
||||
* Components and helpers used by the slides. The look (palette, type, panels,
|
||||
* motif) is in theme.css; nothing here sets a brand colour directly.
|
||||
*/
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- text -------------------------------------------------------------------- */
|
||||
|
||||
/* A quiet aside under the main point. */
|
||||
.reveal .note {
|
||||
font-style: italic;
|
||||
font-size: 75%;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Image and quote credits: <!-- .element: class="attribution" --> */
|
||||
.reveal .attribution {
|
||||
font-size: 45%;
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* One big statement on an otherwise empty slide. */
|
||||
.reveal .big {
|
||||
font-size: 1.9em;
|
||||
font-weight: 300;
|
||||
line-height: 1.25;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
/* ---- layout ------------------------------------------------------------------ */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.flex-around {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-start;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
/* Two equal columns: <div class="cols"><div>…</div><div>…</div></div> */
|
||||
.cols {
|
||||
display: flex;
|
||||
gap: 1.2em;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cols > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.no-border {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.reveal img.blur-edges {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 25px 8px rgba(20, 30, 60, 0.2);
|
||||
}
|
||||
|
||||
.reveal table.borders td,
|
||||
.reveal table.borders th {
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* ---- boxes: small labelled cards, e.g. one per AS or per community ----------- */
|
||||
|
||||
.boxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
font-size: 70%;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.boxes > div {
|
||||
padding: 0.35em 0.8em;
|
||||
margin: 0.3em 0.5em;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 10px;
|
||||
background: var(--ground);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.boxes > div.us {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.35);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.boxes > div.ok {
|
||||
border-color: #1a8a4f;
|
||||
}
|
||||
|
||||
.boxes > div.bad {
|
||||
border-color: #d64545;
|
||||
}
|
||||
|
||||
/* ---- BGP bytes, matching the four groups of the /statusz bytes view ---------- */
|
||||
|
||||
.reveal pre.bytes {
|
||||
font-size: 0.5em;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--code-ground);
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
box-sizing: border-box;
|
||||
color: var(--code-ink);
|
||||
}
|
||||
|
||||
.bytes span,
|
||||
.legend span {
|
||||
border-radius: 3px;
|
||||
padding: 1px 3px;
|
||||
color: var(--code-ink); /* the fills are dark on any ground */
|
||||
}
|
||||
|
||||
.bytes .hh,
|
||||
.legend .hh { background: #34406e; }
|
||||
.bytes .hl,
|
||||
.legend .hl { background: #6e3440; }
|
||||
.bytes .ha,
|
||||
.legend .ha { background: #245c3a; }
|
||||
.bytes .ha2 { background: #2f7a4b; }
|
||||
.bytes .hn,
|
||||
.legend .hn { background: #6b5a1f; }
|
||||
|
||||
.legend {
|
||||
font-size: 50%;
|
||||
font-family: var(--r-code-font);
|
||||
}
|
||||
|
||||
.legend span {
|
||||
padding: 1px 0.6em;
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
|
||||
/* ---- live DNS and RIPE database lookups ------------------------------------ */
|
||||
|
||||
.reveal form.dns-lookup,
|
||||
.reveal form.ripe-lookup {
|
||||
margin: 0.4em auto 0;
|
||||
max-width: 34em;
|
||||
text-align: left;
|
||||
font-family: var(--r-code-font);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input,
|
||||
.reveal .ripe-lookup input {
|
||||
width: 62%;
|
||||
padding: 0.3em 0.5em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input:focus,
|
||||
.reveal .ripe-lookup input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup button,
|
||||
.reveal .ripe-lookup button {
|
||||
margin-left: 0.4em;
|
||||
padding: 0.3em 0.9em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reveal .dns-result {
|
||||
min-height: 3.2em;
|
||||
max-height: 13em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--rule) transparent;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em 0.7em;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .dns-result .ttl,
|
||||
.reveal .dns-result .type,
|
||||
.reveal .dns-result .dim { color: var(--muted); }
|
||||
.reveal .dns-result .addr { color: var(--accent); font-weight: 500; }
|
||||
.reveal .dns-result .bad { color: #c62828; }
|
||||
|
||||
/* RIPE database objects, laid out like apps.db.ripe.net: a light page with
|
||||
one card per object, one row per attribute, references in blue, rows the
|
||||
RIPE NCC manages shaded. */
|
||||
.reveal .ripe-result {
|
||||
/* A fixed height, not a maximum: the box must not grow when the answer
|
||||
arrives, or the slide overflows (reveal centres a slide only when it
|
||||
is shown). */
|
||||
height: 13.5em;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 0.62em;
|
||||
text-align: left;
|
||||
background: #f4f5f7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .ripe-card {
|
||||
margin-bottom: 0.7em;
|
||||
padding: 0.2em 0.5em;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.reveal .ripe-card:last-child { margin-bottom: 0; }
|
||||
|
||||
.reveal .ripe-card table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: inherit;
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
.reveal .ripe-card th,
|
||||
.reveal .ripe-card td {
|
||||
padding: 0.28em 0.6em;
|
||||
border: 0;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reveal .ripe-card table th {
|
||||
width: 8.5em;
|
||||
color: #1c1c28;
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted #9a9aa6;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.reveal .ripe-card td { word-break: break-all; }
|
||||
.reveal .ripe-card tr.managed th,
|
||||
.reveal .ripe-card tr.managed td { background: #dde5f0; }
|
||||
.reveal .ripe-card .ref { color: #2f6fe4; }
|
||||
.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; }
|
||||
|
||||
.reveal .ripe-msg {
|
||||
padding: 0.6em;
|
||||
font-family: var(--r-code-font);
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
/* A framed live page on a slide. */
|
||||
.reveal iframe.live {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ---- bytes with one part in focus: everything not .on is dimmed --------------- */
|
||||
|
||||
.bytes.focus span:not(.on) {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.bytes.focus span.on {
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
|
||||
}
|
||||
|
||||
/* Packet layouts: field boxes in the colours of the bytes view. */
|
||||
.diagram .pkt rect { stroke: none; }
|
||||
.diagram .pkt.hh rect { fill: #34406e; }
|
||||
.diagram .pkt.hl rect { fill: #6e3440; }
|
||||
.diagram .pkt.ha rect { fill: #245c3a; }
|
||||
.diagram .pkt.ha2 rect { fill: #2f7a4b; }
|
||||
.diagram .pkt.hn rect { fill: #6b5a1f; }
|
||||
.diagram text.pkt-name { font-size: 18px; font-weight: 500; fill: #fff; }
|
||||
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: #fff; opacity: 0.8; }
|
||||
.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; }
|
||||
.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; }
|
||||
|
||||
/* ---- byte walk: a hex dump and one row per field, in step on hover --------- */
|
||||
|
||||
.reveal .bytewalk { text-align: left; }
|
||||
.reveal .bytewalk .bw-dump { margin-bottom: 8px; }
|
||||
/* The lit bytes invert: a white block on the dark dump, visible from the back row. */
|
||||
.reveal .bw-dump span.on { background: #fff; color: var(--code-ground); box-shadow: 0 0 0 3px var(--accent); }
|
||||
|
||||
.reveal .bw-rows {
|
||||
position: relative; /* rows measure their offsetTop against the box */
|
||||
height: 19em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 46%;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--accent-rgb), 0.04);
|
||||
}
|
||||
|
||||
.reveal .bw-group {
|
||||
padding: 0.45em 0.8em 0.2em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reveal .bw-row {
|
||||
display: grid;
|
||||
grid-template-columns: 11em 12em 1fr;
|
||||
gap: 0 1em;
|
||||
padding: 0.18em 0.8em;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal .bw-row:hover,
|
||||
.reveal .bw-row:focus,
|
||||
.reveal .bw-row.on { background: rgba(var(--accent-rgb), 0.14); }
|
||||
.reveal .bw-dump span[data-i] { cursor: pointer; }
|
||||
.reveal .bw-row code.hh { background: rgba(52, 64, 110, 0.16); }
|
||||
.reveal .bw-row code.hl { background: rgba(110, 52, 64, 0.16); }
|
||||
.reveal .bw-row code.ha { background: rgba(36, 92, 58, 0.16); }
|
||||
.reveal .bw-row code.ha2 { background: rgba(47, 122, 75, 0.16); }
|
||||
.reveal .bw-row code.hn { background: rgba(107, 90, 31, 0.16); }
|
||||
.reveal .bw-row code { display: block; color: var(--ink-strong); min-width: 0; max-width: 100%; justify-self: start; align-self: start; font-family: var(--r-code-font); border-radius: 3px; padding: 0 4px; white-space: normal; word-break: normal; }
|
||||
.reveal .bw-row b { color: var(--ink-strong); font-weight: 500; }
|
||||
.reveal .bw-row span { color: var(--ink); }
|
||||
|
||||
/* Small two-column explanation under a bytes block. */
|
||||
.reveal table.decode {
|
||||
font-size: 58%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.reveal table.decode td:first-child {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- prefix explorer: type a prefix, see its range ------------------------ */
|
||||
|
||||
.reveal form.prefix-explorer {
|
||||
margin: 0 auto;
|
||||
max-width: 36em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"] {
|
||||
width: 15em;
|
||||
padding: 0.25em 0.5em;
|
||||
font: inherit;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); }
|
||||
|
||||
.reveal .prefix-explorer input[name="len"] {
|
||||
width: 12em;
|
||||
margin-left: 1em;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; }
|
||||
.reveal .prefix-explorer .px-bad { color: #c62828; font-size: 70%; }
|
||||
|
||||
.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; }
|
||||
|
||||
.reveal .px-levels { text-align: left; margin-bottom: 1em; }
|
||||
.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; }
|
||||
.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; }
|
||||
.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; }
|
||||
.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; }
|
||||
.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; }
|
||||
.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); }
|
||||
|
||||
.reveal .px-bar {
|
||||
position: relative;
|
||||
height: 2.6em;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.reveal .px-bar .px-mid {
|
||||
position: absolute; left: 0; right: 0; top: 0.6em;
|
||||
text-align: center; color: var(--ink-strong); font-weight: 500;
|
||||
}
|
||||
.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); }
|
||||
.reveal .px-bar .px-first { left: 0; }
|
||||
.reveal .px-bar .px-last { right: 0; }
|
||||
|
||||
.reveal .prefix-explorer .px-facts { margin-top: 2.6em; }
|
||||
|
||||
/* ---- bits of an address: fixed part vs. free part ------------------------------ */
|
||||
|
||||
.bits {
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 80%;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bits .fixed {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 4px 0 0 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.bits .part {
|
||||
color: var(--accent-soft);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
margin: 0 2px;
|
||||
font-size: 85%;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; }
|
||||
|
||||
.bits .part i { font-style: normal; color: var(--muted); }
|
||||
|
||||
.bits .free {
|
||||
color: var(--ink);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ---- diagrams: inline SVG drawn with the theme's colours ------------------------ */
|
||||
|
||||
.reveal svg.diagram {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 470px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.diagram rect,
|
||||
.diagram ellipse {
|
||||
fill: var(--ground);
|
||||
stroke: var(--rule);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.diagram .hot rect {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.diagram .fabric rect {
|
||||
fill: rgba(var(--accent-rgb), 0.18);
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.diagram text {
|
||||
fill: var(--ink);
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
font-size: 19px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.diagram text.small {
|
||||
font-size: 14px;
|
||||
fill: var(--muted);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.diagram text.accent {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.diagram .link {
|
||||
stroke: var(--muted);
|
||||
stroke-width: 2.5;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.diagram .bgp {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 7 6;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Nested address ranges: each level a little brighter than the one around it. */
|
||||
.diagram .nest rect {
|
||||
stroke: var(--accent);
|
||||
rx: 14;
|
||||
}
|
||||
.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; }
|
||||
.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; }
|
||||
.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; }
|
||||
.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; }
|
||||
.diagram text.left { text-anchor: start; }
|
||||
.diagram text.mono { font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; }
|
||||
.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; }
|
||||
.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .host { fill: var(--accent-soft); stroke: none; }
|
||||
|
||||
.diagram .traffic {
|
||||
stroke: #1a8a4f;
|
||||
stroke-width: 4;
|
||||
fill: none;
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* The look of the deck. Structure borrowed from Matt Godbolt's newer talks
|
||||
* (hrt-theme.css in github.com/mattgodbolt/what-happens-beneath-accu-2026):
|
||||
* every slide on a rounded panel, IBM Plex Sans with thin h1 and bold accent
|
||||
* h2, IBM Plex Mono code, accent-coloured list markers, links and progress
|
||||
* bar, glowing highlighted code lines.
|
||||
*
|
||||
* Made our own: a light palette (his is near-black; change it in :root
|
||||
* below), and his "thread" corner motif becomes a chain of nodes and links,
|
||||
* an AS path. Code blocks and byte dumps stay dark on the light page so the
|
||||
* monokai colours and the byte palette keep working.
|
||||
*
|
||||
* Try accents without editing: index.html?accent=green (blue, green,
|
||||
* amber, magenta). Make one permanent by copying its values into :root.
|
||||
*/
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap");
|
||||
|
||||
:root {
|
||||
--ground: #e9edf5; /* behind the panels */
|
||||
--panel: #ffffff; /* the slide itself */
|
||||
--panel-edge: rgba(31, 111, 235, 0.22);
|
||||
--ink: #1f2333; /* body text */
|
||||
--ink-strong: #0b0d17; /* h1, h3 */
|
||||
--muted: #6b7088; /* h4, captions */
|
||||
--rule: #c3c8d8; /* table rules, borders */
|
||||
--accent: #1f6feb; /* h2, markers, links, motif */
|
||||
--accent-soft: #0b4ea2; /* inline code, emphasis */
|
||||
--accent-rgb: 31, 111, 235;
|
||||
/* Code blocks and byte dumps keep a dark ground of their own. */
|
||||
--code-ground: #1b1e33;
|
||||
--code-ink: #eceff9;
|
||||
--r-code-font: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
--r-heading-text-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-accent="green"] {
|
||||
--accent: #1a8a4f;
|
||||
--accent-soft: #0f6238;
|
||||
--accent-rgb: 26, 138, 79;
|
||||
--panel-edge: rgba(26, 138, 79, 0.22);
|
||||
}
|
||||
|
||||
:root[data-accent="amber"] {
|
||||
--accent: #b86e00;
|
||||
--accent-soft: #7a4a00;
|
||||
--accent-rgb: 184, 110, 0;
|
||||
--panel-edge: rgba(184, 110, 0, 0.22);
|
||||
}
|
||||
|
||||
:root[data-accent="magenta"] {
|
||||
--accent: #c2277f;
|
||||
--accent-soft: #8a1a5a;
|
||||
--accent-rgb: 194, 39, 127;
|
||||
--panel-edge: rgba(194, 39, 127, 0.22);
|
||||
}
|
||||
|
||||
/* ---- ground and panels ---------------------------------------------------- */
|
||||
|
||||
.reveal-viewport {
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.reveal {
|
||||
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 400;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reveal .slides section:not(.stack):not(.title-slide):not(.bare) {
|
||||
background-color: var(--panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 30px rgba(20, 30, 60, 0.1);
|
||||
border: 1px solid var(--panel-edge);
|
||||
padding: 1em 0.8em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- type ------------------------------------------------------------------ */
|
||||
|
||||
.reveal h1,
|
||||
.reveal h2,
|
||||
.reveal h3,
|
||||
.reveal h4 {
|
||||
font-family: inherit;
|
||||
text-transform: none;
|
||||
text-shadow: none;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.reveal h1 {
|
||||
font-weight: 300;
|
||||
color: var(--ink-strong);
|
||||
font-size: 2.2em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.reveal h2 {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.reveal h3 {
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.reveal h4 {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.reveal p {
|
||||
margin: 20px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reveal strong,
|
||||
.reveal b {
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.reveal em {
|
||||
font-style: italic;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.reveal a:hover {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal ul,
|
||||
.reveal ol {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
margin: 0 0 0 1em;
|
||||
}
|
||||
|
||||
.reveal ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.reveal ul li,
|
||||
.reveal ol li {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.reveal li::marker {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal blockquote {
|
||||
background: var(--ground);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 20px 30px;
|
||||
font-style: italic;
|
||||
width: 85%;
|
||||
margin: 20px auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reveal img,
|
||||
.reveal video,
|
||||
.reveal iframe {
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
}
|
||||
|
||||
.reveal kbd {
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 80%;
|
||||
border: 1px solid var(--rule);
|
||||
border-bottom-width: 3px;
|
||||
border-radius: 5px;
|
||||
padding: 0 0.35em;
|
||||
}
|
||||
|
||||
/* ---- code ------------------------------------------------------------------ */
|
||||
|
||||
.reveal pre {
|
||||
width: 100%;
|
||||
margin: 20px auto;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
font-size: 0.55em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reveal code {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal :not(pre) > code {
|
||||
white-space: nowrap;
|
||||
font-size: 92%;
|
||||
}
|
||||
|
||||
.reveal pre code {
|
||||
display: block;
|
||||
color: var(--code-ink);
|
||||
background: var(--code-ground);
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* The lines being talked about glow; the rest dim (reveal handles the dim). */
|
||||
.reveal .hljs.has-highlights tr.highlight-line {
|
||||
text-shadow:
|
||||
0 0 6px currentColor,
|
||||
0 0 12px currentColor;
|
||||
transition: text-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
/* ---- tables ---------------------------------------------------------------- */
|
||||
|
||||
.reveal table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
margin: 20px auto;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.reveal table th {
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.reveal table th,
|
||||
.reveal table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding: 0.25em 0.6em;
|
||||
}
|
||||
|
||||
.reveal table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ---- reveal chrome ---------------------------------------------------------- */
|
||||
|
||||
.reveal .controls {
|
||||
color: var(--accent);
|
||||
bottom: 40px;
|
||||
}
|
||||
|
||||
.reveal .progress {
|
||||
background: var(--panel);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.reveal .progress span {
|
||||
background: var(--accent);
|
||||
transition: width 800ms cubic-bezier(0.26, 0.86, 0.44, 0.985);
|
||||
}
|
||||
|
||||
.reveal .slide-number {
|
||||
color: #fff;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
background: rgba(var(--accent-rgb), 0.9);
|
||||
padding: 3px 9px;
|
||||
border-radius: 4px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- the path motif ---------------------------------------------------------
|
||||
* Matt's decks carry a ring with a line running off the edge. Ours is a short
|
||||
* AS path: nodes joined by links, heading off the top-right corner, mirrored
|
||||
* running up from the bottom-left. Drawn in the accent colour via a mask, so
|
||||
* it follows --accent. Hidden on the title slide and on "bare" slides.
|
||||
*/
|
||||
.reveal::before,
|
||||
.reveal-viewport::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
background: var(--accent);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.reveal::before {
|
||||
top: 26px;
|
||||
right: 0;
|
||||
width: 38vw;
|
||||
height: 22px;
|
||||
-webkit-mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
|
||||
left center / 190px 22px no-repeat,
|
||||
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
|
||||
mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
|
||||
left center / 190px 22px no-repeat,
|
||||
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
|
||||
}
|
||||
|
||||
.reveal-viewport::before {
|
||||
bottom: 64px;
|
||||
left: 40px;
|
||||
width: 22px;
|
||||
height: 36vh;
|
||||
-webkit-mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
|
||||
center bottom / 22px 126px no-repeat,
|
||||
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
|
||||
mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
|
||||
center bottom / 22px 126px no-repeat,
|
||||
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
|
||||
}
|
||||
|
||||
.reveal:has(> .slides > section.title-slide.present)::before,
|
||||
.reveal:has(> .slides section.bare.present)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reveal-viewport:has(.reveal > .slides > section.title-slide.present)::before,
|
||||
.reveal-viewport:has(.reveal > .slides section.bare.present)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reveal:has(section.title-slide.present) .controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- wordmark, where his logo sits ------------------------------------------ */
|
||||
|
||||
.wordmark {
|
||||
position: fixed;
|
||||
left: 22px;
|
||||
bottom: 18px;
|
||||
z-index: 10;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wordmark b {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reveal:has(section.title-slide.present) ~ .wordmark,
|
||||
.reveal:has(section.bare.present) ~ .wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- title slide -------------------------------------------------------------- */
|
||||
|
||||
.reveal section.title-slide {
|
||||
text-align: left;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide h1 {
|
||||
font-size: 2.6em;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide h3 {
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
margin-bottom: 2.2em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide .byline {
|
||||
font-size: 60%;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- shown by index.html when the deck cannot start --------------------------- */
|
||||
|
||||
#deck-help {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
padding: 8vh 10vw;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 20px/1.5 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#deck-help h2 {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#deck-help pre,
|
||||
#deck-help code {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
#deck-help pre {
|
||||
background: var(--panel);
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ---- print ------------------------------------------------------------------ */
|
||||
|
||||
html.print-pdf .wordmark,
|
||||
html.reveal-print .wordmark {
|
||||
display: none;
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Copies reveal.js into ./reveal.js so the deck works with no network.
|
||||
# index.html uses this copy when it exists and the CDN otherwise. Keep VERSION
|
||||
# in step with the one in index.html.
|
||||
set -eu
|
||||
VERSION=6.0.2
|
||||
cd "$(dirname "$0")"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
npm pack "reveal.js@$VERSION" --pack-destination "$tmp" --silent >/dev/null 2>&1
|
||||
tar -xzf "$tmp/reveal.js-$VERSION.tgz" -C "$tmp"
|
||||
rm -rf reveal.js
|
||||
mkdir -p reveal.js
|
||||
cp -R "$tmp/package/dist" reveal.js/dist
|
||||
cp "$tmp/package/LICENSE" reveal.js/LICENSE
|
||||
echo "reveal.js $VERSION vendored into $(pwd)/reveal.js ($(du -sh reveal.js | cut -f1))"
|
||||
Vendored
+20
-1
@@ -5,6 +5,25 @@ listen_endpoints {
|
||||
port: 179
|
||||
}
|
||||
listen_endpoints {
|
||||
address: "[::1]"
|
||||
address: "::1"
|
||||
port: 179
|
||||
}
|
||||
peers {
|
||||
name: "lab-r1"
|
||||
endpoint { address: "127.0.0.1" port: 1791 }
|
||||
remote_asn: 65001
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
# Let a restarted peer replace a stale Established session immediately
|
||||
# (RFC 4271 CollisionDetectEstablishedState). Default false.
|
||||
collision_detect_established_state: false
|
||||
local_route_policy {
|
||||
allowed_prefixes: "10.100.0.0/16"
|
||||
allowed_prefixes: "2001:db8:100::/48"
|
||||
max_routes_per_owner: 4
|
||||
}
|
||||
http_endpoint {
|
||||
address: "127.0.0.1"
|
||||
port: 8080
|
||||
}
|
||||
Executable
+385
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Load test for the ferrix_bgp web API: N students announce at the same moment.
|
||||
|
||||
Each simulated student owns one prefix (student n gets the n-th /48, numbered
|
||||
in hex like the lab's route6 objects) and announces it under its own owner
|
||||
name. All students are released together by a barrier, so the announcements
|
||||
really are concurrent. The script then checks that the daemon did the right
|
||||
thing, not merely that it answered:
|
||||
|
||||
1. every announcement was accepted, with latency percentiles
|
||||
2. the local table holds exactly these routes, under the right owners
|
||||
3. every route was turned into an UPDATE for each established session
|
||||
4. the event stream delivered one "announced" event per route, none lost
|
||||
5. ownership holds under load: nobody could replace another's prefix
|
||||
6. concurrent withdrawal leaves nothing behind
|
||||
7. the daemon's own counters show the matching number of BGP UPDATEs sent,
|
||||
for the announcements and again for the withdrawals
|
||||
|
||||
SAFETY. Pointed at a router that peers with the real Internet, this announces
|
||||
real prefixes. By default every route therefore carries the large community
|
||||
210036:0:0 ("announce nowhere"), so the production router accepts the routes
|
||||
but exports them to nobody, and the script withdraws everything it announced
|
||||
even when it fails half-way. Pass --announce-for-real only if you mean it.
|
||||
|
||||
Standard library only. Examples:
|
||||
|
||||
tools/loadtest.py 128
|
||||
tools/loadtest.py 128 --url https://lab.example.net --user student
|
||||
tools/loadtest.py 64 --rounds 5 --keep
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import statistics
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
QUIET_COMMUNITY = "210036:0:0"
|
||||
OWNER_PREFIX = "loadtest-"
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base, user=None, password=None, insecure=False, timeout=15):
|
||||
self.base = base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.headers = {"content-type": "application/json"}
|
||||
if user:
|
||||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
self.headers["authorization"] = f"Basic {token}"
|
||||
self.ctx = ssl._create_unverified_context() if insecure else None
|
||||
|
||||
def call(self, method, path, body=None):
|
||||
"""Returns (status, parsed JSON or text, seconds)."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(self.base + path, data=data, method=method, headers=self.headers)
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
||||
status, raw = resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
status, raw = e.code, e.read()
|
||||
except Exception as e: # connection refused, timeout, TLS failure, ...
|
||||
return 0, f"{type(e).__name__}: {e}", time.perf_counter() - start
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
return status, json.loads(raw) if raw else None, elapsed
|
||||
except ValueError:
|
||||
return status, raw.decode(errors="replace"), elapsed
|
||||
|
||||
def open_stream(self, path):
|
||||
req = urllib.request.Request(self.base + path, headers={**self.headers, "accept": "text/event-stream"})
|
||||
return urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx)
|
||||
|
||||
|
||||
def updates_sent(api):
|
||||
"""BGP UPDATE messages the daemon has sent so far, from /metricz; None if unavailable."""
|
||||
status, text, _ = api.call("GET", "/metricz")
|
||||
if status != 200 or not isinstance(text, str):
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
if line.startswith('bgp_messages_sent_total{type="update"}'):
|
||||
return int(float(line.rsplit(" ", 1)[1]))
|
||||
return 0 # the counter has no sample until the first UPDATE
|
||||
|
||||
|
||||
def established(api):
|
||||
status, peers, _ = api.call("GET", "/api/peers")
|
||||
return [p for p in peers if p.get("state") == "Established"] if status == 200 else []
|
||||
|
||||
|
||||
def check_wire(api, report, before, routes, sessions_before, what):
|
||||
"""The advertiser works asynchronously, so give the counter a moment to settle."""
|
||||
if before is None:
|
||||
print(f" [skip] /metricz unavailable, cannot count UPDATEs for the {what}")
|
||||
return
|
||||
expected = routes * len(sessions_before)
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and (updates_sent(api) or 0) - before < expected:
|
||||
time.sleep(0.1)
|
||||
delta = (updates_sent(api) or 0) - before
|
||||
if len(established(api)) != len(sessions_before):
|
||||
print(f" [skip] sessions changed during the test; {delta} UPDATEs sent for the {what}")
|
||||
else:
|
||||
report.check(delta == expected, f"{delta} BGP UPDATEs sent for the {what} "
|
||||
f"({routes} routes x {len(sessions_before)} session(s))", [f"expected {expected}"])
|
||||
|
||||
|
||||
class EventCounter(threading.Thread):
|
||||
"""Follows /api/events and records what it sees until stopped."""
|
||||
|
||||
def __init__(self, api):
|
||||
super().__init__(daemon=True)
|
||||
self.api, self.events, self.error = api, [], None
|
||||
self.connected, self._stop = threading.Event(), threading.Event()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
stream = self.api.open_stream("/api/events")
|
||||
except Exception as e:
|
||||
self.error = f"{type(e).__name__}: {e}"
|
||||
self.connected.set()
|
||||
return
|
||||
self.connected.set()
|
||||
try:
|
||||
for raw in stream:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
line = raw.decode(errors="replace").strip()
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
self.events.append(json.loads(line[5:]))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if not self._stop.is_set():
|
||||
self.error = f"{type(e).__name__}: {e}"
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(round(p / 100 * (len(ordered) - 1))))]
|
||||
|
||||
|
||||
def latency_line(latencies):
|
||||
if not latencies:
|
||||
return "no samples"
|
||||
ms = [v * 1000 for v in latencies]
|
||||
return (f"min {min(ms):.0f} ms, median {statistics.median(ms):.0f} ms, "
|
||||
f"p95 {percentile(ms, 95):.0f} ms, max {max(ms):.0f} ms")
|
||||
|
||||
|
||||
def run_concurrently(jobs, worker):
|
||||
"""Runs worker(job) for every job at the same instant; returns results in order."""
|
||||
results = [None] * len(jobs)
|
||||
barrier = threading.Barrier(len(jobs))
|
||||
|
||||
def target(i, job):
|
||||
try:
|
||||
barrier.wait(timeout=30)
|
||||
except threading.BrokenBarrierError:
|
||||
results[i] = (0, "barrier broken", 0.0)
|
||||
return
|
||||
results[i] = worker(job)
|
||||
|
||||
threads = [threading.Thread(target=target, args=(i, job)) for i, job in enumerate(jobs)]
|
||||
wall = time.perf_counter()
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
return results, time.perf_counter() - wall
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.failures = 0
|
||||
|
||||
def check(self, ok, text, detail=None):
|
||||
print(f" [{'ok' if ok else 'FAIL'}] {text}")
|
||||
if not ok:
|
||||
self.failures += 1
|
||||
for line in (detail or [])[:8]:
|
||||
print(f" {line}")
|
||||
if detail and len(detail) > 8:
|
||||
print(f" ... and {len(detail) - 8} more")
|
||||
return ok
|
||||
|
||||
|
||||
def one_round(api, args, students, report, round_no):
|
||||
body_for = lambda s: {
|
||||
"prefix": s["prefix"], "owner": s["owner"],
|
||||
"communities": args.community, "large_communities": s["large"],
|
||||
}
|
||||
|
||||
events = EventCounter(api)
|
||||
if not args.no_events:
|
||||
events.start()
|
||||
events.connected.wait(timeout=10)
|
||||
time.sleep(0.3) # let the subscription settle before the burst
|
||||
|
||||
# ---- 1. everybody announces at once -----------------------------------
|
||||
print(f"\nRound {round_no}: {len(students)} students announce concurrently")
|
||||
sent_before, sessions_before = updates_sent(api), established(api)
|
||||
results, wall = run_concurrently(students, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
|
||||
accepted = [s for s, r in zip(students, results) if r[0] == 200]
|
||||
rejected = [f"{s['owner']} {s['prefix']}: HTTP {r[0]} {str(r[1])[:160]}" for s, r in zip(students, results) if r[0] != 200]
|
||||
report.check(not rejected, f"all {len(students)} announcements accepted in {wall:.2f} s "
|
||||
f"({len(students) / wall:.0f}/s)", rejected)
|
||||
print(f" latency: {latency_line([r[2] for r in results if r[0] == 200])}")
|
||||
if not accepted:
|
||||
print(" [skip] nothing was accepted, so there is nothing further to verify")
|
||||
if not args.no_events:
|
||||
events.stop()
|
||||
return accepted
|
||||
|
||||
# ---- 2. the table holds exactly what was announced --------------------
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
table = table if status == 200 and isinstance(table, list) else []
|
||||
by_prefix = {r["prefix"]: r for r in table}
|
||||
missing = [f"{s['prefix']} missing" for s in accepted if s["prefix"] not in by_prefix]
|
||||
wrong_owner = [f"{s['prefix']} owned by {by_prefix[s['prefix']]['owner']!r}, expected {s['owner']!r}"
|
||||
for s in accepted if s["prefix"] in by_prefix and by_prefix[s["prefix"]]["owner"] != s["owner"]]
|
||||
wrong_attrs = [f"{s['prefix']} large communities {by_prefix[s['prefix']]['large_communities']}"
|
||||
for s in accepted if s["prefix"] in by_prefix
|
||||
and sorted(by_prefix[s["prefix"]]["large_communities"]) != sorted(s["large"])]
|
||||
report.check(not (missing or wrong_owner or wrong_attrs),
|
||||
"local table holds every route with the right owner and communities",
|
||||
missing + wrong_owner + wrong_attrs)
|
||||
|
||||
# ---- 3. each route became an UPDATE on every session -------------------
|
||||
up = established(api)
|
||||
if not up:
|
||||
print(" [skip] no established BGP session, so nothing was put on the wire")
|
||||
else:
|
||||
sample = accepted if len(accepted) <= 32 else accepted[:: max(1, len(accepted) // 32)]
|
||||
bad = []
|
||||
for s in sample:
|
||||
code, wire, _ = api.call("GET", "/api/routes/local/wire?prefix=" + urllib.request.quote(s["prefix"], safe=""))
|
||||
if code != 200 or not wire:
|
||||
bad.append(f"{s['prefix']}: HTTP {code}, {len(wire) if isinstance(wire, list) else 0} session(s)")
|
||||
elif not all(w["hex"].startswith("ff" * 16) for w in wire):
|
||||
bad.append(f"{s['prefix']}: malformed UPDATE")
|
||||
report.check(not bad, f"UPDATE built for {len(up)} established session(s) "
|
||||
f"(checked {len(sample)} of {len(accepted)} routes)", bad)
|
||||
check_wire(api, report, sent_before, len(accepted), sessions_before, "announcements")
|
||||
|
||||
# ---- 4. the event stream lost nothing ----------------------------------
|
||||
if not args.no_events:
|
||||
deadline = time.time() + 5
|
||||
want = {s["prefix"] for s in accepted}
|
||||
seen = lambda: {e["route"]["prefix"] for e in events.events if e.get("type") == "local_announced"}
|
||||
while time.time() < deadline and not want <= seen():
|
||||
time.sleep(0.1)
|
||||
lagged = [e for e in events.events if e.get("type") == "lagged"]
|
||||
lost = sorted(want - seen())
|
||||
detail = [f"no event for {p}" for p in lost] + [f"stream lagged: missed {e['missed']} on {e['stream']}" for e in lagged]
|
||||
if events.error:
|
||||
detail.insert(0, f"event stream: {events.error}")
|
||||
report.check(not detail, f"event stream delivered all {len(want)} announcements", detail)
|
||||
|
||||
# ---- 5. ownership holds under load -------------------------------------
|
||||
if len(accepted) >= 2 and not args.no_policy:
|
||||
# Everyone tries, at once, to take over their neighbour's prefix.
|
||||
thieves = [{**accepted[i], "prefix": accepted[(i + 1) % len(accepted)]["prefix"]} for i in range(len(accepted))]
|
||||
results, _ = run_concurrently(thieves, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
|
||||
leaked = [f"{t['owner']} took {t['prefix']}: HTTP {r[0]}" for t, r in zip(thieves, results) if r[0] != 409]
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
owners = {r["prefix"]: r["owner"] for r in table} if status == 200 else {}
|
||||
leaked += [f"{s['prefix']} now owned by {owners.get(s['prefix'])!r}" for s in accepted if owners.get(s["prefix"]) != s["owner"]]
|
||||
report.check(not leaked, f"{len(thieves)} concurrent takeover attempts all refused (409), owners unchanged", leaked)
|
||||
|
||||
if not args.no_events:
|
||||
events.stop()
|
||||
return accepted
|
||||
|
||||
|
||||
def withdraw_all(api, students, report, verify=True):
|
||||
if not students:
|
||||
return
|
||||
sent_before, sessions_before = (updates_sent(api), established(api)) if verify else (None, [])
|
||||
results, wall = run_concurrently(
|
||||
students, lambda s: api.call("DELETE", "/api/routes/local", {"prefix": s["prefix"], "owner": s["owner"]}))
|
||||
failed = [f"{s['prefix']}: HTTP {r[0]} {r[1]}" for s, r in zip(students, results) if r[0] != 200]
|
||||
if not verify:
|
||||
return
|
||||
report.check(not failed, f"all {len(students)} withdrawals accepted in {wall:.2f} s", failed)
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
left = [r["prefix"] for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else ["(could not read table)"]
|
||||
report.check(not left, "nothing left behind by the load test", left)
|
||||
if sessions_before:
|
||||
check_wire(api, report, sent_before, len(students) - len(failed), sessions_before, "withdrawals")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("students", type=int, help="number of simulated students")
|
||||
ap.add_argument("--url", default="http://127.0.0.1:8080", help="base URL of the status page (default %(default)s)")
|
||||
ap.add_argument("--user", help="HTTP basic auth user; the password is read from $FERRIX_PASSWORD or prompted for")
|
||||
ap.add_argument("--insecure", action="store_true", help="do not verify the TLS certificate")
|
||||
ap.add_argument("--prefix-template", default="2a0d:d741:{n:x}::/48",
|
||||
help="prefix of student n, a Python format string (default %(default)s)")
|
||||
ap.add_argument("--first", type=int, default=1, help="number of the first student (default 1)")
|
||||
ap.add_argument("--rounds", type=int, default=1, help="repeat announce/verify/withdraw this many times")
|
||||
ap.add_argument("--community", action="append", default=[], help="standard community to attach (repeatable)")
|
||||
ap.add_argument("--large-community", action="append", default=[], help="extra large community to attach (repeatable)")
|
||||
ap.add_argument("--announce-for-real", action="store_true",
|
||||
help=f"do NOT add {QUIET_COMMUNITY}; routes may be exported to the Internet")
|
||||
ap.add_argument("--keep", action="store_true", help="leave the routes announced at the end")
|
||||
ap.add_argument("--no-events", action="store_true", help="skip the event stream check")
|
||||
ap.add_argument("--no-policy", action="store_true", help="skip the concurrent takeover check")
|
||||
args = ap.parse_args()
|
||||
if args.students < 1:
|
||||
ap.error("need at least one student")
|
||||
|
||||
password = None
|
||||
if args.user:
|
||||
password = os.environ.get("FERRIX_PASSWORD") or getpass.getpass(f"Password for {args.user}: ")
|
||||
api = Api(args.url, args.user, password, args.insecure)
|
||||
|
||||
large = list(args.large_community)
|
||||
if not args.announce_for_real and QUIET_COMMUNITY not in large:
|
||||
large.insert(0, QUIET_COMMUNITY)
|
||||
students = [{"owner": f"{OWNER_PREFIX}{n}", "prefix": args.prefix_template.format(n=n), "large": large}
|
||||
for n in range(args.first, args.first + args.students)]
|
||||
|
||||
# ---- preflight ----------------------------------------------------------
|
||||
status, info, _ = api.call("GET", "/api/status")
|
||||
if status == 401:
|
||||
sys.exit(f"{args.url} wants a password: HTTP 401. Pass --user, and check the password.")
|
||||
if status != 200:
|
||||
sys.exit(f"Cannot reach {args.url}/api/status: HTTP {status} {str(info)[:200]}")
|
||||
print(f"Target: {args.url} router-id {info['router_id']} AS{info['local_asn']} version {info['version']} "
|
||||
f"on {info.get('hostname', '?')}")
|
||||
print(f" {info['sessions']} established session(s), {info['local_routes']} local route(s) already present")
|
||||
print(f"Prefixes {students[0]['prefix']} .. {students[-1]['prefix']}, large communities {large or 'none'}")
|
||||
if args.announce_for_real:
|
||||
print("WARNING: --announce-for-real: these routes may be exported to the Internet.")
|
||||
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
stale = [r for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else []
|
||||
if stale:
|
||||
print(f"Removing {len(stale)} route(s) left over from an earlier load test")
|
||||
withdraw_all(api, [{"owner": r["owner"], "prefix": r["prefix"]} for r in stale], Report(), verify=False)
|
||||
taken = {r["prefix"]: r["owner"] for r in table if not r["owner"].startswith(OWNER_PREFIX)} if status == 200 else {}
|
||||
clash = [s for s in students if s["prefix"] in taken]
|
||||
if clash:
|
||||
print(f"Skipping {len(clash)} prefix(es) currently announced by real owners: "
|
||||
+ ", ".join(f"{s['prefix']} ({taken[s['prefix']]})" for s in clash[:5]) + (" ..." if len(clash) > 5 else ""))
|
||||
students = [s for s in students if s["prefix"] not in taken]
|
||||
if not students:
|
||||
sys.exit("Nothing left to test.")
|
||||
|
||||
report, announced = Report(), []
|
||||
try:
|
||||
for round_no in range(1, args.rounds + 1):
|
||||
announced = one_round(api, args, students, report, round_no)
|
||||
if round_no < args.rounds or not args.keep:
|
||||
withdraw_all(api, announced, report)
|
||||
announced = []
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
report.failures += 1
|
||||
finally:
|
||||
if announced and not args.keep:
|
||||
withdraw_all(api, announced, Report(), verify=False)
|
||||
|
||||
status, info, _ = api.call("GET", "/api/status")
|
||||
if status == 200:
|
||||
print(f"\nAfterwards: {info['sessions']} established session(s), {info['local_routes']} local route(s)")
|
||||
print("RESULT: " + ("PASS" if report.failures == 0 else f"FAIL ({report.failures} check(s) failed)"))
|
||||
sys.exit(0 if report.failures == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user