Ferrix web UI working for demo and slides mostly done

This commit is contained in:
Rayhaan Jaufeerally committed 2026-09-23 15:38:06 +02:00
1 parent f3bb8cc89e
commit b607058650
33 files changed
+2227 -428

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "slides",
"runtimeExecutable": "python3",
"runtimeArgs": ["slides/serve.py", "8765"],
"port": 8765
}
]
}
Generated
+1
View File
@@ -225,6 +225,7 @@ dependencies = [
"clap",
"eyre",
"futures",
"libc",
"prometheus",
"protobuf",
"protobuf-codegen",
+1
View File
@@ -40,6 +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"] }
+7
View File
@@ -2,6 +2,7 @@ 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;
@@ -32,6 +33,11 @@ async fn main() -> Result<()> {
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(
@@ -45,6 +51,7 @@ async fn main() -> Result<()> {
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 {
+13 -1
View File
@@ -1,7 +1,9 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
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};
@@ -21,6 +23,9 @@ pub struct TestServer {
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,
}
@@ -61,6 +66,7 @@ impl TestServer {
collision_detect_established_state: false,
local_route_policy: LocalRibPolicy::default(),
http_endpoint: None,
ping_monitor: None,
};
customize(&mut config);
@@ -74,8 +80,13 @@ impl TestServer {
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, local_rib, registry, local_asn })
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn })
}
}
@@ -94,6 +105,7 @@ impl TestServer {
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 }))
+109
View File
@@ -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(())
}
+4
View File
@@ -9,6 +9,7 @@ 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
@@ -19,3 +20,6 @@ thiserror.workspace = true
[build-dependencies]
protobuf-codegen.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
+16
View File
@@ -37,6 +37,22 @@ message BgpConfig {
// 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.
+26
View File
@@ -2,6 +2,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr;
use std::time::Duration;
use bgp_primitives::constants::AddressFamilyId;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRibPolicy;
use eyre::{Result, WrapErr, bail, eyre};
@@ -31,6 +32,9 @@ pub struct Config {
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)]
@@ -127,6 +131,27 @@ impl Config {
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 {
@@ -143,6 +168,7 @@ impl Config {
collision_detect_established_state: proto.collision_detect_established_state,
local_route_policy,
http_endpoint,
ping_monitor,
})
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod connection;
pub mod dialer;
pub mod fsm;
pub mod metrics;
pub mod ping;
pub mod server;
pub mod session;
pub mod tracing;
+18
View File
@@ -46,6 +46,22 @@ pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
.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() {
@@ -56,6 +72,8 @@ pub fn init() {
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 {
+412
View File
@@ -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));
}
}
+265
View File
@@ -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()[..]);
}
}
+110
View File
@@ -11,6 +11,7 @@ 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};
@@ -475,3 +476,112 @@ pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<Received
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);
}
}
+19 -4
View File
@@ -1,11 +1,12 @@
//! Server-sent event stream merging session, local-route and received-route
//! changes into one feed the UI can follow.
//! 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;
@@ -13,7 +14,7 @@ use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::AppState;
use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize};
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize};
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -26,6 +27,10 @@ pub enum WireEvent {
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 },
@@ -49,6 +54,13 @@ impl WireEvent {
}
}
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 {
@@ -82,7 +94,10 @@ pub async fn stream(
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 merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| {
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)
+6 -2
View File
@@ -2,7 +2,7 @@
//! 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`]); no routing state lives
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives
//! in this crate.
mod api;
@@ -19,13 +19,15 @@ 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, ConfigView, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView,
ReceivedRouteView, StatusView,
WellKnownCommunity, WireView, WithdrawRequest,
};
@@ -84,6 +86,7 @@ pub struct AppState {
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.
@@ -106,6 +109,7 @@ pub fn router(state: AppState) -> Router {
.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))
+101 -7
View File
@@ -61,7 +61,7 @@ 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><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>
@@ -103,8 +103,16 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<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>
<div class="dim">A yellow row carries a prefix that is also announced locally: it has travelled through other routers and come back.</div>
<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>
@@ -148,7 +156,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() };
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 -----------------------------------------------------------
@@ -238,6 +246,74 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<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 &nbsp;
<span class="hh">&nbsp;type, code&nbsp;</span> <span class="hl">&nbsp;checksum&nbsp;</span> <span class="ha">&nbsp;identifier, sequence&nbsp;</span> <span class="hn">&nbsp;payload&nbsp;</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) {
@@ -348,7 +424,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
}
function showTip(el) {
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
const d = decodeSegment(el.dataset.kind, b, el.dataset.w4 === '1'), tip = $('tip');
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];
@@ -395,7 +472,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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 loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
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) {
@@ -415,6 +493,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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));
}
@@ -426,7 +506,14 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
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.
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
// 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> --------------------
@@ -483,7 +570,14 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
}
appendTo('f-large', v);
});
$('f-owner').addEventListener('input', renderLocal);
$('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();
+22
View File
@@ -41,6 +41,20 @@ Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
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
@@ -148,6 +162,14 @@ 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
+5 -2
View File
@@ -12,7 +12,9 @@ Restart=on-failure
RestartSec=5
# It needs the network and nothing else: no root, no privileged port, no
# write access anywhere.
# 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
@@ -27,7 +29,8 @@ RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
MemoryMax=256M
[Install]
+5
View File
@@ -25,3 +25,8 @@ local_route_policy {
# 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 {}
+9 -5
View File
@@ -13,16 +13,16 @@ chain of nodes and links, an AS path.
| Section | File |
|---|---|
| IP addresses and prefixes (IPv6) | `sections/01-addresses.md` |
| Where AS numbers and prefixes come from (IANA, RIPE) | `sections/02-registries.md` |
| 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: ferrix, pr01.rue and its exchanges | `sections/05-lab.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 ferrix's own encoder and parsed back
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
@@ -55,7 +55,11 @@ 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 -m http.server 8000 --directory slides
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.
+105
View File
@@ -0,0 +1,105 @@
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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();
});
})();
+200 -134
View File
@@ -1,10 +1,10 @@
<!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>
<!--
<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,
@@ -15,150 +15,216 @@
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" id="deck-style" />
<link rel="stylesheet" href="style.css" />
<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
<link rel="stylesheet" href="theme.css?v=2" id="deck-style" />
<link rel="stylesheet" href="style.css?v=10" />
<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>Announce your own prefix to the Internet</h3>
<p class="byline">Rayhaan Jaufeerally &middot; Network Security &middot; ETH Z&uuml;rich, 2026</p>
</section>
<section class="title-slide">
<h1>Hands-on BGP</h1>
<h3>Insights into real world operations</h3>
<p class="byline">
Rayhaan Jaufeerally &middot; Network Security &middot;
ETH Z&uuml;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/01-addresses.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
<section data-markdown="sections/02-registries.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>ferrix</b>_bgp &middot; netsec 2026</div>
<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> &middot; 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
<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>
(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 type="module">
const VERSION = "6.0.2";
const LOCAL = "./reveal.js/dist";
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
<script src="dns-lookup.js?v=3"></script>
<script src="prefix-explorer.js?v=4"></script>
// 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;
};
<script type="module">
const VERSION = "6.0.2";
const LOCAL = "./reveal.js/dist";
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
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);
}),
),
);
// 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 [{ 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}`));
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);
}),
),
);
// 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" },
],
};
}
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}`));
// ?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;
// 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" },
],
};
}
// 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],
});
// ?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;
await deck.initialize().catch(fail("reveal.js failed to start"));
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
</script>
</body>
// 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>
+123
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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);
})();
+15
View File
@@ -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.
-92
View File
@@ -1,92 +0,0 @@
# IP addresses and prefixes
Notes:
Section 1. IPv6 only: the whole lab is IPv6, and the ideas carry over to IPv4
unchanged (32 bits, dotted decimal, /24 where we say /48).
-----
## An IPv6 address is 128 bits
```text
2a0d:d741:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
2a0d:d741:1:0:0:0:0:1 drop leading zeros in a group
2a0d:d741:1::1 one run of zero groups becomes ::
```
`::` may appear once, or the address would be ambiguous.
<!-- .element: class="note" -->
Notes:
Ask: why only once? Because 2a0d::1::1 could put the zeros in several places.
-----
## A prefix is a range of addresses
`2a0d:d741:1::/48`
<p class="bits"><span class="fixed">2a0d:d741:0001</span><span class="free">xxxx:xxxx:xxxx:xxxx:xxxx</span></p>
The first **48** bits are fixed. The other 80 are yours.
Notes:
2^80 addresses in one student prefix. About 1.2 million billion billion.
The /n is a count of leading bits, nothing more.
-----
## Prefixes nest
| Prefix | Who | Holds |
|---|---|---|
| `2a0d:d740::/29` | the network we borrow from | 524,288 /48s |
| `2a0d:d741::/40` | this lab | 256 /48s |
| `2a0d:d741:<n>::/48` | **you** | 65,536 /64s |
| `…/64` | one LAN | the hosts on it |
`<n>` is your student number, written *in hex*.
<!-- .element: class="note" -->
Notes:
Student 10 is 2a0d:d741:a::/48, student 16 is 2a0d:d741:10::/48.
A /40 holds exactly 256 /48s, which caps the class size at 255.
-----
## The most specific route wins
```text [3]
2a0d:d740::/29 via AS210036 the aggregate
2a0d:d741::/40 via AS210036 the lab
2a0d:d741:1::/48 via AS210036 AS… student 1 <-- chosen for 2a0d:d741:1::1
```
Routers forward on the *longest matching prefix*, before anything else is compared.
<!-- .element: class="note" -->
Notes:
This is the single most important forwarding rule for today. It is also why a
hijacker announces a more specific prefix: it wins everywhere it is accepted,
no matter how good the legitimate route is.
-----
## How small can a prefix be?
<div class="boxes">
<div class="ok">IPv6: up to <b>/48</b></div>
<div class="ok">IPv4: up to <b>/24</b></div>
<div class="bad">anything longer: filtered by almost everyone</div>
</div>
Not a protocol rule. A convention, so the global table stays finite.
<!-- .element: class="note" -->
Notes:
Which is why the lab hands out /48s: the smallest thing the Internet at large
will carry. The global IPv6 table is around 230k routes, IPv4 around 1M.
+164
View File
@@ -0,0 +1,164 @@
# 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>
Your computer cannot send a packet to a name. Every time you use a domain name, it is resolved into an IP address.
<!-- .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
<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.
-89
View File
@@ -1,89 +0,0 @@
# Where numbers come from
Notes:
Section 2. Nobody "owns" addresses or AS numbers; they are registered to you.
-----
## A hierarchy of registries
<div class="boxes">
<div>IANA<br><span class="small">the root</span></div>
<div class="us">RIPE NCC<br><span class="small">Europe, Middle East</span></div>
<div>ARIN</div>
<div>APNIC</div>
<div>LACNIC</div>
<div>AFRINIC</div>
</div>
IANA hands large blocks to five regional registries. They hand smaller ones to members (LIRs), who assign to end users.
Notes:
RIR = Regional Internet Registry. LIR = Local Internet Registry, a member of
an RIR, typically an ISP. Our space comes from RIPE NCC via an LIR.
-----
## AS numbers
* 16 bits at first: `1` to `65535`
* 32 bits since RFC 6793: up to `4294967295`
* Old routers see `AS23456` (AS_TRANS) in place of a big one
* Private use: `64512–65534` and `4200000000–4294967294`
The lab daemon is `AS4200000001`. It is stripped before the Internet sees your route.
<!-- .element: class="note" -->
Notes:
RFC 6996 reserves the private ranges. The upstream router removes the private
AS from the path, so the origin everybody else sees is AS210036.
-----
## The RIPE database says who may announce what
```text [3]
route6: 2a0d:d741:1::/48
descr: ETHZ Netsec 2026 BGP lab - student 1
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
```
One of these exists for every student prefix. Networks build their filters from them.
<!-- .element: class="note" -->
Notes:
This is a real object; try the whois on the next slide. `mnt-by` names the
maintainer: whoever holds its credentials can change the object. An `inet6num`
object records the address block itself, a `route6` object who may originate it.
-----
## Two ways to say "this origin is allowed"
| | IRR (`route6`) | RPKI (ROA) |
|---|---|---|
| What it is | an entry in a database | a signed certificate |
| Who can create it | whoever holds the maintainer | only the holder of the addresses |
| Says | prefix + origin AS | prefix + origin AS + **max length** |
| Used for | building prefix filters | dropping "invalid" routes |
Notes:
Max length matters for us: a ROA for the /40 with max length 40 would make
every student /48 RPKI-invalid, and many networks would drop it.
-----
## Look it up yourself
```bash
whois -h whois.ripe.net 2a0d:d741:1::/48
whois -h whois.ripe.net AS210036
```
Or in a browser: RIPEstat, bgp.tools, or a looking glass at an exchange.
<!-- .element: class="note" -->
+161 -39
View File
@@ -3,53 +3,175 @@
Notes:
Section 3. The aim: by the end, the bytes view on the lab page reads like text.
-----
---
## BGP in one slide
* Runs over TCP, port `179`, between neighbours you configure by hand
* Each side tells the other its best route per prefix
* Only *changes* are sent: no periodic refresh
* If the session drops, every route learned over it is gone
- 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:
No discovery, no authentication of content, no notion of truth. A neighbour
says "I can reach X" and you believe it, subject to your filters.
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.
-----
---
## Four kinds of message
## The four message types
| Type | Name | Says |
|---|---|---|
| 1 | OPEN | who I am: AS number, router ID, capabilities |
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
| 3 | NOTIFICATION | something is wrong, I am closing the session |
| 4 | KEEPALIVE | still here |
| Type | Name | Says |
| ---- | ------------ | ------------------------------------------------- |
| 1 | OPEN | who I am: AS number, router ID, capabilities |
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
| 3 | NOTIFICATION | something is wrong, I am closing the session |
| 4 | KEEPALIVE | still here |
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then it is all UPDATEs.
<!-- .element: class="note" -->
Notes:
There is a fifth, ROUTE-REFRESH: "send me everything again, my policy changed".
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 body: who I am</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.
---
## An OPEN message example
<div class="legend"><span class="hh">header</span><span class="hl">who I am</span><span class="ha">capabilities</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 37</span> <span class="hh">01</span> <span class="hl">04</span> <span class="hl">5b a0</span> <span class="hl">00 f0</span> <span class="hl">0a ff 00 01</span> <span class="hl">1a</span> <span class="ha">02 18</span> <span class="ha2">01 04 00 02 00 01</span> <span class="ha2">02 00</span> <span class="ha2">06 00</span> <span class="ha2">40 02 00 00</span> <span class="ha2">41 04 00 03 34 74</span> <span class="ha2">46 00</span> <span class="ha2">47 00</span></pre>
<table class="decode"><tr><td>04 · 5b a0 · 00 f0 · 0a ff 00 01 · 1a</td><td>version 4; AS 23456 (AS_TRANS, see below); hold time 240 s; router ID 10.255.0.1; 26 bytes of parameters</td></tr><tr><td>02 18</td><td>parameter type 2, capabilities, 24 bytes</td></tr><tr><td>01 04 00 02 00 01</td><td>multiprotocol: AFI 2 (IPv6), SAFI 1 (unicast). Without this, no IPv6 routes</td></tr><tr><td>41 04 00 03 34 74</td><td>4-byte AS: the real number, 210036</td></tr><tr><td>02 00 · 06 00 · 40 02 00 00 · 46 00 · 47 00</td><td>route refresh, extended message, graceful restart, enhanced refresh, long-lived GR</td></tr></table>
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.
---
## The shape of an UPDATE
| Bytes | Field |
|---|---|
| 19 | header: marker, length, type |
| 2 + n | withdrawn routes: prefixes that are no longer reachable |
| 2 + n | path attributes: everything *about* the route |
| the rest | NLRI: the IPv4 prefixes being announced |
<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.
<!-- .element: class="note" -->
-----
---
## A real one: 89 bytes
@@ -73,7 +195,7 @@ empty. Hold that thought.
<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>marker. Once carried authentication, now just filler to find message boundaries</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>
<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>
---
@@ -91,7 +213,7 @@ empty. Hold that thought.
<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*.
Every attribute is _flags, type, length, value_.
<!-- .element: class="note" -->
@@ -103,7 +225,7 @@ Every attribute is *flags, type, length, value*.
<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>
Each network prepends itself on the way out. The length of this list is what best-path selection compares.
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" -->
@@ -119,13 +241,13 @@ does not add itself. Seen from further away it reads e.g. 6939 210036.
<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 of *your* entrances to use. Non-transitive: it goes one hop and no further.
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: where IPv6 lives
## 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>
@@ -152,17 +274,17 @@ Three 32-bit numbers. They mean whatever the network named in the first one says
<!-- .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 |
| 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 |
Transitive is why a community you set can act three networks away.
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
<!-- .element: class="note" -->
+23 -43
View File
@@ -4,7 +4,7 @@ Notes:
Section 4. Two very different ways of getting routes, and what an Internet
exchange physically is.
-----
---
## Two ways to reach other networks
@@ -13,18 +13,18 @@ exchange physically is.
### IP transit
* You **pay** a provider
* They give you routes to *everyone*
* They announce you to *everyone*
- 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 two of you only
- Usually **free**, by mutual benefit
- You exchange _your own_ routes, and your customers'
- Traffic between the two of you only
</div>
</div>
@@ -34,7 +34,7 @@ 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
@@ -45,8 +45,8 @@ as the fallback, peering to keep traffic cheap and close.
<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">AS6939 HE</text><text x="620" y="92" class="small">::aa</text>
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335</text><text x="850" y="92" class="small">Cloudflare</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>
@@ -64,52 +64,32 @@ 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.
a5ec is 42476 in hex: the route server's address spells its AS number.
-----
---
## What that implies
* Your neighbour is *on your LAN*: one hop, found by neighbour discovery
* A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
* The exchange never routes your packets. It switches frames
* So the LAN is policed hard: one MAC address per port, no stray broadcasts, no proxy ARP
- Your neighbour is _on your LAN_: one hop, 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:
A misbehaving port can hurt every member, which is why exchanges publish strict
rules for what may appear on the peering LAN and quarantine ports that break them.
-----
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
- after which traffic can flow directly
---
## Route servers
Without one, peering with 200 members is 200 BGP sessions.
* One session to the route server brings routes from everyone who uses it
* It is **not** in the traffic path, and leaves the next hop alone
* It does not add its own AS to the path
* You steer it with communities: `42476:0:<as>` "not to them", `42476:1:<as>` "only to them"
- One session to the route server brings routes from everyone who uses it
- It is **not** in the traffic path, and leaves the next hop alone
- It does not add its own AS to the path
- You steer it with communities: `42476:0:<as>` "not to them", `42476:1:<as>` "only to them"
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.
-----
## Our upstream router, for real
| Where | BGP sessions | Neighbour networks |
|---|---|---|
| Transit (CommunityRack) | 2 | 1 |
| SwissIX, Zürich | 28 | 12 |
| DE-CIX, Frankfurt | 26 | 8 |
| France-IX, Paris | 23 | 10 |
| NL-ix, Amsterdam | 22 | 6 |
One small router, `pr01.rue`, AS210036. IPv4 and IPv6 are separate sessions.
<!-- .element: class="note" -->
Notes:
Counted from its BIRD configuration. It is also on two smaller Swiss exchanges.
The same network (Cloudflare, Apple, Hurricane Electric) often appears at
several exchanges, which matters when you choose where to announce.
+12 -10
View File
@@ -1,16 +1,18 @@
# 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 ferrix to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
<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">ferrix</text><text x="330" y="226" class="small">AS4200000001</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>
@@ -26,11 +28,11 @@ typo and the Internet.
</svg>
Notes:
ferrix 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 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
@@ -44,11 +46,11 @@ 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. ferrix builds an UPDATE. Click *bytes* to see it, hover to decode it
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. Within a minute or so, the Internet can see your prefix
@@ -58,7 +60,7 @@ 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
@@ -79,7 +81,7 @@ Notes:
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.
-----
---
## Your turn
+28
View File
@@ -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()
+235
View File
@@ -157,6 +157,138 @@
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: var(--ground);
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: #ff7a7a; }
/* 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);
@@ -174,6 +306,18 @@
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; }
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: var(--ink); opacity: 0.75; }
.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; }
/* Small two-column explanation under a bytes block. */
.reveal table.decode {
font-size: 58%;
@@ -186,6 +330,67 @@
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: #ff7a7a; 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 {
@@ -201,6 +406,20 @@
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);
@@ -266,6 +485,22 @@
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: #4fd98a;
stroke-width: 4;