diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..f54b504 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "slides", + "runtimeExecutable": "python3", + "runtimeArgs": ["slides/serve.py", "8765"], + "port": 8765 + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index ab238bf..797ab74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,7 @@ dependencies = [ "clap", "eyre", "futures", + "libc", "prometheus", "protobuf", "protobuf-codegen", diff --git a/Cargo.toml b/Cargo.toml index ed88546..3caa5aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ protobuf = { package = "protobuf", version = "3.7.2" } protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" } clap = { version = "4.5.53", features = ["derive"] } futures = "0.3" +libc = "0.2" axum = "0.8.9" tokio-stream = { version = "0.1.19", features = ["sync"] } reqwest = { version = "0.13.5", default-features = false, features = ["json"] } diff --git a/bin/bgp/src/main.rs b/bin/bgp/src/main.rs index 8b2a7bc..bbe137a 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::time::Duration; use bgp_server::config::Config; +use bgp_server::ping::PingMonitor; use bgp_server::proto::config::BgpConfig; use bgp_server::server::BgpServerBuilder; use bgp_server::tracing::TracingArgs; @@ -32,6 +33,11 @@ async fn main() -> Result<()> { let server = BgpServerBuilder::new(config).bind().await?; + let pings = match &server.config().ping_monitor { + Some(prefixes) => PingMonitor::start(prefixes.clone(), server.local_rib.clone()), + None => PingMonitor::disabled(), + }; + if let Some(http_endpoint) = server.config().http_endpoint { let state = AppState { info: Arc::new( @@ -45,6 +51,7 @@ async fn main() -> Result<()> { registry: server.registry.clone(), local_rib: server.local_rib.clone(), adj_rib_in: server.adj_rib_in.clone(), + pings, }; tokio::spawn(async move { if let Err(e) = bgp_web::serve(state, http_endpoint).await { diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index 90c05fe..76129c8 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -1,7 +1,9 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy}; +use bgp_primitives::ip_prefix::IpPrefix; use bgp_server::config::Config; +use bgp_server::ping::PingMonitor; use bgp_server::server::BgpServerBuilder; use bgp_server::session::SessionRegistry; use bgp_web::{AppState, RouterInfo}; @@ -21,6 +23,9 @@ pub struct TestServer { pub local_rib: LocalRib, /// Live view of the server's established sessions. pub registry: SessionRegistry, + /// Ping monitor without a socket, watching 2001:db8::/32; tests feed it + /// through `record`. + pub pings: PingMonitor, pub local_asn: u32, } @@ -61,6 +66,7 @@ impl TestServer { collision_detect_established_state: false, local_route_policy: LocalRibPolicy::default(), http_endpoint: None, + ping_monitor: None, }; customize(&mut config); @@ -74,8 +80,13 @@ impl TestServer { let local_rib = bound.local_rib.clone(); let registry = bound.registry.clone(); + let pings = PingMonitor::without_socket( + vec![IpPrefix::try_from("2001:db8::/32").expect("valid prefix")], + Some(local_rib.clone()), + ); + let handle = tokio::spawn(bound.run()); - Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn }) + Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn }) } } @@ -94,6 +105,7 @@ impl TestServer { registry: self.registry.clone(), local_rib: self.local_rib.clone(), adj_rib_in: self.adj_rib_in.clone(), + pings: self.pings.clone(), }; let handle = tokio::spawn(bgp_web::serve_on(state, listener)); Ok((base_url, HttpGuard { handle })) diff --git a/crates/integration-tests/tests/pings.rs b/crates/integration-tests/tests/pings.rs new file mode 100644 index 0000000..2553028 --- /dev/null +++ b/crates/integration-tests/tests/pings.rs @@ -0,0 +1,109 @@ +//! The ping monitor as seen through the HTTP API. The socket is left out +//! (it needs privileges); observations are fed in directly. + +use std::time::Duration; + +use bgp_integration_tests::test_server::TestServer; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::LocalRoute; +use bgp_server::ping::PingObservation; +use eyre::Result; +use serde_json::Value; + +const TIMEOUT: Duration = Duration::from_secs(5); + +fn ping(src: &str, dst: &str, hop_limit: u8, seq: u16) -> PingObservation { + PingObservation { + src: src.parse().unwrap(), + dst: dst.parse().unwrap(), + interface: Some("swissix0".into()), + hop_limit: Some(hop_limit), + ident: 1, + seq, + bytes: 64, + packet: [&[128, 0, 0xab, 0xcd, 0, 1][..], &seq.to_be_bytes(), b"hi"].concat(), + } +} + +#[tokio::test] +async fn pings_are_listed_with_the_route_they_were_sent_to() -> Result<()> { + let server = TestServer::start(65000).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + server + .local_rib + .announce(LocalRoute::new(IpPrefix::try_from("2001:db8:1a::/48").unwrap(), "alice", Default::default())) + .await?; + + server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 0)).await; + server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 1)).await; + server.pings.record(ping("2a02:1210::9", "2001:db8:ff::1", 243, 0)).await; + server.pings.record(ping("2a02:1210::9", "2a0d:d740::1", 243, 0)).await; // not a watched prefix + + let view: Value = client.get(format!("{base}/api/pings")).send().await?.error_for_status()?.json().await?; + assert_eq!(view["status"], "listening"); + assert_eq!(view["prefixes"][0], "2001:db8::/32"); + assert_eq!(view["total_flows"], 2); + + let flows = view["flows"].as_array().unwrap(); + let alice = flows.iter().find(|f| f["dst"] == "2001:db8:1a::1").unwrap(); + assert_eq!(alice["src"], "2001:67c:10ec::5"); + assert_eq!(alice["prefix"], "2001:db8:1a::/48"); + assert_eq!(alice["owner"], "alice"); + assert_eq!(alice["interface"], "swissix0"); + assert_eq!((alice["hop_limit"].as_u64(), alice["hops"].as_u64()), (Some(57), Some(7))); + assert_eq!((alice["count"].as_u64(), alice["seq"].as_u64()), (Some(2), Some(1))); + assert_eq!(alice["hex"], "8000abcd000100016869"); + assert!(alice["last_seen_unix"].as_u64().unwrap() > 1_700_000_000); + + let stray = flows.iter().find(|f| f["dst"] == "2001:db8:ff::1").unwrap(); + assert!(stray["prefix"].is_null() && stray["owner"].is_null()); + assert_eq!(stray["hops"], 12); + + let limited: Value = client.get(format!("{base}/api/pings?limit=1")).send().await?.json().await?; + assert_eq!(limited["total_flows"], 2); + assert_eq!(limited["flows"].as_array().unwrap().len(), 1); + + let metrics = client.get(format!("{base}/metricz")).send().await?.text().await?; + assert!(metrics.contains("ping_monitor_echo_requests_total")); + Ok(()) +} + +#[tokio::test] +async fn pings_appear_on_the_event_stream() -> Result<()> { + let server = TestServer::start(65000).await?; + let (base, _http) = server.serve_http().await?; + let mut resp = reqwest::Client::new().get(format!("{base}/api/events")).send().await?.error_for_status()?; + + // The subscription exists once the response has started; a ping recorded + // before that would be missed, so keep pinging until one shows up. + let pings = server.pings.clone(); + let pinger = tokio::spawn(async move { + for seq in 0.. { + pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, seq)).await; + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + let mut buf = String::new(); + let event = tokio::time::timeout(TIMEOUT, async { + loop { + let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?; + buf.push_str(std::str::from_utf8(&chunk)?); + for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) { + let ev: Value = serde_json::from_str(line.trim())?; + if ev["type"] == "ping_seen" { + return Ok::<_, eyre::Error>(ev); + } + } + } + }) + .await??; + pinger.abort(); + + assert_eq!(event["ping"]["src"], "2001:67c:10ec::5"); + assert_eq!(event["ping"]["dst"], "2001:db8:1a::1"); + assert_eq!(event["ping"]["interface"], "swissix0"); + assert!(event["new_burst"].is_boolean()); + Ok(()) +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 9ba77ba..722ce14 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -9,6 +9,7 @@ bgp-rib.workspace = true bytes.workspace = true clap.workspace = true futures.workspace = true +libc.workspace = true eyre.workspace = true prometheus.workspace = true tokio.workspace = true @@ -19,3 +20,6 @@ thiserror.workspace = true [build-dependencies] protobuf-codegen.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 32fed00..3f33577 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -37,6 +37,22 @@ message BgpConfig { // restarted take over from a session we have not yet noticed is dead, // instead of being refused until the hold timer expires. bool collision_detect_established_state = 8; + + // Watch for pings (ICMPv6 echo requests) sent to the lab prefixes and show + // them on the status page. Unset disables it. + PingMonitor ping_monitor = 9; +} + +// Shows who is pinging addresses that this host answers for. +// +// The host must already own the addresses, e.g. through an AnyIP route +// ("ip -6 route add local 2001:db8::/40 dev lo"); the kernel keeps answering +// the pings and we only receive a copy. Opening the raw ICMPv6 socket this +// needs takes CAP_NET_RAW on Linux. IPv6 only. +message PingMonitor { + // Only pings to destinations inside these prefixes are shown. Defaults to + // the IPv6 prefixes in local_route_policy.allowed_prefixes. + repeated string prefixes = 1; } // Limits on locally originated routes. diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index e8bcfbe..5671fa6 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -2,6 +2,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::str::FromStr; use std::time::Duration; +use bgp_primitives::constants::AddressFamilyId; use bgp_primitives::ip_prefix::IpPrefix; use bgp_rib::LocalRibPolicy; use eyre::{Result, WrapErr, bail, eyre}; @@ -31,6 +32,9 @@ pub struct Config { pub local_route_policy: LocalRibPolicy, /// Address for the HTTP API; `None` disables it. pub http_endpoint: Option, + /// IPv6 prefixes whose incoming pings are shown; `None` disables the + /// ping monitor. + pub ping_monitor: Option>, } #[derive(Debug, Clone)] @@ -127,6 +131,27 @@ impl Config { None => None, }; + let ping_monitor = match proto.ping_monitor.into_option() { + Some(monitor) => { + let prefixes: Vec = 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::>()? + }; + let prefixes: Vec = + prefixes.into_iter().filter(|p| p.address_family == AddressFamilyId::Ipv6).collect(); + if prefixes.is_empty() { + bail!("ping_monitor needs IPv6 prefixes, either its own or local_route_policy.allowed_prefixes"); + } + Some(prefixes) + } + None => None, + }; + let connect_retry = if proto.connect_retry_seconds == 0 { DEFAULT_CONNECT_RETRY } else { @@ -143,6 +168,7 @@ impl Config { collision_detect_established_state: proto.collision_detect_established_state, local_route_policy, http_endpoint, + ping_monitor, }) } } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 3dd9ee0..d97fc15 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -5,6 +5,7 @@ pub mod connection; pub mod dialer; pub mod fsm; pub mod metrics; +pub mod ping; pub mod server; pub mod session; pub mod tracing; diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs index 37ce935..233bb26 100644 --- a/crates/server/src/metrics.rs +++ b/crates/server/src/metrics.rs @@ -46,6 +46,22 @@ pub static ADJ_RIB_IN_ROUTES: LazyLock = LazyLock::new(|| { .expect("metric registration") }); +pub static PING_ECHO_REQUESTS: LazyLock = 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 = LazyLock::new(|| { + register_int_counter!( + "ping_monitor_events_suppressed_total", + "Ping events not published because of the per-second limit" + ) + .expect("metric registration") +}); + /// Forces registration of every metric so they appear in scrapes before /// their first observation. pub fn init() { @@ -56,6 +72,8 @@ pub fn init() { LazyLock::force(&COLLISIONS); LazyLock::force(&LOCAL_ROUTES); LazyLock::force(&ADJ_RIB_IN_ROUTES); + LazyLock::force(&PING_ECHO_REQUESTS); + LazyLock::force(&PING_EVENTS_SUPPRESSED); } pub fn message_type_name(msg: &Message) -> &'static str { diff --git a/crates/server/src/ping.rs b/crates/server/src/ping.rs new file mode 100644 index 0000000..54219de --- /dev/null +++ b/crates/server/src/ping.rs @@ -0,0 +1,412 @@ +//! Shows who is pinging the prefixes this router originates. +//! +//! The host answers for the lab address space itself (an AnyIP route on the +//! loopback interface), so the kernel already replies to every ping. A raw +//! ICMPv6 socket gets a copy of each echo request as well; we never reply and +//! never see forwarded traffic. For every packet we learn the source, the +//! destination, the interface it arrived on and the hop limit it arrived +//! with, which is enough to show a student that their route is in use and +//! which way the traffic came in. +//! +//! Anyone on the Internet can send these packets, with any source address, so +//! everything here is bounded: a fixed number of flows is remembered and the +//! number of events published per second is capped. + +#[cfg(unix)] +mod sys; + +use std::collections::HashMap; +use std::net::Ipv6Addr; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use bgp_primitives::constants::AddressFamilyId; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRib, LocalRoute}; +use tokio::sync::{Mutex, broadcast}; +use tokio::time::Instant; +use tracing::{error, info, warn}; + +use crate::metrics::{PING_ECHO_REQUESTS, PING_EVENTS_SUPPRESSED}; + +/// Flows (source and destination pairs) remembered. When full, the flow that +/// has been quiet the longest makes room. +const MAX_FLOWS: usize = 2048; +/// A flow that keeps pinging publishes an event this often, carrying the +/// running count. +const EVENT_INTERVAL: Duration = Duration::from_secs(2); +/// A flow that was quiet for this long starts a new burst, which is what the +/// event log reports. +const BURST_GAP: Duration = Duration::from_secs(10); +/// How much of each packet is kept for display. +pub const MAX_PACKET_BYTES: usize = 256; +/// Events published per second across all flows. Packets beyond this are +/// still counted and show up in [`PingMonitor::flows`]. +const MAX_EVENTS_PER_SECOND: u32 = 50; + +/// One echo request, as seen by the socket. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PingObservation { + pub src: Ipv6Addr, + pub dst: Ipv6Addr, + /// Name of the interface the packet arrived on. + pub interface: Option, + /// 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, + 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, +} + +/// 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, +} + +#[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, + /// 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, + 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, + local_rib: Option, + status: PingMonitorStatus, + table: Mutex, + events: broadcast::Sender, +} + +/// Handle to the ping monitor. Cheap to clone. +#[derive(Clone)] +pub struct PingMonitor { + inner: Arc, +} + +impl PingMonitor { + fn new(prefixes: Vec, local_rib: Option, 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, local_rib: Option) -> 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, 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 = 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 { + 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 { + let mut flows: Vec = 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 { + 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 { + 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 { + 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)); + } +} diff --git a/crates/server/src/ping/sys.rs b/crates/server/src/ping/sys.rs new file mode 100644 index 0000000..683c50b --- /dev/null +++ b/crates/server/src/ping/sys.rs @@ -0,0 +1,265 @@ +//! The raw ICMPv6 socket behind the ping monitor. +//! +//! All the `unsafe` in the daemon lives here: the socket options and the +//! ancillary data we need (`ICMP6_FILTER`, `IPV6_PKTINFO`, `IPV6_HOPLIMIT`) +//! have no safe wrapper in the standard library or tokio. + +use std::collections::HashMap; +use std::ffi::CStr; +use std::io; +use std::mem::{size_of, size_of_val, zeroed}; +use std::net::Ipv6Addr; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; + +use libc::{c_int, c_void}; +use tokio::io::Interest; +use tokio::io::unix::AsyncFd; + +use super::{MAX_PACKET_BYTES, PingObservation}; + +const ICMP6_ECHO_REQUEST: u8 = 128; + +#[cfg(any(target_os = "linux", target_os = "android"))] +const ICMP6_FILTER: c_int = 1; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +const ICMP6_FILTER: c_int = 18; + +/// `struct icmp6_filter`: one bit per ICMPv6 type. +type Icmp6Filter = [u32; 8]; + +/// A filter that lets only echo requests through. Linux and the BSDs agree on +/// the layout and disagree on the meaning: on Linux a set bit blocks the +/// type, on the BSDs (and macOS) a set bit passes it. +fn echo_request_only() -> Icmp6Filter { + let (word, bit) = ((ICMP6_ECHO_REQUEST >> 5) as usize, 1u32 << (ICMP6_ECHO_REQUEST & 31)); + if cfg!(any(target_os = "linux", target_os = "android")) { + let mut filter = [u32::MAX; 8]; + filter[word] &= !bit; + filter + } else { + let mut filter = [0u32; 8]; + filter[word] |= bit; + filter + } +} + +fn check(ret: c_int) -> io::Result { + if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret) } +} + +fn setsockopt(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::(), size_of::() 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, + /// 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>, +} + +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::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 { + // 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 { + 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, + ident: u16, + seq: u16, + bytes: usize, + data: Vec, +} + +/// 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> { + // 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::() 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 { + 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()[..]); + } +} diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index ad32d94..6163040 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -11,6 +11,7 @@ use bgp_primitives::ip_prefix::IpPrefix; use bgp_primitives::parser::ParserContext; use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry}; use bgp_server::advertise::build_announce; +use bgp_server::ping::{PingFlow, PingMonitorStatus}; use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry}; use bytes::BytesMut; use serde::{Deserialize, Serialize}; @@ -475,3 +476,112 @@ pub async fn received_routes(State(state): State) -> Json, + pub owner: Option, + /// Interface the most recent packet arrived on. + pub interface: Option, + /// Hop limit left when the most recent packet arrived. + pub hop_limit: Option, + /// Routers the packet crossed, assuming the sender started at the usual + /// 64, 128 or 255. + pub hops: Option, + 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, + /// Pings to destinations inside these prefixes are shown. + pub prefixes: Vec, + /// Flows remembered; `flows` holds the most recently seen of them. + pub total_flows: usize, + pub flows: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PingQuery { + pub limit: Option, +} + +pub async fn pings(State(state): State, Query(query): Query) -> Json { + 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); + } +} diff --git a/crates/web/src/events.rs b/crates/web/src/events.rs index 6c20676..0732e59 100644 --- a/crates/web/src/events.rs +++ b/crates/web/src/events.rs @@ -1,11 +1,12 @@ -//! Server-sent event stream merging session, local-route and received-route -//! changes into one feed the UI can follow. +//! Server-sent event stream merging session, local-route, received-route and +//! ping events into one feed the UI can follow. use std::convert::Infallible; use axum::extract::State; use axum::response::sse::{Event, KeepAlive, Sse}; use bgp_rib::{LocalRibEvent, RibEvent}; +use bgp_server::ping::PingEvent; use bgp_server::session::SessionEvent; use futures::stream::{Stream, StreamExt}; use serde::Serialize; @@ -13,7 +14,7 @@ use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use crate::AppState; -use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize}; +use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize}; #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -26,6 +27,10 @@ pub enum WireEvent { LocalWithdrawn { prefix: String }, ReceivedAnnounced { route: ReceivedRouteView }, ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String }, + /// Somebody is pinging an address in the watched prefixes. `new_burst` + /// marks the start of a run of pings; the events that follow while it + /// continues only update the count. + PingSeen { ping: PingView, new_burst: bool }, /// The client fell behind and missed `missed` events on `stream`; it /// should refetch the corresponding table. Lagged { stream: &'static str, missed: u64 }, @@ -49,6 +54,13 @@ impl WireEvent { } } + fn from_ping(ev: Result) -> 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) -> Self { match ev { Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced { @@ -82,7 +94,10 @@ pub async fn stream( let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local); let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received); - let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| { + let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping); + + let routes = futures::stream::select(local, received); + let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| { Ok(Event::default() .event("bgp") .json_data(&ev) diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 61a127e..1cc3ac5 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -2,7 +2,7 @@ //! inspecting state, a server-sent event stream, and Prometheus metrics. //! //! Everything here is a thin layer over handles owned by the BGP server -//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives +//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives //! in this crate. mod api; @@ -19,13 +19,15 @@ use axum::http::header; use axum::response::{Html, IntoResponse, Redirect}; use axum::routing::{delete, get, post}; use bgp_rib::{AdjRibIn, LocalRib}; +use bgp_server::ping::PingMonitor; use bgp_server::session::SessionRegistry; use eyre::{Result, WrapErr}; use tokio::net::TcpListener; use tracing::info; pub use api::{ - AnnounceRequest, ConfigView, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView, + AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView, + ReceivedRouteView, StatusView, WellKnownCommunity, WireView, WithdrawRequest, }; @@ -84,6 +86,7 @@ pub struct AppState { pub registry: SessionRegistry, pub local_rib: LocalRib, pub adj_rib_in: AdjRibIn, + pub pings: PingMonitor, } /// The status page, embedded so the binary is self-contained. @@ -106,6 +109,7 @@ pub fn router(state: AppState) -> Router { .route("/api/routes/local/withdraw", post(api::withdraw)) .route("/api/routes/local/wire", get(api::wire)) .route("/api/routes/received", get(api::received_routes)) + .route("/api/pings", get(api::pings)) .route("/api/communities/well-known", get(api::well_known_communities)) .route("/api/events", get(events::stream)) .route("/metricz", get(metrics::metricz)) diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index 36511aa..d66dd97 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -61,7 +61,7 @@ Router ID ..., local AS ...
@@ -103,8 +103,16 @@ View: variables, status json, <
+ +

Received routes, Adj-RIB-In (...)

-
A yellow row carries a prefix that is also announced locally: it has travelled through other routers and come back.
@@ -148,7 +156,7 @@ View: variables, status json, < const $ = id => document.getElementById(id); const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); - const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() }; + const state = { status: null, peers: [], local: [], received: [], pings: null, wellKnown: {}, openWire: new Set(), openPing: new Set(), wire: new Map() }; const DASH = '-'; // ---- helpers ----------------------------------------------------------- @@ -238,6 +246,74 @@ View: variables, status json, < `; }).join('')); } + // Pings are shown per source and destination pair. The router answers + // them itself; this table is a copy of what arrived. + function renderPings() { + const p = state.pings; if (!p || p.status === 'disabled') { $('pings-section').hidden = true; return; } + $('pings-section').hidden = false; + if (p.status === 'failed') { + $('pings-count').textContent = 'off'; + $('pings-note').innerHTML = `The ping monitor could not start: ${esc(p.detail || 'unknown error')}`; + setHTML($('pings-body'), ''); return; + } + const more = p.total_flows > p.flows.length ? `, showing the ${p.flows.length} most recent` : ''; + $('pings-count').textContent = `${p.total_flows} flow${p.total_flows === 1 ? '' : 's'}${more}`; + $('pings-note').innerHTML = ''; + if (!p.flows.length) { setHTML($('pings-body'), ''); return; } + const me = myOwner(); + setHTML($('pings-body'), p.flows.map(f => { const key = `${f.src}|${f.dst}`, open = state.openPing.has(key); return ` + + + + + ` + + (open ? `` : ''); }).join('')); + } + // The last echo request of a flow, split into the fields of RFC 4443. It + // starts at the ICMPv6 header: the socket we listen on is never given the + // IPv6 header, whose interesting fields are the columns of the table. + function pingBytesHTML(f) { + const bytes = f.hex.match(/../g) || [], segs = []; + const take = (n, cls, kind) => { if (bytes.length) segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); }; + take(1, 'hh', 'icmp-type'); take(1, 'hh', 'icmp-code'); take(2, 'hl', 'icmp-checksum'); + take(2, 'ha', 'icmp-ident'); take(2, 'ha2', 'icmp-seq'); take(bytes.length, 'hn', 'icmp-payload'); + const kept = f.hex.length / 2; + return `Last ICMPv6 message from ${esc(f.src)}, ${f.bytes} bytes${kept < f.bytes ? ` (first ${kept} shown)` : ''}, IPv6 header not included   +  type, code  checksum  identifier, sequence  payload  + (hover a segment to decode it) +
${segs.map(x => `${x.hex}`).join(' ')}
`; + } + function decodePing(kind, b) { + switch (kind) { + case 'icmp-type': return { title: 'ICMPv6: type', rows: [['value', `${b[0]} = ${{128: 'Echo Request', 129: 'Echo Reply'}[b[0]] || 'other'}`], ['meaning', 'The router answers with type 129, Echo Reply, carrying the same identifier, sequence number and payload.']] }; + case 'icmp-code': return { title: 'ICMPv6: code', rows: [['value', `${b[0]}`], ['meaning', 'Always 0 for echo messages.']] }; + case 'icmp-checksum': return { title: 'ICMPv6: checksum', rows: [['value', `0x${u(b, 0, 2).toString(16).padStart(4, '0')}`], ['covers', 'This message and a pseudo-header holding the IPv6 source and destination addresses, so it differs between two otherwise equal pings to different addresses.']] }; + case 'icmp-ident': return { title: 'Echo: identifier', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Chosen by the sender so that several ping programs on one host can tell their replies apart.']] }; + case 'icmp-seq': return { title: 'Echo: sequence number', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Counts up with every request; a gap in the replies is a lost packet.']] }; + default: { + const rows = [['length', `${b.length} bytes shown`], ['as text', b.map(x => x >= 32 && x < 127 ? String.fromCharCode(x) : '.').join('')]]; + // ping puts the time of sending first so that the reply carries it + // back. Only claim that when the value is in fact close to now. + const near = secs => Math.abs(secs - now()) < 86400; + const le = b.length >= 16 && !b[4] && !b[5] && !b[6] && !b[7] ? b[0] + b[1] * 256 + b[2] * 65536 + b[3] * 16777216 : null; + const be = b.length >= 8 ? u(b, 0, 4) : null; + if (le != null && near(le)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(le * 1000).toLocaleTimeString()} (a 16-byte timeval, as Linux ping writes it)`]); + else if (be != null && near(be)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(be * 1000).toLocaleTimeString()} (an 8-byte timestamp, as BSD and macOS ping6 write it)`]); + rows.push(['meaning', 'Anything the sender likes; the reply must return it unchanged.']); + return { title: 'Echo: payload', rows }; + } + } + } + // A ping event carries the whole row, so the table follows the stream + // without asking the server again. + function pingSeen(f) { + const p = state.pings; if (!p || p.status !== 'listening') return; + const at = p.flows.findIndex(x => x.src === f.src && x.dst === f.dst); + if (at >= 0) p.flows.splice(at, 1); else p.total_flows++; + p.flows.unshift(f); p.flows.length = Math.min(p.flows.length, 100); + renderPings(); + } + // ---- wire view: split the UPDATE bytes into labelled fields ------------ const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'}; function segmentUpdate(hex) { @@ -348,7 +424,8 @@ View: variables, status json, < } function showTip(el) { const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16)); - const d = decodeSegment(el.dataset.kind, b, el.dataset.w4 === '1'), tip = $('tip'); + const kind = el.dataset.kind, tip = $('tip'); + const d = kind.startsWith('icmp-') ? decodePing(kind, b) : decodeSegment(kind, b, el.dataset.w4 === '1'); tip.innerHTML = `
${esc(d.title)}
prefixfrom peerAS pathnext hoporiginMEDcommunitieslarge communitiesage
${commList(s.communities, true)}${commList(s.large_communities)}${age(r.received_at_unix)}
No pings seen yet.
${esc(f.dst)}${f.prefix ? esc(f.prefix) : 'not announced'}${f.owner ? esc(f.owner) : DASH}${esc(f.src)}${f.interface ? esc(f.interface) : DASH}${f.hop_limit == null ? DASH : `${f.hop_limit} (${f.hops} hop${f.hops === 1 ? '' : 's'} away)`}${f.count}${f.seq}${fmtTime(f.first_seen_unix)}${age(f.last_seen_unix)} ago${open ? 'hide bytes' : 'bytes'}
${pingBytesHTML(f)}
${d.rows.map(([k, v]) => ``).join('')}
${esc(k)}${esc(v)}
`; tip.hidden = false; const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1]; @@ -395,7 +472,8 @@ View: variables, status json, < async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); } async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); } async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); } - async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); } + async function loadPings() { state.pings = await getJSON('/api/pings'); renderPings(); } + async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadPings()]); } // ---- events ------------------------------------------------------------ function logEvent(html) { @@ -415,6 +493,8 @@ View: variables, status json, < case 'local_withdrawn': return `withdrew ${esc(ev.prefix)}`; case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`; case 'received_withdrawn': return `peer AS${ev.peer_asn} withdrew ${esc(ev.prefix)}`; + case 'ping_seen': { const f = ev.ping; return `ping to ${esc(f.dst)}${f.owner ? ` (${esc(f.owner)})` : ''} from ${esc(f.src)}` + + (f.interface ? ` via ${esc(f.interface)}` : '') + (f.hop_limit != null ? `, hop limit ${f.hop_limit}` : ''); } case 'lagged': return `missed ${ev.missed} events on ${esc(ev.stream)}; reloading`; default: return esc(JSON.stringify(ev)); } @@ -426,7 +506,14 @@ View: variables, status json, < es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; }; // Events only feed the log. How often the tables reload is decided by // ?refresh=, so the page never changes faster than the reader asked for. - es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data)))); + // Pings are the exception: the reader causes them from a terminal, not + // from this page, so their table follows the stream. A steady ping is + // logged once, when it starts. + es.addEventListener('bgp', e => { + const ev = JSON.parse(e.data); + if (ev.type === 'ping_seen') { pingSeen(ev.ping); if (!ev.new_burst) return; } + logEvent(describe(ev)); + }); } // ---- auto-refresh, opt-in through ?refresh= -------------------- @@ -483,7 +570,14 @@ View: variables, status json, < } appendTo('f-large', v); }); - $('f-owner').addEventListener('input', renderLocal); + $('f-owner').addEventListener('input', () => { renderLocal(); renderPings(); }); + $('pings-body').addEventListener('click', e => { + const link = e.target.closest('a[data-act="ping-bytes"]'); if (!link) return; + e.preventDefault(); + const key = link.dataset.key; + state.openPing.has(key) ? state.openPing.delete(key) : state.openPing.add(key); + renderPings(); + }); $('local-body').addEventListener('click', async e => { const link = e.target.closest('a[data-act]'); if (!link) return; e.preventDefault(); diff --git a/docs/lab/prod/on-router/README.md b/docs/lab/prod/on-router/README.md index 5bd12bf..1f93569 100644 --- a/docs/lab/prod/on-router/README.md +++ b/docs/lab/prod/on-router/README.md @@ -41,6 +41,20 @@ Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then journalctl -u ferrix-bgp -f birdc show protocols all ferrix +The journal should say `Ping monitor listening for ICMPv6 echo requests`. If +it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything +else works without it and the status page says why the table is off. + +To check the monitor before the class, announce a prefix on the page, turn on +auto-refresh and ping an address inside it from somewhere else: + + ping -c 3 2a0d:d741:1::1 + +The ping shows up under "Pings to announced addresses" with its source, the +interface it arrived on and the hop limit it arrived with. Check that +"arrived on" names the real ingress port (the exchange or transit interface) +and not `lo`. + ## 6. Let students in The API has no authentication and announces into a real AS, so do not expose @@ -148,6 +162,14 @@ than 130 lab routes at once are blocked there, by design. ## Things to know +- The ping monitor only listens. The kernel answers the pings, with or + without ferrix running, because of the AnyIP route. It sees echo requests + to the lab space from anyone on the Internet, so source addresses of + strangers will appear on the page next to the students' own. It remembers + at most 2048 source and destination pairs and publishes at most 50 events a + second, so a ping flood costs little; `ping_monitor_echo_requests_total` and + `ping_monitor_events_suppressed_total` on `/metricz` show what it saw. + - Announced routes live in memory. A restart of the service forgets them and students have to announce again. - Stopping the service sends BIRD a Cease first, so restarts reconnect at diff --git a/docs/lab/prod/on-router/ferrix-bgp.service b/docs/lab/prod/on-router/ferrix-bgp.service index 89e27a2..748e851 100644 --- a/docs/lab/prod/on-router/ferrix-bgp.service +++ b/docs/lab/prod/on-router/ferrix-bgp.service @@ -12,7 +12,9 @@ Restart=on-failure RestartSec=5 # It needs the network and nothing else: no root, no privileged port, no -# write access anywhere. +# write access anywhere. The one capability, CAP_NET_RAW, is for the ping +# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not +# configured. DynamicUser=yes NoNewPrivileges=yes ProtectSystem=strict @@ -27,7 +29,8 @@ RestrictNamespaces=yes LockPersonality=yes MemoryDenyWriteExecute=yes SystemCallArchitectures=native -CapabilityBoundingSet= +AmbientCapabilities=CAP_NET_RAW +CapabilityBoundingSet=CAP_NET_RAW MemoryMax=256M [Install] diff --git a/docs/lab/prod/on-router/ferrix.textpb b/docs/lab/prod/on-router/ferrix.textpb index bb2cdd9..acecdcd 100644 --- a/docs/lab/prod/on-router/ferrix.textpb +++ b/docs/lab/prod/on-router/ferrix.textpb @@ -25,3 +25,8 @@ local_route_policy { # Loopback only. Students reach it through the reverse proxy, which adds TLS # and a password; the API itself has neither. http_endpoint { address: "127.0.0.1" port: 8080 } + +# Show who pings the lab addresses. The kernel answers the pings (AnyIP route +# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above. +# Needs CAP_NET_RAW, which the systemd unit grants. +ping_monitor {} diff --git a/slides/README.md b/slides/README.md index 1198b31..3479ba8 100644 --- a/slides/README.md +++ b/slides/README.md @@ -13,16 +13,16 @@ chain of nodes and links, an AS path. | Section | File | |---|---| -| IP addresses and prefixes (IPv6) | `sections/01-addresses.md` | -| Where AS numbers and prefixes come from (IANA, RIPE) | `sections/02-registries.md` | +| About the speaker | `sections/00-intro.md` | +| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` | | BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` | | IP transit and Internet exchanges | `sections/04-transit-and-ix.md` | -| The lab: ferrix, pr01.rue and its exchanges | `sections/05-lab.md` | +| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` | The UPDATE in section 3 is `2a0d:d740::/48` as AS210036 announces it to the SwissIX route servers: next hop `2001:7f8:24::c9` (pr01's port on the peering LAN), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts -on routes to peers. It was produced with ferrix's own encoder and parsed back +on routes to peers. It was produced with the lab daemon's own encoder and parsed back to the same message, not captured off the wire; a capture from pr01 may differ in detail (BIRD can append a link-local next hop). The byte slides step through it one part at a time (press down), and every number on them was checked @@ -55,7 +55,11 @@ double-clicking `index.html`. Opened from disk, browsers block reveal.js and the section files, and you would see a title and empty panels; the page now says so instead: - python3 -m http.server 8000 --directory slides + python3 slides/serve.py 8000 + +(`python3 -m http.server` works too, but it lets the browser cache the +section files and the stylesheet, so edits may not show until a hard reload, +Cmd+Shift+R. `serve.py` is the same server with caching turned off.) Then . Keys: arrows to move, `S` speaker view with notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen. diff --git a/slides/dns-lookup.js b/slides/dns-lookup.js new file mode 100644 index 0000000..d040890 --- /dev/null +++ b/slides/dns-lookup.js @@ -0,0 +1,105 @@ +// Live lookups on slides. +// +//
with 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. +// with and a .dns-result: +// asks the RIPE database (https://apps.db.ripe.net/docs/) what it knows +// about an address, prefix or AS number, and prints the objects the way +// whois would. +(function () { + const TYPES = { 1: "A", 5: "CNAME", 28: "AAAA" }; + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + + async function resolve(name, type) { + const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, { + headers: { accept: "application/dns-json" }, + }); + if (!r.ok) throw new Error(`dns.google answered HTTP ${r.status}`); + return r.json(); + } + + const line = (rr) => + `${esc(rr.name)} ${rr.TTL} IN ${esc(TYPES[rr.type] || rr.type)} ${esc(rr.data)}`; + + async function lookup(form) { + const out = form.querySelector(".dns-result"); + const name = form.elements.domain.value.trim().replace(/\.$/, ""); + if (!name) return; + out.innerHTML = `asking dns.google for ${esc(name)} AAAA ...`; + try { + const v6 = await resolve(name, "AAAA"); + if (v6.Status === 3) { + out.innerHTML = `NXDOMAIN there is no such name`; + return; + } + if (v6.Status !== 0) { + out.innerHTML = `the resolver answered with status ${v6.Status}`; + 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(`no AAAA record: this name has no IPv6 address`); + (v4.Answer || []).filter((rr) => rr.type === 1).forEach((rr) => lines.push(line(rr))); + } + out.innerHTML = lines.join("\n"); + } catch (e) { + out.innerHTML = `lookup failed: ${esc(e.message)} (is there a network?)`; + } + } + + // ---- 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 = `
Searching the RIPE database for ${esc(query)} ...
`; + 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 = `
No results found for ${esc(query)}.
`; + 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 ? `${esc(a.value)}` : esc(a.value); + return `${esc(a.name)}:${value}${a.comment ? `# ${esc(a.comment)}` : ""}`; + }); + return `
${rows.join("")}
`; + }) + .join(""); + out.scrollTop = 0; + } catch (e) { + out.innerHTML = `
Lookup failed: ${esc(e.message)}. Is there a network?
`; + } + } + + // 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(); + }); +})(); diff --git a/slides/index.html b/slides/index.html index 24f67bd..f0029d7 100644 --- a/slides/index.html +++ b/slides/index.html @@ -1,10 +1,10 @@ - - - - Hands-on BGP - - - - - - - -
-
- -
-

Hands-on BGP

-

Announce your own prefix to the Internet

- -
+
+

Hands-on BGP

+

Insights into real world operations

+ +
- -
-
-
-
-
-
-
- -
ferrix_bgp · netsec 2026
+
+
+
+
+
+ + + +
AS210036 · netsec 2026
- - - + - + - // 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; - }; + - + // 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")); + + diff --git a/slides/prefix-explorer.js b/slides/prefix-explorer.js new file mode 100644 index 0000000..86eb74d --- /dev/null +++ b/slides/prefix-explorer.js @@ -0,0 +1,123 @@ +// A live prefix calculator on a slide. Any +// with and gets its +// .px-bits, .px-range and .px-facts filled in as the reader types. +(function () { + const ALL = (1n << 128n) - 1n; + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + + // "2001:db8::1" -> BigInt, or null. + function parse(text) { + const s = text.trim().toLowerCase(); + if (!/^[0-9a-f:]+$/.test(s) || s.split("::").length > 2) return null; + const [left, right = ""] = s.split("::"); + const l = left ? left.split(":") : [], r = right ? right.split(":") : []; + if (s.includes("::") ? l.length + r.length > 7 : l.length !== 8) return null; + const groups = [...l, ...Array(8 - l.length - r.length).fill("0"), ...r]; + let v = 0n; + for (const g of groups) { + if (!/^[0-9a-f]{1,4}$/.test(g)) return null; + v = (v << 16n) | BigInt(parseInt(g, 16)); + } + return v; + } + + function groupsOf(v) { + const g = []; + for (let i = 7; i >= 0; i--) g.push(Number((v >> BigInt(i * 16)) & 0xffffn)); + return g; + } + + // Shortest form, RFC 5952 style. + function format(v) { + const g = groupsOf(v).map((x) => x.toString(16)); + let best = -1, bestLen = 0; + for (let i = 0; i < 8; i++) { + let n = 0; + while (i + n < 8 && g[i + n] === "0") n++; + if (n > bestLen && n > 1) { best = i; bestLen = n; } + } + if (best < 0) return g.join(":"); + return g.slice(0, best).join(":") + "::" + g.slice(best + bestLen).join(":"); + } + + const sup = (k) => String(k).replace(/\d/g, (d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]); + // 2^k as a number people can read: exact up to 2^50, else a power. + const pow2 = (k) => (k <= 50 ? Number(1n << BigInt(k)).toLocaleString("en") : `2${sup(k)}`); + + function render(form) { + const input = form.elements.prefix, slider = form.elements.len; + const bits = form.querySelector(".px-bits"), range = form.querySelector(".px-range"), facts = form.querySelector(".px-facts"); + let [addrText, lenText] = input.value.split("/"); + let len = lenText === undefined ? Number(slider.value) : parseInt(lenText, 10); + const addr = parse(addrText || ""); + if (addr === null || !(len >= 0 && len <= 128)) { + bits.innerHTML = 'not an IPv6 prefix: write address/length, e.g. 2001:db8::/32'; + 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 = `${withColons(hex.slice(0, fullNibbles), 0)}`; + 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 += `${nib.slice(0, partBits)}${"x".repeat(4 - partBits)}`; + rest++; + } + html += `${withColons("x".repeat(32 - rest), rest)}`; + 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(`
${esc(format(enclosing))}/${pl} + + 1 of ${pow2(len - pl)}
`); + } + range.innerHTML = ` +
${levels.length ? `
every shorter prefix contains it; three of them:
${levels.join("")}` : '
nothing is shorter than /0: this is every address there is
'}
+
${esc(format(first))}${pow2(host)} addresses${esc(format(last))}
`; + + const parts = [`The first ${len} bits are fixed; the other ${host} are for use within the network.`]; + if (len < 64) parts.push(`That is ${pow2(64 - len)} /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); +})(); diff --git a/slides/sections/00-intro.md b/slides/sections/00-intro.md new file mode 100644 index 0000000..56522ba --- /dev/null +++ b/slides/sections/00-intro.md @@ -0,0 +1,15 @@ +## About the speaker + +**Rayhaan Jaufeerally** + +- Software engineer at a hyperscaler, working on security and reliability. +- Studied at ETH from 2016 to 2019, and took the network security course. +- Has been running **AS210036** since 2018, for personal use and research + +

Today we'll get to use some connectivity through this network.

+ +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. diff --git a/slides/sections/01-addresses.md b/slides/sections/01-addresses.md deleted file mode 100644 index a8f75f8..0000000 --- a/slides/sections/01-addresses.md +++ /dev/null @@ -1,92 +0,0 @@ -# IP addresses and prefixes - -Notes: -Section 1. IPv6 only: the whole lab is IPv6, and the ideas carry over to IPv4 -unchanged (32 bits, dotted decimal, /24 where we say /48). - ------ - -## An IPv6 address is 128 bits - -```text -2a0d:d741:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex -2a0d:d741:1:0:0:0:0:1 drop leading zeros in a group -2a0d:d741:1::1 one run of zero groups becomes :: -``` - -`::` may appear once, or the address would be ambiguous. - - - -Notes: -Ask: why only once? Because 2a0d::1::1 could put the zeros in several places. - ------ - -## A prefix is a range of addresses - -`2a0d:d741:1::/48` - -

2a0d:d741:0001xxxx:xxxx:xxxx:xxxx:xxxx

- -The first **48** bits are fixed. The other 80 are yours. - -Notes: -2^80 addresses in one student prefix. About 1.2 million billion billion. -The /n is a count of leading bits, nothing more. - ------ - -## Prefixes nest - -| Prefix | Who | Holds | -|---|---|---| -| `2a0d:d740::/29` | the network we borrow from | 524,288 /48s | -| `2a0d:d741::/40` | this lab | 256 /48s | -| `2a0d:d741:::/48` | **you** | 65,536 /64s | -| `…/64` | one LAN | the hosts on it | - -`` is your student number, written *in hex*. - - - -Notes: -Student 10 is 2a0d:d741:a::/48, student 16 is 2a0d:d741:10::/48. -A /40 holds exactly 256 /48s, which caps the class size at 255. - ------ - -## The most specific route wins - -```text [3] -2a0d:d740::/29 via AS210036 the aggregate -2a0d:d741::/40 via AS210036 the lab -2a0d:d741:1::/48 via AS210036 AS… student 1 <-- chosen for 2a0d:d741:1::1 -``` - -Routers forward on the *longest matching prefix*, before anything else is compared. - - - -Notes: -This is the single most important forwarding rule for today. It is also why a -hijacker announces a more specific prefix: it wins everywhere it is accepted, -no matter how good the legitimate route is. - ------ - -## How small can a prefix be? - -
-
IPv6: up to /48
-
IPv4: up to /24
-
anything longer: filtered by almost everyone
-
- -Not a protocol rule. A convention, so the global table stays finite. - - - -Notes: -Which is why the lab hands out /48s: the smallest thing the Internet at large -will carry. The global IPv6 table is around 230k routes, IPv4 around 1M. diff --git a/slides/sections/01-resources.md b/slides/sections/01-resources.md new file mode 100644 index 0000000..7df4423 --- /dev/null +++ b/slides/sections/01-resources.md @@ -0,0 +1,164 @@ +# Internet Numbering Resources + +Notes: +Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed. + +--- + +## Packets are sent to IP addresses + + + +
Ask a resolver which IPv6 address this name stands for.
+ + +Your computer cannot send a packet to a name. Every time you use a domain name, it is resolved into an IP address. + + + +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? + +
+ +
Paste the address from the last slide.
+
+ +The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it. + + + +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. + + + +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 + +
+ +

+
+

+
+ +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 + + + +::/0every IPv6 address there is: 2¹²⁸ of them + +2000::/3global unicast: the eighth of the space that is routed on the Internet + +2001:db8::/32one provider's allocation (this one is reserved for documentation) + +2001:db8:1:2::/64one LAN: 2⁶⁴ addresses for its hosts +::1 +::2 +::3 +… +::ffff:ffff:ffff:ffff +not to scale + + +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 + + +IANA +the root: one pool for the whole world + +a /12 to each region +AFRINIC +APNIC +ARIN +LACNIC +RIPE NCC +RIRs: five regional registries, one per part of the world + +/29 and up to a member +SWITCH +an ISP +LIRs: members of an RIR, usually providers + +a /48 to a site +ETH Zürich +its customers +end users: companies, universities, you + + +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. + + + +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. diff --git a/slides/sections/02-registries.md b/slides/sections/02-registries.md deleted file mode 100644 index 6b72787..0000000 --- a/slides/sections/02-registries.md +++ /dev/null @@ -1,89 +0,0 @@ -# Where numbers come from - -Notes: -Section 2. Nobody "owns" addresses or AS numbers; they are registered to you. - ------ - -## A hierarchy of registries - -
-
IANA
the root
-
RIPE NCC
Europe, Middle East
-
ARIN
-
APNIC
-
LACNIC
-
AFRINIC
-
- -IANA hands large blocks to five regional registries. They hand smaller ones to members (LIRs), who assign to end users. - -Notes: -RIR = Regional Internet Registry. LIR = Local Internet Registry, a member of -an RIR, typically an ISP. Our space comes from RIPE NCC via an LIR. - ------ - -## AS numbers - -* 16 bits at first: `1` to `65535` -* 32 bits since RFC 6793: up to `4294967295` -* Old routers see `AS23456` (AS_TRANS) in place of a big one -* Private use: `64512–65534` and `4200000000–4294967294` - -The lab daemon is `AS4200000001`. It is stripped before the Internet sees your route. - - - -Notes: -RFC 6996 reserves the private ranges. The upstream router removes the private -AS from the path, so the origin everybody else sees is AS210036. - ------ - -## The RIPE database says who may announce what - -```text [3] -route6: 2a0d:d741:1::/48 -descr: ETHZ Netsec 2026 BGP lab - student 1 -origin: AS210036 -mnt-by: mnt-ch-ray-1 -source: RIPE -``` - -One of these exists for every student prefix. Networks build their filters from them. - - - -Notes: -This is a real object; try the whois on the next slide. `mnt-by` names the -maintainer: whoever holds its credentials can change the object. An `inet6num` -object records the address block itself, a `route6` object who may originate it. - ------ - -## Two ways to say "this origin is allowed" - -| | IRR (`route6`) | RPKI (ROA) | -|---|---|---| -| What it is | an entry in a database | a signed certificate | -| Who can create it | whoever holds the maintainer | only the holder of the addresses | -| Says | prefix + origin AS | prefix + origin AS + **max length** | -| Used for | building prefix filters | dropping "invalid" routes | - -Notes: -Max length matters for us: a ROA for the /40 with max length 40 would make -every student /48 RPKI-invalid, and many networks would drop it. - ------ - -## Look it up yourself - -```bash -whois -h whois.ripe.net 2a0d:d741:1::/48 -whois -h whois.ripe.net AS210036 -``` - -Or in a browser: RIPEstat, bgp.tools, or a looking glass at an exchange. - - diff --git a/slides/sections/03-bgp-update.md b/slides/sections/03-bgp-update.md index cb592f7..49d06fa 100644 --- a/slides/sections/03-bgp-update.md +++ b/slides/sections/03-bgp-update.md @@ -3,53 +3,175 @@ Notes: Section 3. The aim: by the end, the bytes view on the lab page reads like text. ------ +--- ## BGP in one slide -* Runs over TCP, port `179`, between neighbours you configure by hand -* Each side tells the other its best route per prefix -* Only *changes* are sent: no periodic refresh -* If the session drops, every route learned over it is gone +- Runs over TCP, port `179`, and is exchanged typically between peering routers +- Each side tells the other its best route per prefix +- Only _changes_ are sent: no periodic refresh +- If the session drops, after a timeout the routes learned over that session are dropped Notes: -No discovery, no authentication of content, no notion of truth. A neighbour -says "I can reach X" and you believe it, subject to your filters. +BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants. +Security and extensions have had to be bolted on, in perhaps strange ways. ------ +--- -## Four kinds of message +## The four message types -| Type | Name | Says | -|---|---|---| -| 1 | OPEN | who I am: AS number, router ID, capabilities | -| 2 | **UPDATE** | these prefixes are reachable, those no longer are | -| 3 | NOTIFICATION | something is wrong, I am closing the session | -| 4 | KEEPALIVE | still here | +| Type | Name | Says | +| ---- | ------------ | ------------------------------------------------- | +| 1 | OPEN | who I am: AS number, router ID, capabilities | +| 2 | **UPDATE** | these prefixes are reachable, those no longer are | +| 3 | NOTIFICATION | something is wrong, I am closing the session | +| 4 | KEEPALIVE | still here | OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then it is all UPDATEs. Notes: -There is a fifth, ROUTE-REFRESH: "send me everything again, my policy changed". +There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed". ------ +--- + +## OPEN: a handshake between peers. + + +header, the same on every message: 19 bytes + +marker +16 bytes, all ff + +length +2 bytes + +type +1 byte +OPEN body: who I am + +version +1 byte: 4 + +my AS +2 bytes, or 23456 + +hold time +2 bytes, seconds + +BGP identifier +4 bytes, the router ID + +opt. length +1 byte + + +optional parameters: today always one, of type 2 = capabilities, each a (code, length, value) + +type 2 +capabilities + +length +1 byte + +IPv6 unicast +code 1: AFI 2, SAFI 1 + +4-byte AS +code 65: 210036 + +route refresh +code 2, empty + +… +more capabilities + + +Notes: +Sent once, by each side, as soon as the TCP connection is up. The two sides +must agree: the AS must be the one configured for the neighbour, the hold +time becomes the smaller of the two, and only capabilities both announced are +used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the +real number rides in a capability, which is how our AS210036 arrives. + +--- + +## An OPEN message example + +
headerwho I amcapabilities
+ +
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 37 01 04 5b a0 00 f0 0a ff 00 01 1a 02 18 01 04 00 02 00 01 02 00 06 00 40 02 00 00 41 04 00 03 34 74 46 00 47 00
+ +
04 · 5b a0 · 00 f0 · 0a ff 00 01 · 1aversion 4; AS 23456 (AS_TRANS, see below); hold time 240 s; router ID 10.255.0.1; 26 bytes of parameters
02 18parameter type 2, capabilities, 24 bytes
01 04 00 02 00 01multiprotocol: AFI 2 (IPv6), SAFI 1 (unicast). Without this, no IPv6 routes
41 04 00 03 34 744-byte AS: the real number, 210036
02 00 · 06 00 · 40 02 00 00 · 46 00 · 47 00route refresh, extended message, graceful restart, enhanced refresh, long-lived GR
+ +Notes: +What BIRD on our upstream router sends the lab daemon, byte for byte. Hold +time 240 is BIRD's default; keepalives go every third of it. + +--- ## The shape of an UPDATE -| Bytes | Field | -|---|---| -| 19 | header: marker, length, type | -| 2 + n | withdrawn routes: prefixes that are no longer reachable | -| 2 + n | path attributes: everything *about* the route | -| the rest | NLRI: the IPv4 prefixes being announced | + +header: 19 bytes + +marker +16 bytes, all ff + +length +2 bytes + +type +1 byte +UPDATE body: two lengths, three variable parts + +withdrawn length +2 bytes + +withdrawn routes +IPv4 prefixes gone + +attributes length +2 bytes + +path attributes +everything about the route + +NLRI +IPv4 prefixes, new + + +one path attribute; repeated for multiple + +flags +1 byte + +type +1 byte: ORIGIN = 1, AS_PATH = 2 … + +length +1 or 2 bytes + +value +as many bytes as it said + +one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs + +length +1 byte: 24 for a /24 + +prefix +3 bytes for a /24, 6 for a /48 … + +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. - - ------ +--- ## A real one: 89 bytes @@ -73,7 +195,7 @@ empty. Hold that thought.
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 59 02 00 00 00 42 40 01 01 00 40 02 06 02 01 00 03 34 74 80 04 04 00 00 00 00 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 c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02
-
ff × 16marker. Once carried authentication, now just filler to find message boundaries
00 59length of the whole message: 89 bytes (maximum 4096)
02type 2 = UPDATE
+
ff × 16Start of message marker.
00 59length of the whole message: 89 bytes (maximum 4096)
02type 2 = UPDATE
--- @@ -91,7 +213,7 @@ empty. Hold that thought.
40flags: well-known, transitive
01type 1 = ORIGIN
011 byte of value
00IGP: the route was originated inside this AS
-Every attribute is *flags, type, length, value*. +Every attribute is _flags, type, length, value_. @@ -103,7 +225,7 @@ Every attribute is *flags, type, length, value*.
40 02 06well-known, transitive; type 2; 6 bytes
02an AS_SEQUENCE, order matters
01of 1 AS number
00 03 34 74210036, the origin. Four bytes each (RFC 6793)
-Each network prepends itself on the way out. The length of this list is what best-path selection compares. +Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path. @@ -119,13 +241,13 @@ does not add itself. Seen from further away it reads e.g. 6939 210036.
80 04 04optional, non-transitive; type 4; 4 bytes
00 00 00 00MED 0. Lower is preferred, among routes from the same neighbour AS
-A hint to a neighbour about which of *your* entrances to use. Non-transitive: it goes one hop and no further. +A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further. --- -## MP_REACH_NLRI: where IPv6 lives +## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 00 59 02 00 00 00 42 40 01 01 00 40 02 06 02 01 00 03 34 74 80 04 04 00 00 00 00 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 c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02
@@ -152,17 +274,17 @@ Three 32-bit numbers. They mean whatever the network named in the first one says ------ +--- ## The flags byte -| Bit | Set means | Clear means | -|---|---|---| -| `0x80` | optional | well-known: every BGP speaker must understand it | -| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop | -| `0x20` | partial: someone on the way did not understand it | | -| `0x10` | length field is 2 bytes | 1 byte | +| Bit | Set means | Clear means | +| ------ | ------------------------------------------------- | ------------------------------------------------ | +| `0x80` | optional | well-known: every BGP speaker must understand it | +| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop | +| `0x20` | partial: someone on the way did not understand it | | +| `0x10` | length field is 2 bytes | 1 byte | -Transitive is why a community you set can act three networks away. +Path attribute transitivity allows adding instructions for networks far away from direct neighbours. diff --git a/slides/sections/04-transit-and-ix.md b/slides/sections/04-transit-and-ix.md index 1500ce5..2d2df06 100644 --- a/slides/sections/04-transit-and-ix.md +++ b/slides/sections/04-transit-and-ix.md @@ -4,7 +4,7 @@ Notes: Section 4. Two very different ways of getting routes, and what an Internet exchange physically is. ------ +--- ## Two ways to reach other networks @@ -13,18 +13,18 @@ exchange physically is. ### IP transit -* You **pay** a provider -* They give you routes to *everyone* -* They announce you to *everyone* +- You **pay** a provider +- They give you routes to _everyone_ +- They announce you to _everyone_
### Peering -* Usually **free**, by mutual benefit -* You exchange *your own* routes, and your customers' -* Traffic between the two of you only +- Usually **free**, by mutual benefit +- You exchange _your own_ routes, and your customers' +- Traffic between the two of you only
@@ -34,7 +34,7 @@ Transit is a customer-provider relationship; peering is between equals. A peer does not carry your traffic to third parties. Most networks have both: transit as the fallback, peering to keep traffic cheap and close. ------ +--- ## An exchange is a big Ethernet switch @@ -45,8 +45,8 @@ as the fallback, peering to keep traffic cheap and close. AS210036::c9 AS559 SWITCH::35 -AS6939 HE::aa -AS13335Cloudflare +AS2906 Netflix::55 +AS13335 Cloudflare::c0 route serversAS42476 ::a5ec:a:1 @@ -64,52 +64,32 @@ address in a shared subnet. No routing inside the exchange: just Ethernet. Dashed blue is a BGP session, green is where the packets actually go. a5ec is 42476 in hex: the route server's address spells its AS number. ------ +--- ## What that implies -* Your neighbour is *on your LAN*: one hop, found by neighbour discovery -* A route's next hop is the peer's LAN address, so traffic goes **directly** member to member -* The exchange never routes your packets. It switches frames -* So the LAN is policed hard: one MAC address per port, no stray broadcasts, no proxy ARP +- Your neighbour is _on your LAN_: one hop, found by neighbour discovery +- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member +- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost +- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets) Notes: -A misbehaving port can hurt every member, which is why exchanges publish strict -rules for what may appear on the peering LAN and quarantine ports that break them. ------ +- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers +- after which traffic can flow directly + +--- ## Route servers Without one, peering with 200 members is 200 BGP sessions. -* One session to the route server brings routes from everyone who uses it -* It is **not** in the traffic path, and leaves the next hop alone -* It does not add its own AS to the path -* You steer it with communities: `42476:0:` "not to them", `42476:1:` "only to them" +- One session to the route server brings routes from everyone who uses it +- It is **not** in the traffic path, and leaves the next hop alone +- It does not add its own AS to the path +- You steer it with communities: `42476:0:` "not to them", `42476:1:` "only to them" Notes: A route server is a BGP speaker that only redistributes. Because it is transparent, the AS path looks exactly as if you peered with each member directly. We will use those communities in the lab. - ------ - -## Our upstream router, for real - -| Where | BGP sessions | Neighbour networks | -|---|---|---| -| Transit (CommunityRack) | 2 | 1 | -| SwissIX, Zürich | 28 | 12 | -| DE-CIX, Frankfurt | 26 | 8 | -| France-IX, Paris | 23 | 10 | -| NL-ix, Amsterdam | 22 | 6 | - -One small router, `pr01.rue`, AS210036. IPv4 and IPv6 are separate sessions. - - - -Notes: -Counted from its BIRD configuration. It is also on two smaller Swiss exchanges. -The same network (Cloudflare, Apple, Hurricane Electric) often appears at -several exchanges, which matters when you choose where to announce. diff --git a/slides/sections/05-lab.md b/slides/sections/05-lab.md index 59a8430..96ea824 100644 --- a/slides/sections/05-lab.md +++ b/slides/sections/05-lab.md @@ -1,16 +1,18 @@ # The lab +### bgplab.rayhaan.net + Notes: Section 5. What the students are about to touch, and what stands between a typo and the Internet. ------ +--- ## What you are connected to - + youa browser -ferrixAS4200000001 +lab speakerAS4200000001 pr01.rue (BIRD)AS210036 HTTPS BGP @@ -26,11 +28,11 @@ typo and the Internet. Notes: -ferrix is a small BGP daemon written for this course; it runs on the same -machine as the router. Both dashed hops are real BGP sessions. Everything to +The lab speaker is a small BGP daemon written for this course; it runs on +the same machine as the router. Both dashed hops are real BGP sessions. Everything to the right of pr01 is the actual Internet. ------ +--- ## Your prefix @@ -44,11 +46,11 @@ Notes: Put the URL and the class password on the board. The page is a snapshot by default; turn on auto-refresh at the top right to watch things change. ------ +--- ## What happens when you press *Announce* -1. ferrix builds an UPDATE. Click *bytes* to see it, hover to decode it +1. The lab speaker builds an UPDATE. Click *bytes* to see it, hover to decode it 2. pr01 checks it against its import filter 3. pr01 decides, per neighbour, whether to pass it on 4. Within a minute or so, the Internet can see your prefix @@ -58,7 +60,7 @@ Notes: Step 5: pr01 answers for every address in the lab space, so reachability of your prefix depends only on whether your announcement got out. ------ +--- ## Steering your route @@ -79,7 +81,7 @@ Notes: same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always wins. The preset menu next to the field has all of these. ------ +--- ## Your turn diff --git a/slides/serve.py b/slides/serve.py new file mode 100644 index 0000000..f19ea63 --- /dev/null +++ b/slides/serve.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Serve the deck for editing: like `python3 -m http.server`, but every +response says Cache-Control: no-store, so a plain reload always shows the +files as they are on disk. (The stock server sends Last-Modified and nothing +else, and browsers then keep the section files and the CSS for a while.) + + python3 slides/serve.py # http://localhost:8765 + python3 slides/serve.py 8000 +""" +import os +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +class NoCacheHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def log_message(self, fmt, *args): + pass # quiet; errors still go through log_error + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 + os.chdir(os.path.dirname(os.path.abspath(__file__))) + print(f"serving {os.getcwd()} on http://localhost:{port}/") + ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever() diff --git a/slides/style.css b/slides/style.css index 8a01d05..062425a 100644 --- a/slides/style.css +++ b/slides/style.css @@ -157,6 +157,138 @@ margin: 0 0.2em; } +/* ---- live DNS and RIPE database lookups ------------------------------------ */ + +.reveal form.dns-lookup, +.reveal form.ripe-lookup { + margin: 0.4em auto 0; + max-width: 34em; + text-align: left; + font-family: var(--r-code-font); +} + +.reveal .dns-lookup input, +.reveal .ripe-lookup input { + width: 62%; + padding: 0.3em 0.5em; + font: inherit; + font-size: 0.8em; + color: var(--r-main-color); + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 6px; + outline: none; +} + +.reveal .dns-lookup input:focus, +.reveal .ripe-lookup input:focus { + border-color: var(--accent); +} + +.reveal .dns-lookup button, +.reveal .ripe-lookup button { + margin-left: 0.4em; + padding: 0.3em 0.9em; + font: inherit; + font-size: 0.8em; + color: var(--ground); + background: var(--accent); + border: 0; + border-radius: 6px; + cursor: pointer; +} + +.reveal .dns-result { + min-height: 3.2em; + max-height: 13em; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--rule) transparent; + margin-top: 0.5em; + padding: 0.5em 0.7em; + font-size: 0.7em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 6px; +} + +.reveal .dns-result .ttl, +.reveal .dns-result .type, +.reveal .dns-result .dim { color: var(--muted); } +.reveal .dns-result .addr { color: var(--accent); font-weight: 500; } +.reveal .dns-result .bad { color: #ff7a7a; } + +/* RIPE database objects, laid out like apps.db.ripe.net: a light page with + one card per object, one row per attribute, references in blue, rows the + RIPE NCC manages shaded. */ +.reveal .ripe-result { + /* A fixed height, not a maximum: the box must not grow when the answer + arrives, or the slide overflows (reveal centres a slide only when it + is shown). */ + height: 13.5em; + margin-top: 0.5em; + padding: 0.5em; + overflow-y: auto; + scrollbar-width: thin; + font-size: 0.62em; + text-align: left; + background: #f4f5f7; + border-radius: 6px; +} + +.reveal .ripe-card { + margin-bottom: 0.7em; + padding: 0.2em 0.5em; + background: #fff; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); +} + +.reveal .ripe-card:last-child { margin-bottom: 0; } + +.reveal .ripe-card table { + width: 100%; + margin: 0; + border-collapse: collapse; + font-family: var(--r-code-font); + font-size: inherit; + color: #1c1c28; +} + +.reveal .ripe-card th, +.reveal .ripe-card td { + padding: 0.28em 0.6em; + border: 0; + font-weight: normal; + text-align: left; + vertical-align: top; + background: transparent; + line-height: 1.4; +} + +.reveal .ripe-card table th { + width: 8.5em; + color: #1c1c28; + white-space: nowrap; + text-decoration: underline dotted #9a9aa6; + text-underline-offset: 0.2em; +} + +.reveal .ripe-card td { word-break: break-all; } +.reveal .ripe-card tr.managed th, +.reveal .ripe-card tr.managed td { background: #dde5f0; } +.reveal .ripe-card .ref { color: #2f6fe4; } +.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; } + +.reveal .ripe-msg { + padding: 0.6em; + font-family: var(--r-code-font); + color: #1c1c28; +} + /* A framed live page on a slide. */ .reveal iframe.live { border: 1px solid var(--rule); @@ -174,6 +306,18 @@ box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9); } +/* Packet layouts: field boxes in the colours of the bytes view. */ +.diagram .pkt rect { stroke: none; } +.diagram .pkt.hh rect { fill: #34406e; } +.diagram .pkt.hl rect { fill: #6e3440; } +.diagram .pkt.ha rect { fill: #245c3a; } +.diagram .pkt.ha2 rect { fill: #2f7a4b; } +.diagram .pkt.hn rect { fill: #6b5a1f; } +.diagram text.pkt-name { font-size: 18px; font-weight: 500; } +.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: var(--ink); opacity: 0.75; } +.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; } +.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; } + /* Small two-column explanation under a bytes block. */ .reveal table.decode { font-size: 58%; @@ -186,6 +330,67 @@ white-space: nowrap; } +/* ---- prefix explorer: type a prefix, see its range ------------------------ */ + +.reveal form.prefix-explorer { + margin: 0 auto; + max-width: 36em; + text-align: center; +} + +.reveal .prefix-explorer input[name="prefix"] { + width: 15em; + padding: 0.25em 0.5em; + font: inherit; + font-family: var(--r-code-font); + font-size: 0.8em; + color: var(--r-main-color); + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 6px; + outline: none; + text-align: center; +} + +.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); } + +.reveal .prefix-explorer input[name="len"] { + width: 12em; + margin-left: 1em; + vertical-align: middle; + accent-color: var(--accent); +} + +.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; } +.reveal .prefix-explorer .px-bad { color: #ff7a7a; font-size: 70%; } + +.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; } + +.reveal .px-levels { text-align: left; margin-bottom: 1em; } +.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; } +.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; } +.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; } +.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; } +.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; } +.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); } + +.reveal .px-bar { + position: relative; + height: 2.6em; + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12)); + border: 1px solid var(--accent); + border-radius: 6px; +} +.reveal .px-bar .px-mid { + position: absolute; left: 0; right: 0; top: 0.6em; + text-align: center; color: var(--ink-strong); font-weight: 500; +} +.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); } +.reveal .px-bar .px-first { left: 0; } +.reveal .px-bar .px-last { right: 0; } + +.reveal .prefix-explorer .px-facts { margin-top: 2.6em; } + /* ---- bits of an address: fixed part vs. free part ------------------------------ */ .bits { @@ -201,6 +406,20 @@ padding: 2px 6px; } +.bits .part { + color: var(--accent-soft); + background: var(--ground); + border: 1px solid var(--accent); + padding: 1px 4px; + margin: 0 2px; + font-size: 85%; + letter-spacing: 0.12em; +} + +.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; } + +.bits .part i { font-style: normal; color: var(--muted); } + .bits .free { color: var(--ink); background: var(--ground); @@ -266,6 +485,22 @@ fill: none; } +/* Nested address ranges: each level a little brighter than the one around it. */ +.diagram .nest rect { + stroke: var(--accent); + rx: 14; +} +.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; } +.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; } +.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; } +.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; } +.diagram text.left { text-anchor: start; } +.diagram text.mono { font-family: "IBM Plex Mono", monospace; } +.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; } +.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; } +.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; } +.diagram .host { fill: var(--accent-soft); stroke: none; } + .diagram .traffic { stroke: #4fd98a; stroke-width: 4;