Remove the lecture material and the ping monitor
The slides, handout, lab router configs, RIPE objects and load test were for the Network Security 2026 lecture and are kept on the netsec-2026 tag. The ping monitor sniffed ICMPv6 echo requests so students could watch their prefix being used. It has no place in a BGP speaker: it needs CAP_NET_RAW, adds the only unsafe code in the tree and a libc dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1b72fed39
commit
c37cfd029a
55 files changed
+7
-7183
No files matched your search
@@ -1,5 +1,3 @@
|
||||
/target
|
||||
__pycache__/
|
||||
slides/reveal.js/
|
||||
slides/dist/
|
||||
.DS_Store
|
||||
Generated
-1
@@ -225,7 +225,6 @@ dependencies = [
|
||||
"clap",
|
||||
"eyre",
|
||||
"futures",
|
||||
"libc",
|
||||
"prometheus",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
|
||||
@@ -40,7 +40,6 @@ 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"] }
|
||||
@@ -2,7 +2,6 @@ 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;
|
||||
@@ -33,11 +32,6 @@ 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(
|
||||
@@ -51,7 +45,6 @@ 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 {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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};
|
||||
@@ -23,9 +21,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -66,7 +61,6 @@ impl TestServer {
|
||||
collision_detect_established_state: false,
|
||||
local_route_policy: LocalRibPolicy::default(),
|
||||
http_endpoint: None,
|
||||
ping_monitor: None,
|
||||
};
|
||||
customize(&mut config);
|
||||
|
||||
@@ -80,13 +74,8 @@ 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, pings, local_asn })
|
||||
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +94,6 @@ impl TestServer {
|
||||
registry: self.registry.clone(),
|
||||
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 }))
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
//! The ping monitor as seen through the HTTP API. The socket is left out
|
||||
//! (it needs privileges); observations are fed in directly.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRoute;
|
||||
use bgp_server::ping::PingObservation;
|
||||
use eyre::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn ping(src: &str, dst: &str, hop_limit: u8, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(hop_limit),
|
||||
ident: 1,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: [&[128, 0, 0xab, 0xcd, 0, 1][..], &seq.to_be_bytes(), b"hi"].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_are_listed_with_the_route_they_were_sent_to() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("2001:db8:1a::/48").unwrap(), "alice", Default::default()))
|
||||
.await?;
|
||||
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 0)).await;
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 1)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2001:db8:ff::1", 243, 0)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2a0d:d740::1", 243, 0)).await; // not a watched prefix
|
||||
|
||||
let view: Value = client.get(format!("{base}/api/pings")).send().await?.error_for_status()?.json().await?;
|
||||
assert_eq!(view["status"], "listening");
|
||||
assert_eq!(view["prefixes"][0], "2001:db8::/32");
|
||||
assert_eq!(view["total_flows"], 2);
|
||||
|
||||
let flows = view["flows"].as_array().unwrap();
|
||||
let alice = flows.iter().find(|f| f["dst"] == "2001:db8:1a::1").unwrap();
|
||||
assert_eq!(alice["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(alice["prefix"], "2001:db8:1a::/48");
|
||||
assert_eq!(alice["owner"], "alice");
|
||||
assert_eq!(alice["interface"], "swissix0");
|
||||
assert_eq!((alice["hop_limit"].as_u64(), alice["hops"].as_u64()), (Some(57), Some(7)));
|
||||
assert_eq!((alice["count"].as_u64(), alice["seq"].as_u64()), (Some(2), Some(1)));
|
||||
assert_eq!(alice["hex"], "8000abcd000100016869");
|
||||
assert!(alice["last_seen_unix"].as_u64().unwrap() > 1_700_000_000);
|
||||
|
||||
let stray = flows.iter().find(|f| f["dst"] == "2001:db8:ff::1").unwrap();
|
||||
assert!(stray["prefix"].is_null() && stray["owner"].is_null());
|
||||
assert_eq!(stray["hops"], 12);
|
||||
|
||||
let limited: Value = client.get(format!("{base}/api/pings?limit=1")).send().await?.json().await?;
|
||||
assert_eq!(limited["total_flows"], 2);
|
||||
assert_eq!(limited["flows"].as_array().unwrap().len(), 1);
|
||||
|
||||
let metrics = client.get(format!("{base}/metricz")).send().await?.text().await?;
|
||||
assert!(metrics.contains("ping_monitor_echo_requests_total"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_appear_on_the_event_stream() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let mut resp = reqwest::Client::new().get(format!("{base}/api/events")).send().await?.error_for_status()?;
|
||||
|
||||
// The subscription exists once the response has started; a ping recorded
|
||||
// before that would be missed, so keep pinging until one shows up.
|
||||
let pings = server.pings.clone();
|
||||
let pinger = tokio::spawn(async move {
|
||||
for seq in 0.. {
|
||||
pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, seq)).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut buf = String::new();
|
||||
let event = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
|
||||
buf.push_str(std::str::from_utf8(&chunk)?);
|
||||
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
|
||||
let ev: Value = serde_json::from_str(line.trim())?;
|
||||
if ev["type"] == "ping_seen" {
|
||||
return Ok::<_, eyre::Error>(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
pinger.abort();
|
||||
|
||||
assert_eq!(event["ping"]["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(event["ping"]["dst"], "2001:db8:1a::1");
|
||||
assert_eq!(event["ping"]["interface"], "swissix0");
|
||||
assert!(event["new_burst"].is_boolean());
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -20,6 +19,3 @@ thiserror.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
protobuf-codegen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -38,21 +38,6 @@ message BgpConfig {
|
||||
// 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.
|
||||
|
||||
@@ -2,7 +2,6 @@ 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};
|
||||
@@ -32,9 +31,6 @@ pub struct Config {
|
||||
pub local_route_policy: LocalRibPolicy,
|
||||
/// Address for the HTTP API; `None` disables it.
|
||||
pub http_endpoint: Option<SocketAddr>,
|
||||
/// IPv6 prefixes whose incoming pings are shown; `None` disables the
|
||||
/// ping monitor.
|
||||
pub ping_monitor: Option<Vec<IpPrefix>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -131,27 +127,6 @@ impl Config {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let ping_monitor = match proto.ping_monitor.into_option() {
|
||||
Some(monitor) => {
|
||||
let prefixes: Vec<IpPrefix> = if monitor.prefixes.is_empty() {
|
||||
local_route_policy.allowed_prefixes.clone()
|
||||
} else {
|
||||
monitor
|
||||
.prefixes
|
||||
.iter()
|
||||
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid ping_monitor prefix {p:?}")))
|
||||
.collect::<Result<_>>()?
|
||||
};
|
||||
let prefixes: Vec<IpPrefix> =
|
||||
prefixes.into_iter().filter(|p| p.address_family == AddressFamilyId::Ipv6).collect();
|
||||
if prefixes.is_empty() {
|
||||
bail!("ping_monitor needs IPv6 prefixes, either its own or local_route_policy.allowed_prefixes");
|
||||
}
|
||||
Some(prefixes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let connect_retry = if proto.connect_retry_seconds == 0 {
|
||||
DEFAULT_CONNECT_RETRY
|
||||
} else {
|
||||
@@ -168,7 +143,6 @@ impl Config {
|
||||
collision_detect_established_state: proto.collision_detect_established_state,
|
||||
local_route_policy,
|
||||
http_endpoint,
|
||||
ping_monitor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ pub mod connection;
|
||||
pub mod dialer;
|
||||
pub mod fsm;
|
||||
pub mod metrics;
|
||||
pub mod ping;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod tracing;
|
||||
|
||||
@@ -46,22 +46,6 @@ pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_ECHO_REQUESTS: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_echo_requests_total",
|
||||
"ICMPv6 echo requests seen for the watched prefixes"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_EVENTS_SUPPRESSED: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_events_suppressed_total",
|
||||
"Ping events not published because of the per-second limit"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
/// Forces registration of every metric so they appear in scrapes before
|
||||
/// their first observation.
|
||||
pub fn init() {
|
||||
@@ -72,8 +56,6 @@ 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 {
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
//! Shows who is pinging the prefixes this router originates.
|
||||
//!
|
||||
//! The host answers for the lab address space itself (an AnyIP route on the
|
||||
//! loopback interface), so the kernel already replies to every ping. A raw
|
||||
//! ICMPv6 socket gets a copy of each echo request as well; we never reply and
|
||||
//! never see forwarded traffic. For every packet we learn the source, the
|
||||
//! destination, the interface it arrived on and the hop limit it arrived
|
||||
//! with, which is enough to show a student that their route is in use and
|
||||
//! which way the traffic came in.
|
||||
//!
|
||||
//! Anyone on the Internet can send these packets, with any source address, so
|
||||
//! everything here is bounded: a fixed number of flows is remembered and the
|
||||
//! number of events published per second is capped.
|
||||
|
||||
#[cfg(unix)]
|
||||
mod sys;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bgp_primitives::constants::AddressFamilyId;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRib, LocalRoute};
|
||||
use tokio::sync::{Mutex, broadcast};
|
||||
use tokio::time::Instant;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::metrics::{PING_ECHO_REQUESTS, PING_EVENTS_SUPPRESSED};
|
||||
|
||||
/// Flows (source and destination pairs) remembered. When full, the flow that
|
||||
/// has been quiet the longest makes room.
|
||||
const MAX_FLOWS: usize = 2048;
|
||||
/// A flow that keeps pinging publishes an event this often, carrying the
|
||||
/// running count.
|
||||
const EVENT_INTERVAL: Duration = Duration::from_secs(2);
|
||||
/// A flow that was quiet for this long starts a new burst, which is what the
|
||||
/// event log reports.
|
||||
const BURST_GAP: Duration = Duration::from_secs(10);
|
||||
/// How much of each packet is kept for display.
|
||||
pub const MAX_PACKET_BYTES: usize = 256;
|
||||
/// Events published per second across all flows. Packets beyond this are
|
||||
/// still counted and show up in [`PingMonitor::flows`].
|
||||
const MAX_EVENTS_PER_SECOND: u32 = 50;
|
||||
|
||||
/// One echo request, as seen by the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingObservation {
|
||||
pub src: Ipv6Addr,
|
||||
pub dst: Ipv6Addr,
|
||||
/// Name of the interface the packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the packet arrived. Senders start at 64, 128 or
|
||||
/// 255, so this tells roughly how many routers the packet crossed.
|
||||
pub hop_limit: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the ICMPv6 message, header included.
|
||||
pub bytes: usize,
|
||||
/// The ICMPv6 message as received, from its type byte on, cut off after
|
||||
/// [`MAX_PACKET_BYTES`]. The IPv6 header is not part of it: a raw ICMPv6
|
||||
/// socket never sees one, which is why the fields above come separately.
|
||||
pub packet: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The local route a ping was addressed to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingedRoute {
|
||||
pub prefix: IpPrefix,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingFlow {
|
||||
/// The most recent packet.
|
||||
pub last: PingObservation,
|
||||
pub count: u64,
|
||||
pub first_seen: SystemTime,
|
||||
pub last_seen: SystemTime,
|
||||
/// The most specific local route covering the destination right now;
|
||||
/// `None` when nobody announces it.
|
||||
pub route: Option<PingedRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PingEvent {
|
||||
pub flow: PingFlow,
|
||||
/// True for the first event of a burst: a new flow, or one that had gone
|
||||
/// quiet. False for the periodic updates while it keeps pinging.
|
||||
pub new_burst: bool,
|
||||
}
|
||||
|
||||
/// Whether the monitor is watching, for display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PingMonitorStatus {
|
||||
/// Not configured.
|
||||
Disabled,
|
||||
Listening,
|
||||
/// Configured, but the socket could not be opened.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct FlowState {
|
||||
flow: PingFlow,
|
||||
last_packet: Instant,
|
||||
last_event: Option<Instant>,
|
||||
/// A burst started and its first event has not gone out yet.
|
||||
burst_pending: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Table {
|
||||
flows: HashMap<(Ipv6Addr, Ipv6Addr), FlowState>,
|
||||
window_start: Option<Instant>,
|
||||
window_events: u32,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn take_event_slot(&mut self, now: Instant) -> bool {
|
||||
if self.window_start.is_none_or(|start| now.duration_since(start) >= Duration::from_secs(1)) {
|
||||
self.window_start = Some(now);
|
||||
self.window_events = 0;
|
||||
}
|
||||
if self.window_events >= MAX_EVENTS_PER_SECOND {
|
||||
return false;
|
||||
}
|
||||
self.window_events += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn make_room(&mut self) {
|
||||
if self.flows.len() < MAX_FLOWS {
|
||||
return;
|
||||
}
|
||||
if let Some(key) = self.flows.iter().min_by_key(|(_, s)| s.last_packet).map(|(k, _)| *k) {
|
||||
self.flows.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
prefixes: Vec<IpPrefix>,
|
||||
local_rib: Option<LocalRib>,
|
||||
status: PingMonitorStatus,
|
||||
table: Mutex<Table>,
|
||||
events: broadcast::Sender<PingEvent>,
|
||||
}
|
||||
|
||||
/// Handle to the ping monitor. Cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub struct PingMonitor {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl PingMonitor {
|
||||
fn new(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>, status: PingMonitorStatus) -> Self {
|
||||
let (events, _) = broadcast::channel(256);
|
||||
Self { inner: Arc::new(Inner { prefixes, local_rib, status, table: Mutex::default(), events }) }
|
||||
}
|
||||
|
||||
/// A monitor that watches nothing.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(vec![], None, PingMonitorStatus::Disabled)
|
||||
}
|
||||
|
||||
/// A monitor without a socket, fed through [`PingMonitor::record`].
|
||||
pub fn without_socket(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>) -> Self {
|
||||
Self::new(prefixes, local_rib, PingMonitorStatus::Listening)
|
||||
}
|
||||
|
||||
/// Opens the socket and starts watching for pings to `prefixes`. Failing
|
||||
/// to open it (usually: no CAP_NET_RAW) is not fatal; the daemon runs on
|
||||
/// without the monitor and the status page says why.
|
||||
pub fn start(prefixes: Vec<IpPrefix>, local_rib: LocalRib) -> Self {
|
||||
#[cfg(unix)]
|
||||
let socket = sys::EchoSocket::open();
|
||||
#[cfg(not(unix))]
|
||||
let socket: std::io::Result<()> = Err(std::io::Error::other("not supported on this platform"));
|
||||
|
||||
match socket {
|
||||
Ok(socket) => {
|
||||
let shown: Vec<String> = prefixes.iter().map(ToString::to_string).collect();
|
||||
info!(prefixes = ?shown, "Ping monitor listening for ICMPv6 echo requests");
|
||||
let monitor = Self::new(prefixes, Some(local_rib), PingMonitorStatus::Listening);
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(monitor.clone().run(socket));
|
||||
monitor
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, "Ping monitor disabled: cannot open a raw ICMPv6 socket (needs CAP_NET_RAW)");
|
||||
Self::new(prefixes, None, PingMonitorStatus::Failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run(self, mut socket: sys::EchoSocket) {
|
||||
loop {
|
||||
match socket.recv().await {
|
||||
Ok(observation) => self.record(observation).await,
|
||||
Err(e) => {
|
||||
error!(err = %e, "Ping monitor socket failed");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &PingMonitorStatus {
|
||||
&self.inner.status
|
||||
}
|
||||
|
||||
/// The prefixes being watched.
|
||||
pub fn prefixes(&self) -> &[IpPrefix] {
|
||||
&self.inner.prefixes
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<PingEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// Accounts for one echo request. Packets to destinations outside the
|
||||
/// watched prefixes are ignored.
|
||||
pub async fn record(&self, observation: PingObservation) {
|
||||
let destination = host_prefix(observation.dst);
|
||||
if !self.inner.prefixes.iter().any(|p| p.contains(&destination)) {
|
||||
return;
|
||||
}
|
||||
PING_ECHO_REQUESTS.inc();
|
||||
|
||||
let now = Instant::now();
|
||||
let wall = SystemTime::now();
|
||||
let key = (observation.src, observation.dst);
|
||||
let mut guard = self.inner.table.lock().await;
|
||||
let table = &mut *guard;
|
||||
|
||||
let is_new = !table.flows.contains_key(&key);
|
||||
if is_new {
|
||||
table.make_room();
|
||||
}
|
||||
let state = table.flows.entry(key).or_insert_with(|| FlowState {
|
||||
flow: PingFlow { last: observation.clone(), count: 0, first_seen: wall, last_seen: wall, route: None },
|
||||
last_packet: now,
|
||||
last_event: None,
|
||||
burst_pending: false,
|
||||
});
|
||||
if is_new || now.duration_since(state.last_packet) >= BURST_GAP {
|
||||
state.burst_pending = true;
|
||||
}
|
||||
state.flow.last = observation;
|
||||
state.flow.count += 1;
|
||||
state.flow.last_seen = wall;
|
||||
state.last_packet = now;
|
||||
|
||||
let due = state.burst_pending || state.last_event.is_none_or(|t| now.duration_since(t) >= EVENT_INTERVAL);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let new_burst = state.burst_pending;
|
||||
let mut flow = state.flow.clone();
|
||||
if !table.take_event_slot(now) {
|
||||
PING_EVENTS_SUPPRESSED.inc();
|
||||
return;
|
||||
}
|
||||
if let Some(state) = table.flows.get_mut(&key) {
|
||||
state.last_event = Some(now);
|
||||
state.burst_pending = false;
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
flow.route = resolve(&self.local_routes().await, &destination);
|
||||
// No subscribers is fine.
|
||||
let _ = self.inner.events.send(PingEvent { flow, new_burst });
|
||||
}
|
||||
|
||||
/// Every remembered flow, most recently seen first.
|
||||
pub async fn flows(&self) -> Vec<PingFlow> {
|
||||
let mut flows: Vec<PingFlow> = self.inner.table.lock().await.flows.values().map(|s| s.flow.clone()).collect();
|
||||
let routes = self.local_routes().await;
|
||||
for flow in &mut flows {
|
||||
flow.route = resolve(&routes, &host_prefix(flow.last.dst));
|
||||
}
|
||||
flows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| a.last.dst.cmp(&b.last.dst)));
|
||||
flows
|
||||
}
|
||||
|
||||
async fn local_routes(&self) -> Vec<LocalRoute> {
|
||||
match &self.inner.local_rib {
|
||||
Some(rib) => rib.snapshot().await,
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_prefix(addr: Ipv6Addr) -> IpPrefix {
|
||||
IpPrefix { address_family: AddressFamilyId::Ipv6, prefix: addr.octets().to_vec(), length: 128 }
|
||||
}
|
||||
|
||||
fn resolve(routes: &[LocalRoute], destination: &IpPrefix) -> Option<PingedRoute> {
|
||||
routes
|
||||
.iter()
|
||||
.filter(|r| r.prefix.contains(destination))
|
||||
.max_by_key(|r| r.prefix.length)
|
||||
.map(|r| PingedRoute { prefix: r.prefix.clone(), owner: r.owner.clone() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bgp_rib::{LocalRibPolicy, LocalRouteAttrs};
|
||||
|
||||
fn lab() -> Vec<IpPrefix> {
|
||||
vec![IpPrefix::try_from("2a0d:d741::/40").unwrap()]
|
||||
}
|
||||
|
||||
fn ping(src: &str, dst: &str, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(57),
|
||||
ident: 7,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: vec![128, 0, 0, 0, 0, 7, (seq >> 8) as u8, seq as u8],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn counts_flows_and_names_the_route_owner() {
|
||||
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
||||
let prefix = IpPrefix::try_from("2a0d:d741:1a::/48").unwrap();
|
||||
rib.announce(LocalRoute::new(prefix.clone(), "alice", LocalRouteAttrs::default())).await.unwrap();
|
||||
let monitor = PingMonitor::without_socket(lab(), Some(rib.clone()));
|
||||
let mut events = monitor.subscribe();
|
||||
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 0)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:5::1", 0)).await;
|
||||
|
||||
let first = events.try_recv().unwrap();
|
||||
assert!(first.new_burst);
|
||||
assert_eq!(first.flow.route, Some(PingedRoute { prefix, owner: "alice".into() }));
|
||||
let other = events.try_recv().unwrap();
|
||||
assert_eq!(other.flow.route, None, "nobody announces 2a0d:d741:5::/48");
|
||||
assert!(events.try_recv().is_err(), "the second packet of a flow is not an event of its own");
|
||||
|
||||
let flows = monitor.flows().await;
|
||||
assert_eq!(flows.len(), 2);
|
||||
let alice = flows.iter().find(|f| f.route.is_some()).unwrap();
|
||||
assert_eq!((alice.count, alice.last.seq), (2, 1));
|
||||
|
||||
// The owner shown follows the routing table, not the first packet.
|
||||
rib.withdraw(alice.route.as_ref().unwrap().prefix.clone(), None).await.unwrap();
|
||||
assert!(monitor.flows().await.iter().all(|f| f.route.is_none()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn ignores_destinations_outside_the_watched_prefixes() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d740::1", 0)).await;
|
||||
monitor.record(ping("2001:db8::1", "2001:7f8:24::c9", 0)).await;
|
||||
assert!(monitor.flows().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_steady_ping_is_an_occasional_update_and_a_pause_starts_a_new_burst() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
let mut seen = vec![];
|
||||
for seq in 0..6 {
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", seq)).await;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
seen.push((ev.flow.count, ev.new_burst));
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(seen, vec![(1, true), (3, false), (5, false)]);
|
||||
|
||||
tokio::time::advance(BURST_GAP).await;
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", 6)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (7, true));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_flood_of_spoofed_sources_is_bounded() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
for n in 0..(MAX_FLOWS as u32 + 500) {
|
||||
let src = Ipv6Addr::from(0x2001_0db8_0000_0000_0000_0000_0000_0000u128 + n as u128);
|
||||
monitor.record(ping(&src.to_string(), "2a0d:d741:1::1", 0)).await;
|
||||
}
|
||||
assert_eq!(monitor.flows().await.len(), MAX_FLOWS);
|
||||
let mut published = 0;
|
||||
while events.try_recv().is_ok() {
|
||||
published += 1;
|
||||
}
|
||||
assert_eq!(published, MAX_EVENTS_PER_SECOND, "time stood still, so this was all one second");
|
||||
|
||||
// A real student still gets through once the flood's second is over,
|
||||
// even though the first attempt at announcing the burst was dropped.
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 0)).await;
|
||||
assert!(events.try_recv().is_err());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 1)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (2, true));
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
//! The raw ICMPv6 socket behind the ping monitor.
|
||||
//!
|
||||
//! All the `unsafe` in the daemon lives here: the socket options and the
|
||||
//! ancillary data we need (`ICMP6_FILTER`, `IPV6_PKTINFO`, `IPV6_HOPLIMIT`)
|
||||
//! have no safe wrapper in the standard library or tokio.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::CStr;
|
||||
use std::io;
|
||||
use std::mem::{size_of, size_of_val, zeroed};
|
||||
use std::net::Ipv6Addr;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
|
||||
use libc::{c_int, c_void};
|
||||
use tokio::io::Interest;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
use super::{MAX_PACKET_BYTES, PingObservation};
|
||||
|
||||
const ICMP6_ECHO_REQUEST: u8 = 128;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const ICMP6_FILTER: c_int = 1;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
const ICMP6_FILTER: c_int = 18;
|
||||
|
||||
/// `struct icmp6_filter`: one bit per ICMPv6 type.
|
||||
type Icmp6Filter = [u32; 8];
|
||||
|
||||
/// A filter that lets only echo requests through. Linux and the BSDs agree on
|
||||
/// the layout and disagree on the meaning: on Linux a set bit blocks the
|
||||
/// type, on the BSDs (and macOS) a set bit passes it.
|
||||
fn echo_request_only() -> Icmp6Filter {
|
||||
let (word, bit) = ((ICMP6_ECHO_REQUEST >> 5) as usize, 1u32 << (ICMP6_ECHO_REQUEST & 31));
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) {
|
||||
let mut filter = [u32::MAX; 8];
|
||||
filter[word] &= !bit;
|
||||
filter
|
||||
} else {
|
||||
let mut filter = [0u32; 8];
|
||||
filter[word] |= bit;
|
||||
filter
|
||||
}
|
||||
}
|
||||
|
||||
fn check(ret: c_int) -> io::Result<c_int> {
|
||||
if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret) }
|
||||
}
|
||||
|
||||
fn setsockopt<T>(fd: RawFd, level: c_int, name: c_int, value: &T) -> io::Result<()> {
|
||||
// SAFETY: `value` is a live reference and the length given is its size.
|
||||
check(unsafe {
|
||||
libc::setsockopt(fd, level, name, (value as *const T).cast::<c_void>(), size_of::<T>() as libc::socklen_t)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receives a copy of every ICMPv6 echo request the kernel delivers to this
|
||||
/// host. The kernel still answers them; we never send anything.
|
||||
pub struct EchoSocket {
|
||||
fd: AsyncFd<OwnedFd>,
|
||||
/// Interface names by index. Looked up once: interfaces on a router do
|
||||
/// not change name, and under a ping flood the lookup would be three
|
||||
/// system calls per packet.
|
||||
interfaces: HashMap<u32, Option<String>>,
|
||||
}
|
||||
|
||||
impl EchoSocket {
|
||||
/// Opens the socket. Needs CAP_NET_RAW on Linux. macOS also hands echo
|
||||
/// requests to unprivileged ICMPv6 datagram sockets, which is enough to
|
||||
/// try the monitor out on a laptop. (On Linux those only ever see the
|
||||
/// replies to their own pings, so there is no such fallback there.)
|
||||
pub fn open() -> io::Result<Self> {
|
||||
Self::open_as(libc::SOCK_RAW).or_else(|e| {
|
||||
if cfg!(target_os = "macos") { Self::open_as(libc::SOCK_DGRAM) } else { Err(e) }
|
||||
})
|
||||
}
|
||||
|
||||
fn open_as(socket_type: c_int) -> io::Result<Self> {
|
||||
// SAFETY: plain system call; the result is checked before use.
|
||||
let raw = check(unsafe { libc::socket(libc::AF_INET6, socket_type, libc::IPPROTO_ICMPV6) })?;
|
||||
// SAFETY: `raw` is a descriptor we just opened and nobody else owns.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
// SAFETY: fcntl on a descriptor we own, with integer arguments.
|
||||
unsafe {
|
||||
let flags = check(libc::fcntl(raw, libc::F_GETFL))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC))?;
|
||||
}
|
||||
|
||||
setsockopt(raw, libc::IPPROTO_ICMPV6, ICMP6_FILTER, &echo_request_only())?;
|
||||
let on: c_int = 1;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVPKTINFO, &on)?;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVHOPLIMIT, &on)?;
|
||||
|
||||
Ok(Self { fd: AsyncFd::with_interest(fd, Interest::READABLE)?, interfaces: HashMap::new() })
|
||||
}
|
||||
|
||||
/// Waits for the next echo request.
|
||||
pub async fn recv(&mut self) -> io::Result<PingObservation> {
|
||||
loop {
|
||||
let mut guard = self.fd.readable().await?;
|
||||
let packet = match guard.try_io(|fd| recv_packet(fd.as_raw_fd())) {
|
||||
Ok(result) => result?,
|
||||
Err(_would_block) => continue,
|
||||
};
|
||||
let Some(packet) = packet else { continue };
|
||||
let interface = self.interfaces.entry(packet.ifindex).or_insert_with(|| interface_name(packet.ifindex)).clone();
|
||||
return Ok(PingObservation {
|
||||
src: packet.src,
|
||||
dst: packet.dst,
|
||||
interface,
|
||||
hop_limit: packet.hop_limit,
|
||||
ident: packet.ident,
|
||||
seq: packet.seq,
|
||||
bytes: packet.bytes,
|
||||
packet: packet.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
src: Ipv6Addr,
|
||||
dst: Ipv6Addr,
|
||||
ifindex: u32,
|
||||
hop_limit: Option<u8>,
|
||||
ident: u16,
|
||||
seq: u16,
|
||||
bytes: usize,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Reads one packet. `Ok(None)` is a packet we cannot use (not an echo
|
||||
/// request, or the kernel did not say where it was going).
|
||||
fn recv_packet(fd: RawFd) -> io::Result<Option<Packet>> {
|
||||
// A raw ICMPv6 socket delivers the ICMPv6 message without the IPv6
|
||||
// header, so everything about the header comes from ancillary data.
|
||||
let mut data = [0u8; 2048];
|
||||
let mut control = [0u64; 16]; // u64 for the alignment cmsghdr needs
|
||||
// SAFETY: all-zero is a valid value for these plain C structs.
|
||||
let mut from: libc::sockaddr_in6 = unsafe { zeroed() };
|
||||
let mut iov = libc::iovec { iov_base: data.as_mut_ptr().cast(), iov_len: data.len() };
|
||||
// SAFETY: as above. msghdr has private padding on some targets, so it
|
||||
// cannot be built with a struct literal.
|
||||
let mut msg: libc::msghdr = unsafe { zeroed() };
|
||||
msg.msg_name = (&mut from as *mut libc::sockaddr_in6).cast();
|
||||
msg.msg_namelen = size_of::<libc::sockaddr_in6>() as _;
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1 as _;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = size_of_val(&control) as _;
|
||||
|
||||
// SAFETY: every pointer in `msg` refers to a local that outlives the call
|
||||
// and the lengths given match the buffers.
|
||||
let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
|
||||
if n < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let n = n as usize;
|
||||
if n < 8 || data[0] != ICMP6_ECHO_REQUEST {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut dst = None;
|
||||
let mut hop_limit = None;
|
||||
// SAFETY: the CMSG_* macros walk the control buffer the kernel just
|
||||
// filled in, bounded by msg_controllen. Payloads are read unaligned.
|
||||
unsafe {
|
||||
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
while !cmsg.is_null() {
|
||||
let header = &*cmsg;
|
||||
if header.cmsg_level == libc::IPPROTO_IPV6 {
|
||||
if header.cmsg_type == libc::IPV6_PKTINFO {
|
||||
let info: libc::in6_pktinfo = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
dst = Some((Ipv6Addr::from(info.ipi6_addr.s6_addr), info.ipi6_ifindex as u32));
|
||||
} else if header.cmsg_type == libc::IPV6_HOPLIMIT {
|
||||
let value: c_int = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
hop_limit = u8::try_from(value).ok();
|
||||
}
|
||||
}
|
||||
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
|
||||
}
|
||||
}
|
||||
let Some((dst, ifindex)) = dst else { return Ok(None) };
|
||||
|
||||
Ok(Some(Packet {
|
||||
src: Ipv6Addr::from(from.sin6_addr.s6_addr),
|
||||
dst,
|
||||
ifindex,
|
||||
hop_limit,
|
||||
ident: u16::from_be_bytes([data[4], data[5]]),
|
||||
seq: u16::from_be_bytes([data[6], data[7]]),
|
||||
bytes: n,
|
||||
data: data[..n.min(MAX_PACKET_BYTES)].to_vec(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn interface_name(index: u32) -> Option<String> {
|
||||
let mut name = [0 as libc::c_char; libc::IF_NAMESIZE];
|
||||
// SAFETY: the buffer is IF_NAMESIZE bytes, as if_indextoname requires,
|
||||
// and on success holds a NUL-terminated string.
|
||||
unsafe {
|
||||
if libc::if_indextoname(index, name.as_mut_ptr()).is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(CStr::from_ptr(name.as_ptr()).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_passes_only_echo_requests() {
|
||||
let filter = echo_request_only();
|
||||
let blocked = |t: u8| {
|
||||
let set = filter[(t >> 5) as usize] & (1 << (t & 31)) != 0;
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) { set } else { !set }
|
||||
};
|
||||
assert!(!blocked(128), "echo request passes");
|
||||
for t in [1u8, 3, 129, 133, 134, 135, 136] {
|
||||
assert!(blocked(t), "type {t} is dropped in the kernel");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pings ::1 and expects to see the request. Skipped where the socket
|
||||
/// cannot be opened, i.e. on Linux without CAP_NET_RAW.
|
||||
#[tokio::test]
|
||||
async fn sees_a_ping_to_loopback() {
|
||||
let mut socket = match EchoSocket::open() {
|
||||
Ok(socket) => socket,
|
||||
Err(e) => return eprintln!("skipped: cannot open an ICMPv6 socket: {e}"),
|
||||
};
|
||||
let ping = if cfg!(target_os = "macos") { "ping6" } else { "ping" };
|
||||
let Ok(mut child) = std::process::Command::new(ping)
|
||||
.args(["-c", "1", "::1"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return eprintln!("skipped: no {ping} command");
|
||||
};
|
||||
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let ping = socket.recv().await.unwrap();
|
||||
if ping.dst == Ipv6Addr::LOCALHOST {
|
||||
return ping;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let _ = child.wait();
|
||||
let seen = seen.expect("echo request to ::1 was not seen");
|
||||
assert_eq!(seen.src, Ipv6Addr::LOCALHOST);
|
||||
assert_eq!(seen.hop_limit, Some(64));
|
||||
assert!(seen.seq <= 1, "first sequence number is 0 (BSD ping6) or 1 (iputils)");
|
||||
assert!(seen.interface.as_deref().is_some_and(|name| name.starts_with("lo")), "{:?}", seen.interface);
|
||||
assert!(seen.bytes >= 8);
|
||||
assert_eq!(seen.packet[..2], [128, 0], "echo request, code 0");
|
||||
assert_eq!(seen.packet[4..8], [seen.ident.to_be_bytes(), seen.seq.to_be_bytes()].concat()[..]);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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};
|
||||
@@ -476,112 +475,3 @@ pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<Received
|
||||
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
|
||||
Json(routes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flows returned by `/api/pings` unless `?limit=` says otherwise.
|
||||
const DEFAULT_PING_FLOWS: usize = 100;
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingView {
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
/// The local route the destination belongs to and who announced it;
|
||||
/// `null` when nobody announces it at the moment.
|
||||
pub prefix: Option<String>,
|
||||
pub owner: Option<String>,
|
||||
/// Interface the most recent packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the most recent packet arrived.
|
||||
pub hop_limit: Option<u8>,
|
||||
/// Routers the packet crossed, assuming the sender started at the usual
|
||||
/// 64, 128 or 255.
|
||||
pub hops: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the most recent ICMPv6 message.
|
||||
pub bytes: usize,
|
||||
/// That message as lowercase hex, from the ICMPv6 type byte on (no IPv6
|
||||
/// header). Long packets are cut off, so this can be shorter than `bytes`.
|
||||
pub hex: String,
|
||||
pub count: u64,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
fn estimated_hops(hop_limit: u8) -> u8 {
|
||||
let initial = [64u8, 128, 255].into_iter().find(|&start| start >= hop_limit).unwrap_or(255);
|
||||
initial - hop_limit
|
||||
}
|
||||
|
||||
impl From<&PingFlow> for PingView {
|
||||
fn from(flow: &PingFlow) -> Self {
|
||||
Self {
|
||||
src: flow.last.src.to_string(),
|
||||
dst: flow.last.dst.to_string(),
|
||||
prefix: flow.route.as_ref().map(|r| r.prefix.to_string()),
|
||||
owner: flow.route.as_ref().map(|r| r.owner.clone()),
|
||||
interface: flow.last.interface.clone(),
|
||||
hop_limit: flow.last.hop_limit,
|
||||
hops: flow.last.hop_limit.map(estimated_hops),
|
||||
ident: flow.last.ident,
|
||||
seq: flow.last.seq,
|
||||
bytes: flow.last.bytes,
|
||||
hex: flow.last.packet.iter().map(|b| format!("{b:02x}")).collect(),
|
||||
count: flow.count,
|
||||
first_seen_unix: unix_secs(flow.first_seen),
|
||||
last_seen_unix: unix_secs(flow.last_seen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingMonitorView {
|
||||
/// `listening`, `disabled` (not configured) or `failed`.
|
||||
pub status: &'static str,
|
||||
/// Why the monitor failed to start.
|
||||
pub detail: Option<String>,
|
||||
/// Pings to destinations inside these prefixes are shown.
|
||||
pub prefixes: Vec<String>,
|
||||
/// Flows remembered; `flows` holds the most recently seen of them.
|
||||
pub total_flows: usize,
|
||||
pub flows: Vec<PingView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PingQuery {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn pings(State(state): State<AppState>, Query(query): Query<PingQuery>) -> Json<PingMonitorView> {
|
||||
let (status, detail) = match state.pings.status() {
|
||||
PingMonitorStatus::Listening => ("listening", None),
|
||||
PingMonitorStatus::Disabled => ("disabled", None),
|
||||
PingMonitorStatus::Failed(why) => ("failed", Some(why.clone())),
|
||||
};
|
||||
let flows = state.pings.flows().await;
|
||||
Json(PingMonitorView {
|
||||
status,
|
||||
detail,
|
||||
prefixes: state.pings.prefixes().iter().map(ToString::to_string).collect(),
|
||||
total_flows: flows.len(),
|
||||
flows: flows.iter().take(query.limit.unwrap_or(DEFAULT_PING_FLOWS)).map(PingView::from).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ping_tests {
|
||||
use super::estimated_hops;
|
||||
|
||||
#[test]
|
||||
fn hops_are_counted_from_the_nearest_usual_starting_value() {
|
||||
assert_eq!(estimated_hops(64), 0);
|
||||
assert_eq!(estimated_hops(57), 7);
|
||||
assert_eq!(estimated_hops(118), 10);
|
||||
assert_eq!(estimated_hops(243), 12);
|
||||
assert_eq!(estimated_hops(255), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
//! Server-sent event stream merging session, local-route, received-route and
|
||||
//! ping events into one feed the UI can follow.
|
||||
//! Server-sent event stream merging session, local-route and received-route
|
||||
//! changes 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;
|
||||
@@ -14,7 +13,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize};
|
||||
use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -27,10 +26,6 @@ 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 },
|
||||
@@ -54,13 +49,6 @@ impl WireEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_ping(ev: Result<PingEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(ev) => Self::PingSeen { ping: PingView::from(&ev.flow), new_burst: ev.new_burst },
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "pings", missed: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
|
||||
@@ -94,10 +82,7 @@ pub async fn stream(
|
||||
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local);
|
||||
let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
|
||||
|
||||
let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping);
|
||||
|
||||
let routes = futures::stream::select(local, received);
|
||||
let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| {
|
||||
let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| {
|
||||
Ok(Event::default()
|
||||
.event("bgp")
|
||||
.json_data(&ev)
|
||||
|
||||
@@ -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`], [`PingMonitor`]); no state lives
|
||||
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives
|
||||
//! in this crate.
|
||||
|
||||
mod api;
|
||||
@@ -19,15 +19,13 @@ use axum::http::header;
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum::routing::{delete, get, post};
|
||||
use bgp_rib::{AdjRibIn, LocalRib};
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use eyre::{Result, WrapErr};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
pub use api::{
|
||||
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView,
|
||||
ReceivedRouteView, StatusView,
|
||||
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
|
||||
WellKnownCommunity, WireView, WithdrawRequest,
|
||||
};
|
||||
|
||||
@@ -86,7 +84,6 @@ 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.
|
||||
@@ -109,7 +106,6 @@ 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))
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Announcing a prefix with BGP
|
||||
|
||||
You have a BGP router. It speaks to two neighbouring autonomous systems, and
|
||||
whatever you announce will travel around a loop and come back to you, so you
|
||||
can see exactly what the network did with it.
|
||||
|
||||
Open the router's status page (your instructor will give you the address,
|
||||
usually `http://<host>:8080/statusz`). Everything on the page is live.
|
||||
|
||||
**Your prefix:** `10.100.<N>.0/24` where `N` is the number you were given.
|
||||
Use your name as *owner*: only you can withdraw what you announce.
|
||||
|
||||
## The topology
|
||||
|
||||
```
|
||||
you ──announce──▶ AS65000 (ferrix) ──▶ AS65001 (r1) ──▶ AS65002 (r2) ──▶ back to AS65000
|
||||
```
|
||||
|
||||
Everything under **Received routes** came in from a neighbour. A yellow row
|
||||
means it is one of the prefixes announced locally: it went around the loop.
|
||||
|
||||
## Exercises
|
||||
|
||||
1. **Plain announcement.** Announce your prefix with no options. Watch the
|
||||
event log, then find your prefix in *Received routes*. What is its AS
|
||||
path, and why is it that long? Which ASN appears first, and what does that
|
||||
tell you about the direction the route travelled?
|
||||
|
||||
2. **Read the bytes.** Click *bytes* on your route under *Local routes*.
|
||||
Hover the coloured segments. Find the 16 bytes of marker, the length, and
|
||||
the AS_PATH attribute. How many bytes does your ASN take up in the path,
|
||||
and why?
|
||||
|
||||
3. **NO_EXPORT.** Announce with the community `NO_EXPORT` (use the
|
||||
well-known picker). Wait ten seconds. Does your prefix come back? Look at
|
||||
the topology and explain which router stopped it and why it still
|
||||
accepted it.
|
||||
|
||||
4. **Asking a neighbour for help.** Announce with the community
|
||||
`65001:100`. r1 has been configured to act on that. What changed in the AS
|
||||
path this time, and who changed it? This is how real operators ask an
|
||||
upstream to prepend on their behalf; look up your ISP's "BGP communities"
|
||||
page later.
|
||||
|
||||
5. **Blackholing.** Announce with `BLACKHOLE`. What happens? When would an
|
||||
operator want this?
|
||||
|
||||
6. **Withdraw.** Withdraw your prefix and watch the event log. How many
|
||||
messages did it take, and what did the withdrawal look like on the wire
|
||||
compared to the announcement? (Click *bytes* before withdrawing to compare.)
|
||||
|
||||
## Things to notice
|
||||
|
||||
- Your router never sent anyone else's routes onward. Only the two FRR
|
||||
routers re-advertise. What is a router that re-advertises called, and what
|
||||
would happen to the AS path if ferrix did it too?
|
||||
- The AS path is BGP's loop prevention. Your prefix came back with your own
|
||||
ASN in the path and the router accepted it anyway. What should it have
|
||||
done, and why is this lab allowed to cheat?
|
||||
- Communities are just 32-bit tags with no built-in meaning except a few
|
||||
well-known ones. Everything r1 did with `65001:100` was policy someone
|
||||
wrote. Whose job is it to document what a community means?
|
||||
@@ -1,81 +0,0 @@
|
||||
# BGP teaching lab
|
||||
|
||||
A small topology in which a prefix announced from the ferrix_bgp web UI is
|
||||
carried around a loop of two routers and arrives back, so students can watch
|
||||
what their path attributes and communities do.
|
||||
|
||||
```
|
||||
announce re-advertise
|
||||
┌─────────┐ ──────▶ ┌────────┐ ──────▶ ┌────────┐
|
||||
│ ferrix │ │ r1 │ │ r2 │
|
||||
│ AS65000 │ │ AS65001│ │ AS65002│
|
||||
│ web UI │ ◀──────────────────────────────────────┘
|
||||
└─────────┘ comes back with AS path 65002 65001 65000
|
||||
```
|
||||
|
||||
- **ferrix** (AS65000, `10.200.0.1`) dials r1 and r2 and announces whatever
|
||||
students enter on `http://localhost:8080/statusz`. It does not forward
|
||||
routes it learns, so the only routes it sends are the students'.
|
||||
- **r1** (AS65001, FRR) applies policy to routes from ferrix based on the
|
||||
communities they carry, then passes them to r2. It originates
|
||||
`192.0.2.0/24`.
|
||||
- **r2** (AS65002, FRR) refuses routes directly from ferrix, so it only learns
|
||||
student prefixes via r1, and re-advertises everything to ferrix. It
|
||||
originates `198.51.100.0/24`.
|
||||
|
||||
## Running it
|
||||
|
||||
From the repository root:
|
||||
|
||||
```
|
||||
docker compose -f docs/lab/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Then open <http://localhost:8080/statusz>. Both peers should show
|
||||
`Established` within a few seconds and the received table should already
|
||||
list the two prefixes r1 and r2 originate.
|
||||
|
||||
Useful on the routers:
|
||||
|
||||
```
|
||||
docker exec r1 vtysh -c 'show bgp ipv4 unicast'
|
||||
docker exec r2 vtysh -c 'show bgp ipv4 unicast 10.100.1.0/24'
|
||||
docker exec r1 vtysh -c 'show bgp neighbors 10.200.0.1 advertised-routes'
|
||||
```
|
||||
|
||||
## Policy on r1
|
||||
|
||||
| Students set | r1 does | Visible effect in ferrix |
|
||||
|---------------------------|---------------------------------------------|-------------------------------------------------------|
|
||||
| nothing | forwards to r2 | prefix returns, AS path `65002 65001 65000` |
|
||||
| `NO_EXPORT` | keeps it, does not send to r2 (RFC 1997) | prefix does not return |
|
||||
| `65001:100` | prepends `65001` three times | returns with path `65002 65001 65001 65001 65001 65000` |
|
||||
| `BLACKHOLE` (65535:666) | drops it on import | prefix does not return |
|
||||
|
||||
## Without containers
|
||||
|
||||
`fake_peer` in `crates/integration-tests/examples` is enough for the core
|
||||
loop on one machine: it echoes every prefix back with its own ASN prepended
|
||||
and honours `NO_EXPORT`. See `bin/bgp/README.md`.
|
||||
|
||||
## Caveats
|
||||
|
||||
These container files were written without a container runtime to hand, so
|
||||
they have not been executed. Things most likely to need a touch:
|
||||
|
||||
- the FRR image tag, and FRR's route-map / community-list syntax if your
|
||||
version differs from 10.x;
|
||||
- `no bgp ebgp-requires-policy` is required on FRR 7.4+ or eBGP routes are
|
||||
silently dropped; it is present in both configs;
|
||||
- ferrix binds port 179 inside its container, which requires no special
|
||||
capability there. If you run it on the host instead, either run as root,
|
||||
grant `CAP_NET_BIND_SERVICE`, or change `listen_endpoints` and the FRR
|
||||
neighbor port.
|
||||
- Both ferrix and the FRR routers dial each other, so connection collisions
|
||||
(RFC 4271 §6.8) are normal at start-up: one of the two TCP connections is
|
||||
closed with a Cease / Connection Collision Resolution NOTIFICATION and the
|
||||
other carries the session. ferrix has the lowest BGP Identifier here, so
|
||||
the connection the FRR router opened is the one that survives; the
|
||||
"opened by" column on the status page shows which side that was.
|
||||
- ferrix does not check AS paths for its own ASN. That is what lets the
|
||||
looped route be accepted; a production BGP speaker would reject it.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Teaching lab: ferrix_bgp plus two FRR routers forming a loop, so a prefix
|
||||
# announced from the web UI travels ferrix -> r1 -> r2 -> ferrix and shows up
|
||||
# in the "received routes" table with the AS path it collected on the way.
|
||||
#
|
||||
# AS65000 ferrix 10.200.0.1 web UI on http://localhost:8080/statusz
|
||||
# AS65001 r1 10.200.0.2 applies community-driven policy
|
||||
# AS65002 r2 10.200.0.3 learns only via r1, re-advertises to ferrix
|
||||
#
|
||||
# Usage (from the repository root):
|
||||
# docker compose -f docs/lab/docker-compose.yml up --build
|
||||
#
|
||||
# NOTE: this topology has not been run by the author of this file on the
|
||||
# machine it was written on (no container runtime was available). Expect to
|
||||
# adjust image tags and FRR config syntax for the FRR version you pull.
|
||||
services:
|
||||
ferrix:
|
||||
build:
|
||||
context: ../..
|
||||
container_name: ferrix
|
||||
volumes:
|
||||
- ./ferrix.textpb:/etc/ferrix/server.textpb:ro
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.1
|
||||
|
||||
r1:
|
||||
image: quay.io/frrouting/frr:10.2.1
|
||||
container_name: r1
|
||||
privileged: true
|
||||
volumes:
|
||||
- ./r1:/etc/frr:ro
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.2
|
||||
|
||||
r2:
|
||||
image: quay.io/frrouting/frr:10.2.1
|
||||
container_name: r2
|
||||
privileged: true
|
||||
volumes:
|
||||
- ./r2:/etc/frr:ro
|
||||
networks:
|
||||
lab:
|
||||
ipv4_address: 10.200.0.3
|
||||
|
||||
networks:
|
||||
lab:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.200.0.0/24
|
||||
@@ -1,30 +0,0 @@
|
||||
router_id: "10.200.0.1"
|
||||
local_asn: 65000
|
||||
listen_endpoints { address: "0.0.0.0" port: 179 }
|
||||
|
||||
peers {
|
||||
name: "r1"
|
||||
endpoint { address: "10.200.0.2" port: 179 }
|
||||
remote_asn: 65001
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
peers {
|
||||
name: "r2"
|
||||
endpoint { address: "10.200.0.3" port: 179 }
|
||||
remote_asn: 65002
|
||||
hold_time_seconds: 30
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
|
||||
# If r1 or r2 is restarted it will dial us again while we still believe the
|
||||
# old session is up. Let that new connection replace the stale one straight
|
||||
# away instead of refusing it until the hold timer (30 s here) expires.
|
||||
collision_detect_established_state: true
|
||||
|
||||
# Students may only announce out of 10.100.0.0/16; one /24 each is plenty.
|
||||
local_route_policy {
|
||||
allowed_prefixes: "10.100.0.0/16"
|
||||
max_routes_per_owner: 4
|
||||
}
|
||||
|
||||
http_endpoint { address: "0.0.0.0" port: 8080 }
|
||||
@@ -1,8 +0,0 @@
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
build.log
|
||||
class.pdf
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One exercise sheet per student, with their prefix filled in, in one PDF.
|
||||
|
||||
python3 build-class.py 100 # students 1..100 -> class.pdf
|
||||
python3 build-class.py 100 -o out.pdf
|
||||
python3 build-class.py 5 12 -o few.pdf # students 5..12
|
||||
|
||||
Student n gets 2a0d:d741:<n in hex>::/48; the lab /40 allows 1..255. Each
|
||||
sheet is compiled separately and must come out to exactly two pages, so
|
||||
the result prints double-sided with one student per leaf. Needs pdflatex
|
||||
and pdfunite (poppler).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def page_count(pdf):
|
||||
out = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True, check=True).stdout
|
||||
return int(next(line.split()[-1] for line in out.splitlines() if line.startswith("Pages:")))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("first", type=int, help="first student number, or the count if it is the only number")
|
||||
ap.add_argument("last", type=int, nargs="?")
|
||||
ap.add_argument("-o", "--output", default=os.path.join(HERE, "class.pdf"))
|
||||
args = ap.parse_args()
|
||||
first, last = (1, args.first) if args.last is None else (args.first, args.last)
|
||||
if not 1 <= first <= last <= 255:
|
||||
sys.exit("student numbers must be within 1..255: the lab /40 has 255 usable /48s")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name in os.listdir(HERE):
|
||||
if name.endswith((".tex", ".cls", ".sty", ".bst", ".bib")):
|
||||
shutil.copy(os.path.join(HERE, name), tmp)
|
||||
parts = []
|
||||
for n in range(first, last + 1):
|
||||
job = f"student-{n:03d}"
|
||||
tex = f"\\def\\studentnum{{{n}}}\\def\\studenthex{{{n:x}}}\\input{{handout.tex}}"
|
||||
for _ in range(2): # twice, for the figure and table references
|
||||
r = subprocess.run(["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "-jobname", job, tex],
|
||||
cwd=tmp, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
sys.exit(f"student {n}: pdflatex failed:\n{r.stdout[-2000:]}")
|
||||
pdf = os.path.join(tmp, job + ".pdf")
|
||||
pages = page_count(pdf)
|
||||
if pages != 2:
|
||||
sys.exit(f"student {n}: sheet is {pages} pages, not 2; double-sided printing would misalign")
|
||||
parts.append(pdf)
|
||||
print(f"\rstudent {n}: 2a0d:d741:{n:x}::/48", end="", flush=True)
|
||||
print()
|
||||
subprocess.run(["pdfunite", *parts, args.output], check=True)
|
||||
print(f"wrote {args.output}: students {first}..{last}, {page_count(args.output)} pages")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -1,195 +0,0 @@
|
||||
% Exercise sheet for the "Hands-on BGP" lecture: two A4 pages, one sheet.
|
||||
% Springer LNCS class (llncs.cls ships with TeX Live). Build: latexmk -pdf handout.tex
|
||||
\documentclass[a4paper]{llncs}
|
||||
|
||||
\usepackage[a4paper,margin=1in]{geometry}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{microtype}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{listings}
|
||||
\usepackage{tikz}
|
||||
\usepackage[bitstream-charter]{mathdesign}
|
||||
\usetikzlibrary{positioning,arrows.meta}
|
||||
\PassOptionsToPackage{hyphens}{url}
|
||||
\usepackage[hidelinks]{hyperref}
|
||||
\renewcommand\UrlFont{\ttfamily\small}
|
||||
\pagestyle{empty}
|
||||
% Floats sit in the text here, so they need less air than in a paper.
|
||||
\setlength{\intextsep}{7pt}
|
||||
\setlength{\abovecaptionskip}{4pt}
|
||||
\setlength{\belowcaptionskip}{0pt}
|
||||
|
||||
% ---- things to change per class -------------------------------------------
|
||||
\newcommand{\laburl}{https://bgplab.rayhaan.net/}
|
||||
% build-class.py compiles one copy per student with \studentnum and
|
||||
% \studenthex defined (e.g. 26 and 1a); without them this is the blank sheet.
|
||||
\providecommand{\studentnum}{}
|
||||
\providecommand{\studenthex}{}
|
||||
\ifx\studentnum\empty
|
||||
\newcommand{\myprefix}{\texttt{2a0d:d741:}\underline{\texttt{\,n\,}}\texttt{::/48}}
|
||||
\newcommand{\nhex}{<n>}
|
||||
\else
|
||||
\newcommand{\myprefix}{\texttt{2a0d:d741:\studenthex::/48}}
|
||||
\newcommand{\nhex}{\studenthex}
|
||||
\fi
|
||||
|
||||
% ---- exercise machinery ------------------------------------------------------
|
||||
\lstset{basicstyle=\ttfamily\small,columns=fullflexible,keepspaces=true,
|
||||
aboveskip=0.5em,belowskip=0.3em}
|
||||
\newcounter{task}[section]
|
||||
\renewcommand{\thetask}{\thesection.\arabic{task}}
|
||||
\newcommand{\task}[1]{\refstepcounter{task}\smallskip\noindent
|
||||
\textbf{Task \thetask}\enspace\textit{#1}\enspace\ignorespaces}
|
||||
\newcommand{\answer}[1][1]{\par\nobreak
|
||||
\foreach \i in {1,...,#1}{\vspace{1.0em}\noindent\makebox[\linewidth]{\dotfill}\par}\smallskip}
|
||||
% A labelled answer box: the label on its own line, then #1 dotted lines to
|
||||
% write on (default 2). Used side by side by \fillintwo and \fillinthree.
|
||||
\newcommand{\writelines}[1]{\foreach \i in {1,...,#1}{\vspace{1.0em}\makebox[\linewidth]{\dotfill}\par}}
|
||||
\newcommand{\fillbox}[3][2]{\parbox[t]{#2}{#3\par\nopagebreak\writelines{#1}}}
|
||||
\newcommand{\fillintwo}[3][2]{\par\nopagebreak\smallskip\noindent
|
||||
\fillbox[#1]{0.485\linewidth}{#2}\hfill\fillbox[#1]{0.485\linewidth}{#3}\par\vspace{0.4em}}
|
||||
\newcommand{\fillinthree}[4][2]{\par\nopagebreak\smallskip\noindent
|
||||
\fillbox[#1]{0.32\linewidth}{#2}\hfill\fillbox[#1]{0.32\linewidth}{#3}\hfill\fillbox[#1]{0.32\linewidth}{#4}\par\vspace{0.4em}}
|
||||
\newcommand{\cbox}{$\square$\;}
|
||||
\newcommand{\reach}{\par\nopagebreak\smallskip\noindent
|
||||
Reachable from:\hspace{1.2em}\cbox laptop on the university network\hspace{2em}\cbox phone on mobile data\par\smallskip}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\title{Hands on BGP: Announcing and Steering an IPv6 Prefix}
|
||||
\author{Rayhaan Jaufeerally}
|
||||
\institute{Network Security, ETHZ\\ Lab page: \url{\laburl} (username ``netsec'', password ``perrig'')}
|
||||
\maketitle
|
||||
|
||||
\medskip\noindent
|
||||
\ifx\studentnum\empty
|
||||
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
|
||||
\parbox[t]{0.27\linewidth}{Prefix: \myprefix}
|
||||
|
||||
\smallskip\noindent
|
||||
Your prefix is \myprefix, where \texttt{n} is your number \emph{in
|
||||
hexadecimal}.\par\noindent
|
||||
Student 10 has \texttt{2a0d:d741:a::/48} and student 26 has
|
||||
\texttt{2a0d:d741:1a::/48}.
|
||||
\else
|
||||
\parbox[t]{0.44\linewidth}{Name:~\dotfill}\hfill
|
||||
\parbox[t]{0.5\linewidth}{Student \studentnum\quad Prefix: \myprefix}
|
||||
|
||||
\smallskip\noindent
|
||||
Your prefix is \myprefix: student number \studentnum, written in
|
||||
hexadecimal. Make sure to use this prefix in the exercise.
|
||||
\fi
|
||||
|
||||
% ==============================================================================
|
||||
\section{Who Announces the Address You Are Using?}
|
||||
|
||||
\task{Find your IPv6 address.}
|
||||
Open \texttt{https://bgp.tools} in a browser: the top of the page shows the address
|
||||
you are connecting from, the prefix it belongs to, and the network that
|
||||
announces that prefix.
|
||||
\fillinthree{Your IPv6 address:}{its prefix:}{announced by AS:}
|
||||
|
||||
\task{Ask the registry.}
|
||||
The RIPE database records who holds address space
|
||||
(\texttt{inet6num}, or \texttt{inetnum} for IPv4) and who may announce it
|
||||
(\texttt{route6}, \texttt{route}). Look up your IPv6 address, then your lab prefix:
|
||||
\begin{lstlisting}[escapeinside={(*}{*)}]
|
||||
whois <your IPv6 address> whois 2a0d:d741:(*\nhex*)::/48
|
||||
\end{lstlisting}
|
||||
\fillintwo{Your IPv6 address: held by (\texttt{netname})}{\texttt{origin} AS:}
|
||||
\fillintwo{Your lab prefix: \texttt{origin} AS}{is it announced right now?}
|
||||
|
||||
% ==============================================================================
|
||||
\section{Announcing Your Prefix}
|
||||
|
||||
\begin{figure}[h]
|
||||
\centering
|
||||
\begin{tikzpicture}[font=\footnotesize, node distance=5mm and 11mm,
|
||||
box/.style={draw, rounded corners=1.5pt, minimum height=7.5mm, inner xsep=4pt, align=center},
|
||||
bgp/.style={-{Stealth[length=2mm]}, dashed}, web/.style={-{Stealth[length=2mm]}}]
|
||||
\node[box] (you) {you\\[-1pt]\scriptsize browser};
|
||||
\node[box, right=of you] (ferrix) {lab daemon\\[-1pt]\scriptsize AS4200000001};
|
||||
\node[box, right=of ferrix] (pr) {\texttt{pr01.rue}\\[-1pt]\scriptsize AS210036};
|
||||
\node[box, right=of pr] (rs) {SwissIX route servers\\[-1pt]\scriptsize AS42476};
|
||||
\node[box, right=of rs] (sw) {SWITCH (ETH)\\[-1pt]\scriptsize AS559};
|
||||
\node[box, below=4mm of rs] (other) {other exchanges, transit};
|
||||
\draw[web] (you) -- node[above, inner sep=1.5pt]{\tiny HTTPS} (ferrix);
|
||||
\draw[bgp] (ferrix) -- node[above, inner sep=1.5pt]{\tiny BGP} (pr);
|
||||
\draw[bgp] (pr) -- (rs);
|
||||
\draw[bgp] (rs) -- (sw);
|
||||
\draw[bgp] (pr) |- (other);
|
||||
\end{tikzpicture}
|
||||
\caption{The lab. Dashed arrows are BGP sessions; everything right of
|
||||
\texttt{pr01.rue} is the real Internet.}
|
||||
\label{fig:lab}
|
||||
\end{figure}
|
||||
|
||||
\task{Announce.}
|
||||
Open \url{\laburl} (username ``netsec'' password ``perrig''). Enter your name as
|
||||
\emph{owner} and your prefix, and press \emph{Announce}.
|
||||
|
||||
\task{Read what you sent.}
|
||||
Click \emph{bytes} next to your route and hover over the coloured segments.
|
||||
\fillintwo{AS number(s) in \texttt{AS\_PATH}:}{attribute carrying your prefix:}
|
||||
|
||||
\task{See it from outside.}
|
||||
Wait a moment, then look your prefix up on \texttt{https://bgp.tools/super-lg}.
|
||||
\fillintwo{Origin AS the world sees:}{an AS path you found:}
|
||||
\noindent The lab daemon's AS4200000001 is missing from that path. Where did it go, and why?
|
||||
\answer[2]
|
||||
|
||||
\task{Reach it.}
|
||||
The router answers for every address in the lab space, so your prefix is
|
||||
reachable exactly when your announcement got out:\quad
|
||||
\texttt{ping -6 2a0d:d741:\nhex::1}
|
||||
\reach
|
||||
|
||||
% ==============================================================================
|
||||
\section{Steering Your Prefix with Large Communities}
|
||||
|
||||
A Large Community is a tag added to a route. It can have any meaning that the network
|
||||
which issued it decides. In BGP large communities, the first part is the AS number
|
||||
and the subsequent two integers are up to the interpretation of that network.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
\begin{tabular}{@{}l@{\hspace{1.6em}}l@{\hspace{1.6em}}l@{}}
|
||||
\toprule
|
||||
Large community & Read by & Effect \\
|
||||
\midrule
|
||||
\texttt{210036:0:0} & \texttt{pr01.rue} & announce nowhere \ldots \\
|
||||
\texttt{210036:1:42476} & \texttt{pr01.rue} & \ldots except to AS42476, the SwissIX route servers \\
|
||||
\midrule
|
||||
\texttt{42476:0:0} & SwissIX & send to no member \ldots \\
|
||||
\texttt{42476:1:559} & SwissIX & \ldots except AS559, SWITCH, the network ETH is part of \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\end{table}
|
||||
|
||||
\task{One exchange only.}
|
||||
Announce your prefix again with \texttt{210036:0:0} and
|
||||
\texttt{210036:1:42476}; announcing a prefix you own replaces it. Before you
|
||||
look: who do you expect to still see it?
|
||||
\answer[2]
|
||||
\noindent Wait a moment, then check on \texttt{https://bgp.tools/super-lg} and with \texttt{ping}.
|
||||
\reach
|
||||
|
||||
\task{To SWITCH only.}
|
||||
Add \texttt{42476:0:0} and \texttt{42476:1:559} to the two you already have and
|
||||
announce again. Our router now tells only the route servers, and they tell
|
||||
only SWITCH.
|
||||
\reach
|
||||
\noindent Is your prefix still visible on \texttt{https://bgp.tools/super-lg}? Explain what you see.
|
||||
\answer[2]
|
||||
|
||||
\task{Use of large communities.}
|
||||
You steered routing in two networks you do not operate, using nothing but
|
||||
numbers attached to a route. What stops you from attaching a community that
|
||||
one of \emph{their} upstreams acts on?
|
||||
\answer[2]
|
||||
|
||||
\task{Clean up.} Withdraw your prefix and see how long it takes to go away.
|
||||
|
||||
\end{document}
|
||||
@@ -1,172 +0,0 @@
|
||||
# BIRD 2.x / 3.x: peer the production router (AS210036) with ferrix_bgp over
|
||||
# WireGuard. ferrix is announce-only: we accept the lab /48s from it and send
|
||||
# it nothing.
|
||||
#
|
||||
# Include from bird.conf: include "/etc/bird/bird-ferrix.conf";
|
||||
# Check and apply: birdc configure check && birdc configure
|
||||
# Inspect: birdc show protocols all ferrix
|
||||
# birdc show route protocol ferrix all
|
||||
|
||||
define MY_ASN = 210036;
|
||||
define FERRIX_ASN = 4200000001;
|
||||
define LAB_PREFIXES = [ 2a0d:d741::/40{48,48} ];
|
||||
|
||||
filter ferrix_import
|
||||
{
|
||||
# Only the students' /48s, nothing shorter or longer, nothing else.
|
||||
if !(net ~ LAB_PREFIXES) then reject;
|
||||
|
||||
# ferrix allows at most 16 prepends on top of its own ASN.
|
||||
if bgp_path.len > 17 then reject;
|
||||
if bgp_path.first != FERRIX_ASN then reject;
|
||||
|
||||
# Students type communities freely into a web form with no login, so
|
||||
# only namespaces we have chosen to expose get through.
|
||||
#
|
||||
# Standard: just the two well-known ones BIRD itself honours, so
|
||||
# NO_EXPORT / NO_ADVERTISE visibly work. Notably BLACKHOLE (65535:666),
|
||||
# which SwissIX acts on, stays scrubbed.
|
||||
bgp_community.filter([ (65535, 65281), (65535, 65282) ]);
|
||||
|
||||
# Large: the SwissIX route-server action communities, all of which live
|
||||
# under their route-server ASN 42476
|
||||
# (https://www.swissix.ch/resources/action-bgp-communities/):
|
||||
# 42476:0:<peer-as> do not redistribute to peer 42476:0:0 to nobody
|
||||
# 42476:1:<peer-as> redistribute to peer 42476:1:0 to all
|
||||
# 42476:101|102|103:<peer-as or 0> prepend 1x / 2x / 3x
|
||||
# 42476:901|902:<peer-as> add NO_EXPORT / NO_ADVERTISE
|
||||
# Everything else is dropped, including the rest of our own
|
||||
# (210036, *, *) space, so students cannot trigger anything else this
|
||||
# network acts on. Add further namespaces here deliberately.
|
||||
#
|
||||
# And our own export-control communities, functions 0 to 3, evaluated by
|
||||
# lab_export_allowed() below.
|
||||
bgp_large_community.filter([ (42476, *, *), (MY_ASN, 0..3, *) ]);
|
||||
|
||||
bgp_ext_community.empty;
|
||||
|
||||
# Announce-only: there is nothing behind these prefixes. Do not forward
|
||||
# their traffic into the tunnel; answer with "unreachable" instead.
|
||||
dest = RTD_UNREACHABLE;
|
||||
|
||||
accept;
|
||||
}
|
||||
|
||||
protocol bgp ferrix
|
||||
{
|
||||
description "ferrix_bgp teaching lab (announce only)";
|
||||
local 2a0d:d747:1::1 as 210036;
|
||||
neighbor 2a0d:d747:1:: as FERRIX_ASN;
|
||||
direct;
|
||||
|
||||
# ferrix dials us. Staying passive avoids log noise from failed attempts
|
||||
# towards a laptop that is asleep or firewalled.
|
||||
passive on;
|
||||
|
||||
# ferrix supports none of these; leave them off.
|
||||
# password "..."; (TCP MD5)
|
||||
# ttl security on; (GTSM)
|
||||
# bfd on;
|
||||
|
||||
ipv6 {
|
||||
import filter ferrix_import;
|
||||
# 128 students, one /48 each. Extra routes are ignored, the session
|
||||
# stays up.
|
||||
import limit 130 action block;
|
||||
# Send nothing: ferrix keeps received routes in memory and renders
|
||||
# every one of them on its status page.
|
||||
export none;
|
||||
};
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export control for lab routes.
|
||||
#
|
||||
# BIRD export filters cannot see which neighbour they are exporting to, and
|
||||
# filters take no arguments; functions do. So every eBGP session states who it
|
||||
# talks to and where, by calling the shared export function with its neighbour
|
||||
# AS and a location:
|
||||
#
|
||||
# ipv6 { export where peers_out(42476, AT_SWISSIX); ... };
|
||||
#
|
||||
# Students steer their prefix with large communities, in this precedence:
|
||||
#
|
||||
# 1. 210036:0:<as> never announce to that neighbour AS
|
||||
# 210036:2:<location> never announce at that location
|
||||
# 2. 210036:1:<as> announce to that neighbour AS
|
||||
# 210036:3:<location> announce at that location
|
||||
# 3. 210036:0:0 announce nowhere (unless rule 2 says otherwise)
|
||||
# 4. nothing announce everywhere <- default
|
||||
#
|
||||
# A "never" always wins, so the two dimensions combine. The shape and
|
||||
# precedence match the SwissIX route-server communities on purpose, so one
|
||||
# idea is applied at two hops.
|
||||
#
|
||||
# only the SwissIX route servers: 210036:0:0 210036:1:42476
|
||||
# ...and there only SWITCH: add 42476:0:0 42476:1:559
|
||||
# every session at SwissIX: 210036:0:0 210036:3:1
|
||||
# Cloudflare, but not at DE-CIX: 210036:0:0 210036:1:13335 210036:2:4
|
||||
# ---------------------------------------------------------------------------
|
||||
define AT_SWISSIX = 1;
|
||||
define AT_NLIX = 2;
|
||||
define AT_FRANCEIX = 3;
|
||||
define AT_DECIX = 4;
|
||||
define AT_FREEIX = 5;
|
||||
define AT_COMMUNITYIX = 6;
|
||||
define AT_TRANSIT = 100;
|
||||
define AT_DOWNSTREAM = 200;
|
||||
|
||||
function lab_export_allowed(int peer_as; int location)
|
||||
{
|
||||
if (MY_ASN, 0, peer_as) ~ bgp_large_community then return false;
|
||||
if (MY_ASN, 2, location) ~ bgp_large_community then return false;
|
||||
if (MY_ASN, 1, peer_as) ~ bgp_large_community then return true;
|
||||
if (MY_ASN, 3, location) ~ bgp_large_community then return true;
|
||||
if (MY_ASN, 0, 0) ~ bgp_large_community then return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
# Decides whether a lab route goes out on a session and, if so, makes it
|
||||
# presentable: the origin must appear as AS210036 (matching the route6
|
||||
# objects) and our action communities must not leak.
|
||||
function lab_route_out(int peer_as; int location)
|
||||
{
|
||||
if !lab_export_allowed(peer_as, location) then return false;
|
||||
bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]);
|
||||
bgp_large_community.delete([ (MY_ASN, 0..3, *) ]);
|
||||
return true;
|
||||
}
|
||||
|
||||
# The shared export function then ends its list of accepted routes with
|
||||
#
|
||||
# if proto = "ferrix" then return lab_route_out(peer_as, location);
|
||||
# return false;
|
||||
#
|
||||
# and returns true for everything else it accepts. Sessions that export
|
||||
# everything (downstream customers) need the same gate, or they receive lab
|
||||
# routes regardless of what the student asked for:
|
||||
#
|
||||
# export filter {
|
||||
# if proto = "ferrix" then {
|
||||
# if lab_route_out(210400, AT_DOWNSTREAM) then accept;
|
||||
# reject;
|
||||
# }
|
||||
# accept;
|
||||
# };
|
||||
#
|
||||
# The ferrix branch only ever applies to routes learned from ferrix, so these
|
||||
# communities cannot be used by anyone else to steer other exports.
|
||||
#
|
||||
# If this BIRD rejects "int peer_as; int location", it wants a comma there.
|
||||
#
|
||||
# Check what a session would send, without waiting for a looking glass:
|
||||
# birdc show route 2a0d:d741:1::/48 export <protocol-name> all
|
||||
#
|
||||
# The status page no longer offers AS-path prepending or the SwissIX prepend
|
||||
# communities (42476:101..103:*), to keep the exercise focused. The import
|
||||
# filter still admits the whole 42476 namespace, so a student who types one
|
||||
# by hand can use it; narrow the filter if that is not wanted.
|
||||
#
|
||||
# The session towards the SwissIX route servers must send large communities
|
||||
# unmodified apart from the delete above; check nothing else clears them.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1,20 +0,0 @@
|
||||
router_id: "10.255.0.2" # must differ from the production router's
|
||||
local_asn: 4200000001 # private asn used for internal peering.
|
||||
|
||||
listen_endpoints { address: "2a0d:d747:1::" port: 9179 }
|
||||
|
||||
peers {
|
||||
name: "pr01.rue.rayhaan.net"
|
||||
endpoint { address: "2a0d:d747:1::1" port: 179 }
|
||||
remote_asn: 210036
|
||||
hold_time_seconds: 90
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
collision_detect_established_state: true
|
||||
|
||||
local_route_policy {
|
||||
allowed_prefixes: "2a0d:d741::/40"
|
||||
max_routes_per_owner: 1
|
||||
}
|
||||
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
@@ -1,20 +0,0 @@
|
||||
# /etc/systemd/network/05-lab-lo.network
|
||||
# If a .network file already matches "lo" (e.g. the AnyIP one), merge these
|
||||
# sections into it instead: networkd applies only the first match.
|
||||
[Match]
|
||||
Name=lo
|
||||
|
||||
# BIRD and ferrix peer over ::1, which needs no configuration. Should BIRD
|
||||
# object to identical local and neighbour addresses, give them one each
|
||||
# instead (and use those in bird.conf and ferrix.textpb):
|
||||
#
|
||||
# [Address]
|
||||
# Address=fd00:179::1/128
|
||||
#
|
||||
# [Address]
|
||||
# Address=fd00:179::2/128
|
||||
|
||||
# AnyIP: the router answers ping for every address in the lab space.
|
||||
[Route]
|
||||
Destination=2a0d:d741::/40
|
||||
Type=local
|
||||
@@ -1,178 +0,0 @@
|
||||
# Running ferrix_bgp on the production router
|
||||
|
||||
ferrix and BIRD run side by side on pr01 and peer over `::1`. Students reach the status page through a reverse proxy that adds
|
||||
TLS and a password. Nothing here has been run on the router; treat each step
|
||||
as something to check.
|
||||
|
||||
## 1. Build a static Linux binary (on the Mac)
|
||||
|
||||
The `x86_64-unknown-linux-musl` target and Homebrew's musl cross-linker are
|
||||
enough; the router needs no Rust toolchain and no `protoc`.
|
||||
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc \
|
||||
cargo build --release -p bgp --target x86_64-unknown-linux-musl
|
||||
|
||||
scp target/x86_64-unknown-linux-musl/release/bgp pr01:/tmp/ferrix-bgp
|
||||
|
||||
Check the router really is x86-64 first (`uname -m`).
|
||||
|
||||
## 2. Install on the router
|
||||
|
||||
install -m 0755 /tmp/ferrix-bgp /usr/local/bin/ferrix-bgp
|
||||
install -d /etc/ferrix-bgp
|
||||
install -m 0644 ferrix.textpb /etc/ferrix-bgp/ferrix.textpb
|
||||
install -m 0644 ferrix-bgp.service /etc/systemd/system/ferrix-bgp.service
|
||||
|
||||
## 3. Loopback
|
||||
|
||||
Peering over `::1` needs no address setup. `05-lab-lo.network` only carries
|
||||
the AnyIP route for the lab space; merge it into the networkd file that
|
||||
matches `lo` if it is not there already, then `networkctl reload`.
|
||||
|
||||
## 4. BIRD
|
||||
|
||||
Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
|
||||
`birdc configure check` and `birdc configure timeout 300` / `confirm`.
|
||||
|
||||
## 5. Start
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now ferrix-bgp
|
||||
journalctl -u ferrix-bgp -f
|
||||
birdc show protocols all ferrix
|
||||
|
||||
The journal should say `Ping monitor listening for ICMPv6 echo requests`. If
|
||||
it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything
|
||||
else works without it and the status page says why the table is off.
|
||||
|
||||
To check the monitor before the class, announce a prefix on the page, turn on
|
||||
auto-refresh and ping an address inside it from somewhere else:
|
||||
|
||||
ping -c 3 2a0d:d741:1::1
|
||||
|
||||
The ping shows up under "Pings to announced addresses" with its source, the
|
||||
interface it arrived on and the hop limit it arrived with. Check that
|
||||
"arrived on" names the real ingress port (the exchange or transit interface)
|
||||
and not `lo`.
|
||||
|
||||
## 6. Let students in
|
||||
|
||||
The API has no authentication and announces into a real AS, so do not expose
|
||||
port 8080. Put a reverse proxy in front with TLS and a class password. The
|
||||
event stream is long-lived, so the proxy must not buffer it.
|
||||
|
||||
Apache (tested against a local instance: 401 without the password, pages,
|
||||
API and POSTs through the proxy, and events arriving within 50 ms):
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName lab.example.net
|
||||
Redirect permanent "/" "https://lab.example.net/"
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName lab.example.net
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/lab.example.net/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/lab.example.net/privkey.pem
|
||||
|
||||
# Everything behind one class password.
|
||||
<Location "/">
|
||||
AuthType Basic
|
||||
AuthName "BGP lab"
|
||||
AuthUserFile /etc/apache2/ferrix.htpasswd
|
||||
Require valid-user
|
||||
</Location>
|
||||
|
||||
# Use Apache's built-in error messages in this vhost. Servers with the
|
||||
# multi-language error documents enabled fetch them from /error/,
|
||||
# which this vhost would proxy to ferrix; a 401 then comes back as
|
||||
# ferrix's empty 404: a blank page and no password prompt. With the
|
||||
# built-in ones nothing is fetched, so nothing needs exempting from
|
||||
# the login.
|
||||
ErrorDocument 400 default
|
||||
ErrorDocument 401 default
|
||||
ErrorDocument 403 default
|
||||
ErrorDocument 404 default
|
||||
ErrorDocument 500 default
|
||||
ErrorDocument 502 default
|
||||
ErrorDocument 503 default
|
||||
ErrorDocument 504 default
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyPass "/" "http://127.0.0.1:8080/"
|
||||
ProxyPassReverse "/" "http://127.0.0.1:8080/"
|
||||
|
||||
# ferrix has no use for the password; do not pass it on.
|
||||
RequestHeader unset Authorization
|
||||
|
||||
# The status page follows a long-lived event stream. If mod_deflate
|
||||
# compresses it, events sit in the compressor and never arrive.
|
||||
SetEnvIf Request_URI "^/api/events" no-gzip=1
|
||||
</VirtualHost>
|
||||
|
||||
Modules: `proxy proxy_http ssl headers setenvif auth_basic authn_file`.
|
||||
Create the password file with `htpasswd -c -B /etc/apache2/ferrix.htpasswd student`.
|
||||
To also limit it to known networks, replace `Require valid-user` with
|
||||
|
||||
<RequireAll>
|
||||
Require valid-user
|
||||
Require ip 192.0.2.0/24 2001:db8::/32
|
||||
</RequireAll>
|
||||
|
||||
Caddy:
|
||||
|
||||
lab.example.net {
|
||||
basic_auth {
|
||||
student <hash from `caddy hash-password`>
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
nginx, inside a TLS `server` block:
|
||||
|
||||
location / {
|
||||
auth_basic "BGP lab";
|
||||
auth_basic_user_file /etc/nginx/lab.htpasswd;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
Restricting the proxy to the university's address ranges as well is cheap
|
||||
and worth doing. Only the Apache example has been tested.
|
||||
|
||||
## 7. Load test before the class
|
||||
|
||||
`tools/loadtest.py` makes N simulated students announce their /48 at the same
|
||||
instant, then verifies the table, ownership under load, the event stream and
|
||||
the number of BGP UPDATEs actually sent, and withdraws everything again.
|
||||
Python 3 standard library only.
|
||||
|
||||
tools/loadtest.py 128 --url https://lab.example.net --user student
|
||||
|
||||
Every test route carries `210036:0:0`, so BIRD accepts it but exports it to no
|
||||
neighbour (apart from `home_v6`, which is outside the export control). While
|
||||
it runs, `birdc show route protocol ferrix count` should rise to the number of
|
||||
students and fall back to what it was. BIRD's `import limit 130` means more
|
||||
than 130 lab routes at once are blocked there, by design.
|
||||
|
||||
## Things to know
|
||||
|
||||
- The ping monitor only listens. The kernel answers the pings, with or
|
||||
without ferrix running, because of the AnyIP route. It sees echo requests
|
||||
to the lab space from anyone on the Internet, so source addresses of
|
||||
strangers will appear on the page next to the students' own. It remembers
|
||||
at most 2048 source and destination pairs and publishes at most 50 events a
|
||||
second, so a ping flood costs little; `ping_monitor_echo_requests_total` and
|
||||
`ping_monitor_events_suppressed_total` on `/metricz` show what it saw.
|
||||
|
||||
- Announced routes live in memory. A restart of the service forgets them and
|
||||
students have to announce again.
|
||||
- Stopping the service sends BIRD a Cease first, so restarts reconnect at
|
||||
once.
|
||||
- ferrix's own safety net is `local_route_policy`; BIRD's import filter and
|
||||
import limit are the second one. Keep both.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Replaces "protocol bgp ferrix" in bird.conf when ferrix runs on this host.
|
||||
# Only the addresses and "multihop" differ from the WireGuard version; the
|
||||
# import filter and everything about export control stay as they are.
|
||||
#
|
||||
# Without "multihop" BIRD looks for the neighbour on a directly connected
|
||||
# network, cannot find ::1 there, and disables the protocol with
|
||||
# "Error: Invalid next hop". After fixing that, `birdc enable ferrix` if a
|
||||
# reconfigure does not bring it back by itself.
|
||||
protocol bgp ferrix
|
||||
{
|
||||
description "ferrix_bgp teaching lab (announce only, same host)";
|
||||
local ::1 as 210036;
|
||||
neighbor ::1 as FERRIX_ASN;
|
||||
# Both ends are this machine, so there is no directly connected
|
||||
# neighbour for "direct" (the eBGP default) to find.
|
||||
multihop;
|
||||
passive on;
|
||||
ipv6 {
|
||||
import filter ferrix_import;
|
||||
import limit 130 action block;
|
||||
export none;
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
[Unit]
|
||||
Description=ferrix_bgp teaching BGP speaker
|
||||
After=network-online.target bird.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/ferrix-bgp /etc/ferrix-bgp/ferrix.textpb --level info
|
||||
# SIGTERM makes ferrix send Cease / Administrative Shutdown to BIRD first.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# It needs the network and nothing else: no root, no privileged port, no
|
||||
# write access anywhere. The one capability, CAP_NET_RAW, is for the ping
|
||||
# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not
|
||||
# configured.
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_RAW
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,32 +0,0 @@
|
||||
# ferrix_bgp running on the production router itself, next to BIRD.
|
||||
router_id: "10.255.0.2"
|
||||
local_asn: 4200000001
|
||||
|
||||
# Unused in practice (BIRD is passive and we dial it), but a listener is
|
||||
# required. 179 belongs to BIRD, so take another port on our own address.
|
||||
listen_endpoints { address: "::1" port: 9179 }
|
||||
|
||||
peers {
|
||||
name: "pr01-bird"
|
||||
endpoint { address: "::1" port: 179 }
|
||||
# BIRD recognises its neighbour by source address, so pin ours.
|
||||
local_address: "::1"
|
||||
remote_asn: 210036
|
||||
hold_time_seconds: 90
|
||||
}
|
||||
connect_retry_seconds: 5
|
||||
collision_detect_established_state: true
|
||||
|
||||
local_route_policy {
|
||||
allowed_prefixes: "2a0d:d741::/40"
|
||||
max_routes_per_owner: 1
|
||||
}
|
||||
|
||||
# Loopback only. Students reach it through the reverse proxy, which adds TLS
|
||||
# and a password; the API itself has neither.
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
|
||||
# Show who pings the lab addresses. The kernel answers the pings (AnyIP route
|
||||
# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above.
|
||||
# Needs CAP_NET_RAW, which the systemd unit grants.
|
||||
ping_monitor {}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
bgpd=yes
|
||||
zebra=yes
|
||||
staticd=yes
|
||||
vtysh_enable=yes
|
||||
@@ -1,44 +0,0 @@
|
||||
! r1: AS65001. Peers with ferrix (AS65000) and r2 (AS65002).
|
||||
! Applies policy on routes from ferrix based on the communities students set:
|
||||
! NO_EXPORT (65535:65281) standard: not sent to r2 (FRR honours it)
|
||||
! 65001:100 r1 prepends its own ASN three times
|
||||
! BLACKHOLE (65535:666) dropped at r1
|
||||
frr defaults traditional
|
||||
hostname r1
|
||||
log stdout
|
||||
!
|
||||
ip route 192.0.2.0/24 Null0
|
||||
!
|
||||
bgp community-list standard PREPEND-ME permit 65001:100
|
||||
bgp community-list standard BLACKHOLE permit 65535:666
|
||||
!
|
||||
route-map FROM-FERRIX deny 10
|
||||
match community BLACKHOLE
|
||||
!
|
||||
route-map FROM-FERRIX permit 20
|
||||
match community PREPEND-ME
|
||||
set as-path prepend 65001 65001 65001
|
||||
!
|
||||
route-map FROM-FERRIX permit 30
|
||||
!
|
||||
route-map PERMIT-ALL permit 10
|
||||
!
|
||||
router bgp 65001
|
||||
bgp router-id 10.200.0.2
|
||||
no bgp ebgp-requires-policy
|
||||
no bgp network import-check
|
||||
neighbor 10.200.0.1 remote-as 65000
|
||||
neighbor 10.200.0.1 description ferrix
|
||||
neighbor 10.200.0.1 timers 10 30
|
||||
neighbor 10.200.0.3 remote-as 65002
|
||||
neighbor 10.200.0.3 description r2
|
||||
neighbor 10.200.0.3 timers 10 30
|
||||
!
|
||||
address-family ipv4 unicast
|
||||
network 192.0.2.0/24
|
||||
neighbor 10.200.0.1 route-map FROM-FERRIX in
|
||||
neighbor 10.200.0.1 route-map PERMIT-ALL out
|
||||
neighbor 10.200.0.3 route-map PERMIT-ALL in
|
||||
neighbor 10.200.0.3 route-map PERMIT-ALL out
|
||||
exit-address-family
|
||||
!
|
||||
@@ -1 +0,0 @@
|
||||
service integrated-vtysh-config
|
||||
@@ -1,4 +0,0 @@
|
||||
bgpd=yes
|
||||
zebra=yes
|
||||
staticd=yes
|
||||
vtysh_enable=yes
|
||||
@@ -1,33 +0,0 @@
|
||||
! r2: AS65002. Peers with r1 (AS65001) and ferrix (AS65000).
|
||||
! Accepts nothing directly from ferrix so every student prefix reaches it
|
||||
! only via r1, then re-advertises everything to ferrix. That closes the loop:
|
||||
! ferrix sees its own prefixes come back with AS path 65002 65001 65000.
|
||||
frr defaults traditional
|
||||
hostname r2
|
||||
log stdout
|
||||
!
|
||||
ip route 198.51.100.0/24 Null0
|
||||
!
|
||||
route-map PERMIT-ALL permit 10
|
||||
!
|
||||
route-map DENY-ALL deny 10
|
||||
!
|
||||
router bgp 65002
|
||||
bgp router-id 10.200.0.3
|
||||
no bgp ebgp-requires-policy
|
||||
no bgp network import-check
|
||||
neighbor 10.200.0.2 remote-as 65001
|
||||
neighbor 10.200.0.2 description r1
|
||||
neighbor 10.200.0.2 timers 10 30
|
||||
neighbor 10.200.0.1 remote-as 65000
|
||||
neighbor 10.200.0.1 description ferrix
|
||||
neighbor 10.200.0.1 timers 10 30
|
||||
!
|
||||
address-family ipv4 unicast
|
||||
network 198.51.100.0/24
|
||||
neighbor 10.200.0.2 route-map PERMIT-ALL in
|
||||
neighbor 10.200.0.2 route-map PERMIT-ALL out
|
||||
neighbor 10.200.0.1 route-map DENY-ALL in
|
||||
neighbor 10.200.0.1 route-map PERMIT-ALL out
|
||||
exit-address-family
|
||||
!
|
||||
@@ -1 +0,0 @@
|
||||
service integrated-vtysh-config
|
||||
@@ -1,779 +0,0 @@
|
||||
inet6num: 2a0d:d741::/40
|
||||
netname: CH-RAY-ETHZ-NETSEC-LAB-2026
|
||||
descr: ETHZ Netsec 2026 BGP hands-on lab, one /48 per student
|
||||
country: CH
|
||||
org: ORG-RAJ2-RIPE
|
||||
admin-c: RJ4890-RIPE
|
||||
tech-c: RJ4890-RIPE
|
||||
status: AGGREGATED-BY-LIR
|
||||
assignment-size: 48
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 1
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 2
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 3
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 4
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 5
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 6
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 7
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:8::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 8
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:9::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 9
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 10
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 11
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 12
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 13
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 14
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 15
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:10::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 16
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:11::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 17
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:12::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 18
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:13::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 19
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:14::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 20
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:15::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 21
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:16::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 22
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:17::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 23
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:18::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 24
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:19::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 25
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 26
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 27
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 28
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 29
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 30
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:1f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 31
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:20::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 32
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:21::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 33
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:22::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 34
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:23::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 35
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:24::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 36
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:25::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 37
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:26::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 38
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:27::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 39
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:28::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 40
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:29::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 41
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 42
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 43
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 44
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 45
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 46
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:2f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 47
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:30::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 48
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:31::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 49
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:32::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 50
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:33::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 51
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:34::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 52
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:35::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 53
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:36::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 54
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:37::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 55
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:38::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 56
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:39::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 57
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 58
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 59
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 60
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 61
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 62
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:3f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 63
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:40::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 64
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:41::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 65
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:42::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 66
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:43::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 67
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:44::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 68
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:45::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 69
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:46::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 70
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:47::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 71
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:48::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 72
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:49::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 73
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 74
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 75
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 76
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 77
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 78
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:4f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 79
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:50::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 80
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:51::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 81
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:52::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 82
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:53::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 83
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:54::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 84
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:55::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 85
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:56::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 86
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:57::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 87
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:58::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 88
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:59::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 89
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 90
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 91
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 92
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 93
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 94
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:5f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 95
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:60::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 96
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:61::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 97
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:62::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 98
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:63::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 99
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:64::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 100
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:65::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 101
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:66::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 102
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:67::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 103
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:68::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 104
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:69::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 105
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 106
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 107
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 108
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 109
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 110
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:6f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 111
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:70::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 112
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:71::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 113
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:72::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 114
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:73::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 115
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:74::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 116
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:75::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 117
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:76::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 118
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:77::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 119
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:78::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 120
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:79::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 121
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7a::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 122
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7b::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 123
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7c::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 124
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7d::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 125
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7e::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 126
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:7f::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 127
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
|
||||
route6: 2a0d:d741:80::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 128
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
@@ -1,125 +0,0 @@
|
||||
# Slides
|
||||
|
||||
A reveal.js deck structured like Matt Godbolt's newer talks
|
||||
([what-happens-beneath-accu-2026](https://github.com/mattgodbolt/what-happens-beneath-accu-2026)):
|
||||
no stock theme but a custom dark one, every slide on a rounded panel, IBM Plex
|
||||
Sans and Mono, monokai code with glowing stepped lines, 16:9 with fade
|
||||
transitions, and the content in one Markdown file per section.
|
||||
|
||||
What is ours: the palette, and the corner motif. His ring-and-thread became a
|
||||
chain of nodes and links, an AS path.
|
||||
|
||||
## The talk
|
||||
|
||||
| Section | File |
|
||||
|---|---|
|
||||
| About the speaker | `sections/00-intro.md` |
|
||||
| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` |
|
||||
| BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` |
|
||||
| IP transit and Internet exchanges | `sections/04-transit-and-ix.md` |
|
||||
| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` |
|
||||
|
||||
The UPDATE in section 3 is `2a0d:d740::/48` as AS210036 announces it to the
|
||||
SwissIX route servers: next hop `2001:7f8:24::c9` (pr01's port on the peering
|
||||
LAN), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts
|
||||
on routes to peers. It was produced with the lab daemon's own encoder and parsed back
|
||||
to the same message, not captured off the wire; a capture from pr01 may differ
|
||||
in detail (BIRD can append a link-local next hop). The byte slides step through
|
||||
it one part at a time (press down), and every number on them was checked
|
||||
against the bytes when the slides were generated. To change the example,
|
||||
regenerate rather than edit the hex by hand.
|
||||
|
||||
The deck deliberately has no fragments or stepped code: a slide shows
|
||||
everything at once. Two code blocks keep a static highlight on one line.
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `theme.css` | the look: palette, type, panels, motif, title slide |
|
||||
| `style.css` | components used by slides: columns, boxes, bytes, notes |
|
||||
| `index.html` | section list, reveal.js loader and options, `bird` highlighter |
|
||||
| `sections/*.md` | the slides |
|
||||
|
||||
## Make it yours
|
||||
|
||||
Everything brand-like is a variable at the top of `theme.css`. To compare
|
||||
accents without editing, open `?accent=green`, `?accent=amber` or
|
||||
`?accent=magenta` (blue is the default); copy the one you like into `:root`.
|
||||
The wordmark bottom-left is the `<div class="wordmark">` in `index.html`.
|
||||
For a full-bleed title image, empty the title `<section>` and give it
|
||||
`data-background="images/title.png"`.
|
||||
|
||||
## Show it
|
||||
|
||||
The sections are loaded over HTTP, so open the deck through a server, not by
|
||||
double-clicking `index.html`. Opened from disk, browsers block reveal.js and
|
||||
the section files, and you would see a title and empty panels; the page now
|
||||
says so instead:
|
||||
|
||||
python3 slides/serve.py 8000
|
||||
|
||||
(`python3 -m http.server` works too, but it lets the browser cache the
|
||||
section files and the stylesheet, so edits may not show until a hard reload,
|
||||
Cmd+Shift+R. `serve.py` is the same server with caching turned off.)
|
||||
|
||||
Then <http://localhost:8000>. Keys: arrows to move, `S` speaker view with
|
||||
notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen.
|
||||
|
||||
## Offline
|
||||
|
||||
slides/vendor-reveal.sh
|
||||
|
||||
copies reveal.js into `slides/reveal.js/` (ignored by git). The deck uses that
|
||||
copy when present and the CDN otherwise. Do this before presenting anywhere
|
||||
with uncertain Wi-Fi. IBM Plex still comes from Google Fonts; without a
|
||||
network the deck falls back to Helvetica/Arial and the system monospace, so
|
||||
install IBM Plex on the presenting machine if that matters.
|
||||
|
||||
## Write slides
|
||||
|
||||
Edit the files in `sections/`; add a section by adding a file and a
|
||||
`<section data-markdown=...>` line in `index.html`.
|
||||
|
||||
----- next slide
|
||||
--- next vertical slide (detail you can skip)
|
||||
Notes: speaker notes from here to the end of the slide
|
||||
|
||||
Apply a class to the element above with `<!-- .element: class="..." -->`.
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `big` | one large statement on the slide |
|
||||
| `note` | quiet italic aside |
|
||||
| `attribution` | small credit under an image or quote |
|
||||
| `borders` | table with visible borders |
|
||||
| `r-stretch` | image or iframe fills the remaining height |
|
||||
| `blur-edges` | soft edge for photos |
|
||||
|
||||
Blocks: `<div class="cols">` two columns, `<div class="boxes">` labelled cards
|
||||
(`us`, `ok`, `bad`), `<pre class="bytes">` BGP bytes in the colours of the lab
|
||||
page (add `focus`, and `on` to the spans to keep lit, to dim the rest),
|
||||
`<table class="decode">` for the explanation under them,
|
||||
`<svg class="diagram">` for diagrams drawn in the theme's colours (`hot`,
|
||||
`fabric`, `link`, `bgp`, `traffic`). Code fences take a language and optional line steps, e.g.
|
||||
` ```bird [1-3|5-9] `; `bird` is a small highlighter defined in `index.html`.
|
||||
|
||||
`<!-- .slide: class="bare" -->` drops the panel, the motif and the wordmark
|
||||
for one slide, e.g. a full-bleed image. The title slide does so by itself.
|
||||
|
||||
## Publish as one file
|
||||
|
||||
slides/vendor-reveal.sh # once, if slides/reveal.js is missing
|
||||
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
|
||||
|
||||
`build.py` inlines everything the page would otherwise fetch: reveal.js and
|
||||
its plugins, the stylesheets, the favicon, our scripts and the Markdown
|
||||
sections. The one file (about 1.3 MB) can be uploaded anywhere and also
|
||||
opens straight from disk. It still uses the network for two things: the IBM
|
||||
Plex fonts from Google Fonts (without them the browser's fallback fonts are
|
||||
used) and the live DNS and RIPE lookups in section 1. Speaker view (`S`)
|
||||
works from the bundle too. `slides/dist/` is not committed; rebuild after
|
||||
editing.
|
||||
|
||||
## PDF
|
||||
|
||||
Open <http://localhost:8000/?print-pdf> in Chrome and print to PDF with
|
||||
background graphics on.
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bundle the deck into one self-contained HTML file for uploading.
|
||||
|
||||
python3 slides/build.py # writes slides/dist/hands-on-bgp.html
|
||||
|
||||
Everything the page fetches at runtime is inlined: reveal.js and its plugins
|
||||
(from the vendored copy, so run vendor-reveal.sh first), the stylesheets, the
|
||||
favicon, our scripts and the Markdown sections. The result opens from any web
|
||||
server and from disk. Two things still need a network: the IBM Plex fonts
|
||||
(Google Fonts) and the live DNS and RIPE lookups.
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REVEAL = os.path.join(HERE, "reveal.js", "dist")
|
||||
OUT = os.path.join(HERE, "dist", "hands-on-bgp.html")
|
||||
|
||||
|
||||
def read(*parts):
|
||||
with open(os.path.join(HERE, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(REVEAL):
|
||||
sys.exit("reveal.js is not vendored; run slides/vendor-reveal.sh first")
|
||||
html = read("index.html")
|
||||
|
||||
# Stylesheets: reveal's first, then ours, as the loader orders them.
|
||||
css = "\n".join(read("reveal.js", "dist", p) for p in ["reset.css", "reveal.css", "plugin/highlight/monokai.css"])
|
||||
css += "\n" + read("theme.css") + "\n" + read("style.css")
|
||||
html = re.sub(r'\s*<link rel="stylesheet" href="theme\.css[^"]*" id="deck-style" />', "", html)
|
||||
html = re.sub(r'\s*<link rel="stylesheet" href="style\.css[^"]*" />', f"\n <style>\n{css}\n </style>", html, count=1)
|
||||
|
||||
icon = "data:image/svg+xml;base64," + base64.b64encode(read("images", "favicon.svg").encode()).decode()
|
||||
html = html.replace('href="images/favicon.svg"', f'href="{icon}"')
|
||||
|
||||
# Sections: the Markdown goes inline in a text/template script.
|
||||
def inline_section(m):
|
||||
attrs, path = m.group(1), m.group(2)
|
||||
text = read(path)
|
||||
if "</script" in text.lower():
|
||||
sys.exit(f"{path} contains '</script', which cannot be inlined")
|
||||
return f'<section data-markdown{attrs}>\n<script type="text/template">\n{text}\n</script>\n</section>'
|
||||
html, n = re.subn(r'<section\s+data-markdown="([^"]+)"((?:\s+data-[a-z-]+="[^"]*")*)\s*></section>',
|
||||
lambda m: inline_section(type("M", (), {"group": lambda self, i: (None, m.group(2), m.group(1))[i]})()), html)
|
||||
if n == 0:
|
||||
sys.exit("no data-markdown sections found in index.html")
|
||||
|
||||
# Our scripts.
|
||||
for name in ["dns-lookup.js", "prefix-explorer.js"]:
|
||||
html, k = re.subn(rf'<script src="{re.escape(name)}[^"]*"></script>', lambda m: f"<script>\n{read(name)}\n</script>", html)
|
||||
if k != 1:
|
||||
sys.exit(f"expected one <script src=\"{name}\"> tag")
|
||||
|
||||
# The help overlay and its watchdog exist to explain a page that could
|
||||
# not fetch its parts; this one fetches nothing.
|
||||
html = re.sub(r'\s*<div id="deck-help" hidden>.*?</div>', "", html, count=1, flags=re.S)
|
||||
html = re.sub(r'\s*<!-- A classic script: it still runs.*?</script>', "", html, count=1, flags=re.S)
|
||||
|
||||
# The loader: the module script that picks a reveal.js location and
|
||||
# imports it becomes reveal.js itself, its plugins, and the deck setup.
|
||||
m = re.search(r'<script type="module">(.*?)</script>', html, flags=re.S)
|
||||
if not m:
|
||||
sys.exit("loader script not found")
|
||||
loader = m.group(1)
|
||||
setup_start = loader.index("// BIRD configuration")
|
||||
setup = loader[setup_start:]
|
||||
setup = setup.replace("await deck.initialize().catch(fail(\"reveal.js failed to start\"));", "deck.initialize().then(() => {")
|
||||
setup = setup.replace("plugins: [Markdown, Highlight, Notes],", "plugins: [RevealMarkdown, RevealHighlight, RevealNotes],")
|
||||
setup = setup.rstrip() + "\n });\n"
|
||||
libs = "\n".join(read("reveal.js", "dist", p) for p in ["reveal.js", "plugin/markdown.js", "plugin/highlight.js", "plugin/notes.js"])
|
||||
html = html[: m.start()] + f"<script>\n{libs}\n</script>\n <script>\n{setup} </script>" + html[m.end():]
|
||||
if "await " in setup or "import(" in setup:
|
||||
sys.exit("loader still contains module-only code")
|
||||
|
||||
html = html.replace("reveal.js is loaded from ./reveal.js/dist if present", "Built by build.py from index.html: everything is inlined. reveal.js was loaded from ./reveal.js/dist if present")
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"wrote {os.path.relpath(OUT)} ({os.path.getsize(OUT) // 1024} KB, {n} sections)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,140 +0,0 @@
|
||||
// Live lookups on slides.
|
||||
//
|
||||
// <form class="dns-lookup"> with <input name="domain"> and a .dns-result:
|
||||
// asks Google Public DNS over HTTPS for the name's AAAA records
|
||||
// (https://developers.google.com/speed/public-dns/docs/doh/json) and
|
||||
// prints the answer the way dig would.
|
||||
// <form class="ripe-lookup"> with <input name="query"> and a .dns-result:
|
||||
// asks the RIPE database (https://apps.db.ripe.net/docs/) what it knows
|
||||
// about an address, prefix or AS number, and prints the objects the way
|
||||
// whois would.
|
||||
(function () {
|
||||
const TYPES = { 1: "A", 5: "CNAME", 28: "AAAA" };
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
async function resolve(name, type) {
|
||||
const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, {
|
||||
headers: { accept: "application/dns-json" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`dns.google answered HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const line = (rr) =>
|
||||
`<span class="name">${esc(rr.name)}</span> <span class="ttl">${rr.TTL}</span> IN <span class="type">${esc(TYPES[rr.type] || rr.type)}</span> <span class="${rr.type === 28 ? "addr" : "data"}">${esc(rr.data)}</span>`;
|
||||
|
||||
async function lookup(form) {
|
||||
const out = form.querySelector(".dns-result");
|
||||
const name = form.elements.domain.value.trim().replace(/\.$/, "");
|
||||
if (!name) return;
|
||||
out.innerHTML = `<span class="dim">asking dns.google for ${esc(name)} AAAA ...</span>`;
|
||||
try {
|
||||
const v6 = await resolve(name, "AAAA");
|
||||
if (v6.Status === 3) {
|
||||
out.innerHTML = `<span class="bad">NXDOMAIN</span> <span class="dim">there is no such name</span>`;
|
||||
return;
|
||||
}
|
||||
if (v6.Status !== 0) {
|
||||
out.innerHTML = `<span class="bad">the resolver answered with status ${v6.Status}</span>`;
|
||||
return;
|
||||
}
|
||||
const answers = v6.Answer || [];
|
||||
const lines = answers.map(line);
|
||||
if (!answers.some((rr) => rr.type === 28)) {
|
||||
// No IPv6 address. Show that this is a real answer, not a failure,
|
||||
// and what the name does have.
|
||||
const v4 = await resolve(name, "A");
|
||||
lines.push(`<span class="dim">no AAAA record: this name has no IPv6 address</span>`);
|
||||
(v4.Answer || []).filter((rr) => rr.type === 1).forEach((rr) => lines.push(line(rr)));
|
||||
}
|
||||
out.innerHTML = lines.join("\n");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="bad">lookup failed:</span> ${esc(e.message)} <span class="dim">(is there a network?)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RIPE database ----------------------------------------------------
|
||||
|
||||
// Objects as apps.db.ripe.net shows them: one card per object, one row per
|
||||
// attribute, references in blue, rows the RIPE NCC manages shaded. The
|
||||
// API does not say which rows those are, so they are the ones the website
|
||||
// shades for an assignment: the key, org, status, source, sponsoring-org
|
||||
// and the RIPE NCC's own maintainers.
|
||||
const RIPE_MANAGED = new Set(["inetnum", "inet6num", "aut-num", "org", "status", "source", "sponsoring-org"]);
|
||||
const managed = (a) => RIPE_MANAGED.has(a.name) || (a.name === "mnt-by" && /^RIPE-NCC-/.test(a.value));
|
||||
|
||||
async function ripe(form) {
|
||||
const out = form.querySelector(".ripe-result");
|
||||
const query = form.elements.query.value.trim();
|
||||
if (!query) return;
|
||||
out.innerHTML = `<div class="ripe-msg">Searching the RIPE database for ${esc(query)} ...</div>`;
|
||||
try {
|
||||
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(query)}&flags=no-referenced&flags=no-irt&source=ripe`;
|
||||
const r = await fetch(url, { headers: { accept: "application/json" } });
|
||||
if (r.status === 404) {
|
||||
out.innerHTML = `<div class="ripe-msg">No results found for ${esc(query)}.</div>`;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`rest.db.ripe.net answered HTTP ${r.status}`);
|
||||
const objects = ((await r.json()).objects || {}).object || [];
|
||||
out.innerHTML = objects
|
||||
.map((o) => {
|
||||
const rows = o.attributes.attribute.map((a) => {
|
||||
const value = a.link || a.name === o.type ? `<span class="ref">${esc(a.value)}</span>` : esc(a.value);
|
||||
return `<tr class="${managed(a) ? "managed" : ""}"><th>${esc(a.name)}:</th><td>${value}${a.comment ? `<span class="comment"># ${esc(a.comment)}</span>` : ""}</td></tr>`;
|
||||
});
|
||||
return `<div class="ripe-card"><table>${rows.join("")}</table></div>`;
|
||||
})
|
||||
.join("");
|
||||
out.scrollTop = 0;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="ripe-msg">Lookup failed: ${esc(e.message)}. Is there a network?</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The answer changes the slide's height; have reveal centre it again.
|
||||
const relayout = () => window.deck && window.deck.layout();
|
||||
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.dns-lookup, form.ripe-lookup");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
await (form.classList.contains("ripe-lookup") ? ripe(form) : lookup(form));
|
||||
relayout();
|
||||
});
|
||||
})();
|
||||
|
||||
// Byte walks: hovering (or tabbing to) a field row lights its bytes in the
|
||||
// dump above it, and hovering bytes in the dump lights their row and scrolls
|
||||
// it into view, so the dump can be used as the index while presenting.
|
||||
(function () {
|
||||
const pair = (el) => {
|
||||
const walk = el.closest(".bytewalk");
|
||||
if (!walk) return [];
|
||||
const i = el.dataset.i;
|
||||
return [walk.querySelector(`.bw-dump span[data-i="${i}"]`), walk.querySelector(`.bw-row[data-i="${i}"]`)].filter(Boolean);
|
||||
};
|
||||
const target = (e) => e.target.closest && e.target.closest(".bw-row, .bw-dump span[data-i]");
|
||||
const on = (e) => {
|
||||
const el = target(e);
|
||||
if (!el) return;
|
||||
const both = pair(el);
|
||||
both.forEach((x) => x.classList.add("on"));
|
||||
if (el.matches(".bw-dump span")) {
|
||||
// Scroll the box itself, and instantly: scrollIntoView would also
|
||||
// move the slide, and Chrome drops smooth scrolls inside the
|
||||
// transformed slide that reveal positions.
|
||||
const row = both.find((x) => x.classList.contains("bw-row"));
|
||||
const box = row && row.closest(".bw-rows");
|
||||
if (box) box.scrollTop = row.offsetTop - box.clientHeight / 2 + row.offsetHeight / 2;
|
||||
}
|
||||
};
|
||||
const off = (e) => {
|
||||
const el = target(e);
|
||||
if (el) pair(el).forEach((x) => x.classList.remove("on"));
|
||||
};
|
||||
document.addEventListener("mouseover", on);
|
||||
document.addEventListener("focusin", on);
|
||||
document.addEventListener("mouseout", off);
|
||||
document.addEventListener("focusout", off);
|
||||
})();
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#11131f"/>
|
||||
<g fill="none" stroke="#5eb1ff" stroke-width="3">
|
||||
<line x1="9" y1="16" x2="23" y2="16"/>
|
||||
<circle cx="7" cy="16" r="4" fill="#5eb1ff"/>
|
||||
<circle cx="25" cy="16" r="4" fill="#11131f"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 326 B |
@@ -1,230 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hands-on BGP</title>
|
||||
<!--
|
||||
Deck structure and look follow Matt Godbolt's newer talks (e.g.
|
||||
github.com/mattgodbolt/what-happens-beneath-accu-2026): reveal.js with
|
||||
no stock theme, a custom dark theme (theme.css), monokai code blocks,
|
||||
16:9, fade transitions, and one Markdown file per section.
|
||||
|
||||
reveal.js is loaded from ./reveal.js/dist if present (run
|
||||
./vendor-reveal.sh once; works offline), otherwise from the CDN. Its
|
||||
stylesheets are inserted BEFORE ours by the loader below, so theme.css
|
||||
and style.css always have the last word.
|
||||
-->
|
||||
<link rel="stylesheet" href="theme.css?v=3" id="deck-style" />
|
||||
<link rel="stylesheet" href="style.css?v=18" />
|
||||
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="shortcut icon" href="images/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
<!-- Title slide. For a full-bleed image instead, empty it and add
|
||||
data-background="images/title.png" like Matt does. -->
|
||||
<section class="title-slide">
|
||||
<h1>Hands-on BGP</h1>
|
||||
<h3>Insights into real world operations</h3>
|
||||
<p class="byline">
|
||||
Rayhaan Jaufeerally (rayhaan@rayhaan.ch) ·
|
||||
Network Security · ETH Zürich, 2026
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
One Markdown file per section. Inside a file:
|
||||
----- next horizontal slide
|
||||
--- next vertical slide (goes down, for detail you may skip)
|
||||
Notes: everything after this is speaker notes (press S)
|
||||
-->
|
||||
<section
|
||||
data-markdown="sections/00-intro.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/01-resources.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/03-bgp-update.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/04-transit-and-ix.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/05-lab.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits where Matt's decks carry the employer logo. -->
|
||||
<div class="wordmark"><b>AS210036</b> · netsec 2026</div>
|
||||
|
||||
<div id="deck-help" hidden>
|
||||
<h2>The slides did not load</h2>
|
||||
<p id="deck-help-why"></p>
|
||||
<pre>python3 -m http.server 8000 --directory slides</pre>
|
||||
<p>
|
||||
then open <code>http://localhost:8000</code>. Details in
|
||||
<code>slides/README.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A classic script: it still runs when the browser refuses to run the
|
||||
module below, which is exactly the case it has to explain. -->
|
||||
<script>
|
||||
(function () {
|
||||
function help(why) {
|
||||
document.getElementById("deck-help-why").textContent = why;
|
||||
document.getElementById("deck-help").hidden = false;
|
||||
}
|
||||
if (location.protocol === "file:") {
|
||||
help(
|
||||
"This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.addEventListener("deck-failed", function (e) {
|
||||
help(
|
||||
e.detail +
|
||||
" Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:",
|
||||
);
|
||||
});
|
||||
setTimeout(function () {
|
||||
if (!window.deck)
|
||||
help(
|
||||
"reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:",
|
||||
);
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="dns-lookup.js?v=7"></script>
|
||||
<script src="prefix-explorer.js?v=4"></script>
|
||||
|
||||
<script type="module">
|
||||
const VERSION = "6.0.2";
|
||||
const LOCAL = "./reveal.js/dist";
|
||||
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
|
||||
|
||||
// Prefer the vendored copy so the deck works without a network.
|
||||
async function pickBase() {
|
||||
try {
|
||||
const r = await fetch(`${LOCAL}/reveal.css`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (r.ok) return LOCAL;
|
||||
} catch (_) {}
|
||||
return CDN;
|
||||
}
|
||||
const base = await pickBase();
|
||||
const fail = (what) => (e) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("deck-failed", {
|
||||
detail: `${what} (${e && e.message ? e.message : e}).`,
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
};
|
||||
|
||||
const deckStyle = document.getElementById("deck-style");
|
||||
await Promise.all(
|
||||
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
|
||||
(href) =>
|
||||
new Promise((resolve) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${base}/${href}`;
|
||||
link.onload = link.onerror = resolve;
|
||||
deckStyle.before(link);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [
|
||||
{ default: Reveal },
|
||||
{ default: Markdown },
|
||||
{ default: Highlight },
|
||||
{ default: Notes },
|
||||
] = await Promise.all(
|
||||
[
|
||||
"reveal.mjs",
|
||||
"plugin/markdown.mjs",
|
||||
"plugin/highlight.mjs",
|
||||
"plugin/notes.mjs",
|
||||
].map((m) => import(/* @vite-ignore */ `${base}/${m}`)),
|
||||
).catch(fail(`Could not load reveal.js from ${base}`));
|
||||
|
||||
// BIRD configuration, so router config on slides is highlighted. Same
|
||||
// trick Matt uses for the assembly dialects in his talks.
|
||||
function bird() {
|
||||
return {
|
||||
name: "bird",
|
||||
keywords: {
|
||||
keyword:
|
||||
"protocol template filter function define table if then else case return accept reject " +
|
||||
"import export where none all limit action block neighbor local as direct multihop passive " +
|
||||
"description ipv4 ipv6 bgp kernel device static pipe include router id",
|
||||
built_in:
|
||||
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
|
||||
"bgp_med bgp_local_pref bgp_next_hop",
|
||||
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
|
||||
},
|
||||
contains: [
|
||||
{ className: "comment", begin: "#", end: "$" },
|
||||
{ className: "string", begin: '"', end: '"' },
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b",
|
||||
},
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b",
|
||||
},
|
||||
{ className: "number", begin: "\\b[0-9]+\\b" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ?accent=green|amber|magenta previews another accent; blue is the default.
|
||||
const accent = new URLSearchParams(location.search).get("accent");
|
||||
if (accent) document.documentElement.dataset.accent = accent;
|
||||
|
||||
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
|
||||
const scaleFactor = 0.65;
|
||||
const deck = new Reveal({
|
||||
hash: true,
|
||||
transition: "fade",
|
||||
slideNumber: false,
|
||||
margin: 0.15,
|
||||
width: 1920 * scaleFactor,
|
||||
height: 1080 * scaleFactor,
|
||||
markdown: { smartypants: true },
|
||||
highlight: {
|
||||
beforeHighlight: (hljs) =>
|
||||
hljs.registerLanguage("bird", bird),
|
||||
},
|
||||
plugins: [Markdown, Highlight, Notes],
|
||||
});
|
||||
|
||||
await deck.initialize().catch(fail("reveal.js failed to start"));
|
||||
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
|
||||
// The slides exist now: scripts that fill live boxes wait for this.
|
||||
window.dispatchEvent(new Event("deck-ready"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,123 +0,0 @@
|
||||
// A live prefix calculator on a slide. Any <form class="prefix-explorer">
|
||||
// with <input name="prefix"> and <input type="range" name="len"> gets its
|
||||
// .px-bits, .px-range and .px-facts filled in as the reader types.
|
||||
(function () {
|
||||
const ALL = (1n << 128n) - 1n;
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// "2001:db8::1" -> BigInt, or null.
|
||||
function parse(text) {
|
||||
const s = text.trim().toLowerCase();
|
||||
if (!/^[0-9a-f:]+$/.test(s) || s.split("::").length > 2) return null;
|
||||
const [left, right = ""] = s.split("::");
|
||||
const l = left ? left.split(":") : [], r = right ? right.split(":") : [];
|
||||
if (s.includes("::") ? l.length + r.length > 7 : l.length !== 8) return null;
|
||||
const groups = [...l, ...Array(8 - l.length - r.length).fill("0"), ...r];
|
||||
let v = 0n;
|
||||
for (const g of groups) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
||||
v = (v << 16n) | BigInt(parseInt(g, 16));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function groupsOf(v) {
|
||||
const g = [];
|
||||
for (let i = 7; i >= 0; i--) g.push(Number((v >> BigInt(i * 16)) & 0xffffn));
|
||||
return g;
|
||||
}
|
||||
|
||||
// Shortest form, RFC 5952 style.
|
||||
function format(v) {
|
||||
const g = groupsOf(v).map((x) => x.toString(16));
|
||||
let best = -1, bestLen = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let n = 0;
|
||||
while (i + n < 8 && g[i + n] === "0") n++;
|
||||
if (n > bestLen && n > 1) { best = i; bestLen = n; }
|
||||
}
|
||||
if (best < 0) return g.join(":");
|
||||
return g.slice(0, best).join(":") + "::" + g.slice(best + bestLen).join(":");
|
||||
}
|
||||
|
||||
const sup = (k) => String(k).replace(/\d/g, (d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]);
|
||||
// 2^k as a number people can read: exact up to 2^50, else a power.
|
||||
const pow2 = (k) => (k <= 50 ? Number(1n << BigInt(k)).toLocaleString("en") : `2${sup(k)}`);
|
||||
|
||||
function render(form) {
|
||||
const input = form.elements.prefix, slider = form.elements.len;
|
||||
const bits = form.querySelector(".px-bits"), range = form.querySelector(".px-range"), facts = form.querySelector(".px-facts");
|
||||
let [addrText, lenText] = input.value.split("/");
|
||||
let len = lenText === undefined ? Number(slider.value) : parseInt(lenText, 10);
|
||||
const addr = parse(addrText || "");
|
||||
if (addr === null || !(len >= 0 && len <= 128)) {
|
||||
bits.innerHTML = '<span class="px-bad">not an IPv6 prefix: write address/length, e.g. 2001:db8::/32</span>';
|
||||
range.innerHTML = ""; facts.textContent = "";
|
||||
return;
|
||||
}
|
||||
slider.value = len;
|
||||
const host = 128 - len;
|
||||
const first = addr & ((ALL << BigInt(host)) & ALL);
|
||||
const last = first | ((1n << BigInt(host)) - 1n);
|
||||
|
||||
// The 32 hex digits with the fixed part in the accent colour; a nibble
|
||||
// that straddles the boundary is shown in binary.
|
||||
const hex = groupsOf(first).map((x) => x.toString(16).padStart(4, "0")).join("");
|
||||
const fullNibbles = Math.floor(len / 4), partBits = len % 4;
|
||||
const withColons = (s, offset) => s.replace(/./g, (c, i) => ((offset + i) % 4 === 3 && offset + i < 31 ? c + ":" : c));
|
||||
let html = `<span class="fixed">${withColons(hex.slice(0, fullNibbles), 0)}</span>`;
|
||||
let rest = fullNibbles;
|
||||
if (partBits) {
|
||||
const nib = parseInt(hex[fullNibbles], 16).toString(2).padStart(4, "0");
|
||||
// One hex digit, four bits, with the boundary inside it.
|
||||
html += `<span class="part" title="this hex digit straddles the boundary, shown as its four bits">${nib.slice(0, partBits)}<i>${"x".repeat(4 - partBits)}</i></span>`;
|
||||
rest++;
|
||||
}
|
||||
html += `<span class="free">${withColons("x".repeat(32 - rest), rest)}</span>`;
|
||||
bits.innerHTML = html;
|
||||
|
||||
// Every shorter prefix contains this one; show three of them, at byte
|
||||
// boundaries, with the range's position and share marked in each, so
|
||||
// that no single one looks like "the" parent. Then the range itself.
|
||||
const levels = [];
|
||||
for (let pl = len === 0 ? -1 : 8 * Math.floor((len - 1) / 8); pl >= 0 && levels.length < 3; pl -= 8) {
|
||||
const slots = 1n << BigInt(len - pl);
|
||||
const index = (first >> BigInt(host)) & (slots - 1n);
|
||||
const enclosing = first & ((ALL << BigInt(128 - pl)) & ALL);
|
||||
// Position and width as fractions; the slice is kept visible.
|
||||
const left = Number((index * 100000n) / slots) / 1000, width = Math.max(100 / Number(slots), 0.3);
|
||||
levels.push(`<div class="px-level"><span class="px-level-name">${esc(format(enclosing))}/${pl}</span>
|
||||
<span class="px-track"><i style="left:${left}%;width:${width}%"></i></span>
|
||||
<span class="px-level-count">1 of ${pow2(len - pl)}</span></div>`);
|
||||
}
|
||||
range.innerHTML = `
|
||||
<div class="px-levels">${levels.length ? `<div class="px-levels-title">every shorter prefix contains it; three of them:</div>${levels.join("")}` : '<div class="px-levels-title">nothing is shorter than /0: this is every address there is</div>'}</div>
|
||||
<div class="px-bar"><span class="px-end px-first">${esc(format(first))}</span><span class="px-mid">${pow2(host)} addresses</span><span class="px-end px-last">${esc(format(last))}</span></div>`;
|
||||
|
||||
const parts = [`The first <b>${len}</b> bits are fixed; the other ${host} are for use within the network.`];
|
||||
if (len < 64) parts.push(`That is <b>${pow2(64 - len)}</b> /64s, one per LAN.`);
|
||||
else if (len === 64) parts.push(`Exactly one LAN.`);
|
||||
else parts.push(`Smaller than a LAN: ${host === 0 ? "a single host" : `part of one /64`}.`);
|
||||
if (len < 48) parts.push(`Or ${pow2(48 - len)} /48s, one per site.`);
|
||||
facts.innerHTML = parts.join(" ");
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.prefix-explorer");
|
||||
if (!form) return;
|
||||
if (e.target.name === "len") {
|
||||
// The slider rewrites the length in the text field.
|
||||
const addr = form.elements.prefix.value.split("/")[0];
|
||||
form.elements.prefix.value = `${addr}/${e.target.value}`;
|
||||
}
|
||||
render(form);
|
||||
if (window.deck) window.deck.layout();
|
||||
});
|
||||
document.addEventListener("submit", (e) => {
|
||||
if (e.target.closest && e.target.closest("form.prefix-explorer")) e.preventDefault();
|
||||
});
|
||||
// Fill in the initial values once the slides exist.
|
||||
const init = () => document.querySelectorAll("form.prefix-explorer").forEach(render);
|
||||
window.addEventListener("deck-ready", init);
|
||||
if (document.readyState === "complete") setTimeout(init, 500);
|
||||
})();
|
||||
@@ -1,15 +0,0 @@
|
||||
## About the speaker
|
||||
|
||||
**Rayhaan Jaufeerally**
|
||||
|
||||
- Software engineer at a hyperscaler, working on security and reliability.
|
||||
- Studied at ETH from 2016 to 2019, and took the network security course.
|
||||
- Has been running **AS210036** since 2018, for personal use and research
|
||||
|
||||
<p class="note">Today we'll get to use some connectivity through this network.</p>
|
||||
|
||||
Notes:
|
||||
|
||||
- Working at a large company with some offices near the main train station
|
||||
- Former ETH masters student and took the network security course
|
||||
- After my degree I signed up to be a LIR at RIPE, and got my own Autonomous System and started peering, for the fun of running my own network.
|
||||
@@ -1,170 +0,0 @@
|
||||
# Internet Numbering Resources
|
||||
|
||||
Notes:
|
||||
Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed.
|
||||
|
||||
---
|
||||
|
||||
## Packets are sent to IP addresses
|
||||
|
||||
<form class="dns-lookup" autocomplete="off">
|
||||
<input name="domain" value="ethz.ch" spellcheck="false" aria-label="domain name"><button type="submit">lookup AAAA</button>
|
||||
<div class="dns-result"><span class="dim">Ask a resolver which IPv6 address this name stands for.</span></div>
|
||||
</form>
|
||||
|
||||
IP packets are sent to IP addresses, not domain names. Every time you use a domain name, it is resolved into an IP address via DNS.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Live: type a few names the students use. ethz.ch has an AAAA record;
|
||||
github.com has none (only A), which shows that the two address families
|
||||
are separate. The answer comes from Google Public DNS over HTTPS
|
||||
(dns.google), so it needs the lecture-hall network.
|
||||
|
||||
---
|
||||
|
||||
## Who does the address belong to?
|
||||
|
||||
<form class="ripe-lookup" autocomplete="off">
|
||||
<input name="query" value="2001:67c:10ec:254::216" spellcheck="false" aria-label="address, prefix or AS number"><button type="submit">whois</button>
|
||||
<div class="ripe-result"><div class="ripe-msg">Paste the address from the last slide.</div></div>
|
||||
</form>
|
||||
|
||||
The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Paste the AAAA answer from the previous slide. ETH's /48 is ASSIGNED PI
|
||||
(provider independent, sponsored by SWITCH) and its route6 names AS559,
|
||||
SWITCH. Try 2a0d:d741:1::1 as well: the lab prefix, origin AS210036. The
|
||||
box takes AS numbers too (AS559). Same source as whois -h whois.ripe.net,
|
||||
over the database's REST API.
|
||||
|
||||
---
|
||||
|
||||
## An IPv6 address is 128 bits
|
||||
|
||||
```text
|
||||
2001:0db8:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
|
||||
2001:db8:1:0:0:0:0:1 drop leading zeros in a group
|
||||
2001:db8:1::1 one run of zero groups becomes ::
|
||||
```
|
||||
|
||||
`::` may appear once, or the address would be ambiguous. `2001:db8::/32` is reserved for documentation, so it never appears on the real Internet.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Ask: why only once? Because 2001::1::1 could put the zeros in several places.
|
||||
2001:db8::/32 is RFC 3849: set aside for examples, never routed.
|
||||
|
||||
---
|
||||
|
||||
## A prefix is a range of addresses
|
||||
|
||||
In BGP, the control plane for the internet, it's not viable to talk about single addresses, so we use prefix ranges.
|
||||
|
||||
<form class="prefix-explorer" autocomplete="off">
|
||||
<input name="prefix" value="2001:db8:1::/48" spellcheck="false" aria-label="IPv6 prefix"><input type="range" name="len" min="0" max="128" value="48" aria-label="prefix length">
|
||||
<p class="bits px-bits"></p>
|
||||
<div class="px-range"></div>
|
||||
<p class="note px-facts"></p>
|
||||
</form>
|
||||
|
||||
Notes:
|
||||
Live. Drag the slider: the fixed part grows and the range shrinks by half per
|
||||
bit. Try /47 or /50 to see a hex digit split in two. The three rows above the
|
||||
bar are three of the shorter prefixes that contain it, with its position and
|
||||
share in each: there is no such thing as "the" parent prefix, only whatever
|
||||
someone chose to allocate from.
|
||||
2^80 addresses in one /48, about 1.2 million billion billion. The /n is a
|
||||
count of leading bits, nothing more.
|
||||
|
||||
---
|
||||
|
||||
## The global IPv6 address space
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="Nested boxes: all of IPv6, inside it global unicast 2000::/3, inside that the documentation prefix 2001:db8::/32, inside that one /64 LAN with its hosts">
|
||||
<g class="nest l0"><rect x="8" y="8" width="984" height="454"/></g>
|
||||
<text x="28" y="42" class="left accent nest-label">::/0</text><text x="112" y="42" class="left nest-desc">every IPv6 address there is: 2¹²⁸ of them</text>
|
||||
<g class="nest l1"><rect x="80" y="70" width="880" height="366"/></g>
|
||||
<text x="100" y="104" class="left accent nest-label">2000::/3</text><text x="230" y="104" class="left nest-desc">global unicast: the eighth of the space that is routed on the Internet</text>
|
||||
<g class="nest l2"><rect x="160" y="134" width="760" height="278"/></g>
|
||||
<text x="180" y="168" class="left accent nest-label">2001:db8::/32</text><text x="376" y="168" class="left nest-desc">one provider's allocation (this one is reserved for documentation)</text>
|
||||
<g class="nest l3"><rect x="260" y="200" width="580" height="188"/></g>
|
||||
<text x="280" y="234" class="left accent nest-label">2001:db8:1:2::/64</text><text x="530" y="234" class="left nest-desc">one LAN: 2⁶⁴ addresses for its hosts</text>
|
||||
<circle class="host" cx="330" cy="310" r="12"/><text x="330" y="352" class="host-addr">::1</text>
|
||||
<circle class="host" cx="430" cy="310" r="12"/><text x="430" y="352" class="host-addr">::2</text>
|
||||
<circle class="host" cx="530" cy="310" r="12"/><text x="530" y="352" class="host-addr">::3</text>
|
||||
<text x="630" y="318" class="host-addr">…</text>
|
||||
<circle class="host" cx="740" cy="310" r="12"/><text x="740" y="352" class="host-addr">::ffff:ffff:ffff:ffff</text>
|
||||
<text x="976" y="452" class="small" style="text-anchor:end">not to scale</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Not to scale: a /64 is 2^-64 of the space. Only the first ring matters for
|
||||
routing; the others are how the space is handed down, which is next.
|
||||
|
||||
---
|
||||
|
||||
## Who hands out the addresses
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="IANA at the top hands blocks to five regional registries; RIPE NCC hands smaller blocks to local registries such as SWITCH and an ISP, which assign to end users such as ETH Zurich and their customers">
|
||||
<g class="hot"><rect x="400" y="14" width="200" height="56" rx="8"/></g><text x="500" y="48">IANA</text>
|
||||
<text x="30" y="48" class="left small">the root: one pool for the whole world</text>
|
||||
<line class="link" x1="500" y1="70" x2="96" y2="130"/><line class="link" x1="500" y1="70" x2="298" y2="130"/><line class="link" x1="500" y1="70" x2="500" y2="130"/><line class="link" x1="500" y1="70" x2="702" y2="130"/><line class="link" x1="500" y1="70" x2="904" y2="130"/>
|
||||
<text x="30" y="108" class="left small">a /12 to each region</text>
|
||||
<rect x="16" y="130" width="160" height="56" rx="8"/><text x="96" y="164">AFRINIC</text>
|
||||
<rect x="218" y="130" width="160" height="56" rx="8"/><text x="298" y="164">APNIC</text>
|
||||
<rect x="420" y="130" width="160" height="56" rx="8"/><text x="500" y="164">ARIN</text>
|
||||
<rect x="622" y="130" width="160" height="56" rx="8"/><text x="702" y="164">LACNIC</text>
|
||||
<g class="hot"><rect x="824" y="130" width="160" height="56" rx="8"/></g><text x="904" y="164" class="accent">RIPE NCC</text>
|
||||
<text x="30" y="222" class="left small">RIRs: five regional registries, one per part of the world</text>
|
||||
<line class="link" x1="904" y1="186" x2="690" y2="256"/><line class="link" x1="904" y1="186" x2="904" y2="256"/>
|
||||
<text x="700" y="238" class="left small">/29 and up to a member</text>
|
||||
<rect x="600" y="256" width="180" height="56" rx="8"/><text x="690" y="290">SWITCH</text>
|
||||
<rect x="814" y="256" width="180" height="56" rx="8"/><text x="904" y="290">an ISP</text>
|
||||
<text x="30" y="298" class="left small">LIRs: members of an RIR, usually providers</text>
|
||||
<line class="link" x1="690" y1="312" x2="690" y2="382"/><line class="link" x1="904" y1="312" x2="904" y2="382"/>
|
||||
<text x="700" y="360" class="left small">a /48 to a site</text>
|
||||
<rect x="600" y="382" width="180" height="56" rx="8"/><text x="690" y="416">ETH Zürich</text>
|
||||
<rect x="814" y="382" width="180" height="56" rx="8"/><text x="904" y="416">its customers</text>
|
||||
<text x="30" y="424" class="left small">end users: companies, universities, you</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
|
||||
- The Internet Assigned Numbers Authority comes up with allocations of IP
|
||||
addresses from the global space
|
||||
- Blocks are assigned to Regional Internet Registries (RIRs), which allocate
|
||||
them to Local Internet Registries (LIRs), who assign to end users
|
||||
- The RIPE lookup two slides back showed exactly this chain: ETH's /48 is
|
||||
ASSIGNED PI, sponsored by SWITCH (the LIR), registered at RIPE NCC (the
|
||||
RIR). Nobody owns addresses; they are registered to you.
|
||||
|
||||
---
|
||||
|
||||
## AS numbers
|
||||
|
||||
- Used to identify networks under a single oeprator, and required for exchanging BGP traffic
|
||||
- Managed by IANA and the RIRs
|
||||
- 16 bits at first: `1` to `65535`
|
||||
- 32 bits since RFC 6793: up to `4294967295`
|
||||
- In order to have a smooth transition, `AS23456` is used as a placeholder when the AS number is > 16 bits
|
||||
- 32 bit AS numbers are then exchanged using a BGP capability message to be backwards compatible
|
||||
- Private use: `64512–65534` and `4200000000–4294967294`
|
||||
|
||||
AS numbers come down the same chain: IANA to the RIRs, an RIR to whoever runs a network. The lab daemon is `AS4200000001`, a private one, stripped before the Internet sees your route.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Nobody "owns" numbers or addresses; they are registered to you. RFC 6996
|
||||
reserves the private ranges. The upstream router removes the private AS from
|
||||
the path, so the origin everybody else sees is AS210036.
|
||||
|
||||
---
|
||||
|
||||
## Handout Part 1
|
||||
@@ -1,329 +0,0 @@
|
||||
# BGP in detail
|
||||
|
||||
Notes:
|
||||
We're going to look at some implementation details of the protocol
|
||||
|
||||
---
|
||||
|
||||
##
|
||||
|
||||
- Runs over TCP, port `179`, and is exchanged typically between peering routers
|
||||
- Each side tells the other its best route per prefix
|
||||
- Only _changes_ are sent: no periodic refresh
|
||||
- If the session drops, after a timeout the routes learned over that session are dropped
|
||||
|
||||
Notes:
|
||||
BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants.
|
||||
Security and extensions have had to be bolted on, in perhaps strange ways.
|
||||
|
||||
---
|
||||
|
||||
## The four message types
|
||||
|
||||
| Type | Name | Says |
|
||||
| ---- | ------------ | ------------------------------------------------------- |
|
||||
| 1 | OPEN | AS number, router ID, capabilities |
|
||||
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
|
||||
| 3 | NOTIFICATION | errors and informational messages: e.g. rebooting |
|
||||
| 4 | KEEPALIVE | Heartbeat to keep the session up when there's no update |
|
||||
|
||||
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then a stream of UPDATEs.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed".
|
||||
|
||||
---
|
||||
|
||||
## OPEN: a handshake between peers.
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 340" role="img" aria-label="An OPEN message: the common header, then version, AS number, hold time, BGP identifier, and optional parameters that carry the capabilities">
|
||||
<text x="20" y="22" class="left small pkt-title">header, the same on every message: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">OPEN: the fixed part</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="169" class="pkt-name">version</text>
|
||||
<text x="95" y="191" class="pkt-size">1 byte: 4</text>
|
||||
<g class="pkt hl"><rect x="174" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="274" y="169" class="pkt-name">my AS</text>
|
||||
<text x="274" y="191" class="pkt-size">2 bytes, or 23456</text>
|
||||
<g class="pkt hl"><rect x="378" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="478" y="169" class="pkt-name">hold time</text>
|
||||
<text x="478" y="191" class="pkt-size">2 bytes, seconds</text>
|
||||
<g class="pkt hl"><rect x="582" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="702" y="169" class="pkt-name">BGP identifier</text>
|
||||
<text x="702" y="191" class="pkt-size">4 bytes, the router ID</text>
|
||||
<g class="pkt hl"><rect x="826" y="140" width="154" height="64" rx="6"/></g>
|
||||
<text x="903" y="169" class="pkt-name">opt. length</text>
|
||||
<text x="903" y="191" class="pkt-size">1 byte</text>
|
||||
<path class="pkt-expand" d="M 826 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 980 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">optional parameters: today always one, of type 2 = capabilities, each a (code, length, value)</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="120" height="64" rx="6"/></g>
|
||||
<text x="80" y="287" class="pkt-name">type 2</text>
|
||||
<text x="80" y="309" class="pkt-size">capabilities</text>
|
||||
<g class="pkt ha"><rect x="144" y="258" width="110" height="64" rx="6"/></g>
|
||||
<text x="199" y="287" class="pkt-name">length</text>
|
||||
<text x="199" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha2"><rect x="258" y="258" width="200" height="64" rx="6"/></g>
|
||||
<text x="358" y="287" class="pkt-name">IPv6 unicast</text>
|
||||
<text x="358" y="309" class="pkt-size">code 1: AFI 2, SAFI 1</text>
|
||||
<g class="pkt ha2"><rect x="462" y="258" width="190" height="64" rx="6"/></g>
|
||||
<text x="557" y="287" class="pkt-name">4-byte AS</text>
|
||||
<text x="557" y="309" class="pkt-size">code 65: 210036</text>
|
||||
<g class="pkt ha2"><rect x="656" y="258" width="170" height="64" rx="6"/></g>
|
||||
<text x="741" y="287" class="pkt-name">route refresh</text>
|
||||
<text x="741" y="309" class="pkt-size">code 2, empty</text>
|
||||
<g class="pkt ha2"><rect x="830" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="905" y="287" class="pkt-name">…</text>
|
||||
<text x="905" y="309" class="pkt-size">more capabilities</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Sent once, by each side, as soon as the TCP connection is up. The two sides
|
||||
must agree: the AS must be the one configured for the neighbour, the hold
|
||||
time becomes the smaller of the two, and only capabilities both announced are
|
||||
used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the
|
||||
real number rides in a capability, which is how our AS210036 arrives.
|
||||
|
||||
---
|
||||
|
||||
## BGP Open message
|
||||
|
||||
<div class="bytewalk">
|
||||
<pre class="bytes bw-dump"><span class="hh" data-i="0">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh" data-i="1">00 37</span> <span class="hh" data-i="2">01</span> <span class="hl" data-i="3">04</span> <span class="hl" data-i="4">5b a0</span> <span class="hl" data-i="5">00 f0</span> <span class="hl" data-i="6">0a ff 00 01</span> <span class="hl" data-i="7">1a</span> <span class="ha" data-i="8">02</span> <span class="ha" data-i="9">18</span> <span class="ha2" data-i="10">01</span> <span class="ha2" data-i="11">04</span> <span class="ha2" data-i="12">00 02</span> <span class="ha2" data-i="13">00</span> <span class="ha2" data-i="14">01</span> <span class="ha2" data-i="15">02</span> <span class="ha2" data-i="16">00</span> <span class="ha2" data-i="17">06</span> <span class="ha2" data-i="18">00</span> <span class="ha2" data-i="19">40</span> <span class="ha2" data-i="20">02</span> <span class="ha2" data-i="21">00 00</span> <span class="ha2" data-i="22">41</span> <span class="ha2" data-i="23">04</span> <span class="ha2" data-i="24">00 03 34 74</span> <span class="ha2" data-i="25">46</span> <span class="ha2" data-i="26">00</span> <span class="ha2" data-i="27">47</span> <span class="ha2" data-i="28">00</span></pre>
|
||||
<div class="bw-rows">
|
||||
<div class="bw-group">BGP header: 19 bytes, on every message</div>
|
||||
<div class="bw-row" data-i="0" tabindex="0"><code class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</code><b>marker</b><span>sixteen bytes of ff.</span></div>
|
||||
<div class="bw-row" data-i="1" tabindex="0"><code class="hh">00 37</code><b>length</b><span>0x0037 = 55 bytes, header included. At most 4096, or 65535 with the extended-message capability</span></div>
|
||||
<div class="bw-row" data-i="2" tabindex="0"><code class="hh">01</code><b>type</b><span>1 = OPEN (2 UPDATE, 3 NOTIFICATION, 4 KEEPALIVE, 5 ROUTE-REFRESH)</span></div>
|
||||
<div class="bw-group">OPEN: the fixed part, 10 bytes</div>
|
||||
<div class="bw-row" data-i="3" tabindex="0"><code class="hl">04</code><b>version</b><span>BGP version 4</span></div>
|
||||
<div class="bw-row" data-i="4" tabindex="0"><code class="hl">5b a0</code><b>my AS</b><span>0x5ba0 = 23456, AS_TRANS: the ASN is longer than 16 bits so is encoded in a capability instead</span></div>
|
||||
<div class="bw-row" data-i="5" tabindex="0"><code class="hl">00 f0</code><b>hold time</b><span>0x00f0 = 240 seconds. both peers use the minimum announced; a KEEPALIVE typically is sent every 1/3rd of this duration</span></div>
|
||||
<div class="bw-row" data-i="6" tabindex="0"><code class="hl">0a ff 00 01</code><b>BGP identifier</b><span>0a ff 00 01 = 10.255.0.1, the router ID. Looks like an IPv4 address, just needs to be a unique identifier.</span></div>
|
||||
<div class="bw-row" data-i="7" tabindex="0"><code class="hl">1a</code><b>optional parameters length</b><span>0x1a = 26 bytes follow</span></div>
|
||||
<div class="bw-group">Optional parameter: 2 + 24 bytes</div>
|
||||
<div class="bw-row" data-i="8" tabindex="0"><code class="ha">02</code><b>parameter type</b><span>2 = capabilities</span></div>
|
||||
<div class="bw-row" data-i="9" tabindex="0"><code class="ha">18</code><b>parameter length</b><span>0x18 = 24 bytes of capabilities follow, each as type, length, value</span></div>
|
||||
<div class="bw-group">Capability 1: multiprotocol (RFC 4760)</div>
|
||||
<div class="bw-row" data-i="10" tabindex="0"><code class="ha2">01</code><b>code</b><span>1 = multiprotocol extensions</span></div>
|
||||
<div class="bw-row" data-i="11" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
|
||||
<div class="bw-row" data-i="12" tabindex="0"><code class="ha2">00 02</code><b>AFI</b><span>0x0002 = IPv6. Address Family Identifier</span></div>
|
||||
<div class="bw-row" data-i="13" tabindex="0"><code class="ha2">00</code><b>reserved</b><span>0</span></div>
|
||||
<div class="bw-row" data-i="14" tabindex="0"><code class="ha2">01</code><b>SAFI</b><span>1 = unicast. Subsequent Address Family Identifier.</span></div>
|
||||
<div class="bw-group">Capability 2: route refresh (RFC 2918)</div>
|
||||
<div class="bw-row" data-i="15" tabindex="0"><code class="ha2">02</code><b>code</b><span>2 = route refresh: “you may ask me to resend everything”</span></div>
|
||||
<div class="bw-row" data-i="16" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 6: extended message (RFC 8654)</div>
|
||||
<div class="bw-row" data-i="17" tabindex="0"><code class="ha2">06</code><b>code</b><span>6 = messages up to 65535 bytes are supported</span></div>
|
||||
<div class="bw-row" data-i="18" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 64: graceful restart (RFC 4724)</div>
|
||||
<div class="bw-row" data-i="19" tabindex="0"><code class="ha2">40</code><b>code</b><span>0x40 = 64, graceful restart</span></div>
|
||||
<div class="bw-row" data-i="20" tabindex="0"><code class="ha2">02</code><b>length</b><span>2</span></div>
|
||||
<div class="bw-row" data-i="21" tabindex="0"><code class="ha2">00 00</code><b>flags and restart time</b><span>0x0000: not restarting, restart time 0, and no address families listed.</span></div>
|
||||
<div class="bw-group">Capability 65: four-octet AS (RFC 6793)</div>
|
||||
<div class="bw-row" data-i="22" tabindex="0"><code class="ha2">41</code><b>code</b><span>0x41 = 65, four-octet AS number</span></div>
|
||||
<div class="bw-row" data-i="23" tabindex="0"><code class="ha2">04</code><b>length</b><span>4</span></div>
|
||||
<div class="bw-row" data-i="24" tabindex="0"><code class="ha2">00 03 34 74</code><b>AS number</b><span>00 03 34 74 = 210036: the real AS. Included here since it doesn't fit in the header</span></div>
|
||||
<div class="bw-group">Capability 70: enhanced route refresh (RFC 7313)</div>
|
||||
<div class="bw-row" data-i="25" tabindex="0"><code class="ha2">46</code><b>code</b><span>0x46 = 70</span></div>
|
||||
<div class="bw-row" data-i="26" tabindex="0"><code class="ha2">00</code><b>length</b><span>0</span></div>
|
||||
<div class="bw-group">Capability 71: long-lived graceful restart</div>
|
||||
<div class="bw-row" data-i="27" tabindex="0"><code class="ha2">47</code><b>code</b><span>0x47 = 71</span></div>
|
||||
<div class="bw-row" data-i="28" tabindex="0"><code class="ha2">00</code><b>length</b><span>0, no address families listed</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Notes:
|
||||
What BIRD on our upstream router sends the lab daemon, byte for byte. Hold
|
||||
time 240 is BIRD's default; keepalives go every third of it.
|
||||
|
||||
---
|
||||
|
||||
## Update message format
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 460" role="img" aria-label="An UPDATE message: the common header, then withdrawn routes, path attributes and NLRI, with one attribute and one prefix expanded">
|
||||
<text x="20" y="22" class="left small pkt-title">header: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">UPDATE body: two lengths, three variable parts</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="105" y="169" class="pkt-name">withdrawn length</text>
|
||||
<text x="105" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hn"><rect x="194" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="294" y="169" class="pkt-name">withdrawn routes</text>
|
||||
<text x="294" y="191" class="pkt-size">IPv4 prefixes gone</text>
|
||||
<g class="pkt hl"><rect x="398" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="483" y="169" class="pkt-name">attributes length</text>
|
||||
<text x="483" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt ha"><rect x="572" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="692" y="169" class="pkt-name">path attributes</text>
|
||||
<text x="692" y="191" class="pkt-size">everything about the route</text>
|
||||
<g class="pkt hn"><rect x="816" y="140" width="172" height="64" rx="6"/></g>
|
||||
<text x="902" y="169" class="pkt-name">NLRI</text>
|
||||
<text x="902" y="191" class="pkt-size">IPv4 prefixes, new</text>
|
||||
<path class="pkt-expand" d="M 572 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 812 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">one path attribute; repeated for multiple</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="287" class="pkt-name">flags</text>
|
||||
<text x="95" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha"><rect x="174" y="258" width="330" height="64" rx="6"/></g>
|
||||
<text x="339" y="287" class="pkt-name">type</text>
|
||||
<text x="339" y="309" class="pkt-size">1 byte: ORIGIN = 1, AS_PATH = 2 …</text>
|
||||
<g class="pkt ha"><rect x="508" y="258" width="180" height="64" rx="6"/></g>
|
||||
<text x="598" y="287" class="pkt-name">length</text>
|
||||
<text x="598" y="309" class="pkt-size">1 or 2 bytes</text>
|
||||
<g class="pkt ha2"><rect x="692" y="258" width="292" height="64" rx="6"/></g>
|
||||
<text x="838" y="287" class="pkt-name">value</text>
|
||||
<text x="838" y="309" class="pkt-size">as many bytes as it said</text>
|
||||
<path class="pkt-expand" d="M 979 204 L 979 376"/>
|
||||
<text x="20" y="368" class="left small pkt-title">one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs</text>
|
||||
<g class="pkt hn"><rect x="20" y="376" width="300" height="64" rx="6"/></g>
|
||||
<text x="170" y="405" class="pkt-name">length</text>
|
||||
<text x="170" y="427" class="pkt-size">1 byte: 24 for a /24</text>
|
||||
<g class="pkt hn"><rect x="324" y="376" width="656" height="64" rx="6"/></g>
|
||||
<text x="652" y="405" class="pkt-name">prefix</text>
|
||||
<text x="652" y="427" class="pkt-size">3 bytes for a /24, 6 for a /48 …</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
One UPDATE carries one set of attributes, and every prefix that shares them.
|
||||
The withdrawn and NLRI fields can only hold IPv4; the next slides show where
|
||||
IPv6 goes. Lengths everywhere: a receiver can walk the message without
|
||||
understanding any of it, which is what lets unknown attributes pass through.
|
||||
|
||||
---
|
||||
|
||||
## An example BGP update
|
||||
|
||||
<div class="legend"><span class="hh">header</span><span class="hl">lengths</span><span class="ha">path attributes</span><span class="hn">NLRI</span></div>
|
||||
|
||||
<pre class="bytes"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
`2a0d:d740::/48`, as AS210036 announces it to the SwissIX route servers.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Built with the lab daemon's own encoder and checked by parsing it back, with
|
||||
the attributes pr01 puts on routes it exports to peers. Press down to walk
|
||||
through it part by part. Note there is nothing yellow: the NLRI field is
|
||||
empty. Hold that thought.
|
||||
|
||||
---
|
||||
|
||||
## Header
|
||||
|
||||
<pre class="bytes focus"><span class="hh on">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh on">00 59</span> <span class="hh on">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>ff × 16</td><td>Start of message marker.</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
|
||||
|
||||
---
|
||||
|
||||
## Two lengths
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl on">00 00</span> <span class="hl on">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>00 00</td><td>0 bytes of withdrawn routes: nothing is being taken back</td></tr><tr><td>00 42</td><td>66 bytes of path attributes follow</td></tr><tr><td></td><td>89 − 19 − 2 − 2 − 66 = 0 bytes left over for NLRI</td></tr></table>
|
||||
|
||||
---
|
||||
|
||||
## ORIGIN
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha on">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>40</td><td>flags: well-known, transitive</td></tr><tr><td>01</td><td>type 1 = ORIGIN</td></tr><tr><td>01</td><td>1 byte of value</td></tr><tr><td>00</td><td>IGP: the route was originated inside this AS</td></tr></table>
|
||||
|
||||
Every attribute is _flags, type, length, value_.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## AS_PATH
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2 on">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>40 02 06</td><td>well-known, transitive; type 2; 6 bytes</td></tr><tr><td>02</td><td>an AS_SEQUENCE, order matters</td></tr><tr><td>01</td><td>of 1 AS number</td></tr><tr><td>00 03 34 74</td><td>210036, the origin. Four bytes each (RFC 6793)</td></tr></table>
|
||||
|
||||
Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
One entry, because SwissIX is one hop from the origin and the route server
|
||||
does not add itself. Seen from further away it reads e.g. 6939 210036.
|
||||
|
||||
---
|
||||
|
||||
## MULTI_EXIT_DISC
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha on">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>80 04 04</td><td>optional, non-transitive; type 4; 4 bytes</td></tr><tr><td>00 00 00 00</td><td>MED 0. Lower is preferred, among routes from the same neighbour AS</td></tr></table>
|
||||
|
||||
A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2 on">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>80 0e 1c</td><td>optional, non-transitive; type 14; 28 bytes</td></tr><tr><td>00 02 · 01</td><td>address family IPv6, unicast</td></tr><tr><td>10 · 2001 07f8 0024 … 00c9</td><td>next hop, 16 bytes: 2001:7f8:24::c9, our port on the SwissIX peering LAN</td></tr><tr><td>00</td><td>reserved</td></tr><tr><td>30 · 2a 0d d7 40 00 00</td><td>48 bits of prefix: 2a0d:d740::/48</td></tr></table>
|
||||
|
||||
Notes:
|
||||
BGP-4 was built for IPv4: its NLRI field cannot carry anything else. RFC 4760
|
||||
bolts other address families on as an attribute, each with its own next hop.
|
||||
A prefix is sent as a bit count plus only as many bytes as the count needs.
|
||||
The next hop is an address on the exchange's shared LAN: whoever receives this
|
||||
route sends packets straight to that address across the switch. Remember ::c9
|
||||
for the exchange diagram in the next section.
|
||||
Routers may also append their link-local address here, making it 32 bytes.
|
||||
|
||||
---
|
||||
|
||||
## LARGE_COMMUNITIES
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha on">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>c0 20 0c</td><td>optional, transitive; type 32; 12 bytes = one community</td></tr><tr><td>00033474 · 0000000a · 00000002</td><td>210036:10:2, a tag our router puts on everything it exports to peers</td></tr></table>
|
||||
|
||||
Three 32-bit numbers. They mean whatever the network named in the first one says they mean.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## The flags byte
|
||||
|
||||
| Bit | Set means | Clear means |
|
||||
| ------ | ------------------------------------------------- | ------------------------------------------------ |
|
||||
| `0x80` | optional | well-known: every BGP speaker must understand it |
|
||||
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
|
||||
| `0x20` | partial: someone on the way did not understand it | |
|
||||
| `0x10` | length field is 2 bytes | 1 byte |
|
||||
|
||||
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
@@ -1,105 +0,0 @@
|
||||
# Transit and exchanges
|
||||
|
||||
Notes:
|
||||
Section 4. Two very different ways of getting routes, and what an Internet
|
||||
exchange physically is.
|
||||
|
||||
---
|
||||
|
||||
## Two ways to reach other networks
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
|
||||
### IP transit
|
||||
|
||||
- You **pay** a provider
|
||||
- They give you routes to _everyone_
|
||||
- They announce you to _everyone_
|
||||
|
||||
</div>
|
||||
<div>
|
||||
|
||||
### Peering
|
||||
|
||||
- Usually **free**, by mutual benefit
|
||||
- You exchange _your own_ routes, and your customers'
|
||||
- Traffic between the locally connected parties only
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Notes:
|
||||
Transit is a customer-provider relationship; peering is between equals. A peer
|
||||
does not carry your traffic to third parties. Most networks have both: transit
|
||||
as the fallback, peering to keep traffic cheap and close.
|
||||
|
||||
---
|
||||
|
||||
## An exchange is a big Ethernet switch
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 400" role="img" aria-label="Members attached to a shared Ethernet peering LAN, with a route server">
|
||||
<g class="fabric"><rect x="60" y="175" width="880" height="56" rx="10"/></g>
|
||||
<text x="500" y="200" class="accent">peering LAN: one Ethernet segment, one subnet</text>
|
||||
<text x="500" y="220" class="small">SwissIX 2001:7f8:24::/64</text>
|
||||
<g class="hot"><rect x="70" y="40" width="180" height="70" rx="8"/></g>
|
||||
<text x="160" y="70">AS210036</text><text x="160" y="92" class="small">::c9</text>
|
||||
<rect x="300" y="40" width="180" height="70" rx="8"/><text x="390" y="70">AS559 SWITCH</text><text x="390" y="92" class="small">::35</text>
|
||||
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS2906 Netflix</text><text x="620" y="92" class="small">::55</text>
|
||||
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335 Cloudflare</text><text x="850" y="92" class="small">::c0</text>
|
||||
<line class="link" x1="160" y1="110" x2="160" y2="175"/><line class="link" x1="390" y1="110" x2="390" y2="175"/>
|
||||
<line class="link" x1="620" y1="110" x2="620" y2="175"/><line class="link" x1="850" y1="110" x2="850" y2="175"/>
|
||||
<rect x="385" y="300" width="230" height="70" rx="8"/><text x="500" y="330">route servers</text><text x="500" y="352" class="small">AS42476 ::a5ec:a:1</text>
|
||||
<line class="link" x1="500" y1="231" x2="500" y2="300"/>
|
||||
<path class="bgp" d="M 140 110 C 140 290, 300 335, 385 335"/>
|
||||
<path class="bgp" d="M 410 110 C 410 250, 440 270, 470 300"/>
|
||||
<path class="traffic" d="M 180 110 C 180 165, 370 165, 370 110"/>
|
||||
<text x="275" y="138" class="small" style="fill:#4fd98a">traffic</text>
|
||||
<text x="215" y="300" class="small accent">BGP</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Every member plugs one router port into the same layer-2 network and gets one
|
||||
address in a shared subnet. No routing inside the exchange: just Ethernet.
|
||||
Dashed blue is a BGP session, green is where the packets actually go.
|
||||
|
||||
---
|
||||
|
||||
## What that implies
|
||||
|
||||
- Peering participants in an exchange are all one hop away, found by neighbour discovery
|
||||
- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
|
||||
- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost
|
||||
- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets)
|
||||
|
||||
Notes:
|
||||
|
||||
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
|
||||
- after which traffic can flow directly
|
||||
|
||||
---
|
||||
|
||||
## Route servers
|
||||
|
||||
Are a piece of shared infrastructure that simplifies peering.
|
||||
|
||||
- Most participants peer with the route server and annouce their prefixes there
|
||||
- The route server can then distribute these routes to every other participant
|
||||
- Simplifes from `n²` to `n` sessions
|
||||
- Not in the traffic path and does not add its own AS into the path
|
||||
- Allows controlling behaviour with communities, e.g. `42476:0:<as>` "not to `<as>`", `42476:1:<as>` "only to `<as>`"
|
||||
|
||||
Notes:
|
||||
A route server is a BGP speaker that only redistributes. Because it is
|
||||
transparent, the AS path looks exactly as if you peered with each member
|
||||
directly. We will use those communities in the lab.
|
||||
|
||||
---
|
||||
|
||||
## Looking glasses
|
||||
|
||||
Are a useful tool to inspect the state of another network or internet exchange
|
||||
|
||||
- SwissIX https://lg.swissix.ch/
|
||||
- NL-ix https://lg.nl-ix.net/
|
||||
- BGP tools https://bgp.tools/
|
||||
@@ -1,90 +0,0 @@
|
||||
# The lab
|
||||
|
||||
### <a href="https://bgplab.rayhaan.net">bgplab.rayhaan.net</a>
|
||||
|
||||
Notes:
|
||||
Section 5. What the students are about to touch, and what stands between a
|
||||
typo and the Internet.
|
||||
|
||||
---
|
||||
|
||||
## What you are connected to
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to the lab BGP speaker to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
|
||||
<rect x="20" y="170" width="150" height="80" rx="8"/><text x="95" y="205">you</text><text x="95" y="228" class="small">a browser</text>
|
||||
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">lab speaker</text><text x="330" y="226" class="small">AS4200000001</text>
|
||||
<rect x="490" y="170" width="200" height="80" rx="8"/><text x="590" y="202">pr01.rue (BIRD)</text><text x="590" y="226" class="small">AS210036</text>
|
||||
<line class="link" x1="170" y1="210" x2="230" y2="210"/><text x="200" y="160" class="small">HTTPS</text>
|
||||
<line class="bgp" x1="430" y1="210" x2="490" y2="210"/><text x="460" y="160" class="small accent">BGP</text>
|
||||
<text x="330" y="280" class="small">web page + BGP speaker</text><text x="590" y="280" class="small">filters, then decides where</text>
|
||||
<rect x="780" y="20" width="200" height="56" rx="8"/><text x="880" y="54">SwissIX</text>
|
||||
<rect x="780" y="98" width="200" height="56" rx="8"/><text x="880" y="132">NL-ix</text>
|
||||
<rect x="780" y="176" width="200" height="56" rx="8"/><text x="880" y="210">France-IX</text>
|
||||
<rect x="780" y="254" width="200" height="56" rx="8"/><text x="880" y="288">DE-CIX</text>
|
||||
<rect x="780" y="352" width="200" height="56" rx="8"/><text x="880" y="386">transit</text>
|
||||
<path class="bgp" d="M 690 195 C 740 195, 730 48, 780 48"/><path class="bgp" d="M 690 203 C 740 203, 735 126, 780 126"/>
|
||||
<path class="bgp" d="M 690 210 L 780 204"/><path class="bgp" d="M 690 217 C 740 217, 735 282, 780 282"/>
|
||||
<path class="bgp" d="M 690 225 C 740 225, 730 380, 780 380"/>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
The lab speaker is a small BGP daemon written for this course; it runs on
|
||||
the same machine as the router. Both dashed hops are real BGP sessions. Everything to
|
||||
the right of pr01 is the actual Internet.
|
||||
|
||||
---
|
||||
|
||||
## Your prefix
|
||||
|
||||
`2a0d:d741:<n>::/48`
|
||||
|
||||
- `<n>` is your number **in hex**: student 10 is `2a0d:d741:a::/48`
|
||||
- Put your name in _owner_: only the owner can withdraw a route
|
||||
- One prefix each
|
||||
|
||||
Notes:
|
||||
Put the URL and the class password on the board. The page is a snapshot by
|
||||
default; turn on auto-refresh at the top right to watch things change.
|
||||
|
||||
---
|
||||
|
||||
## What happens when you press _Announce_
|
||||
|
||||
1. The lab speaker builds an UPDATE. Click _bytes_ to see it, hover to decode it
|
||||
2. pr01 checks it against its import filter
|
||||
3. pr01 decides, per neighbour, whether to pass it on
|
||||
4. Your announcement propagates throughout the whole internet!
|
||||
5. `ping 2a0d:d741:<n>::1` answers, from anywhere
|
||||
|
||||
Notes:
|
||||
Step 5: pr01 answers for every address in the lab space, so reachability of
|
||||
your prefix depends only on whether your announcement got out.
|
||||
|
||||
---
|
||||
|
||||
## Steering your route
|
||||
|
||||
| You add | Who acts | Effect |
|
||||
| ----------------------------- | -------- | --------------------------------- |
|
||||
| nothing | | announced everywhere |
|
||||
| `210036:0:0` | pr01 | announced nowhere |
|
||||
| `210036:0:0` `210036:1:42476` | pr01 | only to the SwissIX route servers |
|
||||
| + `42476:0:0` `42476:1:559` | SwissIX | …and from there only to SWITCH |
|
||||
| `NO_EXPORT` | pr01 | accepted, never passed on |
|
||||
|
||||
Same idea at two hops: _never there_, _only there_, _nowhere_.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
210036:0:<as> never to that AS, 210036:1:<as> to that AS, 210036:2/3:<n> the
|
||||
same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always
|
||||
wins. The preset menu next to the field has all of these.
|
||||
|
||||
---
|
||||
|
||||
## Handout Part 2 & 3
|
||||
|
||||
Notes:
|
||||
Looking glasses: bgp.tools, RIPEstat, and the exchange's own. Global
|
||||
visibility takes a minute or two to settle after each change.
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve the deck for editing: like `python3 -m http.server`, but every
|
||||
response says Cache-Control: no-store, so a plain reload always shows the
|
||||
files as they are on disk. (The stock server sends Last-Modified and nothing
|
||||
else, and browsers then keep the section files and the CSS for a while.)
|
||||
|
||||
python3 slides/serve.py # http://localhost:8765
|
||||
python3 slides/serve.py 8000
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # quiet; errors still go through log_error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
print(f"serving {os.getcwd()} on http://localhost:{port}/")
|
||||
ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever()
|
||||
@@ -1,556 +0,0 @@
|
||||
/*
|
||||
* Components and helpers used by the slides. The look (palette, type, panels,
|
||||
* motif) is in theme.css; nothing here sets a brand colour directly.
|
||||
*/
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- text -------------------------------------------------------------------- */
|
||||
|
||||
/* A quiet aside under the main point. */
|
||||
.reveal .note {
|
||||
font-style: italic;
|
||||
font-size: 75%;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Image and quote credits: <!-- .element: class="attribution" --> */
|
||||
.reveal .attribution {
|
||||
font-size: 45%;
|
||||
font-style: italic;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* One big statement on an otherwise empty slide. */
|
||||
.reveal .big {
|
||||
font-size: 1.9em;
|
||||
font-weight: 300;
|
||||
line-height: 1.25;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
/* ---- layout ------------------------------------------------------------------ */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.flex-around {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-start;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
/* Two equal columns: <div class="cols"><div>…</div><div>…</div></div> */
|
||||
.cols {
|
||||
display: flex;
|
||||
gap: 1.2em;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cols > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.no-border {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.reveal img.blur-edges {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 25px 8px rgba(20, 30, 60, 0.2);
|
||||
}
|
||||
|
||||
.reveal table.borders td,
|
||||
.reveal table.borders th {
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* ---- boxes: small labelled cards, e.g. one per AS or per community ----------- */
|
||||
|
||||
.boxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
font-size: 70%;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.boxes > div {
|
||||
padding: 0.35em 0.8em;
|
||||
margin: 0.3em 0.5em;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 10px;
|
||||
background: var(--ground);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.boxes > div.us {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.35);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.boxes > div.ok {
|
||||
border-color: #1a8a4f;
|
||||
}
|
||||
|
||||
.boxes > div.bad {
|
||||
border-color: #d64545;
|
||||
}
|
||||
|
||||
/* ---- BGP bytes, matching the four groups of the /statusz bytes view ---------- */
|
||||
|
||||
.reveal pre.bytes {
|
||||
font-size: 0.5em;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--code-ground);
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
box-sizing: border-box;
|
||||
color: var(--code-ink);
|
||||
}
|
||||
|
||||
.bytes span,
|
||||
.legend span {
|
||||
border-radius: 3px;
|
||||
padding: 1px 3px;
|
||||
color: var(--code-ink); /* the fills are dark on any ground */
|
||||
}
|
||||
|
||||
.bytes .hh,
|
||||
.legend .hh { background: #34406e; }
|
||||
.bytes .hl,
|
||||
.legend .hl { background: #6e3440; }
|
||||
.bytes .ha,
|
||||
.legend .ha { background: #245c3a; }
|
||||
.bytes .ha2 { background: #2f7a4b; }
|
||||
.bytes .hn,
|
||||
.legend .hn { background: #6b5a1f; }
|
||||
|
||||
.legend {
|
||||
font-size: 50%;
|
||||
font-family: var(--r-code-font);
|
||||
}
|
||||
|
||||
.legend span {
|
||||
padding: 1px 0.6em;
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
|
||||
/* ---- live DNS and RIPE database lookups ------------------------------------ */
|
||||
|
||||
.reveal form.dns-lookup,
|
||||
.reveal form.ripe-lookup {
|
||||
margin: 0.4em auto 0;
|
||||
max-width: 34em;
|
||||
text-align: left;
|
||||
font-family: var(--r-code-font);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input,
|
||||
.reveal .ripe-lookup input {
|
||||
width: 62%;
|
||||
padding: 0.3em 0.5em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input:focus,
|
||||
.reveal .ripe-lookup input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup button,
|
||||
.reveal .ripe-lookup button {
|
||||
margin-left: 0.4em;
|
||||
padding: 0.3em 0.9em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reveal .dns-result {
|
||||
min-height: 3.2em;
|
||||
max-height: 13em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--rule) transparent;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em 0.7em;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .dns-result .ttl,
|
||||
.reveal .dns-result .type,
|
||||
.reveal .dns-result .dim { color: var(--muted); }
|
||||
.reveal .dns-result .addr { color: var(--accent); font-weight: 500; }
|
||||
.reveal .dns-result .bad { color: #c62828; }
|
||||
|
||||
/* RIPE database objects, laid out like apps.db.ripe.net: a light page with
|
||||
one card per object, one row per attribute, references in blue, rows the
|
||||
RIPE NCC manages shaded. */
|
||||
.reveal .ripe-result {
|
||||
/* A fixed height, not a maximum: the box must not grow when the answer
|
||||
arrives, or the slide overflows (reveal centres a slide only when it
|
||||
is shown). */
|
||||
height: 13.5em;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 0.62em;
|
||||
text-align: left;
|
||||
background: #f4f5f7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .ripe-card {
|
||||
margin-bottom: 0.7em;
|
||||
padding: 0.2em 0.5em;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.reveal .ripe-card:last-child { margin-bottom: 0; }
|
||||
|
||||
.reveal .ripe-card table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: inherit;
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
.reveal .ripe-card th,
|
||||
.reveal .ripe-card td {
|
||||
padding: 0.28em 0.6em;
|
||||
border: 0;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reveal .ripe-card table th {
|
||||
width: 8.5em;
|
||||
color: #1c1c28;
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted #9a9aa6;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.reveal .ripe-card td { word-break: break-all; }
|
||||
.reveal .ripe-card tr.managed th,
|
||||
.reveal .ripe-card tr.managed td { background: #dde5f0; }
|
||||
.reveal .ripe-card .ref { color: #2f6fe4; }
|
||||
.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; }
|
||||
|
||||
.reveal .ripe-msg {
|
||||
padding: 0.6em;
|
||||
font-family: var(--r-code-font);
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
/* A framed live page on a slide. */
|
||||
.reveal iframe.live {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ---- bytes with one part in focus: everything not .on is dimmed --------------- */
|
||||
|
||||
.bytes.focus span:not(.on) {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.bytes.focus span.on {
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
|
||||
}
|
||||
|
||||
/* Packet layouts: field boxes in the colours of the bytes view. */
|
||||
.diagram .pkt rect { stroke: none; }
|
||||
.diagram .pkt.hh rect { fill: #34406e; }
|
||||
.diagram .pkt.hl rect { fill: #6e3440; }
|
||||
.diagram .pkt.ha rect { fill: #245c3a; }
|
||||
.diagram .pkt.ha2 rect { fill: #2f7a4b; }
|
||||
.diagram .pkt.hn rect { fill: #6b5a1f; }
|
||||
.diagram text.pkt-name { font-size: 18px; font-weight: 500; fill: #fff; }
|
||||
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: #fff; opacity: 0.8; }
|
||||
.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; }
|
||||
.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; }
|
||||
|
||||
/* ---- byte walk: a hex dump and one row per field, in step on hover --------- */
|
||||
|
||||
.reveal .bytewalk { text-align: left; }
|
||||
.reveal .bytewalk .bw-dump { margin-bottom: 8px; }
|
||||
/* The lit bytes invert: a white block on the dark dump, visible from the back row. */
|
||||
.reveal .bw-dump span.on { background: #fff; color: var(--code-ground); box-shadow: 0 0 0 3px var(--accent); }
|
||||
|
||||
.reveal .bw-rows {
|
||||
position: relative; /* rows measure their offsetTop against the box */
|
||||
height: 19em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 46%;
|
||||
line-height: 1.45;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--accent-rgb), 0.04);
|
||||
}
|
||||
|
||||
.reveal .bw-group {
|
||||
padding: 0.45em 0.8em 0.2em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reveal .bw-row {
|
||||
display: grid;
|
||||
grid-template-columns: 11em 12em 1fr;
|
||||
gap: 0 1em;
|
||||
padding: 0.18em 0.8em;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal .bw-row:hover,
|
||||
.reveal .bw-row:focus,
|
||||
.reveal .bw-row.on { background: rgba(var(--accent-rgb), 0.14); }
|
||||
.reveal .bw-dump span[data-i] { cursor: pointer; }
|
||||
.reveal .bw-row code.hh { background: rgba(52, 64, 110, 0.16); }
|
||||
.reveal .bw-row code.hl { background: rgba(110, 52, 64, 0.16); }
|
||||
.reveal .bw-row code.ha { background: rgba(36, 92, 58, 0.16); }
|
||||
.reveal .bw-row code.ha2 { background: rgba(47, 122, 75, 0.16); }
|
||||
.reveal .bw-row code.hn { background: rgba(107, 90, 31, 0.16); }
|
||||
.reveal .bw-row code { display: block; color: var(--ink-strong); min-width: 0; max-width: 100%; justify-self: start; align-self: start; font-family: var(--r-code-font); border-radius: 3px; padding: 0 4px; white-space: normal; word-break: normal; }
|
||||
.reveal .bw-row b { color: var(--ink-strong); font-weight: 500; }
|
||||
.reveal .bw-row span { color: var(--ink); }
|
||||
|
||||
/* Small two-column explanation under a bytes block. */
|
||||
.reveal table.decode {
|
||||
font-size: 58%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.reveal table.decode td:first-child {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- prefix explorer: type a prefix, see its range ------------------------ */
|
||||
|
||||
.reveal form.prefix-explorer {
|
||||
margin: 0 auto;
|
||||
max-width: 36em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"] {
|
||||
width: 15em;
|
||||
padding: 0.25em 0.5em;
|
||||
font: inherit;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); }
|
||||
|
||||
.reveal .prefix-explorer input[name="len"] {
|
||||
width: 12em;
|
||||
margin-left: 1em;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; }
|
||||
.reveal .prefix-explorer .px-bad { color: #c62828; font-size: 70%; }
|
||||
|
||||
.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; }
|
||||
|
||||
.reveal .px-levels { text-align: left; margin-bottom: 1em; }
|
||||
.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; }
|
||||
.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; }
|
||||
.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; }
|
||||
.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; }
|
||||
.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; }
|
||||
.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); }
|
||||
|
||||
.reveal .px-bar {
|
||||
position: relative;
|
||||
height: 2.6em;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.reveal .px-bar .px-mid {
|
||||
position: absolute; left: 0; right: 0; top: 0.6em;
|
||||
text-align: center; color: var(--ink-strong); font-weight: 500;
|
||||
}
|
||||
.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); }
|
||||
.reveal .px-bar .px-first { left: 0; }
|
||||
.reveal .px-bar .px-last { right: 0; }
|
||||
|
||||
.reveal .prefix-explorer .px-facts { margin-top: 2.6em; }
|
||||
|
||||
/* ---- bits of an address: fixed part vs. free part ------------------------------ */
|
||||
|
||||
.bits {
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 80%;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bits .fixed {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 4px 0 0 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.bits .part {
|
||||
color: var(--accent-soft);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
margin: 0 2px;
|
||||
font-size: 85%;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; }
|
||||
|
||||
.bits .part i { font-style: normal; color: var(--muted); }
|
||||
|
||||
.bits .free {
|
||||
color: var(--ink);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ---- diagrams: inline SVG drawn with the theme's colours ------------------------ */
|
||||
|
||||
.reveal svg.diagram {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 470px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.diagram rect,
|
||||
.diagram ellipse {
|
||||
fill: var(--ground);
|
||||
stroke: var(--rule);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.diagram .hot rect {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.diagram .fabric rect {
|
||||
fill: rgba(var(--accent-rgb), 0.18);
|
||||
stroke: var(--accent);
|
||||
}
|
||||
|
||||
.diagram text {
|
||||
fill: var(--ink);
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
font-size: 19px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.diagram text.small {
|
||||
font-size: 14px;
|
||||
fill: var(--muted);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.diagram text.accent {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.diagram .link {
|
||||
stroke: var(--muted);
|
||||
stroke-width: 2.5;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.diagram .bgp {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 7 6;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Nested address ranges: each level a little brighter than the one around it. */
|
||||
.diagram .nest rect {
|
||||
stroke: var(--accent);
|
||||
rx: 14;
|
||||
}
|
||||
.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; }
|
||||
.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; }
|
||||
.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; }
|
||||
.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; }
|
||||
.diagram text.left { text-anchor: start; }
|
||||
.diagram text.mono { font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; }
|
||||
.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; }
|
||||
.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .host { fill: var(--accent-soft); stroke: none; }
|
||||
|
||||
.diagram .traffic {
|
||||
stroke: #1a8a4f;
|
||||
stroke-width: 4;
|
||||
fill: none;
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
/*
|
||||
* The look of the deck. Structure borrowed from Matt Godbolt's newer talks
|
||||
* (hrt-theme.css in github.com/mattgodbolt/what-happens-beneath-accu-2026):
|
||||
* every slide on a rounded panel, IBM Plex Sans with thin h1 and bold accent
|
||||
* h2, IBM Plex Mono code, accent-coloured list markers, links and progress
|
||||
* bar, glowing highlighted code lines.
|
||||
*
|
||||
* Made our own: a light palette (his is near-black; change it in :root
|
||||
* below), and his "thread" corner motif becomes a chain of nodes and links,
|
||||
* an AS path. Code blocks and byte dumps stay dark on the light page so the
|
||||
* monokai colours and the byte palette keep working.
|
||||
*
|
||||
* Try accents without editing: index.html?accent=green (blue, green,
|
||||
* amber, magenta). Make one permanent by copying its values into :root.
|
||||
*/
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap");
|
||||
|
||||
:root {
|
||||
--ground: #e9edf5; /* behind the panels */
|
||||
--panel: #ffffff; /* the slide itself */
|
||||
--panel-edge: rgba(31, 111, 235, 0.22);
|
||||
--ink: #1f2333; /* body text */
|
||||
--ink-strong: #0b0d17; /* h1, h3 */
|
||||
--muted: #6b7088; /* h4, captions */
|
||||
--rule: #c3c8d8; /* table rules, borders */
|
||||
--accent: #1f6feb; /* h2, markers, links, motif */
|
||||
--accent-soft: #0b4ea2; /* inline code, emphasis */
|
||||
--accent-rgb: 31, 111, 235;
|
||||
/* Code blocks and byte dumps keep a dark ground of their own. */
|
||||
--code-ground: #1b1e33;
|
||||
--code-ink: #eceff9;
|
||||
--r-code-font: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||
--r-heading-text-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-accent="green"] {
|
||||
--accent: #1a8a4f;
|
||||
--accent-soft: #0f6238;
|
||||
--accent-rgb: 26, 138, 79;
|
||||
--panel-edge: rgba(26, 138, 79, 0.22);
|
||||
}
|
||||
|
||||
:root[data-accent="amber"] {
|
||||
--accent: #b86e00;
|
||||
--accent-soft: #7a4a00;
|
||||
--accent-rgb: 184, 110, 0;
|
||||
--panel-edge: rgba(184, 110, 0, 0.22);
|
||||
}
|
||||
|
||||
:root[data-accent="magenta"] {
|
||||
--accent: #c2277f;
|
||||
--accent-soft: #8a1a5a;
|
||||
--accent-rgb: 194, 39, 127;
|
||||
--panel-edge: rgba(194, 39, 127, 0.22);
|
||||
}
|
||||
|
||||
/* ---- ground and panels ---------------------------------------------------- */
|
||||
|
||||
.reveal-viewport {
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.reveal {
|
||||
font-family: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 400;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.reveal .slides section:not(.stack):not(.title-slide):not(.bare) {
|
||||
background-color: var(--panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 30px rgba(20, 30, 60, 0.1);
|
||||
border: 1px solid var(--panel-edge);
|
||||
padding: 1em 0.8em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ---- type ------------------------------------------------------------------ */
|
||||
|
||||
.reveal h1,
|
||||
.reveal h2,
|
||||
.reveal h3,
|
||||
.reveal h4 {
|
||||
font-family: inherit;
|
||||
text-transform: none;
|
||||
text-shadow: none;
|
||||
margin: 0 0 20px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.reveal h1 {
|
||||
font-weight: 300;
|
||||
color: var(--ink-strong);
|
||||
font-size: 2.2em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.reveal h2 {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.reveal h3 {
|
||||
font-weight: 500;
|
||||
color: var(--ink-strong);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.reveal h4 {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.reveal p {
|
||||
margin: 20px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reveal strong,
|
||||
.reveal b {
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.reveal em {
|
||||
font-style: italic;
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.reveal a:hover {
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal ul,
|
||||
.reveal ol {
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
margin: 0 0 0 1em;
|
||||
}
|
||||
|
||||
.reveal ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.reveal ul li,
|
||||
.reveal ol li {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.reveal li::marker {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal blockquote {
|
||||
background: var(--ground);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 20px 30px;
|
||||
font-style: italic;
|
||||
width: 85%;
|
||||
margin: 20px auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reveal img,
|
||||
.reveal video,
|
||||
.reveal iframe {
|
||||
max-width: 95%;
|
||||
max-height: 95%;
|
||||
}
|
||||
|
||||
.reveal kbd {
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 80%;
|
||||
border: 1px solid var(--rule);
|
||||
border-bottom-width: 3px;
|
||||
border-radius: 5px;
|
||||
padding: 0 0.35em;
|
||||
}
|
||||
|
||||
/* ---- code ------------------------------------------------------------------ */
|
||||
|
||||
.reveal pre {
|
||||
width: 100%;
|
||||
margin: 20px auto;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
font-size: 0.55em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reveal code {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reveal :not(pre) > code {
|
||||
white-space: nowrap;
|
||||
font-size: 92%;
|
||||
}
|
||||
|
||||
.reveal pre code {
|
||||
display: block;
|
||||
color: var(--code-ink);
|
||||
background: var(--code-ground);
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* The lines being talked about glow; the rest dim (reveal handles the dim). */
|
||||
.reveal .hljs.has-highlights tr.highlight-line {
|
||||
text-shadow:
|
||||
0 0 6px currentColor,
|
||||
0 0 12px currentColor;
|
||||
transition: text-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
/* ---- tables ---------------------------------------------------------------- */
|
||||
|
||||
.reveal table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
margin: 20px auto;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.reveal table th {
|
||||
font-weight: 700;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.reveal table th,
|
||||
.reveal table td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding: 0.25em 0.6em;
|
||||
}
|
||||
|
||||
.reveal table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ---- reveal chrome ---------------------------------------------------------- */
|
||||
|
||||
.reveal .controls {
|
||||
color: var(--accent);
|
||||
bottom: 40px;
|
||||
}
|
||||
|
||||
.reveal .progress {
|
||||
background: var(--panel);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.reveal .progress span {
|
||||
background: var(--accent);
|
||||
transition: width 800ms cubic-bezier(0.26, 0.86, 0.44, 0.985);
|
||||
}
|
||||
|
||||
.reveal .slide-number {
|
||||
color: #fff;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
background: rgba(var(--accent-rgb), 0.9);
|
||||
padding: 3px 9px;
|
||||
border-radius: 4px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- the path motif ---------------------------------------------------------
|
||||
* Matt's decks carry a ring with a line running off the edge. Ours is a short
|
||||
* AS path: nodes joined by links, heading off the top-right corner, mirrored
|
||||
* running up from the bottom-left. Drawn in the accent colour via a mask, so
|
||||
* it follows --accent. Hidden on the title slide and on "bare" slides.
|
||||
*/
|
||||
.reveal::before,
|
||||
.reveal-viewport::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
background: var(--accent);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.reveal::before {
|
||||
top: 26px;
|
||||
right: 0;
|
||||
width: 38vw;
|
||||
height: 22px;
|
||||
-webkit-mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
|
||||
left center / 190px 22px no-repeat,
|
||||
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
|
||||
mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 190 22'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='11' r='8'/><line x1='19' y1='11' x2='67' y2='11'/><circle cx='75' cy='11' r='8'/><line x1='83' y1='11' x2='131' y2='11'/><circle cx='139' cy='11' r='8'/><line x1='147' y1='11' x2='190' y2='11'/></g></svg>")
|
||||
left center / 190px 22px no-repeat,
|
||||
linear-gradient(black, black) 189px center / calc(100% - 189px) 3px no-repeat;
|
||||
}
|
||||
|
||||
.reveal-viewport::before {
|
||||
bottom: 64px;
|
||||
left: 40px;
|
||||
width: 22px;
|
||||
height: 36vh;
|
||||
-webkit-mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
|
||||
center bottom / 22px 126px no-repeat,
|
||||
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
|
||||
mask:
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 126'><g fill='none' stroke='black' stroke-width='3'><circle cx='11' cy='115' r='8'/><line x1='11' y1='107' x2='11' y2='59'/><circle cx='11' cy='51' r='8'/><line x1='11' y1='43' x2='11' y2='0'/></g></svg>")
|
||||
center bottom / 22px 126px no-repeat,
|
||||
linear-gradient(black, black) center top / 3px calc(100% - 125px) no-repeat;
|
||||
}
|
||||
|
||||
.reveal:has(> .slides > section.title-slide.present)::before,
|
||||
.reveal:has(> .slides section.bare.present)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reveal-viewport:has(.reveal > .slides > section.title-slide.present)::before,
|
||||
.reveal-viewport:has(.reveal > .slides section.bare.present)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reveal:has(section.title-slide.present) .controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ---- wordmark, where his logo sits ------------------------------------------ */
|
||||
|
||||
.wordmark {
|
||||
position: fixed;
|
||||
left: 22px;
|
||||
bottom: 18px;
|
||||
z-index: 10;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wordmark b {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reveal:has(section.title-slide.present) ~ .wordmark,
|
||||
.reveal:has(section.bare.present) ~ .wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- title slide -------------------------------------------------------------- */
|
||||
|
||||
.reveal section.title-slide {
|
||||
text-align: left;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide h1 {
|
||||
font-size: 2.6em;
|
||||
margin-bottom: 0.15em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide h3 {
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
margin-bottom: 2.2em;
|
||||
}
|
||||
|
||||
.reveal section.title-slide .byline {
|
||||
font-size: 60%;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- shown by index.html when the deck cannot start --------------------------- */
|
||||
|
||||
#deck-help {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
padding: 8vh 10vw;
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 20px/1.5 "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
#deck-help h2 {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#deck-help pre,
|
||||
#deck-help code {
|
||||
font-family: var(--r-code-font);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
#deck-help pre {
|
||||
background: var(--panel);
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ---- print ------------------------------------------------------------------ */
|
||||
|
||||
html.print-pdf .wordmark,
|
||||
html.reveal-print .wordmark {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Copies reveal.js into ./reveal.js so the deck works with no network.
|
||||
# index.html uses this copy when it exists and the CDN otherwise. Keep VERSION
|
||||
# in step with the one in index.html.
|
||||
set -eu
|
||||
VERSION=6.0.2
|
||||
cd "$(dirname "$0")"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
npm pack "reveal.js@$VERSION" --pack-destination "$tmp" --silent >/dev/null 2>&1
|
||||
tar -xzf "$tmp/reveal.js-$VERSION.tgz" -C "$tmp"
|
||||
rm -rf reveal.js
|
||||
mkdir -p reveal.js
|
||||
cp -R "$tmp/package/dist" reveal.js/dist
|
||||
cp "$tmp/package/LICENSE" reveal.js/LICENSE
|
||||
echo "reveal.js $VERSION vendored into $(pwd)/reveal.js ($(du -sh reveal.js | cut -f1))"
|
||||
@@ -1,385 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Load test for the ferrix_bgp web API: N students announce at the same moment.
|
||||
|
||||
Each simulated student owns one prefix (student n gets the n-th /48, numbered
|
||||
in hex like the lab's route6 objects) and announces it under its own owner
|
||||
name. All students are released together by a barrier, so the announcements
|
||||
really are concurrent. The script then checks that the daemon did the right
|
||||
thing, not merely that it answered:
|
||||
|
||||
1. every announcement was accepted, with latency percentiles
|
||||
2. the local table holds exactly these routes, under the right owners
|
||||
3. every route was turned into an UPDATE for each established session
|
||||
4. the event stream delivered one "announced" event per route, none lost
|
||||
5. ownership holds under load: nobody could replace another's prefix
|
||||
6. concurrent withdrawal leaves nothing behind
|
||||
7. the daemon's own counters show the matching number of BGP UPDATEs sent,
|
||||
for the announcements and again for the withdrawals
|
||||
|
||||
SAFETY. Pointed at a router that peers with the real Internet, this announces
|
||||
real prefixes. By default every route therefore carries the large community
|
||||
210036:0:0 ("announce nowhere"), so the production router accepts the routes
|
||||
but exports them to nobody, and the script withdraws everything it announced
|
||||
even when it fails half-way. Pass --announce-for-real only if you mean it.
|
||||
|
||||
Standard library only. Examples:
|
||||
|
||||
tools/loadtest.py 128
|
||||
tools/loadtest.py 128 --url https://lab.example.net --user student
|
||||
tools/loadtest.py 64 --rounds 5 --keep
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import statistics
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
QUIET_COMMUNITY = "210036:0:0"
|
||||
OWNER_PREFIX = "loadtest-"
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base, user=None, password=None, insecure=False, timeout=15):
|
||||
self.base = base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.headers = {"content-type": "application/json"}
|
||||
if user:
|
||||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
self.headers["authorization"] = f"Basic {token}"
|
||||
self.ctx = ssl._create_unverified_context() if insecure else None
|
||||
|
||||
def call(self, method, path, body=None):
|
||||
"""Returns (status, parsed JSON or text, seconds)."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(self.base + path, data=data, method=method, headers=self.headers)
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
|
||||
status, raw = resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
status, raw = e.code, e.read()
|
||||
except Exception as e: # connection refused, timeout, TLS failure, ...
|
||||
return 0, f"{type(e).__name__}: {e}", time.perf_counter() - start
|
||||
elapsed = time.perf_counter() - start
|
||||
try:
|
||||
return status, json.loads(raw) if raw else None, elapsed
|
||||
except ValueError:
|
||||
return status, raw.decode(errors="replace"), elapsed
|
||||
|
||||
def open_stream(self, path):
|
||||
req = urllib.request.Request(self.base + path, headers={**self.headers, "accept": "text/event-stream"})
|
||||
return urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx)
|
||||
|
||||
|
||||
def updates_sent(api):
|
||||
"""BGP UPDATE messages the daemon has sent so far, from /metricz; None if unavailable."""
|
||||
status, text, _ = api.call("GET", "/metricz")
|
||||
if status != 200 or not isinstance(text, str):
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
if line.startswith('bgp_messages_sent_total{type="update"}'):
|
||||
return int(float(line.rsplit(" ", 1)[1]))
|
||||
return 0 # the counter has no sample until the first UPDATE
|
||||
|
||||
|
||||
def established(api):
|
||||
status, peers, _ = api.call("GET", "/api/peers")
|
||||
return [p for p in peers if p.get("state") == "Established"] if status == 200 else []
|
||||
|
||||
|
||||
def check_wire(api, report, before, routes, sessions_before, what):
|
||||
"""The advertiser works asynchronously, so give the counter a moment to settle."""
|
||||
if before is None:
|
||||
print(f" [skip] /metricz unavailable, cannot count UPDATEs for the {what}")
|
||||
return
|
||||
expected = routes * len(sessions_before)
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and (updates_sent(api) or 0) - before < expected:
|
||||
time.sleep(0.1)
|
||||
delta = (updates_sent(api) or 0) - before
|
||||
if len(established(api)) != len(sessions_before):
|
||||
print(f" [skip] sessions changed during the test; {delta} UPDATEs sent for the {what}")
|
||||
else:
|
||||
report.check(delta == expected, f"{delta} BGP UPDATEs sent for the {what} "
|
||||
f"({routes} routes x {len(sessions_before)} session(s))", [f"expected {expected}"])
|
||||
|
||||
|
||||
class EventCounter(threading.Thread):
|
||||
"""Follows /api/events and records what it sees until stopped."""
|
||||
|
||||
def __init__(self, api):
|
||||
super().__init__(daemon=True)
|
||||
self.api, self.events, self.error = api, [], None
|
||||
self.connected, self._stop = threading.Event(), threading.Event()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
stream = self.api.open_stream("/api/events")
|
||||
except Exception as e:
|
||||
self.error = f"{type(e).__name__}: {e}"
|
||||
self.connected.set()
|
||||
return
|
||||
self.connected.set()
|
||||
try:
|
||||
for raw in stream:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
line = raw.decode(errors="replace").strip()
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
self.events.append(json.loads(line[5:]))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if not self._stop.is_set():
|
||||
self.error = f"{type(e).__name__}: {e}"
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(round(p / 100 * (len(ordered) - 1))))]
|
||||
|
||||
|
||||
def latency_line(latencies):
|
||||
if not latencies:
|
||||
return "no samples"
|
||||
ms = [v * 1000 for v in latencies]
|
||||
return (f"min {min(ms):.0f} ms, median {statistics.median(ms):.0f} ms, "
|
||||
f"p95 {percentile(ms, 95):.0f} ms, max {max(ms):.0f} ms")
|
||||
|
||||
|
||||
def run_concurrently(jobs, worker):
|
||||
"""Runs worker(job) for every job at the same instant; returns results in order."""
|
||||
results = [None] * len(jobs)
|
||||
barrier = threading.Barrier(len(jobs))
|
||||
|
||||
def target(i, job):
|
||||
try:
|
||||
barrier.wait(timeout=30)
|
||||
except threading.BrokenBarrierError:
|
||||
results[i] = (0, "barrier broken", 0.0)
|
||||
return
|
||||
results[i] = worker(job)
|
||||
|
||||
threads = [threading.Thread(target=target, args=(i, job)) for i, job in enumerate(jobs)]
|
||||
wall = time.perf_counter()
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
return results, time.perf_counter() - wall
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.failures = 0
|
||||
|
||||
def check(self, ok, text, detail=None):
|
||||
print(f" [{'ok' if ok else 'FAIL'}] {text}")
|
||||
if not ok:
|
||||
self.failures += 1
|
||||
for line in (detail or [])[:8]:
|
||||
print(f" {line}")
|
||||
if detail and len(detail) > 8:
|
||||
print(f" ... and {len(detail) - 8} more")
|
||||
return ok
|
||||
|
||||
|
||||
def one_round(api, args, students, report, round_no):
|
||||
body_for = lambda s: {
|
||||
"prefix": s["prefix"], "owner": s["owner"],
|
||||
"communities": args.community, "large_communities": s["large"],
|
||||
}
|
||||
|
||||
events = EventCounter(api)
|
||||
if not args.no_events:
|
||||
events.start()
|
||||
events.connected.wait(timeout=10)
|
||||
time.sleep(0.3) # let the subscription settle before the burst
|
||||
|
||||
# ---- 1. everybody announces at once -----------------------------------
|
||||
print(f"\nRound {round_no}: {len(students)} students announce concurrently")
|
||||
sent_before, sessions_before = updates_sent(api), established(api)
|
||||
results, wall = run_concurrently(students, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
|
||||
accepted = [s for s, r in zip(students, results) if r[0] == 200]
|
||||
rejected = [f"{s['owner']} {s['prefix']}: HTTP {r[0]} {str(r[1])[:160]}" for s, r in zip(students, results) if r[0] != 200]
|
||||
report.check(not rejected, f"all {len(students)} announcements accepted in {wall:.2f} s "
|
||||
f"({len(students) / wall:.0f}/s)", rejected)
|
||||
print(f" latency: {latency_line([r[2] for r in results if r[0] == 200])}")
|
||||
if not accepted:
|
||||
print(" [skip] nothing was accepted, so there is nothing further to verify")
|
||||
if not args.no_events:
|
||||
events.stop()
|
||||
return accepted
|
||||
|
||||
# ---- 2. the table holds exactly what was announced --------------------
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
table = table if status == 200 and isinstance(table, list) else []
|
||||
by_prefix = {r["prefix"]: r for r in table}
|
||||
missing = [f"{s['prefix']} missing" for s in accepted if s["prefix"] not in by_prefix]
|
||||
wrong_owner = [f"{s['prefix']} owned by {by_prefix[s['prefix']]['owner']!r}, expected {s['owner']!r}"
|
||||
for s in accepted if s["prefix"] in by_prefix and by_prefix[s["prefix"]]["owner"] != s["owner"]]
|
||||
wrong_attrs = [f"{s['prefix']} large communities {by_prefix[s['prefix']]['large_communities']}"
|
||||
for s in accepted if s["prefix"] in by_prefix
|
||||
and sorted(by_prefix[s["prefix"]]["large_communities"]) != sorted(s["large"])]
|
||||
report.check(not (missing or wrong_owner or wrong_attrs),
|
||||
"local table holds every route with the right owner and communities",
|
||||
missing + wrong_owner + wrong_attrs)
|
||||
|
||||
# ---- 3. each route became an UPDATE on every session -------------------
|
||||
up = established(api)
|
||||
if not up:
|
||||
print(" [skip] no established BGP session, so nothing was put on the wire")
|
||||
else:
|
||||
sample = accepted if len(accepted) <= 32 else accepted[:: max(1, len(accepted) // 32)]
|
||||
bad = []
|
||||
for s in sample:
|
||||
code, wire, _ = api.call("GET", "/api/routes/local/wire?prefix=" + urllib.request.quote(s["prefix"], safe=""))
|
||||
if code != 200 or not wire:
|
||||
bad.append(f"{s['prefix']}: HTTP {code}, {len(wire) if isinstance(wire, list) else 0} session(s)")
|
||||
elif not all(w["hex"].startswith("ff" * 16) for w in wire):
|
||||
bad.append(f"{s['prefix']}: malformed UPDATE")
|
||||
report.check(not bad, f"UPDATE built for {len(up)} established session(s) "
|
||||
f"(checked {len(sample)} of {len(accepted)} routes)", bad)
|
||||
check_wire(api, report, sent_before, len(accepted), sessions_before, "announcements")
|
||||
|
||||
# ---- 4. the event stream lost nothing ----------------------------------
|
||||
if not args.no_events:
|
||||
deadline = time.time() + 5
|
||||
want = {s["prefix"] for s in accepted}
|
||||
seen = lambda: {e["route"]["prefix"] for e in events.events if e.get("type") == "local_announced"}
|
||||
while time.time() < deadline and not want <= seen():
|
||||
time.sleep(0.1)
|
||||
lagged = [e for e in events.events if e.get("type") == "lagged"]
|
||||
lost = sorted(want - seen())
|
||||
detail = [f"no event for {p}" for p in lost] + [f"stream lagged: missed {e['missed']} on {e['stream']}" for e in lagged]
|
||||
if events.error:
|
||||
detail.insert(0, f"event stream: {events.error}")
|
||||
report.check(not detail, f"event stream delivered all {len(want)} announcements", detail)
|
||||
|
||||
# ---- 5. ownership holds under load -------------------------------------
|
||||
if len(accepted) >= 2 and not args.no_policy:
|
||||
# Everyone tries, at once, to take over their neighbour's prefix.
|
||||
thieves = [{**accepted[i], "prefix": accepted[(i + 1) % len(accepted)]["prefix"]} for i in range(len(accepted))]
|
||||
results, _ = run_concurrently(thieves, lambda s: api.call("POST", "/api/routes/local", body_for(s)))
|
||||
leaked = [f"{t['owner']} took {t['prefix']}: HTTP {r[0]}" for t, r in zip(thieves, results) if r[0] != 409]
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
owners = {r["prefix"]: r["owner"] for r in table} if status == 200 else {}
|
||||
leaked += [f"{s['prefix']} now owned by {owners.get(s['prefix'])!r}" for s in accepted if owners.get(s["prefix"]) != s["owner"]]
|
||||
report.check(not leaked, f"{len(thieves)} concurrent takeover attempts all refused (409), owners unchanged", leaked)
|
||||
|
||||
if not args.no_events:
|
||||
events.stop()
|
||||
return accepted
|
||||
|
||||
|
||||
def withdraw_all(api, students, report, verify=True):
|
||||
if not students:
|
||||
return
|
||||
sent_before, sessions_before = (updates_sent(api), established(api)) if verify else (None, [])
|
||||
results, wall = run_concurrently(
|
||||
students, lambda s: api.call("DELETE", "/api/routes/local", {"prefix": s["prefix"], "owner": s["owner"]}))
|
||||
failed = [f"{s['prefix']}: HTTP {r[0]} {r[1]}" for s, r in zip(students, results) if r[0] != 200]
|
||||
if not verify:
|
||||
return
|
||||
report.check(not failed, f"all {len(students)} withdrawals accepted in {wall:.2f} s", failed)
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
left = [r["prefix"] for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else ["(could not read table)"]
|
||||
report.check(not left, "nothing left behind by the load test", left)
|
||||
if sessions_before:
|
||||
check_wire(api, report, sent_before, len(students) - len(failed), sessions_before, "withdrawals")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("students", type=int, help="number of simulated students")
|
||||
ap.add_argument("--url", default="http://127.0.0.1:8080", help="base URL of the status page (default %(default)s)")
|
||||
ap.add_argument("--user", help="HTTP basic auth user; the password is read from $FERRIX_PASSWORD or prompted for")
|
||||
ap.add_argument("--insecure", action="store_true", help="do not verify the TLS certificate")
|
||||
ap.add_argument("--prefix-template", default="2a0d:d741:{n:x}::/48",
|
||||
help="prefix of student n, a Python format string (default %(default)s)")
|
||||
ap.add_argument("--first", type=int, default=1, help="number of the first student (default 1)")
|
||||
ap.add_argument("--rounds", type=int, default=1, help="repeat announce/verify/withdraw this many times")
|
||||
ap.add_argument("--community", action="append", default=[], help="standard community to attach (repeatable)")
|
||||
ap.add_argument("--large-community", action="append", default=[], help="extra large community to attach (repeatable)")
|
||||
ap.add_argument("--announce-for-real", action="store_true",
|
||||
help=f"do NOT add {QUIET_COMMUNITY}; routes may be exported to the Internet")
|
||||
ap.add_argument("--keep", action="store_true", help="leave the routes announced at the end")
|
||||
ap.add_argument("--no-events", action="store_true", help="skip the event stream check")
|
||||
ap.add_argument("--no-policy", action="store_true", help="skip the concurrent takeover check")
|
||||
args = ap.parse_args()
|
||||
if args.students < 1:
|
||||
ap.error("need at least one student")
|
||||
|
||||
password = None
|
||||
if args.user:
|
||||
password = os.environ.get("FERRIX_PASSWORD") or getpass.getpass(f"Password for {args.user}: ")
|
||||
api = Api(args.url, args.user, password, args.insecure)
|
||||
|
||||
large = list(args.large_community)
|
||||
if not args.announce_for_real and QUIET_COMMUNITY not in large:
|
||||
large.insert(0, QUIET_COMMUNITY)
|
||||
students = [{"owner": f"{OWNER_PREFIX}{n}", "prefix": args.prefix_template.format(n=n), "large": large}
|
||||
for n in range(args.first, args.first + args.students)]
|
||||
|
||||
# ---- preflight ----------------------------------------------------------
|
||||
status, info, _ = api.call("GET", "/api/status")
|
||||
if status == 401:
|
||||
sys.exit(f"{args.url} wants a password: HTTP 401. Pass --user, and check the password.")
|
||||
if status != 200:
|
||||
sys.exit(f"Cannot reach {args.url}/api/status: HTTP {status} {str(info)[:200]}")
|
||||
print(f"Target: {args.url} router-id {info['router_id']} AS{info['local_asn']} version {info['version']} "
|
||||
f"on {info.get('hostname', '?')}")
|
||||
print(f" {info['sessions']} established session(s), {info['local_routes']} local route(s) already present")
|
||||
print(f"Prefixes {students[0]['prefix']} .. {students[-1]['prefix']}, large communities {large or 'none'}")
|
||||
if args.announce_for_real:
|
||||
print("WARNING: --announce-for-real: these routes may be exported to the Internet.")
|
||||
|
||||
status, table, _ = api.call("GET", "/api/routes/local")
|
||||
stale = [r for r in table if r["owner"].startswith(OWNER_PREFIX)] if status == 200 else []
|
||||
if stale:
|
||||
print(f"Removing {len(stale)} route(s) left over from an earlier load test")
|
||||
withdraw_all(api, [{"owner": r["owner"], "prefix": r["prefix"]} for r in stale], Report(), verify=False)
|
||||
taken = {r["prefix"]: r["owner"] for r in table if not r["owner"].startswith(OWNER_PREFIX)} if status == 200 else {}
|
||||
clash = [s for s in students if s["prefix"] in taken]
|
||||
if clash:
|
||||
print(f"Skipping {len(clash)} prefix(es) currently announced by real owners: "
|
||||
+ ", ".join(f"{s['prefix']} ({taken[s['prefix']]})" for s in clash[:5]) + (" ..." if len(clash) > 5 else ""))
|
||||
students = [s for s in students if s["prefix"] not in taken]
|
||||
if not students:
|
||||
sys.exit("Nothing left to test.")
|
||||
|
||||
report, announced = Report(), []
|
||||
try:
|
||||
for round_no in range(1, args.rounds + 1):
|
||||
announced = one_round(api, args, students, report, round_no)
|
||||
if round_no < args.rounds or not args.keep:
|
||||
withdraw_all(api, announced, report)
|
||||
announced = []
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
report.failures += 1
|
||||
finally:
|
||||
if announced and not args.keep:
|
||||
withdraw_all(api, announced, Report(), verify=False)
|
||||
|
||||
status, info, _ = api.call("GET", "/api/status")
|
||||
if status == 200:
|
||||
print(f"\nAfterwards: {info['sessions']} established session(s), {info['local_routes']} local route(s)")
|
||||
print("RESULT: " + ("PASS" if report.failures == 0 else f"FAIL ({report.failures} check(s) failed)"))
|
||||
sys.exit(0 if report.failures == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user