Remove the lecture material and the ping monitor

The slides, handout, lab router configs, RIPE objects and load test were
for the Network Security 2026 lecture and are kept on the netsec-2026 tag.

The ping monitor sniffed ICMPv6 echo requests so students could watch their
prefix being used. It has no place in a BGP speaker: it needs CAP_NET_RAW,
adds the only unsafe code in the tree and a libc dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-01 20:45:24 +02:00
1 parent b1b72fed39
commit c37cfd029a
55 files changed
+7 -7183

No files matched your search

-2
View File
@@ -1,5 +1,3 @@
/target /target
__pycache__/ __pycache__/
slides/reveal.js/
slides/dist/
.DS_Store .DS_Store
Generated
-1
View File
@@ -225,7 +225,6 @@ dependencies = [
"clap", "clap",
"eyre", "eyre",
"futures", "futures",
"libc",
"prometheus", "prometheus",
"protobuf", "protobuf",
"protobuf-codegen", "protobuf-codegen",
-1
View File
@@ -40,7 +40,6 @@ protobuf = { package = "protobuf", version = "3.7.2" }
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" } protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
futures = "0.3" futures = "0.3"
libc = "0.2"
axum = "0.8.9" axum = "0.8.9"
tokio-stream = { version = "0.1.19", features = ["sync"] } tokio-stream = { version = "0.1.19", features = ["sync"] }
reqwest = { version = "0.13.5", default-features = false, features = ["json"] } reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
-7
View File
@@ -2,7 +2,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use bgp_server::config::Config; use bgp_server::config::Config;
use bgp_server::ping::PingMonitor;
use bgp_server::proto::config::BgpConfig; use bgp_server::proto::config::BgpConfig;
use bgp_server::server::BgpServerBuilder; use bgp_server::server::BgpServerBuilder;
use bgp_server::tracing::TracingArgs; use bgp_server::tracing::TracingArgs;
@@ -33,11 +32,6 @@ async fn main() -> Result<()> {
let server = BgpServerBuilder::new(config).bind().await?; 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 { if let Some(http_endpoint) = server.config().http_endpoint {
let state = AppState { let state = AppState {
info: Arc::new( info: Arc::new(
@@ -51,7 +45,6 @@ async fn main() -> Result<()> {
registry: server.registry.clone(), registry: server.registry.clone(),
local_rib: server.local_rib.clone(), local_rib: server.local_rib.clone(),
adj_rib_in: server.adj_rib_in.clone(), adj_rib_in: server.adj_rib_in.clone(),
pings,
}; };
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = bgp_web::serve(state, http_endpoint).await { if let Err(e) = bgp_web::serve(state, http_endpoint).await {
+1 -13
View File
@@ -1,9 +1,7 @@
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy}; use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_server::config::Config; use bgp_server::config::Config;
use bgp_server::ping::PingMonitor;
use bgp_server::server::BgpServerBuilder; use bgp_server::server::BgpServerBuilder;
use bgp_server::session::SessionRegistry; use bgp_server::session::SessionRegistry;
use bgp_web::{AppState, RouterInfo}; use bgp_web::{AppState, RouterInfo};
@@ -23,9 +21,6 @@ pub struct TestServer {
pub local_rib: LocalRib, pub local_rib: LocalRib,
/// Live view of the server's established sessions. /// Live view of the server's established sessions.
pub registry: SessionRegistry, pub registry: SessionRegistry,
/// Ping monitor without a socket, watching 2001:db8::/32; tests feed it
/// through `record`.
pub pings: PingMonitor,
pub local_asn: u32, pub local_asn: u32,
} }
@@ -66,7 +61,6 @@ impl TestServer {
collision_detect_established_state: false, collision_detect_established_state: false,
local_route_policy: LocalRibPolicy::default(), local_route_policy: LocalRibPolicy::default(),
http_endpoint: None, http_endpoint: None,
ping_monitor: None,
}; };
customize(&mut config); customize(&mut config);
@@ -80,13 +74,8 @@ impl TestServer {
let local_rib = bound.local_rib.clone(); let local_rib = bound.local_rib.clone();
let registry = bound.registry.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()); let handle = tokio::spawn(bound.run());
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn }) Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn })
} }
} }
@@ -105,7 +94,6 @@ impl TestServer {
registry: self.registry.clone(), registry: self.registry.clone(),
local_rib: self.local_rib.clone(), local_rib: self.local_rib.clone(),
adj_rib_in: self.adj_rib_in.clone(), adj_rib_in: self.adj_rib_in.clone(),
pings: self.pings.clone(),
}; };
let handle = tokio::spawn(bgp_web::serve_on(state, listener)); let handle = tokio::spawn(bgp_web::serve_on(state, listener));
Ok((base_url, HttpGuard { handle })) Ok((base_url, HttpGuard { handle }))
-109
View File
@@ -1,109 +0,0 @@
//! 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,7 +9,6 @@ bgp-rib.workspace = true
bytes.workspace = true bytes.workspace = true
clap.workspace = true clap.workspace = true
futures.workspace = true futures.workspace = true
libc.workspace = true
eyre.workspace = true eyre.workspace = true
prometheus.workspace = true prometheus.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -20,6 +19,3 @@ thiserror.workspace = true
[build-dependencies] [build-dependencies]
protobuf-codegen.workspace = true protobuf-codegen.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
-15
View File
@@ -38,21 +38,6 @@ message BgpConfig {
// instead of being refused until the hold timer expires. // instead of being refused until the hold timer expires.
bool collision_detect_established_state = 8; 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. // Limits on locally originated routes.
-26
View File
@@ -2,7 +2,6 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use bgp_primitives::constants::AddressFamilyId;
use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::LocalRibPolicy; use bgp_rib::LocalRibPolicy;
use eyre::{Result, WrapErr, bail, eyre}; use eyre::{Result, WrapErr, bail, eyre};
@@ -32,9 +31,6 @@ pub struct Config {
pub local_route_policy: LocalRibPolicy, pub local_route_policy: LocalRibPolicy,
/// Address for the HTTP API; `None` disables it. /// Address for the HTTP API; `None` disables it.
pub http_endpoint: Option<SocketAddr>, 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)] #[derive(Debug, Clone)]
@@ -131,27 +127,6 @@ impl Config {
None => None, 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 { let connect_retry = if proto.connect_retry_seconds == 0 {
DEFAULT_CONNECT_RETRY DEFAULT_CONNECT_RETRY
} else { } else {
@@ -168,7 +143,6 @@ impl Config {
collision_detect_established_state: proto.collision_detect_established_state, collision_detect_established_state: proto.collision_detect_established_state,
local_route_policy, local_route_policy,
http_endpoint, http_endpoint,
ping_monitor,
}) })
} }
} }
-1
View File
@@ -5,7 +5,6 @@ pub mod connection;
pub mod dialer; pub mod dialer;
pub mod fsm; pub mod fsm;
pub mod metrics; pub mod metrics;
pub mod ping;
pub mod server; pub mod server;
pub mod session; pub mod session;
pub mod tracing; pub mod tracing;
-18
View File
@@ -46,22 +46,6 @@ pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
.expect("metric registration") .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 /// Forces registration of every metric so they appear in scrapes before
/// their first observation. /// their first observation.
pub fn init() { pub fn init() {
@@ -72,8 +56,6 @@ pub fn init() {
LazyLock::force(&COLLISIONS); LazyLock::force(&COLLISIONS);
LazyLock::force(&LOCAL_ROUTES); LazyLock::force(&LOCAL_ROUTES);
LazyLock::force(&ADJ_RIB_IN_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 { pub fn message_type_name(msg: &Message) -> &'static str {
-412
View File
@@ -1,412 +0,0 @@
//! 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
@@ -1,265 +0,0 @@
//! 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,7 +11,6 @@ use bgp_primitives::ip_prefix::IpPrefix;
use bgp_primitives::parser::ParserContext; use bgp_primitives::parser::ParserContext;
use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry}; use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry};
use bgp_server::advertise::build_announce; use bgp_server::advertise::build_announce;
use bgp_server::ping::{PingFlow, PingMonitorStatus};
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry}; use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
use bytes::BytesMut; use bytes::BytesMut;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -476,112 +475,3 @@ 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))); routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
Json(routes) 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);
}
}
+4 -19
View File
@@ -1,12 +1,11 @@
//! Server-sent event stream merging session, local-route, received-route and //! Server-sent event stream merging session, local-route and received-route
//! ping events into one feed the UI can follow. //! changes into one feed the UI can follow.
use std::convert::Infallible; use std::convert::Infallible;
use axum::extract::State; use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use bgp_rib::{LocalRibEvent, RibEvent}; use bgp_rib::{LocalRibEvent, RibEvent};
use bgp_server::ping::PingEvent;
use bgp_server::session::SessionEvent; use bgp_server::session::SessionEvent;
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use serde::Serialize; use serde::Serialize;
@@ -14,7 +13,7 @@ use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::AppState; use crate::AppState;
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize}; use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@@ -27,10 +26,6 @@ pub enum WireEvent {
LocalWithdrawn { prefix: String }, LocalWithdrawn { prefix: String },
ReceivedAnnounced { route: ReceivedRouteView }, ReceivedAnnounced { route: ReceivedRouteView },
ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String }, 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 /// The client fell behind and missed `missed` events on `stream`; it
/// should refetch the corresponding table. /// should refetch the corresponding table.
Lagged { stream: &'static str, missed: u64 }, Lagged { stream: &'static str, missed: u64 },
@@ -54,13 +49,6 @@ 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 { fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
match ev { match ev {
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced { Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
@@ -94,10 +82,7 @@ pub async fn stream(
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local); 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 received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping); let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| {
let routes = futures::stream::select(local, received);
let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| {
Ok(Event::default() Ok(Event::default()
.event("bgp") .event("bgp")
.json_data(&ev) .json_data(&ev)
+2 -6
View File
@@ -2,7 +2,7 @@
//! inspecting state, a server-sent event stream, and Prometheus metrics. //! inspecting state, a server-sent event stream, and Prometheus metrics.
//! //!
//! Everything here is a thin layer over handles owned by the BGP server //! Everything here is a thin layer over handles owned by the BGP server
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives //! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives
//! in this crate. //! in this crate.
mod api; mod api;
@@ -19,15 +19,13 @@ use axum::http::header;
use axum::response::{Html, IntoResponse, Redirect}; use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
use bgp_rib::{AdjRibIn, LocalRib}; use bgp_rib::{AdjRibIn, LocalRib};
use bgp_server::ping::PingMonitor;
use bgp_server::session::SessionRegistry; use bgp_server::session::SessionRegistry;
use eyre::{Result, WrapErr}; use eyre::{Result, WrapErr};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::info; use tracing::info;
pub use api::{ pub use api::{
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView, AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
ReceivedRouteView, StatusView,
WellKnownCommunity, WireView, WithdrawRequest, WellKnownCommunity, WireView, WithdrawRequest,
}; };
@@ -86,7 +84,6 @@ pub struct AppState {
pub registry: SessionRegistry, pub registry: SessionRegistry,
pub local_rib: LocalRib, pub local_rib: LocalRib,
pub adj_rib_in: AdjRibIn, pub adj_rib_in: AdjRibIn,
pub pings: PingMonitor,
} }
/// The status page, embedded so the binary is self-contained. /// The status page, embedded so the binary is self-contained.
@@ -109,7 +106,6 @@ pub fn router(state: AppState) -> Router {
.route("/api/routes/local/withdraw", post(api::withdraw)) .route("/api/routes/local/withdraw", post(api::withdraw))
.route("/api/routes/local/wire", get(api::wire)) .route("/api/routes/local/wire", get(api::wire))
.route("/api/routes/received", get(api::received_routes)) .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/communities/well-known", get(api::well_known_communities))
.route("/api/events", get(events::stream)) .route("/api/events", get(events::stream))
.route("/metricz", get(metrics::metricz)) .route("/metricz", get(metrics::metricz))
-62
View File
@@ -1,62 +0,0 @@
# Announcing a prefix with BGP
You have a BGP router. It speaks to two neighbouring autonomous systems, and
whatever you announce will travel around a loop and come back to you, so you
can see exactly what the network did with it.
Open the router's status page (your instructor will give you the address,
usually `http://<host>:8080/statusz`). Everything on the page is live.
**Your prefix:** `10.100.<N>.0/24` where `N` is the number you were given.
Use your name as *owner*: only you can withdraw what you announce.
## The topology
```
you ──announce──▶ AS65000 (ferrix) ──▶ AS65001 (r1) ──▶ AS65002 (r2) ──▶ back to AS65000
```
Everything under **Received routes** came in from a neighbour. A yellow row
means it is one of the prefixes announced locally: it went around the loop.
## Exercises
1. **Plain announcement.** Announce your prefix with no options. Watch the
event log, then find your prefix in *Received routes*. What is its AS
path, and why is it that long? Which ASN appears first, and what does that
tell you about the direction the route travelled?
2. **Read the bytes.** Click *bytes* on your route under *Local routes*.
Hover the coloured segments. Find the 16 bytes of marker, the length, and
the AS_PATH attribute. How many bytes does your ASN take up in the path,
and why?
3. **NO_EXPORT.** Announce with the community `NO_EXPORT` (use the
well-known picker). Wait ten seconds. Does your prefix come back? Look at
the topology and explain which router stopped it and why it still
accepted it.
4. **Asking a neighbour for help.** Announce with the community
`65001:100`. r1 has been configured to act on that. What changed in the AS
path this time, and who changed it? This is how real operators ask an
upstream to prepend on their behalf; look up your ISP's "BGP communities"
page later.
5. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an
operator want this?
6. **Withdraw.** Withdraw your prefix and watch the event log. How many
messages did it take, and what did the withdrawal look like on the wire
compared to the announcement? (Click *bytes* before withdrawing to compare.)
## Things to notice
- Your router never sent anyone else's routes onward. Only the two FRR
routers re-advertise. What is a router that re-advertises called, and what
would happen to the AS path if ferrix did it too?
- The AS path is BGP's loop prevention. Your prefix came back with your own
ASN in the path and the router accepted it anyway. What should it have
done, and why is this lab allowed to cheat?
- Communities are just 32-bit tags with no built-in meaning except a few
well-known ones. Everything r1 did with `65001:100` was policy someone
wrote. Whose job is it to document what a community means?
-81
View File
@@ -1,81 +0,0 @@
# BGP teaching lab
A small topology in which a prefix announced from the ferrix_bgp web UI is
carried around a loop of two routers and arrives back, so students can watch
what their path attributes and communities do.
```
announce re-advertise
┌─────────┐ ──────▶ ┌────────┐ ──────▶ ┌────────┐
│ ferrix │ │ r1 │ │ r2 │
│ AS65000 │ │ AS65001│ │ AS65002│
│ web UI │ ◀──────────────────────────────────────┘
└─────────┘ comes back with AS path 65002 65001 65000
```
- **ferrix** (AS65000, `10.200.0.1`) dials r1 and r2 and announces whatever
students enter on `http://localhost:8080/statusz`. It does not forward
routes it learns, so the only routes it sends are the students'.
- **r1** (AS65001, FRR) applies policy to routes from ferrix based on the
communities they carry, then passes them to r2. It originates
`192.0.2.0/24`.
- **r2** (AS65002, FRR) refuses routes directly from ferrix, so it only learns
student prefixes via r1, and re-advertises everything to ferrix. It
originates `198.51.100.0/24`.
## Running it
From the repository root:
```
docker compose -f docs/lab/docker-compose.yml up --build
```
Then open <http://localhost:8080/statusz>. Both peers should show
`Established` within a few seconds and the received table should already
list the two prefixes r1 and r2 originate.
Useful on the routers:
```
docker exec r1 vtysh -c 'show bgp ipv4 unicast'
docker exec r2 vtysh -c 'show bgp ipv4 unicast 10.100.1.0/24'
docker exec r1 vtysh -c 'show bgp neighbors 10.200.0.1 advertised-routes'
```
## Policy on r1
| Students set | r1 does | Visible effect in ferrix |
|---------------------------|---------------------------------------------|-------------------------------------------------------|
| nothing | forwards to r2 | prefix returns, AS path `65002 65001 65000` |
| `NO_EXPORT` | keeps it, does not send to r2 (RFC 1997) | prefix does not return |
| `65001:100` | prepends `65001` three times | returns with path `65002 65001 65001 65001 65001 65000` |
| `BLACKHOLE` (65535:666) | drops it on import | prefix does not return |
## Without containers
`fake_peer` in `crates/integration-tests/examples` is enough for the core
loop on one machine: it echoes every prefix back with its own ASN prepended
and honours `NO_EXPORT`. See `bin/bgp/README.md`.
## Caveats
These container files were written without a container runtime to hand, so
they have not been executed. Things most likely to need a touch:
- the FRR image tag, and FRR's route-map / community-list syntax if your
version differs from 10.x;
- `no bgp ebgp-requires-policy` is required on FRR 7.4+ or eBGP routes are
silently dropped; it is present in both configs;
- ferrix binds port 179 inside its container, which requires no special
capability there. If you run it on the host instead, either run as root,
grant `CAP_NET_BIND_SERVICE`, or change `listen_endpoints` and the FRR
neighbor port.
- Both ferrix and the FRR routers dial each other, so connection collisions
(RFC 4271 §6.8) are normal at start-up: one of the two TCP connections is
closed with a Cease / Connection Collision Resolution NOTIFICATION and the
other carries the session. ferrix has the lowest BGP Identifier here, so
the connection the FRR router opened is the one that survives; the
"opened by" column on the status page shows which side that was.
- ferrix does not check AS paths for its own ASN. That is what lets the
looped route be accepted; a production BGP speaker would reject it.
-53
View File
@@ -1,53 +0,0 @@
# Teaching lab: ferrix_bgp plus two FRR routers forming a loop, so a prefix
# announced from the web UI travels ferrix -> r1 -> r2 -> ferrix and shows up
# in the "received routes" table with the AS path it collected on the way.
#
# AS65000 ferrix 10.200.0.1 web UI on http://localhost:8080/statusz
# AS65001 r1 10.200.0.2 applies community-driven policy
# AS65002 r2 10.200.0.3 learns only via r1, re-advertises to ferrix
#
# Usage (from the repository root):
# docker compose -f docs/lab/docker-compose.yml up --build
#
# NOTE: this topology has not been run by the author of this file on the
# machine it was written on (no container runtime was available). Expect to
# adjust image tags and FRR config syntax for the FRR version you pull.
services:
ferrix:
build:
context: ../..
container_name: ferrix
volumes:
- ./ferrix.textpb:/etc/ferrix/server.textpb:ro
ports:
- "8080:8080"
networks:
lab:
ipv4_address: 10.200.0.1
r1:
image: quay.io/frrouting/frr:10.2.1
container_name: r1
privileged: true
volumes:
- ./r1:/etc/frr:ro
networks:
lab:
ipv4_address: 10.200.0.2
r2:
image: quay.io/frrouting/frr:10.2.1
container_name: r2
privileged: true
volumes:
- ./r2:/etc/frr:ro
networks:
lab:
ipv4_address: 10.200.0.3
networks:
lab:
driver: bridge
ipam:
config:
- subnet: 10.200.0.0/24
-30
View File
@@ -1,30 +0,0 @@
router_id: "10.200.0.1"
local_asn: 65000
listen_endpoints { address: "0.0.0.0" port: 179 }
peers {
name: "r1"
endpoint { address: "10.200.0.2" port: 179 }
remote_asn: 65001
hold_time_seconds: 30
}
peers {
name: "r2"
endpoint { address: "10.200.0.3" port: 179 }
remote_asn: 65002
hold_time_seconds: 30
}
connect_retry_seconds: 5
# If r1 or r2 is restarted it will dial us again while we still believe the
# old session is up. Let that new connection replace the stale one straight
# away instead of refusing it until the hold timer (30 s here) expires.
collision_detect_established_state: true
# Students may only announce out of 10.100.0.0/16; one /24 each is plenty.
local_route_policy {
allowed_prefixes: "10.100.0.0/16"
max_routes_per_owner: 4
}
http_endpoint { address: "0.0.0.0" port: 8080 }
-8
View File
@@ -1,8 +0,0 @@
*.aux
*.log
*.out
*.fls
*.fdb_latexmk
*.synctex.gz
build.log
class.pdf
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""One exercise sheet per student, with their prefix filled in, in one PDF.
python3 build-class.py 100 # students 1..100 -> class.pdf
python3 build-class.py 100 -o out.pdf
python3 build-class.py 5 12 -o few.pdf # students 5..12
Student n gets 2a0d:d741:<n in hex>::/48; the lab /40 allows 1..255. Each
sheet is compiled separately and must come out to exactly two pages, so
the result prints double-sided with one student per leaf. Needs pdflatex
and pdfunite (poppler).
"""
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
def page_count(pdf):
out = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, check=True).stdout
return int(next(line.split()[-1] for line in out.splitlines() if line.startswith("Pages:")))
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("first", type=int, help="first student number, or the count if it is the only number")
ap.add_argument("last", type=int, nargs="?")
ap.add_argument("-o", "--output", default=os.path.join(HERE, "class.pdf"))
args = ap.parse_args()
first, last = (1, args.first) if args.last is None else (args.first, args.last)
if not 1 <= first <= last <= 255:
sys.exit("student numbers must be within 1..255: the lab /40 has 255 usable /48s")
with tempfile.TemporaryDirectory() as tmp:
for name in os.listdir(HERE):
if name.endswith((".tex", ".cls", ".sty", ".bst", ".bib")):
shutil.copy(os.path.join(HERE, name), tmp)
parts = []
for n in range(first, last + 1):
job = f"student-{n:03d}"
tex = f"\\def\\studentnum{{{n}}}\\def\\studenthex{{{n:x}}}\\input{{handout.tex}}"
for _ in range(2): # twice, for the figure and table references
r = subprocess.run(["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "-jobname", job, tex],
cwd=tmp, capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"student {n}: pdflatex failed:\n{r.stdout[-2000:]}")
pdf = os.path.join(tmp, job + ".pdf")
pages = page_count(pdf)
if pages != 2:
sys.exit(f"student {n}: sheet is {pages} pages, not 2; double-sided printing would misalign")
parts.append(pdf)
print(f"\rstudent {n}: 2a0d:d741:{n:x}::/48", end="", flush=True)
print()
subprocess.run(["pdfunite", *parts, args.output], check=True)
print(f"wrote {args.output}: students {first}..{last}, {page_count(args.output)} pages")
if __name__ == "__main__":
main()
Binary file not shown.
-195
View File
@@ -1,195 +0,0 @@
% Exercise sheet for the "Hands-on BGP" lecture: two A4 pages, one sheet.
% Springer LNCS class (llncs.cls ships with TeX Live). Build: latexmk -pdf handout.tex
\documentclass[a4paper]{llncs}
\usepackage[a4paper,margin=1in]{geometry}
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{microtype}
\usepackage{booktabs}
\usepackage{amssymb}
\usepackage{listings}
\usepackage{tikz}
\usepackage[bitstream-charter]{mathdesign}
\usetikzlibrary{positioning,arrows.meta}
\PassOptionsToPackage{hyphens}{url}
\usepackage[hidelinks]{hyperref}
\renewcommand\UrlFont{\ttfamily\small}
\pagestyle{empty}
% Floats sit in the text here, so they need less air than in a paper.
\setlength{\intextsep}{7pt}
\setlength{\abovecaptionskip}{4pt}
\setlength{\belowcaptionskip}{0pt}
% ---- things to change per class -------------------------------------------
\newcommand{\laburl}{https://bgplab.rayhaan.net/}
% build-class.py compiles one copy per student with \studentnum and
% \studenthex defined (e.g. 26 and 1a); without them this is the blank sheet.
\providecommand{\studentnum}{}
\providecommand{\studenthex}{}
\ifx\studentnum\empty
\newcommand{\myprefix}{\texttt{2a0d:d741:}\underline{\texttt{\,n\,}}\texttt{::/48}}
\newcommand{\nhex}{<n>}
\else
\newcommand{\myprefix}{\texttt{2a0d:d741:\studenthex::/48}}
\newcommand{\nhex}{\studenthex}
\fi
% ---- exercise machinery ------------------------------------------------------
\lstset{basicstyle=\ttfamily\small,columns=fullflexible,keepspaces=true,
aboveskip=0.5em,belowskip=0.3em}
\newcounter{task}[section]
\renewcommand{\thetask}{\thesection.\arabic{task}}
\newcommand{\task}[1]{\refstepcounter{task}\smallskip\noindent
\textbf{Task \thetask}\enspace\textit{#1}\enspace\ignorespaces}
\newcommand{\answer}[1][1]{\par\nobreak
\foreach \i in {1,...,#1}{\vspace{1.0em}\noindent\makebox[\linewidth]{\dotfill}\par}\smallskip}
% A labelled answer box: the label on its own line, then #1 dotted lines to
% write on (default 2). Used side by side by \fillintwo and \fillinthree.
\newcommand{\writelines}[1]{\foreach \i in {1,...,#1}{\vspace{1.0em}\makebox[\linewidth]{\dotfill}\par}}
\newcommand{\fillbox}[3][2]{\parbox[t]{#2}{#3\par\nopagebreak\writelines{#1}}}
\newcommand{\fillintwo}[3][2]{\par\nopagebreak\smallskip\noindent
\fillbox[#1]{0.485\linewidth}{#2}\hfill\fillbox[#1]{0.485\linewidth}{#3}\par\vspace{0.4em}}
\newcommand{\fillinthree}[4][2]{\par\nopagebreak\smallskip\noindent
\fillbox[#1]{0.32\linewidth}{#2}\hfill\fillbox[#1]{0.32\linewidth}{#3}\hfill\fillbox[#1]{0.32\linewidth}{#4}\par\vspace{0.4em}}
\newcommand{\cbox}{$\square$\;}
\newcommand{\reach}{\par\nopagebreak\smallskip\noindent
Reachable from:\hspace{1.2em}\cbox laptop on the university network\hspace{2em}\cbox phone on mobile data\par\smallskip}
\begin{document}
\title{Hands on BGP: Announcing and Steering an IPv6 Prefix}
\author{Rayhaan Jaufeerally}
\institute{Network Security, ETHZ\\ Lab page: \url{\laburl} (username ``netsec'', password ``perrig'')}
\maketitle
\medskip\noindent
\ifx\studentnum\empty
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
\parbox[t]{0.27\linewidth}{Prefix: \myprefix}
\smallskip\noindent
Your prefix is \myprefix, where \texttt{n} is your number \emph{in
hexadecimal}.\par\noindent
Student 10 has \texttt{2a0d:d741:a::/48} and student 26 has
\texttt{2a0d:d741:1a::/48}.
\else
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
\parbox[t]{0.5\linewidth}{Student \studentnum\quad Prefix: \myprefix}
\smallskip\noindent
Your prefix is \myprefix: student number \studentnum, written in
hexadecimal. Make sure to use this prefix in the exercise.
\fi
% ==============================================================================
\section{Who Announces the Address You Are Using?}
\task{Find your IPv6 address.}
Open \texttt{https://bgp.tools} in a browser: the top of the page shows the address
you are connecting from, the prefix it belongs to, and the network that
announces that prefix.
\fillinthree{Your IPv6 address:}{its prefix:}{announced by AS:}
\task{Ask the registry.}
The RIPE database records who holds address space
(\texttt{inet6num}, or \texttt{inetnum} for IPv4) and who may announce it
(\texttt{route6}, \texttt{route}). Look up your IPv6 address, then your lab prefix:
\begin{lstlisting}[escapeinside={(*}{*)}]
whois <your IPv6 address> whois 2a0d:d741:(*\nhex*)::/48
\end{lstlisting}
\fillintwo{Your IPv6 address: held by (\texttt{netname})}{\texttt{origin} AS:}
\fillintwo{Your lab prefix: \texttt{origin} AS}{is it announced right now?}
% ==============================================================================
\section{Announcing Your Prefix}
\begin{figure}[h]
\centering
\begin{tikzpicture}[font=\footnotesize, node distance=5mm and 11mm,
box/.style={draw, rounded corners=1.5pt, minimum height=7.5mm, inner xsep=4pt, align=center},
bgp/.style={-{Stealth[length=2mm]}, dashed}, web/.style={-{Stealth[length=2mm]}}]
\node[box] (you) {you\\[-1pt]\scriptsize browser};
\node[box, right=of you] (ferrix) {lab daemon\\[-1pt]\scriptsize AS4200000001};
\node[box, right=of ferrix] (pr) {\texttt{pr01.rue}\\[-1pt]\scriptsize AS210036};
\node[box, right=of pr] (rs) {SwissIX route servers\\[-1pt]\scriptsize AS42476};
\node[box, right=of rs] (sw) {SWITCH (ETH)\\[-1pt]\scriptsize AS559};
\node[box, below=4mm of rs] (other) {other exchanges, transit};
\draw[web] (you) -- node[above, inner sep=1.5pt]{\tiny HTTPS} (ferrix);
\draw[bgp] (ferrix) -- node[above, inner sep=1.5pt]{\tiny BGP} (pr);
\draw[bgp] (pr) -- (rs);
\draw[bgp] (rs) -- (sw);
\draw[bgp] (pr) |- (other);
\end{tikzpicture}
\caption{The lab. Dashed arrows are BGP sessions; everything right of
\texttt{pr01.rue} is the real Internet.}
\label{fig:lab}
\end{figure}
\task{Announce.}
Open \url{\laburl} (username ``netsec'' password ``perrig''). Enter your name as
\emph{owner} and your prefix, and press \emph{Announce}.
\task{Read what you sent.}
Click \emph{bytes} next to your route and hover over the coloured segments.
\fillintwo{AS number(s) in \texttt{AS\_PATH}:}{attribute carrying your prefix:}
\task{See it from outside.}
Wait a moment, then look your prefix up on \texttt{https://bgp.tools/super-lg}.
\fillintwo{Origin AS the world sees:}{an AS path you found:}
\noindent The lab daemon's AS4200000001 is missing from that path. Where did it go, and why?
\answer[2]
\task{Reach it.}
The router answers for every address in the lab space, so your prefix is
reachable exactly when your announcement got out:\quad
\texttt{ping -6 2a0d:d741:\nhex::1}
\reach
% ==============================================================================
\section{Steering Your Prefix with Large Communities}
A Large Community is a tag added to a route. It can have any meaning that the network
which issued it decides. In BGP large communities, the first part is the AS number
and the subsequent two integers are up to the interpretation of that network.
\begin{table}[h]
\centering
\begin{tabular}{@{}l@{\hspace{1.6em}}l@{\hspace{1.6em}}l@{}}
\toprule
Large community & Read by & Effect \\
\midrule
\texttt{210036:0:0} & \texttt{pr01.rue} & announce nowhere \ldots \\
\texttt{210036:1:42476} & \texttt{pr01.rue} & \ldots except to AS42476, the SwissIX route servers \\
\midrule
\texttt{42476:0:0} & SwissIX & send to no member \ldots \\
\texttt{42476:1:559} & SwissIX & \ldots except AS559, SWITCH, the network ETH is part of \\
\bottomrule
\end{tabular}
\end{table}
\task{One exchange only.}
Announce your prefix again with \texttt{210036:0:0} and
\texttt{210036:1:42476}; announcing a prefix you own replaces it. Before you
look: who do you expect to still see it?
\answer[2]
\noindent Wait a moment, then check on \texttt{https://bgp.tools/super-lg} and with \texttt{ping}.
\reach
\task{To SWITCH only.}
Add \texttt{42476:0:0} and \texttt{42476:1:559} to the two you already have and
announce again. Our router now tells only the route servers, and they tell
only SWITCH.
\reach
\noindent Is your prefix still visible on \texttt{https://bgp.tools/super-lg}? Explain what you see.
\answer[2]
\task{Use of large communities.}
You steered routing in two networks you do not operate, using nothing but
numbers attached to a route. What stops you from attaching a community that
one of \emph{their} upstreams acts on?
\answer[2]
\task{Clean up.} Withdraw your prefix and see how long it takes to go away.
\end{document}
-172
View File
@@ -1,172 +0,0 @@
# BIRD 2.x / 3.x: peer the production router (AS210036) with ferrix_bgp over
# WireGuard. ferrix is announce-only: we accept the lab /48s from it and send
# it nothing.
#
# Include from bird.conf: include "/etc/bird/bird-ferrix.conf";
# Check and apply: birdc configure check && birdc configure
# Inspect: birdc show protocols all ferrix
# birdc show route protocol ferrix all
define MY_ASN = 210036;
define FERRIX_ASN = 4200000001;
define LAB_PREFIXES = [ 2a0d:d741::/40{48,48} ];
filter ferrix_import
{
# Only the students' /48s, nothing shorter or longer, nothing else.
if !(net ~ LAB_PREFIXES) then reject;
# ferrix allows at most 16 prepends on top of its own ASN.
if bgp_path.len > 17 then reject;
if bgp_path.first != FERRIX_ASN then reject;
# Students type communities freely into a web form with no login, so
# only namespaces we have chosen to expose get through.
#
# Standard: just the two well-known ones BIRD itself honours, so
# NO_EXPORT / NO_ADVERTISE visibly work. Notably BLACKHOLE (65535:666),
# which SwissIX acts on, stays scrubbed.
bgp_community.filter([ (65535, 65281), (65535, 65282) ]);
# Large: the SwissIX route-server action communities, all of which live
# under their route-server ASN 42476
# (https://www.swissix.ch/resources/action-bgp-communities/):
# 42476:0:<peer-as> do not redistribute to peer 42476:0:0 to nobody
# 42476:1:<peer-as> redistribute to peer 42476:1:0 to all
# 42476:101|102|103:<peer-as or 0> prepend 1x / 2x / 3x
# 42476:901|902:<peer-as> add NO_EXPORT / NO_ADVERTISE
# Everything else is dropped, including the rest of our own
# (210036, *, *) space, so students cannot trigger anything else this
# network acts on. Add further namespaces here deliberately.
#
# And our own export-control communities, functions 0 to 3, evaluated by
# lab_export_allowed() below.
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..3, *) ]);
bgp_ext_community.empty;
# Announce-only: there is nothing behind these prefixes. Do not forward
# their traffic into the tunnel; answer with "unreachable" instead.
dest = RTD_UNREACHABLE;
accept;
}
protocol bgp ferrix
{
description "ferrix_bgp teaching lab (announce only)";
local 2a0d:d747:1::1 as 210036;
neighbor 2a0d:d747:1:: as FERRIX_ASN;
direct;
# ferrix dials us. Staying passive avoids log noise from failed attempts
# towards a laptop that is asleep or firewalled.
passive on;
# ferrix supports none of these; leave them off.
# password "..."; (TCP MD5)
# ttl security on; (GTSM)
# bfd on;
ipv6 {
import filter ferrix_import;
# 128 students, one /48 each. Extra routes are ignored, the session
# stays up.
import limit 130 action block;
# Send nothing: ferrix keeps received routes in memory and renders
# every one of them on its status page.
export none;
};
}
# ---------------------------------------------------------------------------
# Export control for lab routes.
#
# BIRD export filters cannot see which neighbour they are exporting to, and
# filters take no arguments; functions do. So every eBGP session states who it
# talks to and where, by calling the shared export function with its neighbour
# AS and a location:
#
# ipv6 { export where peers_out(42476, AT_SWISSIX); ... };
#
# Students steer their prefix with large communities, in this precedence:
#
# 1. 210036:0:<as> never announce to that neighbour AS
# 210036:2:<location> never announce at that location
# 2. 210036:1:<as> announce to that neighbour AS
# 210036:3:<location> announce at that location
# 3. 210036:0:0 announce nowhere (unless rule 2 says otherwise)
# 4. nothing announce everywhere <- default
#
# A "never" always wins, so the two dimensions combine. The shape and
# precedence match the SwissIX route-server communities on purpose, so one
# idea is applied at two hops.
#
# only the SwissIX route servers: 210036:0:0 210036:1:42476
# ...and there only SWITCH: add 42476:0:0 42476:1:559
# every session at SwissIX: 210036:0:0 210036:3:1
# Cloudflare, but not at DE-CIX: 210036:0:0 210036:1:13335 210036:2:4
# ---------------------------------------------------------------------------
define AT_SWISSIX = 1;
define AT_NLIX = 2;
define AT_FRANCEIX = 3;
define AT_DECIX = 4;
define AT_FREEIX = 5;
define AT_COMMUNITYIX = 6;
define AT_TRANSIT = 100;
define AT_DOWNSTREAM = 200;
function lab_export_allowed(int peer_as; int location)
{
if (MY_ASN, 0, peer_as) ~ bgp_large_community then return false;
if (MY_ASN, 2, location) ~ bgp_large_community then return false;
if (MY_ASN, 1, peer_as) ~ bgp_large_community then return true;
if (MY_ASN, 3, location) ~ bgp_large_community then return true;
if (MY_ASN, 0, 0) ~ bgp_large_community then return false;
return true;
}
# Decides whether a lab route goes out on a session and, if so, makes it
# presentable: the origin must appear as AS210036 (matching the route6
# objects) and our action communities must not leak.
function lab_route_out(int peer_as; int location)
{
if !lab_export_allowed(peer_as, location) then return false;
bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
bgp_large_community.delete([ (MY_ASN, 0..3, *) ]);
return true;
}
# The shared export function then ends its list of accepted routes with
#
# if proto = "ferrix" then return lab_route_out(peer_as, location);
# return false;
#
# and returns true for everything else it accepts. Sessions that export
# everything (downstream customers) need the same gate, or they receive lab
# routes regardless of what the student asked for:
#
# export filter {
# if proto = "ferrix" then {
# if lab_route_out(210400, AT_DOWNSTREAM) then accept;
# reject;
# }
# accept;
# };
#
# The ferrix branch only ever applies to routes learned from ferrix, so these
# communities cannot be used by anyone else to steer other exports.
#
# If this BIRD rejects "int peer_as; int location", it wants a comma there.
#
# Check what a session would send, without waiting for a looking glass:
# birdc show route 2a0d:d741:1::/48 export <protocol-name> all
#
# The status page no longer offers AS-path prepending or the SwissIX prepend
# communities (42476:101..103:*), to keep the exercise focused. The import
# filter still admits the whole 42476 namespace, so a student who types one
# by hand can use it; narrow the filter if that is not wanted.
#
# The session towards the SwissIX route servers must send large communities
# unmodified apart from the delete above; check nothing else clears them.
# ---------------------------------------------------------------------------
-20
View File
@@ -1,20 +0,0 @@
router_id: "10.255.0.2" # must differ from the production router's
local_asn: 4200000001 # private asn used for internal peering.
listen_endpoints { address: "2a0d:d747:1::" port: 9179 }
peers {
name: "pr01.rue.rayhaan.net"
endpoint { address: "2a0d:d747:1::1" port: 179 }
remote_asn: 210036
hold_time_seconds: 90
}
connect_retry_seconds: 5
collision_detect_established_state: true
local_route_policy {
allowed_prefixes: "2a0d:d741::/40"
max_routes_per_owner: 1
}
http_endpoint { address: "127.0.0.1" port: 8080 }
-20
View File
@@ -1,20 +0,0 @@
# /etc/systemd/network/05-lab-lo.network
# If a .network file already matches "lo" (e.g. the AnyIP one), merge these
# sections into it instead: networkd applies only the first match.
[Match]
Name=lo
# BIRD and ferrix peer over ::1, which needs no configuration. Should BIRD
# object to identical local and neighbour addresses, give them one each
# instead (and use those in bird.conf and ferrix.textpb):
#
# [Address]
# Address=fd00:179::1/128
#
# [Address]
# Address=fd00:179::2/128
# AnyIP: the router answers ping for every address in the lab space.
[Route]
Destination=2a0d:d741::/40
Type=local
-178
View File
@@ -1,178 +0,0 @@
# Running ferrix_bgp on the production router
ferrix and BIRD run side by side on pr01 and peer over `::1`. Students reach the status page through a reverse proxy that adds
TLS and a password. Nothing here has been run on the router; treat each step
as something to check.
## 1. Build a static Linux binary (on the Mac)
The `x86_64-unknown-linux-musl` target and Homebrew's musl cross-linker are
enough; the router needs no Rust toolchain and no `protoc`.
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
cargo build --release -p bgp --target x86_64-unknown-linux-musl
scp target/x86_64-unknown-linux-musl/release/bgp pr01:/tmp/ferrix-bgp
Check the router really is x86-64 first (`uname -m`).
## 2. Install on the router
install -m 0755 /tmp/ferrix-bgp /usr/local/bin/ferrix-bgp
install -d /etc/ferrix-bgp
install -m 0644 ferrix.textpb /etc/ferrix-bgp/ferrix.textpb
install -m 0644 ferrix-bgp.service /etc/systemd/system/ferrix-bgp.service
## 3. Loopback
Peering over `::1` needs no address setup. `05-lab-lo.network` only carries
the AnyIP route for the lab space; merge it into the networkd file that
matches `lo` if it is not there already, then `networkctl reload`.
## 4. BIRD
Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
`birdc configure check` and `birdc configure timeout 300` / `confirm`.
## 5. Start
systemctl daemon-reload
systemctl enable --now ferrix-bgp
journalctl -u ferrix-bgp -f
birdc show protocols all ferrix
The journal should say `Ping monitor listening for ICMPv6 echo requests`. If
it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything
else works without it and the status page says why the table is off.
To check the monitor before the class, announce a prefix on the page, turn on
auto-refresh and ping an address inside it from somewhere else:
ping -c 3 2a0d:d741:1::1
The ping shows up under "Pings to announced addresses" with its source, the
interface it arrived on and the hop limit it arrived with. Check that
"arrived on" names the real ingress port (the exchange or transit interface)
and not `lo`.
## 6. Let students in
The API has no authentication and announces into a real AS, so do not expose
port 8080. Put a reverse proxy in front with TLS and a class password. The
event stream is long-lived, so the proxy must not buffer it.
Apache (tested against a local instance: 401 without the password, pages,
API and POSTs through the proxy, and events arriving within 50 ms):
<VirtualHost *:80>
ServerName lab.example.net
Redirect permanent "/" "https://lab.example.net/"
</VirtualHost>
<VirtualHost *:443>
ServerName lab.example.net
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/lab.example.net/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/lab.example.net/privkey.pem
# Everything behind one class password.
<Location "/">
AuthType Basic
AuthName "BGP lab"
AuthUserFile /etc/apache2/ferrix.htpasswd
Require valid-user
</Location>
# Use Apache's built-in error messages in this vhost. Servers with the
# multi-language error documents enabled fetch them from /error/,
# which this vhost would proxy to ferrix; a 401 then comes back as
# ferrix's empty 404: a blank page and no password prompt. With the
# built-in ones nothing is fetched, so nothing needs exempting from
# the login.
ErrorDocument 400 default
ErrorDocument 401 default
ErrorDocument 403 default
ErrorDocument 404 default
ErrorDocument 500 default
ErrorDocument 502 default
ErrorDocument 503 default
ErrorDocument 504 default
ProxyPreserveHost On
ProxyPass "/" "http://127.0.0.1:8080/"
ProxyPassReverse "/" "http://127.0.0.1:8080/"
# ferrix has no use for the password; do not pass it on.
RequestHeader unset Authorization
# The status page follows a long-lived event stream. If mod_deflate
# compresses it, events sit in the compressor and never arrive.
SetEnvIf Request_URI "^/api/events" no-gzip=1
</VirtualHost>
Modules: `proxy proxy_http ssl headers setenvif auth_basic authn_file`.
Create the password file with `htpasswd -c -B /etc/apache2/ferrix.htpasswd student`.
To also limit it to known networks, replace `Require valid-user` with
<RequireAll>
Require valid-user
Require ip 192.0.2.0/24 2001:db8::/32
</RequireAll>
Caddy:
lab.example.net {
basic_auth {
student <hash from `caddy hash-password`>
}
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
nginx, inside a TLS `server` block:
location / {
auth_basic "BGP lab";
auth_basic_user_file /etc/nginx/lab.htpasswd;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 1h;
}
Restricting the proxy to the university's address ranges as well is cheap
and worth doing. Only the Apache example has been tested.
## 7. Load test before the class
`tools/loadtest.py` makes N simulated students announce their /48 at the same
instant, then verifies the table, ownership under load, the event stream and
the number of BGP UPDATEs actually sent, and withdraws everything again.
Python 3 standard library only.
tools/loadtest.py 128 --url https://lab.example.net --user student
Every test route carries `210036:0:0`, so BIRD accepts it but exports it to no
neighbour (apart from `home_v6`, which is outside the export control). While
it runs, `birdc show route protocol ferrix count` should rise to the number of
students and fall back to what it was. BIRD's `import limit 130` means more
than 130 lab routes at once are blocked there, by design.
## Things to know
- The ping monitor only listens. The kernel answers the pings, with or
without ferrix running, because of the AnyIP route. It sees echo requests
to the lab space from anyone on the Internet, so source addresses of
strangers will appear on the page next to the students' own. It remembers
at most 2048 source and destination pairs and publishes at most 50 events a
second, so a ping flood costs little; `ping_monitor_echo_requests_total` and
`ping_monitor_events_suppressed_total` on `/metricz` show what it saw.
- Announced routes live in memory. A restart of the service forgets them and
students have to announce again.
- Stopping the service sends BIRD a Cease first, so restarts reconnect at
once.
- ferrix's own safety net is `local_route_policy`; BIRD's import filter and
import limit are the second one. Keep both.
@@ -1,23 +0,0 @@
# Replaces "protocol bgp ferrix" in bird.conf when ferrix runs on this host.
# Only the addresses and "multihop" differ from the WireGuard version; the
# import filter and everything about export control stay as they are.
#
# Without "multihop" BIRD looks for the neighbour on a directly connected
# network, cannot find ::1 there, and disables the protocol with
# "Error: Invalid next hop". After fixing that, `birdc enable ferrix` if a
# reconfigure does not bring it back by itself.
protocol bgp ferrix
{
description "ferrix_bgp teaching lab (announce only, same host)";
local ::1 as 210036;
neighbor ::1 as FERRIX_ASN;
# Both ends are this machine, so there is no directly connected
# neighbour for "direct" (the eBGP default) to find.
multihop;
passive on;
ipv6 {
import filter ferrix_import;
import limit 130 action block;
export none;
};
}
@@ -1,37 +0,0 @@
[Unit]
Description=ferrix_bgp teaching BGP speaker
After=network-online.target bird.service
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/ferrix-bgp /etc/ferrix-bgp/ferrix.textpb --level info
# SIGTERM makes ferrix send Cease / Administrative Shutdown to BIRD first.
KillSignal=SIGTERM
TimeoutStopSec=10
Restart=on-failure
RestartSec=5
# It needs the network and nothing else: no root, no privileged port, no
# write access anywhere. The one capability, CAP_NET_RAW, is for the ping
# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not
# configured.
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
MemoryMax=256M
[Install]
WantedBy=multi-user.target
-32
View File
@@ -1,32 +0,0 @@
# ferrix_bgp running on the production router itself, next to BIRD.
router_id: "10.255.0.2"
local_asn: 4200000001
# Unused in practice (BIRD is passive and we dial it), but a listener is
# required. 179 belongs to BIRD, so take another port on our own address.
listen_endpoints { address: "::1" port: 9179 }
peers {
name: "pr01-bird"
endpoint { address: "::1" port: 179 }
# BIRD recognises its neighbour by source address, so pin ours.
local_address: "::1"
remote_asn: 210036
hold_time_seconds: 90
}
connect_retry_seconds: 5
collision_detect_established_state: true
local_route_policy {
allowed_prefixes: "2a0d:d741::/40"
max_routes_per_owner: 1
}
# Loopback only. Students reach it through the reverse proxy, which adds TLS
# and a password; the API itself has neither.
http_endpoint { address: "127.0.0.1" port: 8080 }
# Show who pings the lab addresses. The kernel answers the pings (AnyIP route
# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above.
# Needs CAP_NET_RAW, which the systemd unit grants.
ping_monitor {}
File diff suppressed because it is too large. Load diff
-4
View File
@@ -1,4 +0,0 @@
bgpd=yes
zebra=yes
staticd=yes
vtysh_enable=yes
-44
View File
@@ -1,44 +0,0 @@
! r1: AS65001. Peers with ferrix (AS65000) and r2 (AS65002).
! Applies policy on routes from ferrix based on the communities students set:
! NO_EXPORT (65535:65281) standard: not sent to r2 (FRR honours it)
! 65001:100 r1 prepends its own ASN three times
! BLACKHOLE (65535:666) dropped at r1
frr defaults traditional
hostname r1
log stdout
!
ip route 192.0.2.0/24 Null0
!
bgp community-list standard PREPEND-ME permit 65001:100
bgp community-list standard BLACKHOLE permit 65535:666
!
route-map FROM-FERRIX deny 10
match community BLACKHOLE
!
route-map FROM-FERRIX permit 20
match community PREPEND-ME
set as-path prepend 65001 65001 65001
!
route-map FROM-FERRIX permit 30
!
route-map PERMIT-ALL permit 10
!
router bgp 65001
bgp router-id 10.200.0.2
no bgp ebgp-requires-policy
no bgp network import-check
neighbor 10.200.0.1 remote-as 65000
neighbor 10.200.0.1 description ferrix
neighbor 10.200.0.1 timers 10 30
neighbor 10.200.0.3 remote-as 65002
neighbor 10.200.0.3 description r2
neighbor 10.200.0.3 timers 10 30
!
address-family ipv4 unicast
network 192.0.2.0/24
neighbor 10.200.0.1 route-map FROM-FERRIX in
neighbor 10.200.0.1 route-map PERMIT-ALL out
neighbor 10.200.0.3 route-map PERMIT-ALL in
neighbor 10.200.0.3 route-map PERMIT-ALL out
exit-address-family
!
-1
View File
@@ -1 +0,0 @@
service integrated-vtysh-config
-4
View File
@@ -1,4 +0,0 @@
bgpd=yes
zebra=yes
staticd=yes
vtysh_enable=yes
-33
View File
@@ -1,33 +0,0 @@
! r2: AS65002. Peers with r1 (AS65001) and ferrix (AS65000).
! Accepts nothing directly from ferrix so every student prefix reaches it
! only via r1, then re-advertises everything to ferrix. That closes the loop:
! ferrix sees its own prefixes come back with AS path 65002 65001 65000.
frr defaults traditional
hostname r2
log stdout
!
ip route 198.51.100.0/24 Null0
!
route-map PERMIT-ALL permit 10
!
route-map DENY-ALL deny 10
!
router bgp 65002
bgp router-id 10.200.0.3
no bgp ebgp-requires-policy
no bgp network import-check
neighbor 10.200.0.2 remote-as 65001
neighbor 10.200.0.2 description r1
neighbor 10.200.0.2 timers 10 30
neighbor 10.200.0.1 remote-as 65000
neighbor 10.200.0.1 description ferrix
neighbor 10.200.0.1 timers 10 30
!
address-family ipv4 unicast
network 198.51.100.0/24
neighbor 10.200.0.2 route-map PERMIT-ALL in
neighbor 10.200.0.2 route-map PERMIT-ALL out
neighbor 10.200.0.1 route-map DENY-ALL in
neighbor 10.200.0.1 route-map PERMIT-ALL out
exit-address-family
!
-1
View File
@@ -1 +0,0 @@
service integrated-vtysh-config
-779
View File
@@ -1,779 +0,0 @@
inet6num: 2a0d:d741::/40
netname: CH-RAY-ETHZ-NETSEC-LAB-2026
descr: ETHZ Netsec 2026 BGP hands-on lab, one /48 per student
country: CH
org: ORG-RAJ2-RIPE
admin-c: RJ4890-RIPE
tech-c: RJ4890-RIPE
status: AGGREGATED-BY-LIR
assignment-size: 48
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1::/48
descr: ETHZ Netsec 2026 BGP lab - student 1
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2::/48
descr: ETHZ Netsec 2026 BGP lab - student 2
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3::/48
descr: ETHZ Netsec 2026 BGP lab - student 3
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4::/48
descr: ETHZ Netsec 2026 BGP lab - student 4
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5::/48
descr: ETHZ Netsec 2026 BGP lab - student 5
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6::/48
descr: ETHZ Netsec 2026 BGP lab - student 6
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7::/48
descr: ETHZ Netsec 2026 BGP lab - student 7
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:8::/48
descr: ETHZ Netsec 2026 BGP lab - student 8
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:9::/48
descr: ETHZ Netsec 2026 BGP lab - student 9
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:a::/48
descr: ETHZ Netsec 2026 BGP lab - student 10
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:b::/48
descr: ETHZ Netsec 2026 BGP lab - student 11
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:c::/48
descr: ETHZ Netsec 2026 BGP lab - student 12
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:d::/48
descr: ETHZ Netsec 2026 BGP lab - student 13
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:e::/48
descr: ETHZ Netsec 2026 BGP lab - student 14
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:f::/48
descr: ETHZ Netsec 2026 BGP lab - student 15
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:10::/48
descr: ETHZ Netsec 2026 BGP lab - student 16
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:11::/48
descr: ETHZ Netsec 2026 BGP lab - student 17
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:12::/48
descr: ETHZ Netsec 2026 BGP lab - student 18
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:13::/48
descr: ETHZ Netsec 2026 BGP lab - student 19
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:14::/48
descr: ETHZ Netsec 2026 BGP lab - student 20
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:15::/48
descr: ETHZ Netsec 2026 BGP lab - student 21
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:16::/48
descr: ETHZ Netsec 2026 BGP lab - student 22
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:17::/48
descr: ETHZ Netsec 2026 BGP lab - student 23
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:18::/48
descr: ETHZ Netsec 2026 BGP lab - student 24
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:19::/48
descr: ETHZ Netsec 2026 BGP lab - student 25
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1a::/48
descr: ETHZ Netsec 2026 BGP lab - student 26
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1b::/48
descr: ETHZ Netsec 2026 BGP lab - student 27
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1c::/48
descr: ETHZ Netsec 2026 BGP lab - student 28
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1d::/48
descr: ETHZ Netsec 2026 BGP lab - student 29
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1e::/48
descr: ETHZ Netsec 2026 BGP lab - student 30
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:1f::/48
descr: ETHZ Netsec 2026 BGP lab - student 31
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:20::/48
descr: ETHZ Netsec 2026 BGP lab - student 32
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:21::/48
descr: ETHZ Netsec 2026 BGP lab - student 33
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:22::/48
descr: ETHZ Netsec 2026 BGP lab - student 34
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:23::/48
descr: ETHZ Netsec 2026 BGP lab - student 35
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:24::/48
descr: ETHZ Netsec 2026 BGP lab - student 36
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:25::/48
descr: ETHZ Netsec 2026 BGP lab - student 37
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:26::/48
descr: ETHZ Netsec 2026 BGP lab - student 38
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:27::/48
descr: ETHZ Netsec 2026 BGP lab - student 39
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:28::/48
descr: ETHZ Netsec 2026 BGP lab - student 40
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:29::/48
descr: ETHZ Netsec 2026 BGP lab - student 41
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2a::/48
descr: ETHZ Netsec 2026 BGP lab - student 42
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2b::/48
descr: ETHZ Netsec 2026 BGP lab - student 43
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2c::/48
descr: ETHZ Netsec 2026 BGP lab - student 44
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2d::/48
descr: ETHZ Netsec 2026 BGP lab - student 45
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2e::/48
descr: ETHZ Netsec 2026 BGP lab - student 46
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:2f::/48
descr: ETHZ Netsec 2026 BGP lab - student 47
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:30::/48
descr: ETHZ Netsec 2026 BGP lab - student 48
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:31::/48
descr: ETHZ Netsec 2026 BGP lab - student 49
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:32::/48
descr: ETHZ Netsec 2026 BGP lab - student 50
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:33::/48
descr: ETHZ Netsec 2026 BGP lab - student 51
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:34::/48
descr: ETHZ Netsec 2026 BGP lab - student 52
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:35::/48
descr: ETHZ Netsec 2026 BGP lab - student 53
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:36::/48
descr: ETHZ Netsec 2026 BGP lab - student 54
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:37::/48
descr: ETHZ Netsec 2026 BGP lab - student 55
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:38::/48
descr: ETHZ Netsec 2026 BGP lab - student 56
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:39::/48
descr: ETHZ Netsec 2026 BGP lab - student 57
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3a::/48
descr: ETHZ Netsec 2026 BGP lab - student 58
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3b::/48
descr: ETHZ Netsec 2026 BGP lab - student 59
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3c::/48
descr: ETHZ Netsec 2026 BGP lab - student 60
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3d::/48
descr: ETHZ Netsec 2026 BGP lab - student 61
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3e::/48
descr: ETHZ Netsec 2026 BGP lab - student 62
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:3f::/48
descr: ETHZ Netsec 2026 BGP lab - student 63
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:40::/48
descr: ETHZ Netsec 2026 BGP lab - student 64
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:41::/48
descr: ETHZ Netsec 2026 BGP lab - student 65
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:42::/48
descr: ETHZ Netsec 2026 BGP lab - student 66
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:43::/48
descr: ETHZ Netsec 2026 BGP lab - student 67
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:44::/48
descr: ETHZ Netsec 2026 BGP lab - student 68
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:45::/48
descr: ETHZ Netsec 2026 BGP lab - student 69
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:46::/48
descr: ETHZ Netsec 2026 BGP lab - student 70
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:47::/48
descr: ETHZ Netsec 2026 BGP lab - student 71
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:48::/48
descr: ETHZ Netsec 2026 BGP lab - student 72
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:49::/48
descr: ETHZ Netsec 2026 BGP lab - student 73
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4a::/48
descr: ETHZ Netsec 2026 BGP lab - student 74
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4b::/48
descr: ETHZ Netsec 2026 BGP lab - student 75
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4c::/48
descr: ETHZ Netsec 2026 BGP lab - student 76
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4d::/48
descr: ETHZ Netsec 2026 BGP lab - student 77
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4e::/48
descr: ETHZ Netsec 2026 BGP lab - student 78
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:4f::/48
descr: ETHZ Netsec 2026 BGP lab - student 79
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:50::/48
descr: ETHZ Netsec 2026 BGP lab - student 80
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:51::/48
descr: ETHZ Netsec 2026 BGP lab - student 81
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:52::/48
descr: ETHZ Netsec 2026 BGP lab - student 82
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:53::/48
descr: ETHZ Netsec 2026 BGP lab - student 83
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:54::/48
descr: ETHZ Netsec 2026 BGP lab - student 84
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:55::/48
descr: ETHZ Netsec 2026 BGP lab - student 85
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:56::/48
descr: ETHZ Netsec 2026 BGP lab - student 86
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:57::/48
descr: ETHZ Netsec 2026 BGP lab - student 87
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:58::/48
descr: ETHZ Netsec 2026 BGP lab - student 88
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:59::/48
descr: ETHZ Netsec 2026 BGP lab - student 89
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5a::/48
descr: ETHZ Netsec 2026 BGP lab - student 90
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5b::/48
descr: ETHZ Netsec 2026 BGP lab - student 91
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5c::/48
descr: ETHZ Netsec 2026 BGP lab - student 92
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5d::/48
descr: ETHZ Netsec 2026 BGP lab - student 93
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5e::/48
descr: ETHZ Netsec 2026 BGP lab - student 94
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:5f::/48
descr: ETHZ Netsec 2026 BGP lab - student 95
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:60::/48
descr: ETHZ Netsec 2026 BGP lab - student 96
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:61::/48
descr: ETHZ Netsec 2026 BGP lab - student 97
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:62::/48
descr: ETHZ Netsec 2026 BGP lab - student 98
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:63::/48
descr: ETHZ Netsec 2026 BGP lab - student 99
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:64::/48
descr: ETHZ Netsec 2026 BGP lab - student 100
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:65::/48
descr: ETHZ Netsec 2026 BGP lab - student 101
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:66::/48
descr: ETHZ Netsec 2026 BGP lab - student 102
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:67::/48
descr: ETHZ Netsec 2026 BGP lab - student 103
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:68::/48
descr: ETHZ Netsec 2026 BGP lab - student 104
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:69::/48
descr: ETHZ Netsec 2026 BGP lab - student 105
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6a::/48
descr: ETHZ Netsec 2026 BGP lab - student 106
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6b::/48
descr: ETHZ Netsec 2026 BGP lab - student 107
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6c::/48
descr: ETHZ Netsec 2026 BGP lab - student 108
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6d::/48
descr: ETHZ Netsec 2026 BGP lab - student 109
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6e::/48
descr: ETHZ Netsec 2026 BGP lab - student 110
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:6f::/48
descr: ETHZ Netsec 2026 BGP lab - student 111
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:70::/48
descr: ETHZ Netsec 2026 BGP lab - student 112
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:71::/48
descr: ETHZ Netsec 2026 BGP lab - student 113
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:72::/48
descr: ETHZ Netsec 2026 BGP lab - student 114
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:73::/48
descr: ETHZ Netsec 2026 BGP lab - student 115
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:74::/48
descr: ETHZ Netsec 2026 BGP lab - student 116
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:75::/48
descr: ETHZ Netsec 2026 BGP lab - student 117
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:76::/48
descr: ETHZ Netsec 2026 BGP lab - student 118
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:77::/48
descr: ETHZ Netsec 2026 BGP lab - student 119
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:78::/48
descr: ETHZ Netsec 2026 BGP lab - student 120
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:79::/48
descr: ETHZ Netsec 2026 BGP lab - student 121
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7a::/48
descr: ETHZ Netsec 2026 BGP lab - student 122
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7b::/48
descr: ETHZ Netsec 2026 BGP lab - student 123
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7c::/48
descr: ETHZ Netsec 2026 BGP lab - student 124
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7d::/48
descr: ETHZ Netsec 2026 BGP lab - student 125
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7e::/48
descr: ETHZ Netsec 2026 BGP lab - student 126
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:7f::/48
descr: ETHZ Netsec 2026 BGP lab - student 127
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
route6: 2a0d:d741:80::/48
descr: ETHZ Netsec 2026 BGP lab - student 128
origin: AS210036
mnt-by: mnt-ch-ray-1
source: RIPE
-125
View File
@@ -1,125 +0,0 @@
# Slides
A reveal.js deck structured like Matt Godbolt's newer talks
([what-happens-beneath-accu-2026](https://github.com/mattgodbolt/what-happens-beneath-accu-2026)):
no stock theme but a custom dark one, every slide on a rounded panel, IBM Plex
Sans and Mono, monokai code with glowing stepped lines, 16:9 with fade
transitions, and the content in one Markdown file per section.
What is ours: the palette, and the corner motif. His ring-and-thread became a
chain of nodes and links, an AS path.
## The talk
| Section | File |
|---|---|
| About the speaker | `sections/00-intro.md` |
| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` |
| BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` |
| IP transit and Internet exchanges | `sections/04-transit-and-ix.md` |
| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` |
The UPDATE in section 3 is `2a0d:d740::/48` as AS210036 announces it to the
SwissIX route servers: next hop `2001:7f8:24::c9` (pr01's port on the peering
LAN), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts
on routes to peers. It was produced with the lab daemon's own encoder and parsed back
to the same message, not captured off the wire; a capture from pr01 may differ
in detail (BIRD can append a link-local next hop). The byte slides step through
it one part at a time (press down), and every number on them was checked
against the bytes when the slides were generated. To change the example,
regenerate rather than edit the hex by hand.
The deck deliberately has no fragments or stepped code: a slide shows
everything at once. Two code blocks keep a static highlight on one line.
| File | Holds |
|---|---|
| `theme.css` | the look: palette, type, panels, motif, title slide |
| `style.css` | components used by slides: columns, boxes, bytes, notes |
| `index.html` | section list, reveal.js loader and options, `bird` highlighter |
| `sections/*.md` | the slides |
## Make it yours
Everything brand-like is a variable at the top of `theme.css`. To compare
accents without editing, open `?accent=green`, `?accent=amber` or
`?accent=magenta` (blue is the default); copy the one you like into `:root`.
The wordmark bottom-left is the `<div class="wordmark">` in `index.html`.
For a full-bleed title image, empty the title `<section>` and give it
`data-background="images/title.png"`.
## Show it
The sections are loaded over HTTP, so open the deck through a server, not by
double-clicking `index.html`. Opened from disk, browsers block reveal.js and
the section files, and you would see a title and empty panels; the page now
says so instead:
python3 slides/serve.py 8000
(`python3 -m http.server` works too, but it lets the browser cache the
section files and the stylesheet, so edits may not show until a hard reload,
Cmd+Shift+R. `serve.py` is the same server with caching turned off.)
Then <http://localhost:8000>. Keys: arrows to move, `S` speaker view with
notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen.
## Offline
slides/vendor-reveal.sh
copies reveal.js into `slides/reveal.js/` (ignored by git). The deck uses that
copy when present and the CDN otherwise. Do this before presenting anywhere
with uncertain Wi-Fi. IBM Plex still comes from Google Fonts; without a
network the deck falls back to Helvetica/Arial and the system monospace, so
install IBM Plex on the presenting machine if that matters.
## Write slides
Edit the files in `sections/`; add a section by adding a file and a
`<section data-markdown=...>` line in `index.html`.
----- next slide
--- next vertical slide (detail you can skip)
Notes: speaker notes from here to the end of the slide
Apply a class to the element above with `<!-- .element: class="..." -->`.
| Class | Effect |
|---|---|
| `big` | one large statement on the slide |
| `note` | quiet italic aside |
| `attribution` | small credit under an image or quote |
| `borders` | table with visible borders |
| `r-stretch` | image or iframe fills the remaining height |
| `blur-edges` | soft edge for photos |
Blocks: `<div class="cols">` two columns, `<div class="boxes">` labelled cards
(`us`, `ok`, `bad`), `<pre class="bytes">` BGP bytes in the colours of the lab
page (add `focus`, and `on` to the spans to keep lit, to dim the rest),
`<table class="decode">` for the explanation under them,
`<svg class="diagram">` for diagrams drawn in the theme's colours (`hot`,
`fabric`, `link`, `bgp`, `traffic`). Code fences take a language and optional line steps, e.g.
` ```bird [1-3|5-9] `; `bird` is a small highlighter defined in `index.html`.
`<!-- .slide: class="bare" -->` drops the panel, the motif and the wordmark
for one slide, e.g. a full-bleed image. The title slide does so by itself.
## Publish as one file
slides/vendor-reveal.sh # once, if slides/reveal.js is missing
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
`build.py` inlines everything the page would otherwise fetch: reveal.js and
its plugins, the stylesheets, the favicon, our scripts and the Markdown
sections. The one file (about 1.3 MB) can be uploaded anywhere and also
opens straight from disk. It still uses the network for two things: the IBM
Plex fonts from Google Fonts (without them the browser's fallback fonts are
used) and the live DNS and RIPE lookups in section 1. Speaker view (`S`)
works from the bundle too. `slides/dist/` is not committed; rebuild after
editing.
## PDF
Open <http://localhost:8000/?print-pdf> in Chrome and print to PDF with
background graphics on.
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""Bundle the deck into one self-contained HTML file for uploading.
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
Everything the page fetches at runtime is inlined: reveal.js and its plugins
(from the vendored copy, so run vendor-reveal.sh first), the stylesheets, the
favicon, our scripts and the Markdown sections. The result opens from any web
server and from disk. Two things still need a network: the IBM Plex fonts
(Google Fonts) and the live DNS and RIPE lookups.
"""
import base64
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REVEAL = os.path.join(HERE, "reveal.js", "dist")
OUT = os.path.join(HERE, "dist", "hands-on-bgp.html")
def read(*parts):
with open(os.path.join(HERE, *parts), encoding="utf-8") as f:
return f.read()
def main():
if not os.path.isdir(REVEAL):
sys.exit("reveal.js is not vendored; run slides/vendor-reveal.sh first")
html = read("index.html")
# Stylesheets: reveal's first, then ours, as the loader orders them.
css = "\n".join(read("reveal.js", "dist", p) for p in ["reset.css", "reveal.css", "plugin/highlight/monokai.css"])
css += "\n" + read("theme.css") + "\n" + read("style.css")
html = re.sub(r'\s*<link rel="stylesheet" href="theme\.css[^"]*" id="deck-style" />', "", html)
html = re.sub(r'\s*<link rel="stylesheet" href="style\.css[^"]*" />', f"\n <style>\n{css}\n </style>", html, count=1)
icon = "data:image/svg+xml;base64," + base64.b64encode(read("images", "favicon.svg").encode()).decode()
html = html.replace('href="images/favicon.svg"', f'href="{icon}"')
# Sections: the Markdown goes inline in a text/template script.
def inline_section(m):
attrs, path = m.group(1), m.group(2)
text = read(path)
if "</script" in text.lower():
sys.exit(f"{path} contains '</script', which cannot be inlined")
return f'<section data-markdown{attrs}>\n<script type="text/template">\n{text}\n</script>\n</section>'
html, n = re.subn(r'<section\s+data-markdown="([^"]+)"((?:\s+data-[a-z-]+="[^"]*")*)\s*></section>',
lambda m: inline_section(type("M", (), {"group": lambda self, i: (None, m.group(2), m.group(1))[i]})()), html)
if n == 0:
sys.exit("no data-markdown sections found in index.html")
# Our scripts.
for name in ["dns-lookup.js", "prefix-explorer.js"]:
html, k = re.subn(rf'<script src="{re.escape(name)}[^"]*"></script>', lambda m: f"<script>\n{read(name)}\n</script>", html)
if k != 1:
sys.exit(f"expected one <script src=\"{name}\"> tag")
# The help overlay and its watchdog exist to explain a page that could
# not fetch its parts; this one fetches nothing.
html = re.sub(r'\s*<div id="deck-help" hidden>.*?</div>', "", html, count=1, flags=re.S)
html = re.sub(r'\s*<!-- A classic script: it still runs.*?</script>', "", html, count=1, flags=re.S)
# The loader: the module script that picks a reveal.js location and
# imports it becomes reveal.js itself, its plugins, and the deck setup.
m = re.search(r'<script type="module">(.*?)</script>', html, flags=re.S)
if not m:
sys.exit("loader script not found")
loader = m.group(1)
setup_start = loader.index("// BIRD configuration")
setup = loader[setup_start:]
setup = setup.replace("await deck.initialize().catch(fail(\"reveal.js failed to start\"));", "deck.initialize().then(() => {")
setup = setup.replace("plugins: [Markdown, Highlight, Notes],", "plugins: [RevealMarkdown, RevealHighlight, RevealNotes],")
setup = setup.rstrip() + "\n });\n"
libs = "\n".join(read("reveal.js", "dist", p) for p in ["reveal.js", "plugin/markdown.js", "plugin/highlight.js", "plugin/notes.js"])
html = html[: m.start()] + f"<script>\n{libs}\n</script>\n <script>\n{setup} </script>" + html[m.end():]
if "await " in setup or "import(" in setup:
sys.exit("loader still contains module-only code")
html = html.replace("reveal.js is loaded from ./reveal.js/dist if present", "Built by build.py from index.html: everything is inlined. reveal.js was loaded from ./reveal.js/dist if present")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
f.write(html)
print(f"wrote {os.path.relpath(OUT)} ({os.path.getsize(OUT) // 1024} KB, {n} sections)")
if __name__ == "__main__":
main()
-140
View File
@@ -1,140 +0,0 @@
// 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();
});
})();
// Byte walks: hovering (or tabbing to) a field row lights its bytes in the
// dump above it, and hovering bytes in the dump lights their row and scrolls
// it into view, so the dump can be used as the index while presenting.
(function () {
const pair = (el) => {
const walk = el.closest(".bytewalk");
if (!walk) return [];
const i = el.dataset.i;
return [walk.querySelector(`.bw-dump span[data-i="${i}"]`), walk.querySelector(`.bw-row[data-i="${i}"]`)].filter(Boolean);
};
const target = (e) => e.target.closest && e.target.closest(".bw-row, .bw-dump span[data-i]");
const on = (e) => {
const el = target(e);
if (!el) return;
const both = pair(el);
both.forEach((x) => x.classList.add("on"));
if (el.matches(".bw-dump span")) {
// Scroll the box itself, and instantly: scrollIntoView would also
// move the slide, and Chrome drops smooth scrolls inside the
// transformed slide that reveal positions.
const row = both.find((x) => x.classList.contains("bw-row"));
const box = row && row.closest(".bw-rows");
if (box) box.scrollTop = row.offsetTop - box.clientHeight / 2 + row.offsetHeight / 2;
}
};
const off = (e) => {
const el = target(e);
if (el) pair(el).forEach((x) => x.classList.remove("on"));
};
document.addEventListener("mouseover", on);
document.addEventListener("focusin", on);
document.addEventListener("mouseout", off);
document.addEventListener("focusout", off);
})();
-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#11131f"/>
<g fill="none" stroke="#5eb1ff" stroke-width="3">
<line x1="9" y1="16" x2="23" y2="16"/>
<circle cx="7" cy="16" r="4" fill="#5eb1ff"/>
<circle cx="25" cy="16" r="4" fill="#11131f"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 326 B

-230
View File
@@ -1,230 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hands-on BGP</title>
<!--
Deck structure and look follow Matt Godbolt's newer talks (e.g.
github.com/mattgodbolt/what-happens-beneath-accu-2026): reveal.js with
no stock theme, a custom dark theme (theme.css), monokai code blocks,
16:9, fade transitions, and one Markdown file per section.
reveal.js is loaded from ./reveal.js/dist if present (run
./vendor-reveal.sh once; works offline), otherwise from the CDN. Its
stylesheets are inserted BEFORE ours by the loader below, so theme.css
and style.css always have the last word.
-->
<link rel="stylesheet" href="theme.css?v=3" id="deck-style" />
<link rel="stylesheet" href="style.css?v=18" />
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
<link rel="shortcut icon" href="images/favicon.svg" />
</head>
<body>
<div class="reveal">
<div class="slides">
<!-- Title slide. For a full-bleed image instead, empty it and add
data-background="images/title.png" like Matt does. -->
<section class="title-slide">
<h1>Hands-on BGP</h1>
<h3>Insights into real world operations</h3>
<p class="byline">
Rayhaan Jaufeerally (rayhaan@rayhaan.ch) &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/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
module below, which is exactly the case it has to explain. -->
<script>
(function () {
function help(why) {
document.getElementById("deck-help-why").textContent = why;
document.getElementById("deck-help").hidden = false;
}
if (location.protocol === "file:") {
help(
"This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:",
);
return;
}
window.addEventListener("deck-failed", function (e) {
help(
e.detail +
" Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:",
);
});
setTimeout(function () {
if (!window.deck)
help(
"reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:",
);
}, 10000);
})();
</script>
<script src="dns-lookup.js?v=7"></script>
<script src="prefix-explorer.js?v=4"></script>
<script type="module">
const VERSION = "6.0.2";
const LOCAL = "./reveal.js/dist";
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
// Prefer the vendored copy so the deck works without a network.
async function pickBase() {
try {
const r = await fetch(`${LOCAL}/reveal.css`, {
method: "HEAD",
});
if (r.ok) return LOCAL;
} catch (_) {}
return CDN;
}
const base = await pickBase();
const fail = (what) => (e) => {
window.dispatchEvent(
new CustomEvent("deck-failed", {
detail: `${what} (${e && e.message ? e.message : e}).`,
}),
);
throw e;
};
const deckStyle = document.getElementById("deck-style");
await Promise.all(
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
(href) =>
new Promise((resolve) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `${base}/${href}`;
link.onload = link.onerror = resolve;
deckStyle.before(link);
}),
),
);
const [
{ default: Reveal },
{ default: Markdown },
{ default: Highlight },
{ default: Notes },
] = await Promise.all(
[
"reveal.mjs",
"plugin/markdown.mjs",
"plugin/highlight.mjs",
"plugin/notes.mjs",
].map((m) => import(/* @vite-ignore */ `${base}/${m}`)),
).catch(fail(`Could not load reveal.js from ${base}`));
// BIRD configuration, so router config on slides is highlighted. Same
// trick Matt uses for the assembly dialects in his talks.
function bird() {
return {
name: "bird",
keywords: {
keyword:
"protocol template filter function define table if then else case return accept reject " +
"import export where none all limit action block neighbor local as direct multihop passive " +
"description ipv4 ipv6 bgp kernel device static pipe include router id",
built_in:
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
"bgp_med bgp_local_pref bgp_next_hop",
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
},
contains: [
{ className: "comment", begin: "#", end: "$" },
{ className: "string", begin: '"', end: '"' },
{
className: "number",
begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b",
},
{
className: "number",
begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b",
},
{ className: "number", begin: "\\b[0-9]+\\b" },
],
};
}
// ?accent=green|amber|magenta previews another accent; blue is the default.
const accent = new URLSearchParams(location.search).get("accent");
if (accent) document.documentElement.dataset.accent = accent;
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
const scaleFactor = 0.65;
const deck = new Reveal({
hash: true,
transition: "fade",
slideNumber: false,
margin: 0.15,
width: 1920 * scaleFactor,
height: 1080 * scaleFactor,
markdown: { smartypants: true },
highlight: {
beforeHighlight: (hljs) =>
hljs.registerLanguage("bird", bird),
},
plugins: [Markdown, Highlight, Notes],
});
await deck.initialize().catch(fail("reveal.js failed to start"));
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
// The slides exist now: scripts that fill live boxes wait for this.
window.dispatchEvent(new Event("deck-ready"));
</script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
// 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
@@ -1,15 +0,0 @@
## 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.
-170
View File
@@ -1,170 +0,0 @@
# Internet Numbering Resources
Notes:
Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed.
---
## Packets are sent to IP addresses
<form class="dns-lookup" autocomplete="off">
<input name="domain" value="ethz.ch" spellcheck="false" aria-label="domain name"><button type="submit">lookup AAAA</button>
<div class="dns-result"><span class="dim">Ask a resolver which IPv6 address this name stands for.</span></div>
</form>
IP packets are sent to IP addresses, not domain names. Every time you use a domain name, it is resolved into an IP address via DNS.
<!-- .element: class="note" -->
Notes:
Live: type a few names the students use. ethz.ch has an AAAA record;
github.com has none (only A), which shows that the two address families
are separate. The answer comes from Google Public DNS over HTTPS
(dns.google), so it needs the lecture-hall network.
---
## Who does the address belong to?
<form class="ripe-lookup" autocomplete="off">
<input name="query" value="2001:67c:10ec:254::216" spellcheck="false" aria-label="address, prefix or AS number"><button type="submit">whois</button>
<div class="ripe-result"><div class="ripe-msg">Paste the address from the last slide.</div></div>
</form>
The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it.
<!-- .element: class="note" -->
Notes:
Paste the AAAA answer from the previous slide. ETH's /48 is ASSIGNED PI
(provider independent, sponsored by SWITCH) and its route6 names AS559,
SWITCH. Try 2a0d:d741:1::1 as well: the lab prefix, origin AS210036. The
box takes AS numbers too (AS559). Same source as whois -h whois.ripe.net,
over the database's REST API.
---
## An IPv6 address is 128 bits
```text
2001:0db8:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
2001:db8:1:0:0:0:0:1 drop leading zeros in a group
2001:db8:1::1 one run of zero groups becomes ::
```
`::` may appear once, or the address would be ambiguous. `2001:db8::/32` is reserved for documentation, so it never appears on the real Internet.
<!-- .element: class="note" -->
Notes:
Ask: why only once? Because 2001::1::1 could put the zeros in several places.
2001:db8::/32 is RFC 3849: set aside for examples, never routed.
---
## A prefix is a range of addresses
In BGP, the control plane for the internet, it's not viable to talk about single addresses, so we use prefix ranges.
<form class="prefix-explorer" autocomplete="off">
<input name="prefix" value="2001:db8:1::/48" spellcheck="false" aria-label="IPv6 prefix"><input type="range" name="len" min="0" max="128" value="48" aria-label="prefix length">
<p class="bits px-bits"></p>
<div class="px-range"></div>
<p class="note px-facts"></p>
</form>
Notes:
Live. Drag the slider: the fixed part grows and the range shrinks by half per
bit. Try /47 or /50 to see a hex digit split in two. The three rows above the
bar are three of the shorter prefixes that contain it, with its position and
share in each: there is no such thing as "the" parent prefix, only whatever
someone chose to allocate from.
2^80 addresses in one /48, about 1.2 million billion billion. The /n is a
count of leading bits, nothing more.
---
## The global IPv6 address space
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="Nested boxes: all of IPv6, inside it global unicast 2000::/3, inside that the documentation prefix 2001:db8::/32, inside that one /64 LAN with its hosts">
<g class="nest l0"><rect x="8" y="8" width="984" height="454"/></g>
<text x="28" y="42" class="left accent nest-label">::/0</text><text x="112" y="42" class="left nest-desc">every IPv6 address there is: 2¹²⁸ of them</text>
<g class="nest l1"><rect x="80" y="70" width="880" height="366"/></g>
<text x="100" y="104" class="left accent nest-label">2000::/3</text><text x="230" y="104" class="left nest-desc">global unicast: the eighth of the space that is routed on the Internet</text>
<g class="nest l2"><rect x="160" y="134" width="760" height="278"/></g>
<text x="180" y="168" class="left accent nest-label">2001:db8::/32</text><text x="376" y="168" class="left nest-desc">one provider's allocation (this one is reserved for documentation)</text>
<g class="nest l3"><rect x="260" y="200" width="580" height="188"/></g>
<text x="280" y="234" class="left accent nest-label">2001:db8:1:2::/64</text><text x="530" y="234" class="left nest-desc">one LAN: 2⁶⁴ addresses for its hosts</text>
<circle class="host" cx="330" cy="310" r="12"/><text x="330" y="352" class="host-addr">::1</text>
<circle class="host" cx="430" cy="310" r="12"/><text x="430" y="352" class="host-addr">::2</text>
<circle class="host" cx="530" cy="310" r="12"/><text x="530" y="352" class="host-addr">::3</text>
<text x="630" y="318" class="host-addr">…</text>
<circle class="host" cx="740" cy="310" r="12"/><text x="740" y="352" class="host-addr">::ffff:ffff:ffff:ffff</text>
<text x="976" y="452" class="small" style="text-anchor:end">not to scale</text>
</svg>
Notes:
Not to scale: a /64 is 2^-64 of the space. Only the first ring matters for
routing; the others are how the space is handed down, which is next.
---
## Who hands out the addresses
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="IANA at the top hands blocks to five regional registries; RIPE NCC hands smaller blocks to local registries such as SWITCH and an ISP, which assign to end users such as ETH Zurich and their customers">
<g class="hot"><rect x="400" y="14" width="200" height="56" rx="8"/></g><text x="500" y="48">IANA</text>
<text x="30" y="48" class="left small">the root: one pool for the whole world</text>
<line class="link" x1="500" y1="70" x2="96" y2="130"/><line class="link" x1="500" y1="70" x2="298" y2="130"/><line class="link" x1="500" y1="70" x2="500" y2="130"/><line class="link" x1="500" y1="70" x2="702" y2="130"/><line class="link" x1="500" y1="70" x2="904" y2="130"/>
<text x="30" y="108" class="left small">a /12 to each region</text>
<rect x="16" y="130" width="160" height="56" rx="8"/><text x="96" y="164">AFRINIC</text>
<rect x="218" y="130" width="160" height="56" rx="8"/><text x="298" y="164">APNIC</text>
<rect x="420" y="130" width="160" height="56" rx="8"/><text x="500" y="164">ARIN</text>
<rect x="622" y="130" width="160" height="56" rx="8"/><text x="702" y="164">LACNIC</text>
<g class="hot"><rect x="824" y="130" width="160" height="56" rx="8"/></g><text x="904" y="164" class="accent">RIPE NCC</text>
<text x="30" y="222" class="left small">RIRs: five regional registries, one per part of the world</text>
<line class="link" x1="904" y1="186" x2="690" y2="256"/><line class="link" x1="904" y1="186" x2="904" y2="256"/>
<text x="700" y="238" class="left small">/29 and up to a member</text>
<rect x="600" y="256" width="180" height="56" rx="8"/><text x="690" y="290">SWITCH</text>
<rect x="814" y="256" width="180" height="56" rx="8"/><text x="904" y="290">an ISP</text>
<text x="30" y="298" class="left small">LIRs: members of an RIR, usually providers</text>
<line class="link" x1="690" y1="312" x2="690" y2="382"/><line class="link" x1="904" y1="312" x2="904" y2="382"/>
<text x="700" y="360" class="left small">a /48 to a site</text>
<rect x="600" y="382" width="180" height="56" rx="8"/><text x="690" y="416">ETH Zürich</text>
<rect x="814" y="382" width="180" height="56" rx="8"/><text x="904" y="416">its customers</text>
<text x="30" y="424" class="left small">end users: companies, universities, you</text>
</svg>
Notes:
- The Internet Assigned Numbers Authority comes up with allocations of IP
addresses from the global space
- Blocks are assigned to Regional Internet Registries (RIRs), which allocate
them to Local Internet Registries (LIRs), who assign to end users
- The RIPE lookup two slides back showed exactly this chain: ETH's /48 is
ASSIGNED PI, sponsored by SWITCH (the LIR), registered at RIPE NCC (the
RIR). Nobody owns addresses; they are registered to you.
---
## AS numbers
- Used to identify networks under a single oeprator, and required for exchanging BGP traffic
- Managed by IANA and the RIRs
- 16 bits at first: `1` to `65535`
- 32 bits since RFC 6793: up to `4294967295`
- In order to have a smooth transition, `AS23456` is used as a placeholder when the AS number is > 16 bits
- 32 bit AS numbers are then exchanged using a BGP capability message to be backwards compatible
- Private use: `64512–65534` and `4200000000–4294967294`
AS numbers come down the same chain: IANA to the RIRs, an RIR to whoever runs a network. The lab daemon is `AS4200000001`, a private one, stripped before the Internet sees your route.
<!-- .element: class="note" -->
Notes:
Nobody "owns" numbers or addresses; they are registered to you. RFC 6996
reserves the private ranges. The upstream router removes the private AS from
the path, so the origin everybody else sees is AS210036.
---
## Handout Part 1
-329
View File
@@ -1,329 +0,0 @@
# BGP in detail
Notes:
We're going to look at some implementation details of the protocol
---
##
- Runs over TCP, port `179`, and is exchanged typically between peering routers
- Each side tells the other its best route per prefix
- Only _changes_ are sent: no periodic refresh
- If the session drops, after a timeout the routes learned over that session are dropped
Notes:
BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants.
Security and extensions have had to be bolted on, in perhaps strange ways.
---
## The four message types
| Type | Name | Says |
| ---- | ------------ | ------------------------------------------------------- |
| 1 | OPEN | AS number, router ID, capabilities |
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
| 3 | NOTIFICATION | errors and informational messages: e.g. rebooting |
| 4 | KEEPALIVE | Heartbeat to keep the session up when there's no update |
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then a stream of UPDATEs.
<!-- .element: class="note" -->
Notes:
There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed".
---
## OPEN: a handshake between peers.
<svg class="diagram" viewBox="0 0 1000 340" role="img" aria-label="An OPEN message: the common header, then version, AS number, hold time, BGP identifier, and optional parameters that carry the capabilities">
<text x="20" y="22" class="left small pkt-title">header, the same on every message: 19 bytes</text>
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
<text x="255" y="59" class="pkt-name">marker</text>
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
<text x="614" y="59" class="pkt-name">length</text>
<text x="614" y="81" class="pkt-size">2 bytes</text>
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
<text x="859" y="59" class="pkt-name">type</text>
<text x="859" y="81" class="pkt-size">1 byte</text>
<text x="20" y="132" class="left small pkt-title">OPEN: the fixed part</text>
<g class="pkt hl"><rect x="20" y="140" width="150" height="64" rx="6"/></g>
<text x="95" y="169" class="pkt-name">version</text>
<text x="95" y="191" class="pkt-size">1 byte: 4</text>
<g class="pkt hl"><rect x="174" y="140" width="200" height="64" rx="6"/></g>
<text x="274" y="169" class="pkt-name">my AS</text>
<text x="274" y="191" class="pkt-size">2 bytes, or 23456</text>
<g class="pkt hl"><rect x="378" y="140" width="200" height="64" rx="6"/></g>
<text x="478" y="169" class="pkt-name">hold time</text>
<text x="478" y="191" class="pkt-size">2 bytes, seconds</text>
<g class="pkt hl"><rect x="582" y="140" width="240" height="64" rx="6"/></g>
<text x="702" y="169" class="pkt-name">BGP identifier</text>
<text x="702" y="191" class="pkt-size">4 bytes, the router ID</text>
<g class="pkt hl"><rect x="826" y="140" width="154" height="64" rx="6"/></g>
<text x="903" y="169" class="pkt-name">opt. length</text>
<text x="903" y="191" class="pkt-size">1 byte</text>
<path class="pkt-expand" d="M 826 204 L 20 250"/>
<path class="pkt-expand" d="M 980 204 L 980 250"/>
<text x="20" y="250" class="left small pkt-title">optional parameters: today always one, of type 2 = capabilities, each a (code, length, value)</text>
<g class="pkt ha"><rect x="20" y="258" width="120" height="64" rx="6"/></g>
<text x="80" y="287" class="pkt-name">type 2</text>
<text x="80" y="309" class="pkt-size">capabilities</text>
<g class="pkt ha"><rect x="144" y="258" width="110" height="64" rx="6"/></g>
<text x="199" y="287" class="pkt-name">length</text>
<text x="199" y="309" class="pkt-size">1 byte</text>
<g class="pkt ha2"><rect x="258" y="258" width="200" height="64" rx="6"/></g>
<text x="358" y="287" class="pkt-name">IPv6 unicast</text>
<text x="358" y="309" class="pkt-size">code 1: AFI 2, SAFI 1</text>
<g class="pkt ha2"><rect x="462" y="258" width="190" height="64" rx="6"/></g>
<text x="557" y="287" class="pkt-name">4-byte AS</text>
<text x="557" y="309" class="pkt-size">code 65: 210036</text>
<g class="pkt ha2"><rect x="656" y="258" width="170" height="64" rx="6"/></g>
<text x="741" y="287" class="pkt-name">route refresh</text>
<text x="741" y="309" class="pkt-size">code 2, empty</text>
<g class="pkt ha2"><rect x="830" y="258" width="150" height="64" rx="6"/></g>
<text x="905" y="287" class="pkt-name">…</text>
<text x="905" y="309" class="pkt-size">more capabilities</text>
</svg>
Notes:
Sent once, by each side, as soon as the TCP connection is up. The two sides
must agree: the AS must be the one configured for the neighbour, the hold
time becomes the smaller of the two, and only capabilities both announced are
used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the
real number rides in a capability, which is how our AS210036 arrives.
---
## BGP Open message
<div class="bytewalk">
<pre class="bytes bw-dump"><span class="hh" data-i="0">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh" data-i="1">00 37</span> <span class="hh" data-i="2">01</span> <span class="hl" data-i="3">04</span> <span class="hl" data-i="4">5b a0</span> <span class="hl" data-i="5">00 f0</span> <span class="hl" data-i="6">0a ff 00 01</span> <span class="hl" data-i="7">1a</span> <span class="ha" data-i="8">02</span> <span class="ha" data-i="9">18</span> <span class="ha2" data-i="10">01</span> <span class="ha2" data-i="11">04</span> <span class="ha2" data-i="12">00 02</span> <span class="ha2" data-i="13">00</span> <span class="ha2" data-i="14">01</span> <span class="ha2" data-i="15">02</span> <span class="ha2" data-i="16">00</span> <span class="ha2" data-i="17">06</span> <span class="ha2" data-i="18">00</span> <span class="ha2" data-i="19">40</span> <span class="ha2" data-i="20">02</span> <span class="ha2" data-i="21">00 00</span> <span class="ha2" data-i="22">41</span> <span class="ha2" data-i="23">04</span> <span class="ha2" data-i="24">00 03 34 74</span> <span class="ha2" data-i="25">46</span> <span class="ha2" data-i="26">00</span> <span class="ha2" data-i="27">47</span> <span class="ha2" data-i="28">00</span></pre>
<div class="bw-rows">
<div class="bw-group">BGP header: 19 bytes, on every message</div>
<div class="bw-row" data-i="0" tabindex="0"><code class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</code><b>marker</b><span>sixteen bytes of ff.</span></div>
<div class="bw-row" data-i="1" tabindex="0"><code class="hh">00 37</code><b>length</b><span>0x0037 = 55 bytes, header included. At most 4096, or 65535 with the extended-message capability</span></div>
<div class="bw-row" data-i="2" tabindex="0"><code class="hh">01</code><b>type</b><span>1 = OPEN (2 UPDATE, 3 NOTIFICATION, 4 KEEPALIVE, 5 ROUTE-REFRESH)</span></div>
<div class="bw-group">OPEN: the fixed part, 10 bytes</div>
<div class="bw-row" data-i="3" tabindex="0"><code class="hl">04</code><b>version</b><span>BGP version 4</span></div>
<div class="bw-row" data-i="4" tabindex="0"><code class="hl">5b a0</code><b>my AS</b><span>0x5ba0 = 23456, AS_TRANS: the ASN is longer than 16 bits so is encoded in a capability instead</span></div>
<div class="bw-row" data-i="5" tabindex="0"><code class="hl">00 f0</code><b>hold time</b><span>0x00f0 = 240 seconds. both peers use the minimum announced; a KEEPALIVE typically is sent every 1/3rd of this duration</span></div>
<div class="bw-row" data-i="6" tabindex="0"><code class="hl">0a ff 00 01</code><b>BGP identifier</b><span>0a ff 00 01 = 10.255.0.1, the router ID. Looks like an IPv4 address, just needs to be a unique identifier.</span></div>
<div class="bw-row" data-i="7" tabindex="0"><code class="hl">1a</code><b>optional parameters length</b><span>0x1a = 26 bytes follow</span></div>
<div class="bw-group">Optional parameter: 2 + 24 bytes</div>
<div class="bw-row" data-i="8" tabindex="0"><code class="ha">02</code><b>parameter type</b><span>2 = capabilities</span></div>
<div class="bw-row" data-i="9" tabindex="0"><code class="ha">18</code><b>parameter length</b><span>0x18 = 24 bytes of capabilities follow, each as type, length, value</span></div>
<div class="bw-group">Capability 1: multiprotocol (RFC 4760)</div>
<div class="bw-row" data-i="10" tabindex="0"><code class="ha2">01</code><b>code</b><span>1 = multiprotocol extensions</span></div>
<div class="bw-row" data-i="11" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
<div class="bw-row" data-i="12" tabindex="0"><code class="ha2">00 02</code><b>AFI</b><span>0x0002 = IPv6. Address Family Identifier</span></div>
<div class="bw-row" data-i="13" tabindex="0"><code class="ha2">00</code><b>reserved</b><span>0</span></div>
<div class="bw-row" data-i="14" tabindex="0"><code class="ha2">01</code><b>SAFI</b><span>1 = unicast. Subsequent Address Family Identifier.</span></div>
<div class="bw-group">Capability 2: route refresh (RFC 2918)</div>
<div class="bw-row" data-i="15" tabindex="0"><code class="ha2">02</code><b>code</b><span>2 = route refresh: “you may ask me to resend everything”</span></div>
<div class="bw-row" data-i="16" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 6: extended message (RFC 8654)</div>
<div class="bw-row" data-i="17" tabindex="0"><code class="ha2">06</code><b>code</b><span>6 = messages up to 65535 bytes are supported</span></div>
<div class="bw-row" data-i="18" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 64: graceful restart (RFC 4724)</div>
<div class="bw-row" data-i="19" tabindex="0"><code class="ha2">40</code><b>code</b><span>0x40 = 64, graceful restart</span></div>
<div class="bw-row" data-i="20" tabindex="0"><code class="ha2">02</code><b>length</b><span>2</span></div>
<div class="bw-row" data-i="21" tabindex="0"><code class="ha2">00 00</code><b>flags and restart time</b><span>0x0000: not restarting, restart time 0, and no address families listed.</span></div>
<div class="bw-group">Capability 65: four-octet AS (RFC 6793)</div>
<div class="bw-row" data-i="22" tabindex="0"><code class="ha2">41</code><b>code</b><span>0x41 = 65, four-octet AS number</span></div>
<div class="bw-row" data-i="23" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
<div class="bw-row" data-i="24" tabindex="0"><code class="ha2">00 03 34 74</code><b>AS number</b><span>00 03 34 74 = 210036: the real AS. Included here since it doesn't fit in the header</span></div>
<div class="bw-group">Capability 70: enhanced route refresh (RFC 7313)</div>
<div class="bw-row" data-i="25" tabindex="0"><code class="ha2">46</code><b>code</b><span>0x46 = 70</span></div>
<div class="bw-row" data-i="26" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
<div class="bw-group">Capability 71: long-lived graceful restart</div>
<div class="bw-row" data-i="27" tabindex="0"><code class="ha2">47</code><b>code</b><span>0x47 = 71</span></div>
<div class="bw-row" data-i="28" tabindex="0"><code class="ha2">00</code><b>length</b><span>0, no address families listed</span></div>
</div>
</div>
Notes:
What BIRD on our upstream router sends the lab daemon, byte for byte. Hold
time 240 is BIRD's default; keepalives go every third of it.
---
## Update message format
<svg class="diagram" viewBox="0 0 1000 460" role="img" aria-label="An UPDATE message: the common header, then withdrawn routes, path attributes and NLRI, with one attribute and one prefix expanded">
<text x="20" y="22" class="left small pkt-title">header: 19 bytes</text>
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
<text x="255" y="59" class="pkt-name">marker</text>
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
<text x="614" y="59" class="pkt-name">length</text>
<text x="614" y="81" class="pkt-size">2 bytes</text>
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
<text x="859" y="59" class="pkt-name">type</text>
<text x="859" y="81" class="pkt-size">1 byte</text>
<text x="20" y="132" class="left small pkt-title">UPDATE body: two lengths, three variable parts</text>
<g class="pkt hl"><rect x="20" y="140" width="170" height="64" rx="6"/></g>
<text x="105" y="169" class="pkt-name">withdrawn length</text>
<text x="105" y="191" class="pkt-size">2 bytes</text>
<g class="pkt hn"><rect x="194" y="140" width="200" height="64" rx="6"/></g>
<text x="294" y="169" class="pkt-name">withdrawn routes</text>
<text x="294" y="191" class="pkt-size">IPv4 prefixes gone</text>
<g class="pkt hl"><rect x="398" y="140" width="170" height="64" rx="6"/></g>
<text x="483" y="169" class="pkt-name">attributes length</text>
<text x="483" y="191" class="pkt-size">2 bytes</text>
<g class="pkt ha"><rect x="572" y="140" width="240" height="64" rx="6"/></g>
<text x="692" y="169" class="pkt-name">path attributes</text>
<text x="692" y="191" class="pkt-size">everything about the route</text>
<g class="pkt hn"><rect x="816" y="140" width="172" height="64" rx="6"/></g>
<text x="902" y="169" class="pkt-name">NLRI</text>
<text x="902" y="191" class="pkt-size">IPv4 prefixes, new</text>
<path class="pkt-expand" d="M 572 204 L 20 250"/>
<path class="pkt-expand" d="M 812 204 L 980 250"/>
<text x="20" y="250" class="left small pkt-title">one path attribute; repeated for multiple</text>
<g class="pkt ha"><rect x="20" y="258" width="150" height="64" rx="6"/></g>
<text x="95" y="287" class="pkt-name">flags</text>
<text x="95" y="309" class="pkt-size">1 byte</text>
<g class="pkt ha"><rect x="174" y="258" width="330" height="64" rx="6"/></g>
<text x="339" y="287" class="pkt-name">type</text>
<text x="339" y="309" class="pkt-size">1 byte: ORIGIN = 1, AS_PATH = 2 …</text>
<g class="pkt ha"><rect x="508" y="258" width="180" height="64" rx="6"/></g>
<text x="598" y="287" class="pkt-name">length</text>
<text x="598" y="309" class="pkt-size">1 or 2 bytes</text>
<g class="pkt ha2"><rect x="692" y="258" width="292" height="64" rx="6"/></g>
<text x="838" y="287" class="pkt-name">value</text>
<text x="838" y="309" class="pkt-size">as many bytes as it said</text>
<path class="pkt-expand" d="M 979 204 L 979 376"/>
<text x="20" y="368" class="left small pkt-title">one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs</text>
<g class="pkt hn"><rect x="20" y="376" width="300" height="64" rx="6"/></g>
<text x="170" y="405" class="pkt-name">length</text>
<text x="170" y="427" class="pkt-size">1 byte: 24 for a /24</text>
<g class="pkt hn"><rect x="324" y="376" width="656" height="64" rx="6"/></g>
<text x="652" y="405" class="pkt-name">prefix</text>
<text x="652" y="427" class="pkt-size">3 bytes for a /24, 6 for a /48 …</text>
</svg>
Notes:
One UPDATE carries one set of attributes, and every prefix that shares them.
The withdrawn and NLRI fields can only hold IPv4; the next slides show where
IPv6 goes. Lengths everywhere: a receiver can walk the message without
understanding any of it, which is what lets unknown attributes pass through.
---
## An example BGP update
<div class="legend"><span class="hh">header</span><span class="hl">lengths</span><span class="ha">path attributes</span><span class="hn">NLRI</span></div>
<pre class="bytes"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
`2a0d:d740::/48`, as AS210036 announces it to the SwissIX route servers.
<!-- .element: class="note" -->
Notes:
Built with the lab daemon's own encoder and checked by parsing it back, with
the attributes pr01 puts on routes it exports to peers. Press down to walk
through it part by part. Note there is nothing yellow: the NLRI field is
empty. Hold that thought.
---
## Header
<pre class="bytes focus"><span class="hh on">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh on">00 59</span> <span class="hh on">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>ff × 16</td><td>Start of message marker.</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
---
## Two lengths
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl on">00 00</span> <span class="hl on">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>00 00</td><td>0 bytes of withdrawn routes: nothing is being taken back</td></tr><tr><td>00 42</td><td>66 bytes of path attributes follow</td></tr><tr><td></td><td>89 − 19 − 2 − 2 − 66 = 0 bytes left over for NLRI</td></tr></table>
---
## ORIGIN
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha on">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>40</td><td>flags: well-known, transitive</td></tr><tr><td>01</td><td>type 1 = ORIGIN</td></tr><tr><td>01</td><td>1 byte of value</td></tr><tr><td>00</td><td>IGP: the route was originated inside this AS</td></tr></table>
Every attribute is _flags, type, length, value_.
<!-- .element: class="note" -->
---
## AS_PATH
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2 on">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>40 02 06</td><td>well-known, transitive; type 2; 6 bytes</td></tr><tr><td>02</td><td>an AS_SEQUENCE, order matters</td></tr><tr><td>01</td><td>of 1 AS number</td></tr><tr><td>00 03 34 74</td><td>210036, the origin. Four bytes each (RFC 6793)</td></tr></table>
Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path.
<!-- .element: class="note" -->
Notes:
One entry, because SwissIX is one hop from the origin and the route server
does not add itself. Seen from further away it reads e.g. 6939 210036.
---
## MULTI_EXIT_DISC
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha on">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>80 04 04</td><td>optional, non-transitive; type 4; 4 bytes</td></tr><tr><td>00 00 00 00</td><td>MED 0. Lower is preferred, among routes from the same neighbour AS</td></tr></table>
A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further.
<!-- .element: class="note" -->
---
## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2 on">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>80 0e 1c</td><td>optional, non-transitive; type 14; 28 bytes</td></tr><tr><td>00 02 · 01</td><td>address family IPv6, unicast</td></tr><tr><td>10 · 2001 07f8 0024 … 00c9</td><td>next hop, 16 bytes: 2001:7f8:24::c9, our port on the SwissIX peering LAN</td></tr><tr><td>00</td><td>reserved</td></tr><tr><td>30 · 2a 0d d7 40 00 00</td><td>48 bits of prefix: 2a0d:d740::/48</td></tr></table>
Notes:
BGP-4 was built for IPv4: its NLRI field cannot carry anything else. RFC 4760
bolts other address families on as an attribute, each with its own next hop.
A prefix is sent as a bit count plus only as many bytes as the count needs.
The next hop is an address on the exchange's shared LAN: whoever receives this
route sends packets straight to that address across the switch. Remember ::c9
for the exchange diagram in the next section.
Routers may also append their link-local address here, making it 32 bytes.
---
## LARGE_COMMUNITIES
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha on">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
<table class="decode"><tr><td>c0 20 0c</td><td>optional, transitive; type 32; 12 bytes = one community</td></tr><tr><td>00033474 · 0000000a · 00000002</td><td>210036:10:2, a tag our router puts on everything it exports to peers</td></tr></table>
Three 32-bit numbers. They mean whatever the network named in the first one says they mean.
<!-- .element: class="note" -->
---
## The flags byte
| Bit | Set means | Clear means |
| ------ | ------------------------------------------------- | ------------------------------------------------ |
| `0x80` | optional | well-known: every BGP speaker must understand it |
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
| `0x20` | partial: someone on the way did not understand it | |
| `0x10` | length field is 2 bytes | 1 byte |
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
<!-- .element: class="note" -->
-105
View File
@@ -1,105 +0,0 @@
# Transit and exchanges
Notes:
Section 4. Two very different ways of getting routes, and what an Internet
exchange physically is.
---
## Two ways to reach other networks
<div class="cols">
<div>
### IP transit
- You **pay** a provider
- They give you routes to _everyone_
- They announce you to _everyone_
</div>
<div>
### Peering
- Usually **free**, by mutual benefit
- You exchange _your own_ routes, and your customers'
- Traffic between the locally connected parties only
</div>
</div>
Notes:
Transit is a customer-provider relationship; peering is between equals. A peer
does not carry your traffic to third parties. Most networks have both: transit
as the fallback, peering to keep traffic cheap and close.
---
## An exchange is a big Ethernet switch
<svg class="diagram" viewBox="0 0 1000 400" role="img" aria-label="Members attached to a shared Ethernet peering LAN, with a route server">
<g class="fabric"><rect x="60" y="175" width="880" height="56" rx="10"/></g>
<text x="500" y="200" class="accent">peering LAN: one Ethernet segment, one subnet</text>
<text x="500" y="220" class="small">SwissIX 2001:7f8:24::/64</text>
<g class="hot"><rect x="70" y="40" width="180" height="70" rx="8"/></g>
<text x="160" y="70">AS210036</text><text x="160" y="92" class="small">::c9</text>
<rect x="300" y="40" width="180" height="70" rx="8"/><text x="390" y="70">AS559 SWITCH</text><text x="390" y="92" class="small">::35</text>
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS2906 Netflix</text><text x="620" y="92" class="small">::55</text>
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335 Cloudflare</text><text x="850" y="92" class="small">::c0</text>
<line class="link" x1="160" y1="110" x2="160" y2="175"/><line class="link" x1="390" y1="110" x2="390" y2="175"/>
<line class="link" x1="620" y1="110" x2="620" y2="175"/><line class="link" x1="850" y1="110" x2="850" y2="175"/>
<rect x="385" y="300" width="230" height="70" rx="8"/><text x="500" y="330">route servers</text><text x="500" y="352" class="small">AS42476 ::a5ec:a:1</text>
<line class="link" x1="500" y1="231" x2="500" y2="300"/>
<path class="bgp" d="M 140 110 C 140 290, 300 335, 385 335"/>
<path class="bgp" d="M 410 110 C 410 250, 440 270, 470 300"/>
<path class="traffic" d="M 180 110 C 180 165, 370 165, 370 110"/>
<text x="275" y="138" class="small" style="fill:#4fd98a">traffic</text>
<text x="215" y="300" class="small accent">BGP</text>
</svg>
Notes:
Every member plugs one router port into the same layer-2 network and gets one
address in a shared subnet. No routing inside the exchange: just Ethernet.
Dashed blue is a BGP session, green is where the packets actually go.
---
## What that implies
- Peering participants in an exchange are all one hop away, found by neighbour discovery
- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost
- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets)
Notes:
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
- after which traffic can flow directly
---
## Route servers
Are a piece of shared infrastructure that simplifies peering.
- Most participants peer with the route server and annouce their prefixes there
- The route server can then distribute these routes to every other participant
- Simplifes from `n²` to `n` sessions
- Not in the traffic path and does not add its own AS into the path
- Allows controlling behaviour with communities, e.g. `42476:0:<as>` "not to `<as>`", `42476:1:<as>` "only to `<as>`"
Notes:
A route server is a BGP speaker that only redistributes. Because it is
transparent, the AS path looks exactly as if you peered with each member
directly. We will use those communities in the lab.
---
## Looking glasses
Are a useful tool to inspect the state of another network or internet exchange
- SwissIX https://lg.swissix.ch/
- NL-ix https://lg.nl-ix.net/
- BGP tools https://bgp.tools/
-90
View File
@@ -1,90 +0,0 @@
# The lab
### <a href="https://bgplab.rayhaan.net">bgplab.rayhaan.net</a>
Notes:
Section 5. What the students are about to touch, and what stands between a
typo and the Internet.
---
## What you are connected to
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to the lab BGP speaker to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
<rect x="20" y="170" width="150" height="80" rx="8"/><text x="95" y="205">you</text><text x="95" y="228" class="small">a browser</text>
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">lab speaker</text><text x="330" y="226" class="small">AS4200000001</text>
<rect x="490" y="170" width="200" height="80" rx="8"/><text x="590" y="202">pr01.rue (BIRD)</text><text x="590" y="226" class="small">AS210036</text>
<line class="link" x1="170" y1="210" x2="230" y2="210"/><text x="200" y="160" class="small">HTTPS</text>
<line class="bgp" x1="430" y1="210" x2="490" y2="210"/><text x="460" y="160" class="small accent">BGP</text>
<text x="330" y="280" class="small">web page + BGP speaker</text><text x="590" y="280" class="small">filters, then decides where</text>
<rect x="780" y="20" width="200" height="56" rx="8"/><text x="880" y="54">SwissIX</text>
<rect x="780" y="98" width="200" height="56" rx="8"/><text x="880" y="132">NL-ix</text>
<rect x="780" y="176" width="200" height="56" rx="8"/><text x="880" y="210">France-IX</text>
<rect x="780" y="254" width="200" height="56" rx="8"/><text x="880" y="288">DE-CIX</text>
<rect x="780" y="352" width="200" height="56" rx="8"/><text x="880" y="386">transit</text>
<path class="bgp" d="M 690 195 C 740 195, 730 48, 780 48"/><path class="bgp" d="M 690 203 C 740 203, 735 126, 780 126"/>
<path class="bgp" d="M 690 210 L 780 204"/><path class="bgp" d="M 690 217 C 740 217, 735 282, 780 282"/>
<path class="bgp" d="M 690 225 C 740 225, 730 380, 780 380"/>
</svg>
Notes:
The lab speaker is a small BGP daemon written for this course; it runs on
the same machine as the router. Both dashed hops are real BGP sessions. Everything to
the right of pr01 is the actual Internet.
---
## Your prefix
`2a0d:d741:<n>::/48`
- `<n>` is your number **in hex**: student 10 is `2a0d:d741:a::/48`
- Put your name in _owner_: only the owner can withdraw a route
- One prefix each
Notes:
Put the URL and the class password on the board. The page is a snapshot by
default; turn on auto-refresh at the top right to watch things change.
---
## What happens when you press _Announce_
1. The lab speaker builds an UPDATE. Click _bytes_ to see it, hover to decode it
2. pr01 checks it against its import filter
3. pr01 decides, per neighbour, whether to pass it on
4. Your announcement propagates throughout the whole internet!
5. `ping 2a0d:d741:<n>::1` answers, from anywhere
Notes:
Step 5: pr01 answers for every address in the lab space, so reachability of
your prefix depends only on whether your announcement got out.
---
## Steering your route
| You add | Who acts | Effect |
| ----------------------------- | -------- | --------------------------------- |
| nothing | | announced everywhere |
| `210036:0:0` | pr01 | announced nowhere |
| `210036:0:0` `210036:1:42476` | pr01 | only to the SwissIX route servers |
| + `42476:0:0` `42476:1:559` | SwissIX | …and from there only to SWITCH |
| `NO_EXPORT` | pr01 | accepted, never passed on |
Same idea at two hops: _never there_, _only there_, _nowhere_.
<!-- .element: class="note" -->
Notes:
210036:0:<as> never to that AS, 210036:1:<as> to that AS, 210036:2/3:<n> the
same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always
wins. The preset menu next to the field has all of these.
---
## Handout Part 2 & 3
Notes:
Looking glasses: bgp.tools, RIPEstat, and the exchange's own. Global
visibility takes a minute or two to settle after each change.
-28
View File
@@ -1,28 +0,0 @@
#!/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()
-556
View File
@@ -1,556 +0,0 @@
/*
* Components and helpers used by the slides. The look (palette, type, panels,
* motif) is in theme.css; nothing here sets a brand colour directly.
*/
.hidden {
display: none;
}
/* ---- text -------------------------------------------------------------------- */
/* A quiet aside under the main point. */
.reveal .note {
font-style: italic;
font-size: 75%;
color: var(--muted);
}
/* Image and quote credits: <!-- .element: class="attribution" --> */
.reveal .attribution {
font-size: 45%;
font-style: italic;
color: var(--muted);
}
/* One big statement on an otherwise empty slide. */
.reveal .big {
font-size: 1.9em;
font-weight: 300;
line-height: 1.25;
color: var(--ink-strong);
}
/* ---- layout ------------------------------------------------------------------ */
.container {
display: flex;
justify-content: center;
align-items: center;
gap: 1em;
}
.flex-around {
display: flex;
justify-content: space-around;
align-items: flex-start;
gap: 1em;
}
/* Two equal columns: <div class="cols"><div>…</div><div>…</div></div> */
.cols {
display: flex;
gap: 1.2em;
align-items: center;
text-align: left;
}
.cols > * {
flex: 1 1 0;
min-width: 0;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
.no-border {
background: none !important;
border: none !important;
box-shadow: none !important;
}
.reveal img.blur-edges {
border-radius: 12px;
box-shadow: 0 0 25px 8px rgba(20, 30, 60, 0.2);
}
.reveal table.borders td,
.reveal table.borders th {
border: 1px solid var(--rule);
}
/* ---- boxes: small labelled cards, e.g. one per AS or per community ----------- */
.boxes {
display: flex;
flex-wrap: wrap;
justify-content: center;
font-size: 70%;
margin: 0.6em 0;
}
.boxes > div {
padding: 0.35em 0.8em;
margin: 0.3em 0.5em;
border: 1px solid var(--rule);
border-radius: 10px;
background: var(--ground);
line-height: 1.35;
}
.boxes > div.us {
border-color: var(--accent);
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.35);
color: var(--ink-strong);
}
.boxes > div.ok {
border-color: #1a8a4f;
}
.boxes > div.bad {
border-color: #d64545;
}
/* ---- BGP bytes, matching the four groups of the /statusz bytes view ---------- */
.reveal pre.bytes {
font-size: 0.5em;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-all;
background: var(--code-ground);
border-radius: 6px;
padding: 15px 20px;
box-sizing: border-box;
color: var(--code-ink);
}
.bytes span,
.legend span {
border-radius: 3px;
padding: 1px 3px;
color: var(--code-ink); /* the fills are dark on any ground */
}
.bytes .hh,
.legend .hh { background: #34406e; }
.bytes .hl,
.legend .hl { background: #6e3440; }
.bytes .ha,
.legend .ha { background: #245c3a; }
.bytes .ha2 { background: #2f7a4b; }
.bytes .hn,
.legend .hn { background: #6b5a1f; }
.legend {
font-size: 50%;
font-family: var(--r-code-font);
}
.legend span {
padding: 1px 0.6em;
margin: 0 0.2em;
}
/* ---- live DNS and RIPE database lookups ------------------------------------ */
.reveal form.dns-lookup,
.reveal form.ripe-lookup {
margin: 0.4em auto 0;
max-width: 34em;
text-align: left;
font-family: var(--r-code-font);
}
.reveal .dns-lookup input,
.reveal .ripe-lookup input {
width: 62%;
padding: 0.3em 0.5em;
font: inherit;
font-size: 0.8em;
color: var(--r-main-color);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
outline: none;
}
.reveal .dns-lookup input:focus,
.reveal .ripe-lookup input:focus {
border-color: var(--accent);
}
.reveal .dns-lookup button,
.reveal .ripe-lookup button {
margin-left: 0.4em;
padding: 0.3em 0.9em;
font: inherit;
font-size: 0.8em;
color: #fff;
background: var(--accent);
border: 0;
border-radius: 6px;
cursor: pointer;
}
.reveal .dns-result {
min-height: 3.2em;
max-height: 13em;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--rule) transparent;
margin-top: 0.5em;
padding: 0.5em 0.7em;
font-size: 0.7em;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
}
.reveal .dns-result .ttl,
.reveal .dns-result .type,
.reveal .dns-result .dim { color: var(--muted); }
.reveal .dns-result .addr { color: var(--accent); font-weight: 500; }
.reveal .dns-result .bad { color: #c62828; }
/* RIPE database objects, laid out like apps.db.ripe.net: a light page with
one card per object, one row per attribute, references in blue, rows the
RIPE NCC manages shaded. */
.reveal .ripe-result {
/* A fixed height, not a maximum: the box must not grow when the answer
arrives, or the slide overflows (reveal centres a slide only when it
is shown). */
height: 13.5em;
margin-top: 0.5em;
padding: 0.5em;
overflow-y: auto;
scrollbar-width: thin;
font-size: 0.62em;
text-align: left;
background: #f4f5f7;
border-radius: 6px;
}
.reveal .ripe-card {
margin-bottom: 0.7em;
padding: 0.2em 0.5em;
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
}
.reveal .ripe-card:last-child { margin-bottom: 0; }
.reveal .ripe-card table {
width: 100%;
margin: 0;
border-collapse: collapse;
font-family: var(--r-code-font);
font-size: inherit;
color: #1c1c28;
}
.reveal .ripe-card th,
.reveal .ripe-card td {
padding: 0.28em 0.6em;
border: 0;
font-weight: normal;
text-align: left;
vertical-align: top;
background: transparent;
line-height: 1.4;
}
.reveal .ripe-card table th {
width: 8.5em;
color: #1c1c28;
white-space: nowrap;
text-decoration: underline dotted #9a9aa6;
text-underline-offset: 0.2em;
}
.reveal .ripe-card td { word-break: break-all; }
.reveal .ripe-card tr.managed th,
.reveal .ripe-card tr.managed td { background: #dde5f0; }
.reveal .ripe-card .ref { color: #2f6fe4; }
.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; }
.reveal .ripe-msg {
padding: 0.6em;
font-family: var(--r-code-font);
color: #1c1c28;
}
/* A framed live page on a slide. */
.reveal iframe.live {
border: 1px solid var(--rule);
border-radius: 8px;
background: #fff;
}
/* ---- bytes with one part in focus: everything not .on is dimmed --------------- */
.bytes.focus span:not(.on) {
opacity: 0.22;
}
.bytes.focus span.on {
box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
}
/* Packet layouts: field boxes in the colours of the bytes view. */
.diagram .pkt rect { stroke: none; }
.diagram .pkt.hh rect { fill: #34406e; }
.diagram .pkt.hl rect { fill: #6e3440; }
.diagram .pkt.ha rect { fill: #245c3a; }
.diagram .pkt.ha2 rect { fill: #2f7a4b; }
.diagram .pkt.hn rect { fill: #6b5a1f; }
.diagram text.pkt-name { font-size: 18px; font-weight: 500; fill: #fff; }
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: #fff; opacity: 0.8; }
.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; }
.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; }
/* ---- byte walk: a hex dump and one row per field, in step on hover --------- */
.reveal .bytewalk { text-align: left; }
.reveal .bytewalk .bw-dump { margin-bottom: 8px; }
/* The lit bytes invert: a white block on the dark dump, visible from the back row. */
.reveal .bw-dump span.on { background: #fff; color: var(--code-ground); box-shadow: 0 0 0 3px var(--accent); }
.reveal .bw-rows {
position: relative; /* rows measure their offsetTop against the box */
height: 19em;
overflow-y: auto;
scrollbar-width: thin;
font-size: 46%;
line-height: 1.45;
border: 1px solid var(--rule);
border-radius: 6px;
background: rgba(var(--accent-rgb), 0.04);
}
.reveal .bw-group {
padding: 0.45em 0.8em 0.2em;
color: var(--accent);
font-weight: 700;
}
.reveal .bw-row {
display: grid;
grid-template-columns: 11em 12em 1fr;
gap: 0 1em;
padding: 0.18em 0.8em;
cursor: default;
outline: none;
}
.reveal .bw-row:hover,
.reveal .bw-row:focus,
.reveal .bw-row.on { background: rgba(var(--accent-rgb), 0.14); }
.reveal .bw-dump span[data-i] { cursor: pointer; }
.reveal .bw-row code.hh { background: rgba(52, 64, 110, 0.16); }
.reveal .bw-row code.hl { background: rgba(110, 52, 64, 0.16); }
.reveal .bw-row code.ha { background: rgba(36, 92, 58, 0.16); }
.reveal .bw-row code.ha2 { background: rgba(47, 122, 75, 0.16); }
.reveal .bw-row code.hn { background: rgba(107, 90, 31, 0.16); }
.reveal .bw-row code { display: block; color: var(--ink-strong); min-width: 0; max-width: 100%; justify-self: start; align-self: start; font-family: var(--r-code-font); border-radius: 3px; padding: 0 4px; white-space: normal; word-break: normal; }
.reveal .bw-row b { color: var(--ink-strong); font-weight: 500; }
.reveal .bw-row span { color: var(--ink); }
/* Small two-column explanation under a bytes block. */
.reveal table.decode {
font-size: 58%;
margin-top: 10px;
}
.reveal table.decode td:first-child {
font-family: var(--r-code-font);
color: var(--accent-soft);
white-space: nowrap;
}
/* ---- prefix explorer: type a prefix, see its range ------------------------ */
.reveal form.prefix-explorer {
margin: 0 auto;
max-width: 36em;
text-align: center;
}
.reveal .prefix-explorer input[name="prefix"] {
width: 15em;
padding: 0.25em 0.5em;
font: inherit;
font-family: var(--r-code-font);
font-size: 0.8em;
color: var(--r-main-color);
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
outline: none;
text-align: center;
}
.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); }
.reveal .prefix-explorer input[name="len"] {
width: 12em;
margin-left: 1em;
vertical-align: middle;
accent-color: var(--accent);
}
.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; }
.reveal .prefix-explorer .px-bad { color: #c62828; font-size: 70%; }
.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; }
.reveal .px-levels { text-align: left; margin-bottom: 1em; }
.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; }
.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; }
.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; }
.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; }
.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; }
.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); }
.reveal .px-bar {
position: relative;
height: 2.6em;
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
border: 1px solid var(--accent);
border-radius: 6px;
}
.reveal .px-bar .px-mid {
position: absolute; left: 0; right: 0; top: 0.6em;
text-align: center; color: var(--ink-strong); font-weight: 500;
}
.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); }
.reveal .px-bar .px-first { left: 0; }
.reveal .px-bar .px-last { right: 0; }
.reveal .prefix-explorer .px-facts { margin-top: 2.6em; }
/* ---- bits of an address: fixed part vs. free part ------------------------------ */
.bits {
font-family: var(--r-code-font);
font-size: 80%;
letter-spacing: 0.02em;
}
.bits .fixed {
color: #fff;
background: var(--accent);
border-radius: 4px 0 0 4px;
padding: 2px 6px;
}
.bits .part {
color: var(--accent-soft);
background: var(--ground);
border: 1px solid var(--accent);
padding: 1px 4px;
margin: 0 2px;
font-size: 85%;
letter-spacing: 0.12em;
}
.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; }
.bits .part i { font-style: normal; color: var(--muted); }
.bits .free {
color: var(--ink);
background: var(--ground);
border: 1px solid var(--rule);
border-left: none;
border-radius: 0 4px 4px 0;
padding: 1px 6px;
}
/* ---- diagrams: inline SVG drawn with the theme's colours ------------------------ */
.reveal svg.diagram {
width: 100%;
height: auto;
max-height: 470px;
display: block;
margin: 0 auto;
}
.diagram rect,
.diagram ellipse {
fill: var(--ground);
stroke: var(--rule);
stroke-width: 2;
}
.diagram .hot rect {
stroke: var(--accent);
}
.diagram .fabric rect {
fill: rgba(var(--accent-rgb), 0.18);
stroke: var(--accent);
}
.diagram text {
fill: var(--ink);
font-family: "IBM Plex Sans", sans-serif;
font-size: 19px;
text-anchor: middle;
}
.diagram text.small {
font-size: 14px;
fill: var(--muted);
font-family: "IBM Plex Mono", monospace;
}
.diagram text.accent {
fill: var(--accent);
}
.diagram .link {
stroke: var(--muted);
stroke-width: 2.5;
fill: none;
}
.diagram .bgp {
stroke: var(--accent);
stroke-width: 2.5;
stroke-dasharray: 7 6;
fill: none;
}
/* Nested address ranges: each level a little brighter than the one around it. */
.diagram .nest rect {
stroke: var(--accent);
rx: 14;
}
.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; }
.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; }
.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; }
.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; }
.diagram text.left { text-anchor: start; }
.diagram text.mono { font-family: "IBM Plex Mono", monospace; }
.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; }
.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; }
.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; }
.diagram .host { fill: var(--accent-soft); stroke: none; }
.diagram .traffic {
stroke: #1a8a4f;
stroke-width: 4;
fill: none;
}
-430
View File
@@ -1,430 +0,0 @@
/*
* The look of the deck. Structure borrowed from Matt Godbolt's newer talks
* (hrt-theme.css in github.com/mattgodbolt/what-happens-beneath-accu-2026):
* every slide on a rounded panel, IBM Plex Sans with thin h1 and bold accent
* h2, IBM Plex Mono code, accent-coloured list markers, links and progress
* bar, glowing highlighted code lines.
*
* Made our own: a light palette (his is near-black; change it in :root
* below), and his "thread" corner motif becomes a chain of nodes and links,
* an AS path. Code blocks and byte dumps stay dark on the light page so the
* monokai colours and the byte palette keep working.
*
* Try accents without editing: index.html?accent=green (blue, green,
* amber, magenta). Make one permanent by copying its values into :root.
*/
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap");
:root {
--ground: #e9edf5; /* behind the panels */
--panel: #ffffff; /* the slide itself */
--panel-edge: rgba(31, 111, 235, 0.22);
--ink: #1f2333; /* body text */
--ink-strong: #0b0d17; /* h1, h3 */
--muted: #6b7088; /* h4, captions */
--rule: #c3c8d8; /* table rules, borders */
--accent: #1f6feb; /* h2, markers, links, motif */
--accent-soft: #0b4ea2; /* inline code, emphasis */
--accent-rgb: 31, 111, 235;
/* Code blocks and byte dumps keep a dark ground of their own. */
--code-ground: #1b1e33;
--code-ink: #eceff9;
--r-code-font: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;
--r-heading-text-shadow: none;
}
:root[data-accent="green"] {
--accent: #1a8a4f;
--accent-soft: #0f6238;
--accent-rgb: 26, 138, 79;
--panel-edge: rgba(26, 138, 79, 0.22);
}
:root[data-accent="amber"] {
--accent: #b86e00;
--accent-soft: #7a4a00;
--accent-rgb: 184, 110, 0;
--panel-edge: rgba(184, 110, 0, 0.22);
}
:root[data-accent="magenta"] {
--accent: #c2277f;
--accent-soft: #8a1a5a;
--accent-rgb: 194, 39, 127;
--panel-edge: rgba(194, 39, 127, 0.22);
}
/* ---- ground and panels ---------------------------------------------------- */
.reveal-viewport {
background: var(--ground);
}
.reveal {
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
font-size: 40px;
font-weight: 400;
color: var(--ink);
}
.reveal .slides section:not(.stack):not(.title-slide):not(.bare) {
background-color: var(--panel);
border-radius: 12px;
box-shadow: 0 4px 30px rgba(20, 30, 60, 0.1);
border: 1px solid var(--panel-edge);
padding: 1em 0.8em;
box-sizing: border-box;
}
/* ---- type ------------------------------------------------------------------ */
.reveal h1,
.reveal h2,
.reveal h3,
.reveal h4 {
font-family: inherit;
text-transform: none;
text-shadow: none;
margin: 0 0 20px 0;
line-height: 1.25;
}
.reveal h1 {
font-weight: 300;
color: var(--ink-strong);
font-size: 2.2em;
line-height: 1.15;
}
.reveal h2 {
font-weight: 700;
color: var(--accent);
font-size: 1.5em;
}
.reveal h3 {
font-weight: 500;
color: var(--ink-strong);
font-size: 1.2em;
}
.reveal h4 {
font-weight: 500;
color: var(--muted);
font-size: 1em;
}
.reveal p {
margin: 20px 0;
line-height: 1.5;
}
.reveal strong,
.reveal b {
font-weight: 700;
color: var(--ink-strong);
}
.reveal em {
font-style: italic;
color: var(--accent-soft);
}
.reveal a {
color: var(--accent);
text-decoration: none;
transition: color 0.15s ease;
}
.reveal a:hover {
color: var(--accent-soft);
}
.reveal ul,
.reveal ol {
display: inline-block;
text-align: left;
margin: 0 0 0 1em;
}
.reveal ul {
list-style-type: square;
}
.reveal ul li,
.reveal ol li {
margin-bottom: 0.4em;
}
.reveal li::marker {
color: var(--accent);
}
.reveal blockquote {
background: var(--ground);
border-left: 4px solid var(--accent);
padding: 20px 30px;
font-style: italic;
width: 85%;
margin: 20px auto;
text-align: left;
}
.reveal img,
.reveal video,
.reveal iframe {
max-width: 95%;
max-height: 95%;
}
.reveal kbd {
font-family: var(--r-code-font);
font-size: 80%;
border: 1px solid var(--rule);
border-bottom-width: 3px;
border-radius: 5px;
padding: 0 0.35em;
}
/* ---- code ------------------------------------------------------------------ */
.reveal pre {
width: 100%;
margin: 20px auto;
box-shadow: none;
text-align: left;
position: relative;
font-size: 0.55em;
line-height: 1.4;
}
.reveal code {
font-family: var(--r-code-font);
color: var(--accent-soft);
}
.reveal :not(pre) > code {
white-space: nowrap;
font-size: 92%;
}
.reveal pre code {
display: block;
color: var(--code-ink);
background: var(--code-ground);
border-radius: 6px;
padding: 15px 20px;
max-height: 560px;
overflow: auto;
}
/* The lines being talked about glow; the rest dim (reveal handles the dim). */
.reveal .hljs.has-highlights tr.highlight-line {
text-shadow:
0 0 6px currentColor,
0 0 12px currentColor;
transition: text-shadow 0.4s ease;
}
/* ---- tables ---------------------------------------------------------------- */
.reveal table {
border-collapse: collapse;
border-spacing: 0;
margin: 20px auto;
font-size: 75%;
}
.reveal table th {
font-weight: 700;
color: var(--ink-strong);
}
.reveal table th,
.reveal table td {
text-align: left;
border-bottom: 1px solid var(--rule);
padding: 0.25em 0.6em;
}
.reveal table tr:last-child td {
border-bottom: none;
}
/* ---- reveal chrome ---------------------------------------------------------- */
.reveal .controls {
color: var(--accent);
bottom: 40px;
}
.reveal .progress {
background: var(--panel);
height: 4px;
}
.reveal .progress span {
background: var(--accent);
transition: width 800ms cubic-bezier(0.26, 0.86, 0.44, 0.985);
}
.reveal .slide-number {
color: #fff;
font-family: var(--r-code-font);
font-size: 18px;
font-weight: 500;
background: rgba(var(--accent-rgb), 0.9);
padding: 3px 9px;
border-radius: 4px;
right: 16px;
bottom: 16px;
}
::selection {
color: #fff;
background: var(--accent);
}
/* ---- the path motif ---------------------------------------------------------
* Matt's decks carry a ring with a line running off the edge. Ours is a short
* AS path: nodes joined by links, heading off the top-right corner, mirrored
* running up from the bottom-left. Drawn in the accent colour via a mask, so
* it follows --accent. Hidden on the title slide and on "bare" slides.
*/
.reveal::before,
.reveal-viewport::before {
content: "";
position: fixed;
background: var(--accent);
z-index: 0;
pointer-events: none;
opacity: 0.9;
}
.reveal::before {
top: 26px;
right: 0;
width: 38vw;
height: 22px;
-webkit-mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
left center / 190px 22px no-repeat,
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
left center / 190px 22px no-repeat,
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
}
.reveal-viewport::before {
bottom: 64px;
left: 40px;
width: 22px;
height: 36vh;
-webkit-mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
center bottom / 22px 126px no-repeat,
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
mask:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
center bottom / 22px 126px no-repeat,
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
}
.reveal:has(> .slides > section.title-slide.present)::before,
.reveal:has(> .slides section.bare.present)::before {
display: none;
}
.reveal-viewport:has(.reveal > .slides > section.title-slide.present)::before,
.reveal-viewport:has(.reveal > .slides section.bare.present)::before {
display: none;
}
.reveal:has(section.title-slide.present) .controls {
display: none !important;
}
/* ---- wordmark, where his logo sits ------------------------------------------ */
.wordmark {
position: fixed;
left: 22px;
bottom: 18px;
z-index: 10;
font-family: var(--r-code-font);
font-size: 15px;
letter-spacing: 0.04em;
color: var(--muted);
pointer-events: none;
}
.wordmark b {
color: var(--accent);
font-weight: 500;
}
.reveal:has(section.title-slide.present) ~ .wordmark,
.reveal:has(section.bare.present) ~ .wordmark {
display: none;
}
/* ---- title slide -------------------------------------------------------------- */
.reveal section.title-slide {
text-align: left;
padding: 0 1.5em;
}
.reveal section.title-slide h1 {
font-size: 2.6em;
margin-bottom: 0.15em;
}
.reveal section.title-slide h3 {
font-weight: 400;
color: var(--accent);
margin-bottom: 2.2em;
}
.reveal section.title-slide .byline {
font-size: 60%;
color: var(--muted);
margin: 0;
}
/* ---- shown by index.html when the deck cannot start --------------------------- */
#deck-help {
position: fixed;
inset: 0;
z-index: 100;
padding: 8vh 10vw;
background: var(--ground);
color: var(--ink);
font: 20px/1.5 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
}
#deck-help h2 {
color: var(--accent);
font-weight: 700;
}
#deck-help pre,
#deck-help code {
font-family: var(--r-code-font);
color: var(--accent-soft);
}
#deck-help pre {
background: var(--panel);
padding: 12px 16px;
border-radius: 6px;
}
/* ---- print ------------------------------------------------------------------ */
html.print-pdf .wordmark,
html.reveal-print .wordmark {
display: none;
}
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# Copies reveal.js into ./reveal.js so the deck works with no network.
# index.html uses this copy when it exists and the CDN otherwise. Keep VERSION
# in step with the one in index.html.
set -eu
VERSION=6.0.2
cd "$(dirname "$0")"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
npm pack "reveal.js@$VERSION" --pack-destination "$tmp" --silent >/dev/null 2>&1
tar -xzf "$tmp/reveal.js-$VERSION.tgz" -C "$tmp"
rm -rf reveal.js
mkdir -p reveal.js
cp -R "$tmp/package/dist" reveal.js/dist
cp "$tmp/package/LICENSE" reveal.js/LICENSE
echo "reveal.js $VERSION vendored into $(pwd)/reveal.js ($(du -sh reveal.js | cut -f1))"
-385
View File
@@ -1,385 +0,0 @@
#!/usr/bin/env python3
"""Load test for the ferrix_bgp web API: N students announce at the same moment.
Each simulated student owns one prefix (student n gets the n-th /48, numbered
in hex like the lab's route6 objects) and announces it under its own owner
name. All students are released together by a barrier, so the announcements
really are concurrent. The script then checks that the daemon did the right
thing, not merely that it answered:
1. every announcement was accepted, with latency percentiles
2. the local table holds exactly these routes, under the right owners
3. every route was turned into an UPDATE for each established session
4. the event stream delivered one "announced" event per route, none lost
5. ownership holds under load: nobody could replace another's prefix
6. concurrent withdrawal leaves nothing behind
7. the daemon's own counters show the matching number of BGP UPDATEs sent,
for the announcements and again for the withdrawals
SAFETY. Pointed at a router that peers with the real Internet, this announces
real prefixes. By default every route therefore carries the large community
210036:0:0 ("announce nowhere"), so the production router accepts the routes
but exports them to nobody, and the script withdraws everything it announced
even when it fails half-way. Pass --announce-for-real only if you mean it.
Standard library only. Examples:
tools/loadtest.py 128
tools/loadtest.py 128 --url https://lab.example.net --user student
tools/loadtest.py 64 --rounds 5 --keep
"""
import argparse
import base64
import getpass
import json
import os
import ssl
import statistics
import sys
import threading
import time
import urllib.error
import urllib.request
QUIET_COMMUNITY = "210036:0:0"
OWNER_PREFIX = "loadtest-"
class Api:
def __init__(self, base, user=None, password=None, insecure=False, timeout=15):
self.base = base.rstrip("/")
self.timeout = timeout
self.headers = {"content-type": "application/json"}
if user:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
self.headers["authorization"] = f"Basic {token}"
self.ctx = ssl._create_unverified_context() if insecure else None
def call(self, method, path, body=None):
"""Returns (status, parsed JSON or text, seconds)."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.base + path, data=data, method=method, headers=self.headers)
start = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
status, raw = resp.status, resp.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
except Exception as e: # connection refused, timeout, TLS failure, ...
return 0, f"{type(e).__name__}: {e}", time.perf_counter() - start
elapsed = time.perf_counter() - start
try:
return status, json.loads(raw) if raw else None, elapsed
except ValueError:
return status, raw.decode(errors="replace"), elapsed
def open_stream(self, path):
req = urllib.request.Request(self.base + path, headers={**self.headers, "accept": "text/event-stream"})
return urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx)
def updates_sent(api):
"""BGP UPDATE messages the daemon has sent so far, from /metricz; None if unavailable."""
status, text, _ = api.call("GET", "/metricz")
if status != 200 or not isinstance(text, str):
return None
for line in text.splitlines():
if line.startswith('bgp_messages_sent_total{type="update"}'):
return int(float(line.rsplit(" ", 1)[1]))
return 0 # the counter has no sample until the first UPDATE
def established(api):
status, peers, _ = api.call("GET", "/api/peers")
return [p for p in peers if p.get("state") == "Established"] if status == 200 else []
def check_wire(api, report, before, routes, sessions_before, what):
"""The advertiser works asynchronously, so give the counter a moment to settle."""
if before is None:
print(f" [skip] /metricz unavailable, cannot count UPDATEs for the {what}")
return
expected = routes * len(sessions_before)
deadline = time.time() + 5
while time.time() < deadline and (updates_sent(api) or 0) - before < expected:
time.sleep(0.1)
delta = (updates_sent(api) or 0) - before
if len(established(api)) != len(sessions_before):
print(f" [skip] sessions changed during the test; {delta} UPDATEs sent for the {what}")
else:
report.check(delta == expected, f"{delta} BGP UPDATEs sent for the {what} "
f"({routes} routes x {len(sessions_before)} session(s))", [f"expected {expected}"])
class EventCounter(threading.Thread):
"""Follows /api/events and records what it sees until stopped."""
def __init__(self, api):
super().__init__(daemon=True)
self.api, self.events, self.error = api, [], None
self.connected, self._stop = threading.Event(), threading.Event()
def run(self):
try:
stream = self.api.open_stream("/api/events")
except Exception as e:
self.error = f"{type(e).__name__}: {e}"
self.connected.set()
return
self.connected.set()
try:
for raw in stream:
if self._stop.is_set():
break
line = raw.decode(errors="replace").strip()
if line.startswith("data:"):
try:
self.events.append(json.loads(line[5:]))
except ValueError:
pass
except Exception as e:
if not self._stop.is_set():
self.error = f"{type(e).__name__}: {e}"
def stop(self):
self._stop.set()
def percentile(values, p):
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(round(p / 100 * (len(ordered) - 1))))]
def latency_line(latencies):
if not latencies:
return "no samples"
ms = [v * 1000 for v in latencies]
return (f"min {min(ms):.0f} ms, median {statistics.median(ms):.0f} ms, "
f"p95 {percentile(ms, 95):.0f} ms, max {max(ms):.0f} ms")
def run_concurrently(jobs, worker):
"""Runs worker(job) for every job at the same instant; returns results in order."""
results = [None] * len(jobs)
barrier = threading.Barrier(len(jobs))
def target(i, job):
try:
barrier.wait(timeout=30)
except threading.BrokenBarrierError:
results[i] = (0, "barrier broken", 0.0)
return
results[i] = worker(job)
threads = [threading.Thread(target=target, args=(i, job)) for i, job in enumerate(jobs)]
wall = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
return results, time.perf_counter() - wall
class Report:
def __init__(self):
self.failures = 0
def check(self, ok, text, detail=None):
print(f" [{'ok' if ok else 'FAIL'}] {text}")
if not ok:
self.failures += 1
for line in (detail or [])[:8]:
print(f" {line}")
if detail and len(detail) > 8:
print(f" ... and {len(detail) - 8} more")
return ok
def one_round(api, args, students, report, round_no):
body_for = lambda s: {
"prefix": s["prefix"], "owner": s["owner"],
"communities": args.community, "large_communities": s["large"],
}
events = EventCounter(api)
if not args.no_events:
events.start()
events.connected.wait(timeout=10)
time.sleep(0.3) # let the subscription settle before the burst
# ---- 1. everybody announces at once -----------------------------------
print(f"\nRound {round_no}: {len(students)} students announce concurrently")
sent_before, sessions_before = updates_sent(api), established(api)
results, wall = run_concurrently(students, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
accepted = [s for s, r in zip(students, results) if r[0] == 200]
rejected = [f"{s['owner']} {s['prefix']}: HTTP {r[0]} {str(r[1])[:160]}" for s, r in zip(students, results) if r[0] != 200]
report.check(not rejected, f"all {len(students)} announcements accepted in {wall:.2f} s "
f"({len(students) / wall:.0f}/s)", rejected)
print(f" latency: {latency_line([r[2] for r in results if r[0] == 200])}")
if not accepted:
print(" [skip] nothing was accepted, so there is nothing further to verify")
if not args.no_events:
events.stop()
return accepted
# ---- 2. the table holds exactly what was announced --------------------
status, table, _ = api.call("GET", "/api/routes/local")
table = table if status == 200 and isinstance(table, list) else []
by_prefix = {r["prefix"]: r for r in table}
missing = [f"{s['prefix']} missing" for s in accepted if s["prefix"] not in by_prefix]
wrong_owner = [f"{s['prefix']} owned by {by_prefix[s['prefix']]['owner']!r}, expected {s['owner']!r}"
for s in accepted if s["prefix"] in by_prefix and by_prefix[s["prefix"]]["owner"] != s["owner"]]
wrong_attrs = [f"{s['prefix']} large communities {by_prefix[s['prefix']]['large_communities']}"
for s in accepted if s["prefix"] in by_prefix
and sorted(by_prefix[s["prefix"]]["large_communities"]) != sorted(s["large"])]
report.check(not (missing or wrong_owner or wrong_attrs),
"local table holds every route with the right owner and communities",
missing + wrong_owner + wrong_attrs)
# ---- 3. each route became an UPDATE on every session -------------------
up = established(api)
if not up:
print(" [skip] no established BGP session, so nothing was put on the wire")
else:
sample = accepted if len(accepted) <= 32 else accepted[:: max(1, len(accepted) // 32)]
bad = []
for s in sample:
code, wire, _ = api.call("GET", "/api/routes/local/wire?prefix=" + urllib.request.quote(s["prefix"], safe=""))
if code != 200 or not wire:
bad.append(f"{s['prefix']}: HTTP {code}, {len(wire) if isinstance(wire, list) else 0} session(s)")
elif not all(w["hex"].startswith("ff" * 16) for w in wire):
bad.append(f"{s['prefix']}: malformed UPDATE")
report.check(not bad, f"UPDATE built for {len(up)} established session(s) "
f"(checked {len(sample)} of {len(accepted)} routes)", bad)
check_wire(api, report, sent_before, len(accepted), sessions_before, "announcements")
# ---- 4. the event stream lost nothing ----------------------------------
if not args.no_events:
deadline = time.time() + 5
want = {s["prefix"] for s in accepted}
seen = lambda: {e["route"]["prefix"] for e in events.events if e.get("type") == "local_announced"}
while time.time() < deadline and not want <= seen():
time.sleep(0.1)
lagged = [e for e in events.events if e.get("type") == "lagged"]
lost = sorted(want - seen())
detail = [f"no event for {p}" for p in lost] + [f"stream lagged: missed {e['missed']} on {e['stream']}" for e in lagged]
if events.error:
detail.insert(0, f"event stream: {events.error}")
report.check(not detail, f"event stream delivered all {len(want)} announcements", detail)
# ---- 5. ownership holds under load -------------------------------------
if len(accepted) >= 2 and not args.no_policy:
# Everyone tries, at once, to take over their neighbour's prefix.
thieves = [{**accepted[i], "prefix": accepted[(i + 1) % len(accepted)]["prefix"]} for i in range(len(accepted))]
results, _ = run_concurrently(thieves, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
leaked = [f"{t['owner']} took {t['prefix']}: HTTP {r[0]}" for t, r in zip(thieves, results) if r[0] != 409]
status, table, _ = api.call("GET", "/api/routes/local")
owners = {r["prefix"]: r["owner"] for r in table} if status == 200 else {}
leaked += [f"{s['prefix']} now owned by {owners.get(s['prefix'])!r}" for s in accepted if owners.get(s["prefix"]) != s["owner"]]
report.check(not leaked, f"{len(thieves)} concurrent takeover attempts all refused (409), owners unchanged", leaked)
if not args.no_events:
events.stop()
return accepted
def withdraw_all(api, students, report, verify=True):
if not students:
return
sent_before, sessions_before = (updates_sent(api), established(api)) if verify else (None, [])
results, wall = run_concurrently(
students, lambda s: api.call("DELETE", "/api/routes/local", {"prefix": s["prefix"], "owner": s["owner"]}))
failed = [f"{s['prefix']}: HTTP {r[0]} {r[1]}" for s, r in zip(students, results) if r[0] != 200]
if not verify:
return
report.check(not failed, f"all {len(students)} withdrawals accepted in {wall:.2f} s", failed)
status, table, _ = api.call("GET", "/api/routes/local")
left = [r["prefix"] for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else ["(could not read table)"]
report.check(not left, "nothing left behind by the load test", left)
if sessions_before:
check_wire(api, report, sent_before, len(students) - len(failed), sessions_before, "withdrawals")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("students", type=int, help="number of simulated students")
ap.add_argument("--url", default="http://127.0.0.1:8080", help="base URL of the status page (default %(default)s)")
ap.add_argument("--user", help="HTTP basic auth user; the password is read from $FERRIX_PASSWORD or prompted for")
ap.add_argument("--insecure", action="store_true", help="do not verify the TLS certificate")
ap.add_argument("--prefix-template", default="2a0d:d741:{n:x}::/48",
help="prefix of student n, a Python format string (default %(default)s)")
ap.add_argument("--first", type=int, default=1, help="number of the first student (default 1)")
ap.add_argument("--rounds", type=int, default=1, help="repeat announce/verify/withdraw this many times")
ap.add_argument("--community", action="append", default=[], help="standard community to attach (repeatable)")
ap.add_argument("--large-community", action="append", default=[], help="extra large community to attach (repeatable)")
ap.add_argument("--announce-for-real", action="store_true",
help=f"do NOT add {QUIET_COMMUNITY}; routes may be exported to the Internet")
ap.add_argument("--keep", action="store_true", help="leave the routes announced at the end")
ap.add_argument("--no-events", action="store_true", help="skip the event stream check")
ap.add_argument("--no-policy", action="store_true", help="skip the concurrent takeover check")
args = ap.parse_args()
if args.students < 1:
ap.error("need at least one student")
password = None
if args.user:
password = os.environ.get("FERRIX_PASSWORD") or getpass.getpass(f"Password for {args.user}: ")
api = Api(args.url, args.user, password, args.insecure)
large = list(args.large_community)
if not args.announce_for_real and QUIET_COMMUNITY not in large:
large.insert(0, QUIET_COMMUNITY)
students = [{"owner": f"{OWNER_PREFIX}{n}", "prefix": args.prefix_template.format(n=n), "large": large}
for n in range(args.first, args.first + args.students)]
# ---- preflight ----------------------------------------------------------
status, info, _ = api.call("GET", "/api/status")
if status == 401:
sys.exit(f"{args.url} wants a password: HTTP 401. Pass --user, and check the password.")
if status != 200:
sys.exit(f"Cannot reach {args.url}/api/status: HTTP {status} {str(info)[:200]}")
print(f"Target: {args.url} router-id {info['router_id']} AS{info['local_asn']} version {info['version']} "
f"on {info.get('hostname', '?')}")
print(f" {info['sessions']} established session(s), {info['local_routes']} local route(s) already present")
print(f"Prefixes {students[0]['prefix']} .. {students[-1]['prefix']}, large communities {large or 'none'}")
if args.announce_for_real:
print("WARNING: --announce-for-real: these routes may be exported to the Internet.")
status, table, _ = api.call("GET", "/api/routes/local")
stale = [r for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else []
if stale:
print(f"Removing {len(stale)} route(s) left over from an earlier load test")
withdraw_all(api, [{"owner": r["owner"], "prefix": r["prefix"]} for r in stale], Report(), verify=False)
taken = {r["prefix"]: r["owner"] for r in table if not r["owner"].startswith(OWNER_PREFIX)} if status == 200 else {}
clash = [s for s in students if s["prefix"] in taken]
if clash:
print(f"Skipping {len(clash)} prefix(es) currently announced by real owners: "
+ ", ".join(f"{s['prefix']} ({taken[s['prefix']]})" for s in clash[:5]) + (" ..." if len(clash) > 5 else ""))
students = [s for s in students if s["prefix"] not in taken]
if not students:
sys.exit("Nothing left to test.")
report, announced = Report(), []
try:
for round_no in range(1, args.rounds + 1):
announced = one_round(api, args, students, report, round_no)
if round_no < args.rounds or not args.keep:
withdraw_all(api, announced, report)
announced = []
except KeyboardInterrupt:
print("\nInterrupted.")
report.failures += 1
finally:
if announced and not args.keep:
withdraw_all(api, announced, Report(), verify=False)
status, info, _ = api.call("GET", "/api/status")
if status == 200:
print(f"\nAfterwards: {info['sessions']} established session(s), {info['local_routes']} local route(s)")
print("RESULT: " + ("PASS" if report.failures == 0 else f"FAIL ({report.failures} check(s) failed)"))
sys.exit(0 if report.failures == 0 else 1)
if __name__ == "__main__":
main()