Ferrix web UI working for demo and slides mostly done
This commit is contained in:
1 parent
f3bb8cc89e
commit
b607058650
33 files changed
+2227
-428
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "slides",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["slides/serve.py", "8765"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
@@ -225,6 +225,7 @@ dependencies = [
|
||||
"clap",
|
||||
"eyre",
|
||||
"futures",
|
||||
"libc",
|
||||
"prometheus",
|
||||
"protobuf",
|
||||
"protobuf-codegen",
|
||||
|
||||
@@ -40,6 +40,7 @@ protobuf = { package = "protobuf", version = "3.7.2" }
|
||||
protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" }
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
axum = "0.8.9"
|
||||
tokio-stream = { version = "0.1.19", features = ["sync"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json"] }
|
||||
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_server::config::Config;
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::proto::config::BgpConfig;
|
||||
use bgp_server::server::BgpServerBuilder;
|
||||
use bgp_server::tracing::TracingArgs;
|
||||
@@ -32,6 +33,11 @@ async fn main() -> Result<()> {
|
||||
|
||||
let server = BgpServerBuilder::new(config).bind().await?;
|
||||
|
||||
let pings = match &server.config().ping_monitor {
|
||||
Some(prefixes) => PingMonitor::start(prefixes.clone(), server.local_rib.clone()),
|
||||
None => PingMonitor::disabled(),
|
||||
};
|
||||
|
||||
if let Some(http_endpoint) = server.config().http_endpoint {
|
||||
let state = AppState {
|
||||
info: Arc::new(
|
||||
@@ -45,6 +51,7 @@ async fn main() -> Result<()> {
|
||||
registry: server.registry.clone(),
|
||||
local_rib: server.local_rib.clone(),
|
||||
adj_rib_in: server.adj_rib_in.clone(),
|
||||
pings,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bgp_web::serve(state, http_endpoint).await {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
|
||||
use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy};
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_server::config::Config;
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::server::BgpServerBuilder;
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use bgp_web::{AppState, RouterInfo};
|
||||
@@ -21,6 +23,9 @@ pub struct TestServer {
|
||||
pub local_rib: LocalRib,
|
||||
/// Live view of the server's established sessions.
|
||||
pub registry: SessionRegistry,
|
||||
/// Ping monitor without a socket, watching 2001:db8::/32; tests feed it
|
||||
/// through `record`.
|
||||
pub pings: PingMonitor,
|
||||
pub local_asn: u32,
|
||||
}
|
||||
|
||||
@@ -61,6 +66,7 @@ impl TestServer {
|
||||
collision_detect_established_state: false,
|
||||
local_route_policy: LocalRibPolicy::default(),
|
||||
http_endpoint: None,
|
||||
ping_monitor: None,
|
||||
};
|
||||
customize(&mut config);
|
||||
|
||||
@@ -74,8 +80,13 @@ impl TestServer {
|
||||
let local_rib = bound.local_rib.clone();
|
||||
let registry = bound.registry.clone();
|
||||
|
||||
let pings = PingMonitor::without_socket(
|
||||
vec![IpPrefix::try_from("2001:db8::/32").expect("valid prefix")],
|
||||
Some(local_rib.clone()),
|
||||
);
|
||||
|
||||
let handle = tokio::spawn(bound.run());
|
||||
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn })
|
||||
Ok(Self { handle, addr, adj_rib_in, local_rib, registry, pings, local_asn })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +105,7 @@ impl TestServer {
|
||||
registry: self.registry.clone(),
|
||||
local_rib: self.local_rib.clone(),
|
||||
adj_rib_in: self.adj_rib_in.clone(),
|
||||
pings: self.pings.clone(),
|
||||
};
|
||||
let handle = tokio::spawn(bgp_web::serve_on(state, listener));
|
||||
Ok((base_url, HttpGuard { handle }))
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The ping monitor as seen through the HTTP API. The socket is left out
|
||||
//! (it needs privileges); observations are fed in directly.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_integration_tests::test_server::TestServer;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRoute;
|
||||
use bgp_server::ping::PingObservation;
|
||||
use eyre::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn ping(src: &str, dst: &str, hop_limit: u8, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(hop_limit),
|
||||
ident: 1,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: [&[128, 0, 0xab, 0xcd, 0, 1][..], &seq.to_be_bytes(), b"hi"].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_are_listed_with_the_route_they_were_sent_to() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let client = reqwest::Client::new();
|
||||
server
|
||||
.local_rib
|
||||
.announce(LocalRoute::new(IpPrefix::try_from("2001:db8:1a::/48").unwrap(), "alice", Default::default()))
|
||||
.await?;
|
||||
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 0)).await;
|
||||
server.pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, 1)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2001:db8:ff::1", 243, 0)).await;
|
||||
server.pings.record(ping("2a02:1210::9", "2a0d:d740::1", 243, 0)).await; // not a watched prefix
|
||||
|
||||
let view: Value = client.get(format!("{base}/api/pings")).send().await?.error_for_status()?.json().await?;
|
||||
assert_eq!(view["status"], "listening");
|
||||
assert_eq!(view["prefixes"][0], "2001:db8::/32");
|
||||
assert_eq!(view["total_flows"], 2);
|
||||
|
||||
let flows = view["flows"].as_array().unwrap();
|
||||
let alice = flows.iter().find(|f| f["dst"] == "2001:db8:1a::1").unwrap();
|
||||
assert_eq!(alice["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(alice["prefix"], "2001:db8:1a::/48");
|
||||
assert_eq!(alice["owner"], "alice");
|
||||
assert_eq!(alice["interface"], "swissix0");
|
||||
assert_eq!((alice["hop_limit"].as_u64(), alice["hops"].as_u64()), (Some(57), Some(7)));
|
||||
assert_eq!((alice["count"].as_u64(), alice["seq"].as_u64()), (Some(2), Some(1)));
|
||||
assert_eq!(alice["hex"], "8000abcd000100016869");
|
||||
assert!(alice["last_seen_unix"].as_u64().unwrap() > 1_700_000_000);
|
||||
|
||||
let stray = flows.iter().find(|f| f["dst"] == "2001:db8:ff::1").unwrap();
|
||||
assert!(stray["prefix"].is_null() && stray["owner"].is_null());
|
||||
assert_eq!(stray["hops"], 12);
|
||||
|
||||
let limited: Value = client.get(format!("{base}/api/pings?limit=1")).send().await?.json().await?;
|
||||
assert_eq!(limited["total_flows"], 2);
|
||||
assert_eq!(limited["flows"].as_array().unwrap().len(), 1);
|
||||
|
||||
let metrics = client.get(format!("{base}/metricz")).send().await?.text().await?;
|
||||
assert!(metrics.contains("ping_monitor_echo_requests_total"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pings_appear_on_the_event_stream() -> Result<()> {
|
||||
let server = TestServer::start(65000).await?;
|
||||
let (base, _http) = server.serve_http().await?;
|
||||
let mut resp = reqwest::Client::new().get(format!("{base}/api/events")).send().await?.error_for_status()?;
|
||||
|
||||
// The subscription exists once the response has started; a ping recorded
|
||||
// before that would be missed, so keep pinging until one shows up.
|
||||
let pings = server.pings.clone();
|
||||
let pinger = tokio::spawn(async move {
|
||||
for seq in 0.. {
|
||||
pings.record(ping("2001:67c:10ec::5", "2001:db8:1a::1", 57, seq)).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut buf = String::new();
|
||||
let event = tokio::time::timeout(TIMEOUT, async {
|
||||
loop {
|
||||
let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?;
|
||||
buf.push_str(std::str::from_utf8(&chunk)?);
|
||||
for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) {
|
||||
let ev: Value = serde_json::from_str(line.trim())?;
|
||||
if ev["type"] == "ping_seen" {
|
||||
return Ok::<_, eyre::Error>(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
pinger.abort();
|
||||
|
||||
assert_eq!(event["ping"]["src"], "2001:67c:10ec::5");
|
||||
assert_eq!(event["ping"]["dst"], "2001:db8:1a::1");
|
||||
assert_eq!(event["ping"]["interface"], "swissix0");
|
||||
assert!(event["new_burst"].is_boolean());
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,6 +9,7 @@ bgp-rib.workspace = true
|
||||
bytes.workspace = true
|
||||
clap.workspace = true
|
||||
futures.workspace = true
|
||||
libc.workspace = true
|
||||
eyre.workspace = true
|
||||
prometheus.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -19,3 +20,6 @@ thiserror.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
protobuf-codegen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
@@ -37,6 +37,22 @@ message BgpConfig {
|
||||
// restarted take over from a session we have not yet noticed is dead,
|
||||
// instead of being refused until the hold timer expires.
|
||||
bool collision_detect_established_state = 8;
|
||||
|
||||
// Watch for pings (ICMPv6 echo requests) sent to the lab prefixes and show
|
||||
// them on the status page. Unset disables it.
|
||||
PingMonitor ping_monitor = 9;
|
||||
}
|
||||
|
||||
// Shows who is pinging addresses that this host answers for.
|
||||
//
|
||||
// The host must already own the addresses, e.g. through an AnyIP route
|
||||
// ("ip -6 route add local 2001:db8::/40 dev lo"); the kernel keeps answering
|
||||
// the pings and we only receive a copy. Opening the raw ICMPv6 socket this
|
||||
// needs takes CAP_NET_RAW on Linux. IPv6 only.
|
||||
message PingMonitor {
|
||||
// Only pings to destinations inside these prefixes are shown. Defaults to
|
||||
// the IPv6 prefixes in local_route_policy.allowed_prefixes.
|
||||
repeated string prefixes = 1;
|
||||
}
|
||||
|
||||
// Limits on locally originated routes.
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use bgp_primitives::constants::AddressFamilyId;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::LocalRibPolicy;
|
||||
use eyre::{Result, WrapErr, bail, eyre};
|
||||
@@ -31,6 +32,9 @@ pub struct Config {
|
||||
pub local_route_policy: LocalRibPolicy,
|
||||
/// Address for the HTTP API; `None` disables it.
|
||||
pub http_endpoint: Option<SocketAddr>,
|
||||
/// IPv6 prefixes whose incoming pings are shown; `None` disables the
|
||||
/// ping monitor.
|
||||
pub ping_monitor: Option<Vec<IpPrefix>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -127,6 +131,27 @@ impl Config {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let ping_monitor = match proto.ping_monitor.into_option() {
|
||||
Some(monitor) => {
|
||||
let prefixes: Vec<IpPrefix> = if monitor.prefixes.is_empty() {
|
||||
local_route_policy.allowed_prefixes.clone()
|
||||
} else {
|
||||
monitor
|
||||
.prefixes
|
||||
.iter()
|
||||
.map(|p| IpPrefix::try_from(p.as_str()).wrap_err_with(|| format!("Invalid ping_monitor prefix {p:?}")))
|
||||
.collect::<Result<_>>()?
|
||||
};
|
||||
let prefixes: Vec<IpPrefix> =
|
||||
prefixes.into_iter().filter(|p| p.address_family == AddressFamilyId::Ipv6).collect();
|
||||
if prefixes.is_empty() {
|
||||
bail!("ping_monitor needs IPv6 prefixes, either its own or local_route_policy.allowed_prefixes");
|
||||
}
|
||||
Some(prefixes)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let connect_retry = if proto.connect_retry_seconds == 0 {
|
||||
DEFAULT_CONNECT_RETRY
|
||||
} else {
|
||||
@@ -143,6 +168,7 @@ impl Config {
|
||||
collision_detect_established_state: proto.collision_detect_established_state,
|
||||
local_route_policy,
|
||||
http_endpoint,
|
||||
ping_monitor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod connection;
|
||||
pub mod dialer;
|
||||
pub mod fsm;
|
||||
pub mod metrics;
|
||||
pub mod ping;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod tracing;
|
||||
|
||||
@@ -46,6 +46,22 @@ pub static ADJ_RIB_IN_ROUTES: LazyLock<IntGauge> = LazyLock::new(|| {
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_ECHO_REQUESTS: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_echo_requests_total",
|
||||
"ICMPv6 echo requests seen for the watched prefixes"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
pub static PING_EVENTS_SUPPRESSED: LazyLock<IntCounter> = LazyLock::new(|| {
|
||||
register_int_counter!(
|
||||
"ping_monitor_events_suppressed_total",
|
||||
"Ping events not published because of the per-second limit"
|
||||
)
|
||||
.expect("metric registration")
|
||||
});
|
||||
|
||||
/// Forces registration of every metric so they appear in scrapes before
|
||||
/// their first observation.
|
||||
pub fn init() {
|
||||
@@ -56,6 +72,8 @@ pub fn init() {
|
||||
LazyLock::force(&COLLISIONS);
|
||||
LazyLock::force(&LOCAL_ROUTES);
|
||||
LazyLock::force(&ADJ_RIB_IN_ROUTES);
|
||||
LazyLock::force(&PING_ECHO_REQUESTS);
|
||||
LazyLock::force(&PING_EVENTS_SUPPRESSED);
|
||||
}
|
||||
|
||||
pub fn message_type_name(msg: &Message) -> &'static str {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Shows who is pinging the prefixes this router originates.
|
||||
//!
|
||||
//! The host answers for the lab address space itself (an AnyIP route on the
|
||||
//! loopback interface), so the kernel already replies to every ping. A raw
|
||||
//! ICMPv6 socket gets a copy of each echo request as well; we never reply and
|
||||
//! never see forwarded traffic. For every packet we learn the source, the
|
||||
//! destination, the interface it arrived on and the hop limit it arrived
|
||||
//! with, which is enough to show a student that their route is in use and
|
||||
//! which way the traffic came in.
|
||||
//!
|
||||
//! Anyone on the Internet can send these packets, with any source address, so
|
||||
//! everything here is bounded: a fixed number of flows is remembered and the
|
||||
//! number of events published per second is capped.
|
||||
|
||||
#[cfg(unix)]
|
||||
mod sys;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use bgp_primitives::constants::AddressFamilyId;
|
||||
use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_rib::{LocalRib, LocalRoute};
|
||||
use tokio::sync::{Mutex, broadcast};
|
||||
use tokio::time::Instant;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::metrics::{PING_ECHO_REQUESTS, PING_EVENTS_SUPPRESSED};
|
||||
|
||||
/// Flows (source and destination pairs) remembered. When full, the flow that
|
||||
/// has been quiet the longest makes room.
|
||||
const MAX_FLOWS: usize = 2048;
|
||||
/// A flow that keeps pinging publishes an event this often, carrying the
|
||||
/// running count.
|
||||
const EVENT_INTERVAL: Duration = Duration::from_secs(2);
|
||||
/// A flow that was quiet for this long starts a new burst, which is what the
|
||||
/// event log reports.
|
||||
const BURST_GAP: Duration = Duration::from_secs(10);
|
||||
/// How much of each packet is kept for display.
|
||||
pub const MAX_PACKET_BYTES: usize = 256;
|
||||
/// Events published per second across all flows. Packets beyond this are
|
||||
/// still counted and show up in [`PingMonitor::flows`].
|
||||
const MAX_EVENTS_PER_SECOND: u32 = 50;
|
||||
|
||||
/// One echo request, as seen by the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingObservation {
|
||||
pub src: Ipv6Addr,
|
||||
pub dst: Ipv6Addr,
|
||||
/// Name of the interface the packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the packet arrived. Senders start at 64, 128 or
|
||||
/// 255, so this tells roughly how many routers the packet crossed.
|
||||
pub hop_limit: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the ICMPv6 message, header included.
|
||||
pub bytes: usize,
|
||||
/// The ICMPv6 message as received, from its type byte on, cut off after
|
||||
/// [`MAX_PACKET_BYTES`]. The IPv6 header is not part of it: a raw ICMPv6
|
||||
/// socket never sees one, which is why the fields above come separately.
|
||||
pub packet: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The local route a ping was addressed to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingedRoute {
|
||||
pub prefix: IpPrefix,
|
||||
pub owner: String,
|
||||
}
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PingFlow {
|
||||
/// The most recent packet.
|
||||
pub last: PingObservation,
|
||||
pub count: u64,
|
||||
pub first_seen: SystemTime,
|
||||
pub last_seen: SystemTime,
|
||||
/// The most specific local route covering the destination right now;
|
||||
/// `None` when nobody announces it.
|
||||
pub route: Option<PingedRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PingEvent {
|
||||
pub flow: PingFlow,
|
||||
/// True for the first event of a burst: a new flow, or one that had gone
|
||||
/// quiet. False for the periodic updates while it keeps pinging.
|
||||
pub new_burst: bool,
|
||||
}
|
||||
|
||||
/// Whether the monitor is watching, for display.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PingMonitorStatus {
|
||||
/// Not configured.
|
||||
Disabled,
|
||||
Listening,
|
||||
/// Configured, but the socket could not be opened.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct FlowState {
|
||||
flow: PingFlow,
|
||||
last_packet: Instant,
|
||||
last_event: Option<Instant>,
|
||||
/// A burst started and its first event has not gone out yet.
|
||||
burst_pending: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Table {
|
||||
flows: HashMap<(Ipv6Addr, Ipv6Addr), FlowState>,
|
||||
window_start: Option<Instant>,
|
||||
window_events: u32,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn take_event_slot(&mut self, now: Instant) -> bool {
|
||||
if self.window_start.is_none_or(|start| now.duration_since(start) >= Duration::from_secs(1)) {
|
||||
self.window_start = Some(now);
|
||||
self.window_events = 0;
|
||||
}
|
||||
if self.window_events >= MAX_EVENTS_PER_SECOND {
|
||||
return false;
|
||||
}
|
||||
self.window_events += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn make_room(&mut self) {
|
||||
if self.flows.len() < MAX_FLOWS {
|
||||
return;
|
||||
}
|
||||
if let Some(key) = self.flows.iter().min_by_key(|(_, s)| s.last_packet).map(|(k, _)| *k) {
|
||||
self.flows.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
prefixes: Vec<IpPrefix>,
|
||||
local_rib: Option<LocalRib>,
|
||||
status: PingMonitorStatus,
|
||||
table: Mutex<Table>,
|
||||
events: broadcast::Sender<PingEvent>,
|
||||
}
|
||||
|
||||
/// Handle to the ping monitor. Cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub struct PingMonitor {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl PingMonitor {
|
||||
fn new(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>, status: PingMonitorStatus) -> Self {
|
||||
let (events, _) = broadcast::channel(256);
|
||||
Self { inner: Arc::new(Inner { prefixes, local_rib, status, table: Mutex::default(), events }) }
|
||||
}
|
||||
|
||||
/// A monitor that watches nothing.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(vec![], None, PingMonitorStatus::Disabled)
|
||||
}
|
||||
|
||||
/// A monitor without a socket, fed through [`PingMonitor::record`].
|
||||
pub fn without_socket(prefixes: Vec<IpPrefix>, local_rib: Option<LocalRib>) -> Self {
|
||||
Self::new(prefixes, local_rib, PingMonitorStatus::Listening)
|
||||
}
|
||||
|
||||
/// Opens the socket and starts watching for pings to `prefixes`. Failing
|
||||
/// to open it (usually: no CAP_NET_RAW) is not fatal; the daemon runs on
|
||||
/// without the monitor and the status page says why.
|
||||
pub fn start(prefixes: Vec<IpPrefix>, local_rib: LocalRib) -> Self {
|
||||
#[cfg(unix)]
|
||||
let socket = sys::EchoSocket::open();
|
||||
#[cfg(not(unix))]
|
||||
let socket: std::io::Result<()> = Err(std::io::Error::other("not supported on this platform"));
|
||||
|
||||
match socket {
|
||||
Ok(socket) => {
|
||||
let shown: Vec<String> = prefixes.iter().map(ToString::to_string).collect();
|
||||
info!(prefixes = ?shown, "Ping monitor listening for ICMPv6 echo requests");
|
||||
let monitor = Self::new(prefixes, Some(local_rib), PingMonitorStatus::Listening);
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(monitor.clone().run(socket));
|
||||
monitor
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(err = %e, "Ping monitor disabled: cannot open a raw ICMPv6 socket (needs CAP_NET_RAW)");
|
||||
Self::new(prefixes, None, PingMonitorStatus::Failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn run(self, mut socket: sys::EchoSocket) {
|
||||
loop {
|
||||
match socket.recv().await {
|
||||
Ok(observation) => self.record(observation).await,
|
||||
Err(e) => {
|
||||
error!(err = %e, "Ping monitor socket failed");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &PingMonitorStatus {
|
||||
&self.inner.status
|
||||
}
|
||||
|
||||
/// The prefixes being watched.
|
||||
pub fn prefixes(&self) -> &[IpPrefix] {
|
||||
&self.inner.prefixes
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<PingEvent> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// Accounts for one echo request. Packets to destinations outside the
|
||||
/// watched prefixes are ignored.
|
||||
pub async fn record(&self, observation: PingObservation) {
|
||||
let destination = host_prefix(observation.dst);
|
||||
if !self.inner.prefixes.iter().any(|p| p.contains(&destination)) {
|
||||
return;
|
||||
}
|
||||
PING_ECHO_REQUESTS.inc();
|
||||
|
||||
let now = Instant::now();
|
||||
let wall = SystemTime::now();
|
||||
let key = (observation.src, observation.dst);
|
||||
let mut guard = self.inner.table.lock().await;
|
||||
let table = &mut *guard;
|
||||
|
||||
let is_new = !table.flows.contains_key(&key);
|
||||
if is_new {
|
||||
table.make_room();
|
||||
}
|
||||
let state = table.flows.entry(key).or_insert_with(|| FlowState {
|
||||
flow: PingFlow { last: observation.clone(), count: 0, first_seen: wall, last_seen: wall, route: None },
|
||||
last_packet: now,
|
||||
last_event: None,
|
||||
burst_pending: false,
|
||||
});
|
||||
if is_new || now.duration_since(state.last_packet) >= BURST_GAP {
|
||||
state.burst_pending = true;
|
||||
}
|
||||
state.flow.last = observation;
|
||||
state.flow.count += 1;
|
||||
state.flow.last_seen = wall;
|
||||
state.last_packet = now;
|
||||
|
||||
let due = state.burst_pending || state.last_event.is_none_or(|t| now.duration_since(t) >= EVENT_INTERVAL);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let new_burst = state.burst_pending;
|
||||
let mut flow = state.flow.clone();
|
||||
if !table.take_event_slot(now) {
|
||||
PING_EVENTS_SUPPRESSED.inc();
|
||||
return;
|
||||
}
|
||||
if let Some(state) = table.flows.get_mut(&key) {
|
||||
state.last_event = Some(now);
|
||||
state.burst_pending = false;
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
flow.route = resolve(&self.local_routes().await, &destination);
|
||||
// No subscribers is fine.
|
||||
let _ = self.inner.events.send(PingEvent { flow, new_burst });
|
||||
}
|
||||
|
||||
/// Every remembered flow, most recently seen first.
|
||||
pub async fn flows(&self) -> Vec<PingFlow> {
|
||||
let mut flows: Vec<PingFlow> = self.inner.table.lock().await.flows.values().map(|s| s.flow.clone()).collect();
|
||||
let routes = self.local_routes().await;
|
||||
for flow in &mut flows {
|
||||
flow.route = resolve(&routes, &host_prefix(flow.last.dst));
|
||||
}
|
||||
flows.sort_by(|a, b| b.last_seen.cmp(&a.last_seen).then_with(|| a.last.dst.cmp(&b.last.dst)));
|
||||
flows
|
||||
}
|
||||
|
||||
async fn local_routes(&self) -> Vec<LocalRoute> {
|
||||
match &self.inner.local_rib {
|
||||
Some(rib) => rib.snapshot().await,
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn host_prefix(addr: Ipv6Addr) -> IpPrefix {
|
||||
IpPrefix { address_family: AddressFamilyId::Ipv6, prefix: addr.octets().to_vec(), length: 128 }
|
||||
}
|
||||
|
||||
fn resolve(routes: &[LocalRoute], destination: &IpPrefix) -> Option<PingedRoute> {
|
||||
routes
|
||||
.iter()
|
||||
.filter(|r| r.prefix.contains(destination))
|
||||
.max_by_key(|r| r.prefix.length)
|
||||
.map(|r| PingedRoute { prefix: r.prefix.clone(), owner: r.owner.clone() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bgp_rib::{LocalRibPolicy, LocalRouteAttrs};
|
||||
|
||||
fn lab() -> Vec<IpPrefix> {
|
||||
vec![IpPrefix::try_from("2a0d:d741::/40").unwrap()]
|
||||
}
|
||||
|
||||
fn ping(src: &str, dst: &str, seq: u16) -> PingObservation {
|
||||
PingObservation {
|
||||
src: src.parse().unwrap(),
|
||||
dst: dst.parse().unwrap(),
|
||||
interface: Some("swissix0".into()),
|
||||
hop_limit: Some(57),
|
||||
ident: 7,
|
||||
seq,
|
||||
bytes: 64,
|
||||
packet: vec![128, 0, 0, 0, 0, 7, (seq >> 8) as u8, seq as u8],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn counts_flows_and_names_the_route_owner() {
|
||||
let rib = LocalRib::spawn(LocalRibPolicy::default());
|
||||
let prefix = IpPrefix::try_from("2a0d:d741:1a::/48").unwrap();
|
||||
rib.announce(LocalRoute::new(prefix.clone(), "alice", LocalRouteAttrs::default())).await.unwrap();
|
||||
let monitor = PingMonitor::without_socket(lab(), Some(rib.clone()));
|
||||
let mut events = monitor.subscribe();
|
||||
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 0)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1a::1", 1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:5::1", 0)).await;
|
||||
|
||||
let first = events.try_recv().unwrap();
|
||||
assert!(first.new_burst);
|
||||
assert_eq!(first.flow.route, Some(PingedRoute { prefix, owner: "alice".into() }));
|
||||
let other = events.try_recv().unwrap();
|
||||
assert_eq!(other.flow.route, None, "nobody announces 2a0d:d741:5::/48");
|
||||
assert!(events.try_recv().is_err(), "the second packet of a flow is not an event of its own");
|
||||
|
||||
let flows = monitor.flows().await;
|
||||
assert_eq!(flows.len(), 2);
|
||||
let alice = flows.iter().find(|f| f.route.is_some()).unwrap();
|
||||
assert_eq!((alice.count, alice.last.seq), (2, 1));
|
||||
|
||||
// The owner shown follows the routing table, not the first packet.
|
||||
rib.withdraw(alice.route.as_ref().unwrap().prefix.clone(), None).await.unwrap();
|
||||
assert!(monitor.flows().await.iter().all(|f| f.route.is_none()));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn ignores_destinations_outside_the_watched_prefixes() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d740::1", 0)).await;
|
||||
monitor.record(ping("2001:db8::1", "2001:7f8:24::c9", 0)).await;
|
||||
assert!(monitor.flows().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_steady_ping_is_an_occasional_update_and_a_pause_starts_a_new_burst() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
let mut seen = vec![];
|
||||
for seq in 0..6 {
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", seq)).await;
|
||||
while let Ok(ev) = events.try_recv() {
|
||||
seen.push((ev.flow.count, ev.new_burst));
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
}
|
||||
assert_eq!(seen, vec![(1, true), (3, false), (5, false)]);
|
||||
|
||||
tokio::time::advance(BURST_GAP).await;
|
||||
monitor.record(ping("2001:db8::1", "2a0d:d741:1::1", 6)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (7, true));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_flood_of_spoofed_sources_is_bounded() {
|
||||
let monitor = PingMonitor::without_socket(lab(), None);
|
||||
let mut events = monitor.subscribe();
|
||||
for n in 0..(MAX_FLOWS as u32 + 500) {
|
||||
let src = Ipv6Addr::from(0x2001_0db8_0000_0000_0000_0000_0000_0000u128 + n as u128);
|
||||
monitor.record(ping(&src.to_string(), "2a0d:d741:1::1", 0)).await;
|
||||
}
|
||||
assert_eq!(monitor.flows().await.len(), MAX_FLOWS);
|
||||
let mut published = 0;
|
||||
while events.try_recv().is_ok() {
|
||||
published += 1;
|
||||
}
|
||||
assert_eq!(published, MAX_EVENTS_PER_SECOND, "time stood still, so this was all one second");
|
||||
|
||||
// A real student still gets through once the flood's second is over,
|
||||
// even though the first attempt at announcing the burst was dropped.
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 0)).await;
|
||||
assert!(events.try_recv().is_err());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
monitor.record(ping("2001:67c:10ec::5", "2a0d:d741:1::1", 1)).await;
|
||||
let ev = events.try_recv().unwrap();
|
||||
assert_eq!((ev.flow.count, ev.new_burst), (2, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The raw ICMPv6 socket behind the ping monitor.
|
||||
//!
|
||||
//! All the `unsafe` in the daemon lives here: the socket options and the
|
||||
//! ancillary data we need (`ICMP6_FILTER`, `IPV6_PKTINFO`, `IPV6_HOPLIMIT`)
|
||||
//! have no safe wrapper in the standard library or tokio.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::CStr;
|
||||
use std::io;
|
||||
use std::mem::{size_of, size_of_val, zeroed};
|
||||
use std::net::Ipv6Addr;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
|
||||
|
||||
use libc::{c_int, c_void};
|
||||
use tokio::io::Interest;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
use super::{MAX_PACKET_BYTES, PingObservation};
|
||||
|
||||
const ICMP6_ECHO_REQUEST: u8 = 128;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const ICMP6_FILTER: c_int = 1;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
const ICMP6_FILTER: c_int = 18;
|
||||
|
||||
/// `struct icmp6_filter`: one bit per ICMPv6 type.
|
||||
type Icmp6Filter = [u32; 8];
|
||||
|
||||
/// A filter that lets only echo requests through. Linux and the BSDs agree on
|
||||
/// the layout and disagree on the meaning: on Linux a set bit blocks the
|
||||
/// type, on the BSDs (and macOS) a set bit passes it.
|
||||
fn echo_request_only() -> Icmp6Filter {
|
||||
let (word, bit) = ((ICMP6_ECHO_REQUEST >> 5) as usize, 1u32 << (ICMP6_ECHO_REQUEST & 31));
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) {
|
||||
let mut filter = [u32::MAX; 8];
|
||||
filter[word] &= !bit;
|
||||
filter
|
||||
} else {
|
||||
let mut filter = [0u32; 8];
|
||||
filter[word] |= bit;
|
||||
filter
|
||||
}
|
||||
}
|
||||
|
||||
fn check(ret: c_int) -> io::Result<c_int> {
|
||||
if ret < 0 { Err(io::Error::last_os_error()) } else { Ok(ret) }
|
||||
}
|
||||
|
||||
fn setsockopt<T>(fd: RawFd, level: c_int, name: c_int, value: &T) -> io::Result<()> {
|
||||
// SAFETY: `value` is a live reference and the length given is its size.
|
||||
check(unsafe {
|
||||
libc::setsockopt(fd, level, name, (value as *const T).cast::<c_void>(), size_of::<T>() as libc::socklen_t)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receives a copy of every ICMPv6 echo request the kernel delivers to this
|
||||
/// host. The kernel still answers them; we never send anything.
|
||||
pub struct EchoSocket {
|
||||
fd: AsyncFd<OwnedFd>,
|
||||
/// Interface names by index. Looked up once: interfaces on a router do
|
||||
/// not change name, and under a ping flood the lookup would be three
|
||||
/// system calls per packet.
|
||||
interfaces: HashMap<u32, Option<String>>,
|
||||
}
|
||||
|
||||
impl EchoSocket {
|
||||
/// Opens the socket. Needs CAP_NET_RAW on Linux. macOS also hands echo
|
||||
/// requests to unprivileged ICMPv6 datagram sockets, which is enough to
|
||||
/// try the monitor out on a laptop. (On Linux those only ever see the
|
||||
/// replies to their own pings, so there is no such fallback there.)
|
||||
pub fn open() -> io::Result<Self> {
|
||||
Self::open_as(libc::SOCK_RAW).or_else(|e| {
|
||||
if cfg!(target_os = "macos") { Self::open_as(libc::SOCK_DGRAM) } else { Err(e) }
|
||||
})
|
||||
}
|
||||
|
||||
fn open_as(socket_type: c_int) -> io::Result<Self> {
|
||||
// SAFETY: plain system call; the result is checked before use.
|
||||
let raw = check(unsafe { libc::socket(libc::AF_INET6, socket_type, libc::IPPROTO_ICMPV6) })?;
|
||||
// SAFETY: `raw` is a descriptor we just opened and nobody else owns.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
// SAFETY: fcntl on a descriptor we own, with integer arguments.
|
||||
unsafe {
|
||||
let flags = check(libc::fcntl(raw, libc::F_GETFL))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK))?;
|
||||
check(libc::fcntl(raw, libc::F_SETFD, libc::FD_CLOEXEC))?;
|
||||
}
|
||||
|
||||
setsockopt(raw, libc::IPPROTO_ICMPV6, ICMP6_FILTER, &echo_request_only())?;
|
||||
let on: c_int = 1;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVPKTINFO, &on)?;
|
||||
setsockopt(raw, libc::IPPROTO_IPV6, libc::IPV6_RECVHOPLIMIT, &on)?;
|
||||
|
||||
Ok(Self { fd: AsyncFd::with_interest(fd, Interest::READABLE)?, interfaces: HashMap::new() })
|
||||
}
|
||||
|
||||
/// Waits for the next echo request.
|
||||
pub async fn recv(&mut self) -> io::Result<PingObservation> {
|
||||
loop {
|
||||
let mut guard = self.fd.readable().await?;
|
||||
let packet = match guard.try_io(|fd| recv_packet(fd.as_raw_fd())) {
|
||||
Ok(result) => result?,
|
||||
Err(_would_block) => continue,
|
||||
};
|
||||
let Some(packet) = packet else { continue };
|
||||
let interface = self.interfaces.entry(packet.ifindex).or_insert_with(|| interface_name(packet.ifindex)).clone();
|
||||
return Ok(PingObservation {
|
||||
src: packet.src,
|
||||
dst: packet.dst,
|
||||
interface,
|
||||
hop_limit: packet.hop_limit,
|
||||
ident: packet.ident,
|
||||
seq: packet.seq,
|
||||
bytes: packet.bytes,
|
||||
packet: packet.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
src: Ipv6Addr,
|
||||
dst: Ipv6Addr,
|
||||
ifindex: u32,
|
||||
hop_limit: Option<u8>,
|
||||
ident: u16,
|
||||
seq: u16,
|
||||
bytes: usize,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Reads one packet. `Ok(None)` is a packet we cannot use (not an echo
|
||||
/// request, or the kernel did not say where it was going).
|
||||
fn recv_packet(fd: RawFd) -> io::Result<Option<Packet>> {
|
||||
// A raw ICMPv6 socket delivers the ICMPv6 message without the IPv6
|
||||
// header, so everything about the header comes from ancillary data.
|
||||
let mut data = [0u8; 2048];
|
||||
let mut control = [0u64; 16]; // u64 for the alignment cmsghdr needs
|
||||
// SAFETY: all-zero is a valid value for these plain C structs.
|
||||
let mut from: libc::sockaddr_in6 = unsafe { zeroed() };
|
||||
let mut iov = libc::iovec { iov_base: data.as_mut_ptr().cast(), iov_len: data.len() };
|
||||
// SAFETY: as above. msghdr has private padding on some targets, so it
|
||||
// cannot be built with a struct literal.
|
||||
let mut msg: libc::msghdr = unsafe { zeroed() };
|
||||
msg.msg_name = (&mut from as *mut libc::sockaddr_in6).cast();
|
||||
msg.msg_namelen = size_of::<libc::sockaddr_in6>() as _;
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1 as _;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = size_of_val(&control) as _;
|
||||
|
||||
// SAFETY: every pointer in `msg` refers to a local that outlives the call
|
||||
// and the lengths given match the buffers.
|
||||
let n = unsafe { libc::recvmsg(fd, &mut msg, 0) };
|
||||
if n < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let n = n as usize;
|
||||
if n < 8 || data[0] != ICMP6_ECHO_REQUEST {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut dst = None;
|
||||
let mut hop_limit = None;
|
||||
// SAFETY: the CMSG_* macros walk the control buffer the kernel just
|
||||
// filled in, bounded by msg_controllen. Payloads are read unaligned.
|
||||
unsafe {
|
||||
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
while !cmsg.is_null() {
|
||||
let header = &*cmsg;
|
||||
if header.cmsg_level == libc::IPPROTO_IPV6 {
|
||||
if header.cmsg_type == libc::IPV6_PKTINFO {
|
||||
let info: libc::in6_pktinfo = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
dst = Some((Ipv6Addr::from(info.ipi6_addr.s6_addr), info.ipi6_ifindex as u32));
|
||||
} else if header.cmsg_type == libc::IPV6_HOPLIMIT {
|
||||
let value: c_int = std::ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast());
|
||||
hop_limit = u8::try_from(value).ok();
|
||||
}
|
||||
}
|
||||
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
|
||||
}
|
||||
}
|
||||
let Some((dst, ifindex)) = dst else { return Ok(None) };
|
||||
|
||||
Ok(Some(Packet {
|
||||
src: Ipv6Addr::from(from.sin6_addr.s6_addr),
|
||||
dst,
|
||||
ifindex,
|
||||
hop_limit,
|
||||
ident: u16::from_be_bytes([data[4], data[5]]),
|
||||
seq: u16::from_be_bytes([data[6], data[7]]),
|
||||
bytes: n,
|
||||
data: data[..n.min(MAX_PACKET_BYTES)].to_vec(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn interface_name(index: u32) -> Option<String> {
|
||||
let mut name = [0 as libc::c_char; libc::IF_NAMESIZE];
|
||||
// SAFETY: the buffer is IF_NAMESIZE bytes, as if_indextoname requires,
|
||||
// and on success holds a NUL-terminated string.
|
||||
unsafe {
|
||||
if libc::if_indextoname(index, name.as_mut_ptr()).is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(CStr::from_ptr(name.as_ptr()).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_passes_only_echo_requests() {
|
||||
let filter = echo_request_only();
|
||||
let blocked = |t: u8| {
|
||||
let set = filter[(t >> 5) as usize] & (1 << (t & 31)) != 0;
|
||||
if cfg!(any(target_os = "linux", target_os = "android")) { set } else { !set }
|
||||
};
|
||||
assert!(!blocked(128), "echo request passes");
|
||||
for t in [1u8, 3, 129, 133, 134, 135, 136] {
|
||||
assert!(blocked(t), "type {t} is dropped in the kernel");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pings ::1 and expects to see the request. Skipped where the socket
|
||||
/// cannot be opened, i.e. on Linux without CAP_NET_RAW.
|
||||
#[tokio::test]
|
||||
async fn sees_a_ping_to_loopback() {
|
||||
let mut socket = match EchoSocket::open() {
|
||||
Ok(socket) => socket,
|
||||
Err(e) => return eprintln!("skipped: cannot open an ICMPv6 socket: {e}"),
|
||||
};
|
||||
let ping = if cfg!(target_os = "macos") { "ping6" } else { "ping" };
|
||||
let Ok(mut child) = std::process::Command::new(ping)
|
||||
.args(["-c", "1", "::1"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
else {
|
||||
return eprintln!("skipped: no {ping} command");
|
||||
};
|
||||
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let ping = socket.recv().await.unwrap();
|
||||
if ping.dst == Ipv6Addr::LOCALHOST {
|
||||
return ping;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let _ = child.wait();
|
||||
let seen = seen.expect("echo request to ::1 was not seen");
|
||||
assert_eq!(seen.src, Ipv6Addr::LOCALHOST);
|
||||
assert_eq!(seen.hop_limit, Some(64));
|
||||
assert!(seen.seq <= 1, "first sequence number is 0 (BSD ping6) or 1 (iputils)");
|
||||
assert!(seen.interface.as_deref().is_some_and(|name| name.starts_with("lo")), "{:?}", seen.interface);
|
||||
assert!(seen.bytes >= 8);
|
||||
assert_eq!(seen.packet[..2], [128, 0], "echo request, code 0");
|
||||
assert_eq!(seen.packet[4..8], [seen.ident.to_be_bytes(), seen.seq.to_be_bytes()].concat()[..]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use bgp_primitives::ip_prefix::IpPrefix;
|
||||
use bgp_primitives::parser::ParserContext;
|
||||
use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry};
|
||||
use bgp_server::advertise::build_announce;
|
||||
use bgp_server::ping::{PingFlow, PingMonitorStatus};
|
||||
use bgp_server::session::{ConfiguredPeer, DialState, SessionHandle, SessionRegistry};
|
||||
use bytes::BytesMut;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -475,3 +476,112 @@ pub async fn received_routes(State(state): State<AppState>) -> Json<Vec<Received
|
||||
routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr)));
|
||||
Json(routes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flows returned by `/api/pings` unless `?limit=` says otherwise.
|
||||
const DEFAULT_PING_FLOWS: usize = 100;
|
||||
|
||||
/// All pings from one source to one destination.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingView {
|
||||
pub src: String,
|
||||
pub dst: String,
|
||||
/// The local route the destination belongs to and who announced it;
|
||||
/// `null` when nobody announces it at the moment.
|
||||
pub prefix: Option<String>,
|
||||
pub owner: Option<String>,
|
||||
/// Interface the most recent packet arrived on.
|
||||
pub interface: Option<String>,
|
||||
/// Hop limit left when the most recent packet arrived.
|
||||
pub hop_limit: Option<u8>,
|
||||
/// Routers the packet crossed, assuming the sender started at the usual
|
||||
/// 64, 128 or 255.
|
||||
pub hops: Option<u8>,
|
||||
pub ident: u16,
|
||||
pub seq: u16,
|
||||
/// Size of the most recent ICMPv6 message.
|
||||
pub bytes: usize,
|
||||
/// That message as lowercase hex, from the ICMPv6 type byte on (no IPv6
|
||||
/// header). Long packets are cut off, so this can be shorter than `bytes`.
|
||||
pub hex: String,
|
||||
pub count: u64,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
}
|
||||
|
||||
fn estimated_hops(hop_limit: u8) -> u8 {
|
||||
let initial = [64u8, 128, 255].into_iter().find(|&start| start >= hop_limit).unwrap_or(255);
|
||||
initial - hop_limit
|
||||
}
|
||||
|
||||
impl From<&PingFlow> for PingView {
|
||||
fn from(flow: &PingFlow) -> Self {
|
||||
Self {
|
||||
src: flow.last.src.to_string(),
|
||||
dst: flow.last.dst.to_string(),
|
||||
prefix: flow.route.as_ref().map(|r| r.prefix.to_string()),
|
||||
owner: flow.route.as_ref().map(|r| r.owner.clone()),
|
||||
interface: flow.last.interface.clone(),
|
||||
hop_limit: flow.last.hop_limit,
|
||||
hops: flow.last.hop_limit.map(estimated_hops),
|
||||
ident: flow.last.ident,
|
||||
seq: flow.last.seq,
|
||||
bytes: flow.last.bytes,
|
||||
hex: flow.last.packet.iter().map(|b| format!("{b:02x}")).collect(),
|
||||
count: flow.count,
|
||||
first_seen_unix: unix_secs(flow.first_seen),
|
||||
last_seen_unix: unix_secs(flow.last_seen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PingMonitorView {
|
||||
/// `listening`, `disabled` (not configured) or `failed`.
|
||||
pub status: &'static str,
|
||||
/// Why the monitor failed to start.
|
||||
pub detail: Option<String>,
|
||||
/// Pings to destinations inside these prefixes are shown.
|
||||
pub prefixes: Vec<String>,
|
||||
/// Flows remembered; `flows` holds the most recently seen of them.
|
||||
pub total_flows: usize,
|
||||
pub flows: Vec<PingView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PingQuery {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn pings(State(state): State<AppState>, Query(query): Query<PingQuery>) -> Json<PingMonitorView> {
|
||||
let (status, detail) = match state.pings.status() {
|
||||
PingMonitorStatus::Listening => ("listening", None),
|
||||
PingMonitorStatus::Disabled => ("disabled", None),
|
||||
PingMonitorStatus::Failed(why) => ("failed", Some(why.clone())),
|
||||
};
|
||||
let flows = state.pings.flows().await;
|
||||
Json(PingMonitorView {
|
||||
status,
|
||||
detail,
|
||||
prefixes: state.pings.prefixes().iter().map(ToString::to_string).collect(),
|
||||
total_flows: flows.len(),
|
||||
flows: flows.iter().take(query.limit.unwrap_or(DEFAULT_PING_FLOWS)).map(PingView::from).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ping_tests {
|
||||
use super::estimated_hops;
|
||||
|
||||
#[test]
|
||||
fn hops_are_counted_from_the_nearest_usual_starting_value() {
|
||||
assert_eq!(estimated_hops(64), 0);
|
||||
assert_eq!(estimated_hops(57), 7);
|
||||
assert_eq!(estimated_hops(118), 10);
|
||||
assert_eq!(estimated_hops(243), 12);
|
||||
assert_eq!(estimated_hops(255), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Server-sent event stream merging session, local-route and received-route
|
||||
//! changes into one feed the UI can follow.
|
||||
//! Server-sent event stream merging session, local-route, received-route and
|
||||
//! ping events into one feed the UI can follow.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use bgp_rib::{LocalRibEvent, RibEvent};
|
||||
use bgp_server::ping::PingEvent;
|
||||
use bgp_server::session::SessionEvent;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::Serialize;
|
||||
@@ -13,7 +14,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize};
|
||||
use crate::api::{LocalRouteView, PeerView, PingView, ReceivedRouteView, summarize};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -26,6 +27,10 @@ pub enum WireEvent {
|
||||
LocalWithdrawn { prefix: String },
|
||||
ReceivedAnnounced { route: ReceivedRouteView },
|
||||
ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String },
|
||||
/// Somebody is pinging an address in the watched prefixes. `new_burst`
|
||||
/// marks the start of a run of pings; the events that follow while it
|
||||
/// continues only update the count.
|
||||
PingSeen { ping: PingView, new_burst: bool },
|
||||
/// The client fell behind and missed `missed` events on `stream`; it
|
||||
/// should refetch the corresponding table.
|
||||
Lagged { stream: &'static str, missed: u64 },
|
||||
@@ -49,6 +54,13 @@ impl WireEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_ping(ev: Result<PingEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(ev) => Self::PingSeen { ping: PingView::from(&ev.flow), new_burst: ev.new_burst },
|
||||
Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "pings", missed: n },
|
||||
}
|
||||
}
|
||||
|
||||
fn from_received(ev: Result<RibEvent, BroadcastStreamRecvError>) -> Self {
|
||||
match ev {
|
||||
Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced {
|
||||
@@ -82,7 +94,10 @@ pub async fn stream(
|
||||
let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local);
|
||||
let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received);
|
||||
|
||||
let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| {
|
||||
let pings = BroadcastStream::new(state.pings.subscribe()).map(WireEvent::from_ping);
|
||||
|
||||
let routes = futures::stream::select(local, received);
|
||||
let merged = futures::stream::select(futures::stream::select(sessions, pings), routes).map(|ev| {
|
||||
Ok(Event::default()
|
||||
.event("bgp")
|
||||
.json_data(&ev)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! inspecting state, a server-sent event stream, and Prometheus metrics.
|
||||
//!
|
||||
//! Everything here is a thin layer over handles owned by the BGP server
|
||||
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives
|
||||
//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`], [`PingMonitor`]); no state lives
|
||||
//! in this crate.
|
||||
|
||||
mod api;
|
||||
@@ -19,13 +19,15 @@ use axum::http::header;
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum::routing::{delete, get, post};
|
||||
use bgp_rib::{AdjRibIn, LocalRib};
|
||||
use bgp_server::ping::PingMonitor;
|
||||
use bgp_server::session::SessionRegistry;
|
||||
use eyre::{Result, WrapErr};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
pub use api::{
|
||||
AnnounceRequest, ConfigView, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView,
|
||||
AnnounceRequest, AttrSummary, ConfigView, LocalRouteView, PeerView, PingMonitorView, PingView,
|
||||
ReceivedRouteView, StatusView,
|
||||
WellKnownCommunity, WireView, WithdrawRequest,
|
||||
};
|
||||
|
||||
@@ -84,6 +86,7 @@ pub struct AppState {
|
||||
pub registry: SessionRegistry,
|
||||
pub local_rib: LocalRib,
|
||||
pub adj_rib_in: AdjRibIn,
|
||||
pub pings: PingMonitor,
|
||||
}
|
||||
|
||||
/// The status page, embedded so the binary is self-contained.
|
||||
@@ -106,6 +109,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/routes/local/withdraw", post(api::withdraw))
|
||||
.route("/api/routes/local/wire", get(api::wire))
|
||||
.route("/api/routes/received", get(api::received_routes))
|
||||
.route("/api/pings", get(api::pings))
|
||||
.route("/api/communities/well-known", get(api::well_known_communities))
|
||||
.route("/api/events", get(events::stream))
|
||||
.route("/metricz", get(metrics::metricz))
|
||||
|
||||
@@ -61,7 +61,7 @@ Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
|
||||
</div>
|
||||
<div class="righthand">
|
||||
Running on <span id="s-hostname">...</span><br>
|
||||
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a><br>
|
||||
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/pings">pings json</a><br>
|
||||
<span id="refresh-ctl"></span><br>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,8 +103,16 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
<tbody id="local-body"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pings-section" hidden>
|
||||
<h1 id="pings">Pings to announced addresses (<span id="pings-count">...</span>)</h1>
|
||||
<div class="dim" id="pings-note"></div>
|
||||
<table class="data">
|
||||
<thead><tr><th>destination</th><th>route</th><th>owner</th><th>from</th><th>arrived on</th><th>hop limit</th><th>pings</th><th>last sequence</th><th>first seen</th><th>last seen</th><th>actions</th></tr></thead>
|
||||
<tbody id="pings-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
|
||||
<div class="dim">A yellow row carries a prefix that is also announced locally: it has travelled through other routers and come back.</div>
|
||||
<table class="data">
|
||||
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
|
||||
<tbody id="received-body"></tbody>
|
||||
@@ -148,7 +156,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() };
|
||||
const state = { status: null, peers: [], local: [], received: [], pings: null, wellKnown: {}, openWire: new Set(), openPing: new Set(), wire: new Map() };
|
||||
const DASH = '<span class="dim">-</span>';
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
@@ -238,6 +246,74 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''));
|
||||
}
|
||||
|
||||
// Pings are shown per source and destination pair. The router answers
|
||||
// them itself; this table is a copy of what arrived.
|
||||
function renderPings() {
|
||||
const p = state.pings; if (!p || p.status === 'disabled') { $('pings-section').hidden = true; return; }
|
||||
$('pings-section').hidden = false;
|
||||
if (p.status === 'failed') {
|
||||
$('pings-count').textContent = 'off';
|
||||
$('pings-note').innerHTML = `<span class="bad">The ping monitor could not start:</span> ${esc(p.detail || 'unknown error')}`;
|
||||
setHTML($('pings-body'), ''); return;
|
||||
}
|
||||
const more = p.total_flows > p.flows.length ? `, showing the ${p.flows.length} most recent` : '';
|
||||
$('pings-count').textContent = `${p.total_flows} flow${p.total_flows === 1 ? '' : 's'}${more}`;
|
||||
$('pings-note').innerHTML = '';
|
||||
if (!p.flows.length) { setHTML($('pings-body'), '<tr><td colspan="11" class="dim">No pings seen yet.</td></tr>'); return; }
|
||||
const me = myOwner();
|
||||
setHTML($('pings-body'), p.flows.map(f => { const key = `${f.src}|${f.dst}`, open = state.openPing.has(key); return `<tr class="${f.owner && f.owner === me ? 'mine' : ''}">
|
||||
<td class="mono">${esc(f.dst)}</td><td class="mono">${f.prefix ? esc(f.prefix) : '<span class="dim">not announced</span>'}</td><td class="owner">${f.owner ? esc(f.owner) : DASH}</td>
|
||||
<td class="mono">${esc(f.src)}</td><td class="mono">${f.interface ? esc(f.interface) : DASH}</td>
|
||||
<td>${f.hop_limit == null ? DASH : `${f.hop_limit} <span class="dim">(${f.hops} hop${f.hops === 1 ? '' : 's'} away)</span>`}</td>
|
||||
<td>${f.count}</td><td>${f.seq}</td><td>${fmtTime(f.first_seen_unix)}</td><td>${age(f.last_seen_unix)} ago</td>
|
||||
<td><a href="#" data-act="ping-bytes" data-key="${esc(key)}">${open ? 'hide bytes' : 'bytes'}</a></td></tr>` +
|
||||
(open ? `<tr><td colspan="11">${pingBytesHTML(f)}</td></tr>` : ''); }).join(''));
|
||||
}
|
||||
// The last echo request of a flow, split into the fields of RFC 4443. It
|
||||
// starts at the ICMPv6 header: the socket we listen on is never given the
|
||||
// IPv6 header, whose interesting fields are the columns of the table.
|
||||
function pingBytesHTML(f) {
|
||||
const bytes = f.hex.match(/../g) || [], segs = [];
|
||||
const take = (n, cls, kind) => { if (bytes.length) segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
|
||||
take(1, 'hh', 'icmp-type'); take(1, 'hh', 'icmp-code'); take(2, 'hl', 'icmp-checksum');
|
||||
take(2, 'ha', 'icmp-ident'); take(2, 'ha2', 'icmp-seq'); take(bytes.length, 'hn', 'icmp-payload');
|
||||
const kept = f.hex.length / 2;
|
||||
return `Last ICMPv6 message from <b>${esc(f.src)}</b>, ${f.bytes} bytes${kept < f.bytes ? ` (first ${kept} shown)` : ''}, IPv6 header not included
|
||||
<span class="hh"> type, code </span> <span class="hl"> checksum </span> <span class="ha"> identifier, sequence </span> <span class="hn"> payload </span>
|
||||
<span class="dim">(hover a segment to decode it)</span>
|
||||
<pre class="bytes">${segs.map(x => `<span class="${x.cls}" tabindex="0" data-kind="${x.kind}" data-hex="${x.hex}">${x.hex}</span>`).join(' ')}</pre>`;
|
||||
}
|
||||
function decodePing(kind, b) {
|
||||
switch (kind) {
|
||||
case 'icmp-type': return { title: 'ICMPv6: type', rows: [['value', `${b[0]} = ${{128: 'Echo Request', 129: 'Echo Reply'}[b[0]] || 'other'}`], ['meaning', 'The router answers with type 129, Echo Reply, carrying the same identifier, sequence number and payload.']] };
|
||||
case 'icmp-code': return { title: 'ICMPv6: code', rows: [['value', `${b[0]}`], ['meaning', 'Always 0 for echo messages.']] };
|
||||
case 'icmp-checksum': return { title: 'ICMPv6: checksum', rows: [['value', `0x${u(b, 0, 2).toString(16).padStart(4, '0')}`], ['covers', 'This message and a pseudo-header holding the IPv6 source and destination addresses, so it differs between two otherwise equal pings to different addresses.']] };
|
||||
case 'icmp-ident': return { title: 'Echo: identifier', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Chosen by the sender so that several ping programs on one host can tell their replies apart.']] };
|
||||
case 'icmp-seq': return { title: 'Echo: sequence number', rows: [['value', `${u(b, 0, 2)}`], ['meaning', 'Counts up with every request; a gap in the replies is a lost packet.']] };
|
||||
default: {
|
||||
const rows = [['length', `${b.length} bytes shown`], ['as text', b.map(x => x >= 32 && x < 127 ? String.fromCharCode(x) : '.').join('')]];
|
||||
// ping puts the time of sending first so that the reply carries it
|
||||
// back. Only claim that when the value is in fact close to now.
|
||||
const near = secs => Math.abs(secs - now()) < 86400;
|
||||
const le = b.length >= 16 && !b[4] && !b[5] && !b[6] && !b[7] ? b[0] + b[1] * 256 + b[2] * 65536 + b[3] * 16777216 : null;
|
||||
const be = b.length >= 8 ? u(b, 0, 4) : null;
|
||||
if (le != null && near(le)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(le * 1000).toLocaleTimeString()} (a 16-byte timeval, as Linux ping writes it)`]);
|
||||
else if (be != null && near(be)) rows.push(['starts with', `the sender's clock when it sent this: ${new Date(be * 1000).toLocaleTimeString()} (an 8-byte timestamp, as BSD and macOS ping6 write it)`]);
|
||||
rows.push(['meaning', 'Anything the sender likes; the reply must return it unchanged.']);
|
||||
return { title: 'Echo: payload', rows };
|
||||
}
|
||||
}
|
||||
}
|
||||
// A ping event carries the whole row, so the table follows the stream
|
||||
// without asking the server again.
|
||||
function pingSeen(f) {
|
||||
const p = state.pings; if (!p || p.status !== 'listening') return;
|
||||
const at = p.flows.findIndex(x => x.src === f.src && x.dst === f.dst);
|
||||
if (at >= 0) p.flows.splice(at, 1); else p.total_flows++;
|
||||
p.flows.unshift(f); p.flows.length = Math.min(p.flows.length, 100);
|
||||
renderPings();
|
||||
}
|
||||
|
||||
// ---- wire view: split the UPDATE bytes into labelled fields ------------
|
||||
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
|
||||
function segmentUpdate(hex) {
|
||||
@@ -348,7 +424,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
}
|
||||
function showTip(el) {
|
||||
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
|
||||
const d = decodeSegment(el.dataset.kind, b, el.dataset.w4 === '1'), tip = $('tip');
|
||||
const kind = el.dataset.kind, tip = $('tip');
|
||||
const d = kind.startsWith('icmp-') ? decodePing(kind, b) : decodeSegment(kind, b, el.dataset.w4 === '1');
|
||||
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
|
||||
tip.hidden = false;
|
||||
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
|
||||
@@ -395,7 +472,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); }
|
||||
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
|
||||
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
|
||||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
|
||||
async function loadPings() { state.pings = await getJSON('/api/pings'); renderPings(); }
|
||||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadPings()]); }
|
||||
|
||||
// ---- events ------------------------------------------------------------
|
||||
function logEvent(html) {
|
||||
@@ -415,6 +493,8 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
case 'local_withdrawn': return `<span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
||||
case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
|
||||
case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
||||
case 'ping_seen': { const f = ev.ping; return `ping to ${esc(f.dst)}${f.owner ? ` (${esc(f.owner)})` : ''} from ${esc(f.src)}` +
|
||||
(f.interface ? ` via ${esc(f.interface)}` : '') + (f.hop_limit != null ? `, hop limit ${f.hop_limit}` : ''); }
|
||||
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
|
||||
default: return esc(JSON.stringify(ev));
|
||||
}
|
||||
@@ -426,7 +506,14 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
|
||||
// Events only feed the log. How often the tables reload is decided by
|
||||
// ?refresh=, so the page never changes faster than the reader asked for.
|
||||
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
|
||||
// Pings are the exception: the reader causes them from a terminal, not
|
||||
// from this page, so their table follows the stream. A steady ping is
|
||||
// logged once, when it starts.
|
||||
es.addEventListener('bgp', e => {
|
||||
const ev = JSON.parse(e.data);
|
||||
if (ev.type === 'ping_seen') { pingSeen(ev.ping); if (!ev.new_burst) return; }
|
||||
logEvent(describe(ev));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
|
||||
@@ -483,7 +570,14 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
|
||||
}
|
||||
appendTo('f-large', v);
|
||||
});
|
||||
$('f-owner').addEventListener('input', renderLocal);
|
||||
$('f-owner').addEventListener('input', () => { renderLocal(); renderPings(); });
|
||||
$('pings-body').addEventListener('click', e => {
|
||||
const link = e.target.closest('a[data-act="ping-bytes"]'); if (!link) return;
|
||||
e.preventDefault();
|
||||
const key = link.dataset.key;
|
||||
state.openPing.has(key) ? state.openPing.delete(key) : state.openPing.add(key);
|
||||
renderPings();
|
||||
});
|
||||
$('local-body').addEventListener('click', async e => {
|
||||
const link = e.target.closest('a[data-act]'); if (!link) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -41,6 +41,20 @@ Replace the `protocol bgp ferrix` stanza with `bird-ferrix-local.conf`, then
|
||||
journalctl -u ferrix-bgp -f
|
||||
birdc show protocols all ferrix
|
||||
|
||||
The journal should say `Ping monitor listening for ICMPv6 echo requests`. If
|
||||
it says the monitor is disabled, the unit is missing `CAP_NET_RAW`; everything
|
||||
else works without it and the status page says why the table is off.
|
||||
|
||||
To check the monitor before the class, announce a prefix on the page, turn on
|
||||
auto-refresh and ping an address inside it from somewhere else:
|
||||
|
||||
ping -c 3 2a0d:d741:1::1
|
||||
|
||||
The ping shows up under "Pings to announced addresses" with its source, the
|
||||
interface it arrived on and the hop limit it arrived with. Check that
|
||||
"arrived on" names the real ingress port (the exchange or transit interface)
|
||||
and not `lo`.
|
||||
|
||||
## 6. Let students in
|
||||
|
||||
The API has no authentication and announces into a real AS, so do not expose
|
||||
@@ -148,6 +162,14 @@ than 130 lab routes at once are blocked there, by design.
|
||||
|
||||
## Things to know
|
||||
|
||||
- The ping monitor only listens. The kernel answers the pings, with or
|
||||
without ferrix running, because of the AnyIP route. It sees echo requests
|
||||
to the lab space from anyone on the Internet, so source addresses of
|
||||
strangers will appear on the page next to the students' own. It remembers
|
||||
at most 2048 source and destination pairs and publishes at most 50 events a
|
||||
second, so a ping flood costs little; `ping_monitor_echo_requests_total` and
|
||||
`ping_monitor_events_suppressed_total` on `/metricz` show what it saw.
|
||||
|
||||
- Announced routes live in memory. A restart of the service forgets them and
|
||||
students have to announce again.
|
||||
- Stopping the service sends BIRD a Cease first, so restarts reconnect at
|
||||
|
||||
@@ -12,7 +12,9 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# It needs the network and nothing else: no root, no privileged port, no
|
||||
# write access anywhere.
|
||||
# write access anywhere. The one capability, CAP_NET_RAW, is for the ping
|
||||
# monitor's raw ICMPv6 socket; drop both lines if ping_monitor is not
|
||||
# configured.
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
@@ -27,7 +29,8 @@ RestrictNamespaces=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=CAP_NET_RAW
|
||||
CapabilityBoundingSet=CAP_NET_RAW
|
||||
MemoryMax=256M
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -25,3 +25,8 @@ local_route_policy {
|
||||
# Loopback only. Students reach it through the reverse proxy, which adds TLS
|
||||
# and a password; the API itself has neither.
|
||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||
|
||||
# Show who pings the lab addresses. The kernel answers the pings (AnyIP route
|
||||
# in 05-lab-lo.network); this only listens. Watches allowed_prefixes above.
|
||||
# Needs CAP_NET_RAW, which the systemd unit grants.
|
||||
ping_monitor {}
|
||||
+9
-5
@@ -13,16 +13,16 @@ chain of nodes and links, an AS path.
|
||||
|
||||
| Section | File |
|
||||
|---|---|
|
||||
| IP addresses and prefixes (IPv6) | `sections/01-addresses.md` |
|
||||
| Where AS numbers and prefixes come from (IANA, RIPE) | `sections/02-registries.md` |
|
||||
| About the speaker | `sections/00-intro.md` |
|
||||
| Internet numbering resources: addresses, prefixes, registries, AS numbers | `sections/01-resources.md` |
|
||||
| BGP and the bytes of an UPDATE | `sections/03-bgp-update.md` |
|
||||
| IP transit and Internet exchanges | `sections/04-transit-and-ix.md` |
|
||||
| The lab: ferrix, pr01.rue and its exchanges | `sections/05-lab.md` |
|
||||
| The lab: the lab speaker, pr01.rue and its exchanges | `sections/05-lab.md` |
|
||||
|
||||
The UPDATE in section 3 is `2a0d:d740::/48` as AS210036 announces it to the
|
||||
SwissIX route servers: next hop `2001:7f8:24::c9` (pr01's port on the peering
|
||||
LAN), MED 0 and the `210036:10:2` tag, which is what pr01's export filter puts
|
||||
on routes to peers. It was produced with ferrix's own encoder and parsed back
|
||||
on routes to peers. It was produced with the lab daemon's own encoder and parsed back
|
||||
to the same message, not captured off the wire; a capture from pr01 may differ
|
||||
in detail (BIRD can append a link-local next hop). The byte slides step through
|
||||
it one part at a time (press down), and every number on them was checked
|
||||
@@ -55,7 +55,11 @@ double-clicking `index.html`. Opened from disk, browsers block reveal.js and
|
||||
the section files, and you would see a title and empty panels; the page now
|
||||
says so instead:
|
||||
|
||||
python3 -m http.server 8000 --directory slides
|
||||
python3 slides/serve.py 8000
|
||||
|
||||
(`python3 -m http.server` works too, but it lets the browser cache the
|
||||
section files and the stylesheet, so edits may not show until a hard reload,
|
||||
Cmd+Shift+R. `serve.py` is the same server with caching turned off.)
|
||||
|
||||
Then <http://localhost:8000>. Keys: arrows to move, `S` speaker view with
|
||||
notes and a timer, `O` overview, `F` fullscreen, `B` blank the screen.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Live lookups on slides.
|
||||
//
|
||||
// <form class="dns-lookup"> with <input name="domain"> and a .dns-result:
|
||||
// asks Google Public DNS over HTTPS for the name's AAAA records
|
||||
// (https://developers.google.com/speed/public-dns/docs/doh/json) and
|
||||
// prints the answer the way dig would.
|
||||
// <form class="ripe-lookup"> with <input name="query"> and a .dns-result:
|
||||
// asks the RIPE database (https://apps.db.ripe.net/docs/) what it knows
|
||||
// about an address, prefix or AS number, and prints the objects the way
|
||||
// whois would.
|
||||
(function () {
|
||||
const TYPES = { 1: "A", 5: "CNAME", 28: "AAAA" };
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
async function resolve(name, type) {
|
||||
const r = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(name)}&type=${type}`, {
|
||||
headers: { accept: "application/dns-json" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`dns.google answered HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
const line = (rr) =>
|
||||
`<span class="name">${esc(rr.name)}</span> <span class="ttl">${rr.TTL}</span> IN <span class="type">${esc(TYPES[rr.type] || rr.type)}</span> <span class="${rr.type === 28 ? "addr" : "data"}">${esc(rr.data)}</span>`;
|
||||
|
||||
async function lookup(form) {
|
||||
const out = form.querySelector(".dns-result");
|
||||
const name = form.elements.domain.value.trim().replace(/\.$/, "");
|
||||
if (!name) return;
|
||||
out.innerHTML = `<span class="dim">asking dns.google for ${esc(name)} AAAA ...</span>`;
|
||||
try {
|
||||
const v6 = await resolve(name, "AAAA");
|
||||
if (v6.Status === 3) {
|
||||
out.innerHTML = `<span class="bad">NXDOMAIN</span> <span class="dim">there is no such name</span>`;
|
||||
return;
|
||||
}
|
||||
if (v6.Status !== 0) {
|
||||
out.innerHTML = `<span class="bad">the resolver answered with status ${v6.Status}</span>`;
|
||||
return;
|
||||
}
|
||||
const answers = v6.Answer || [];
|
||||
const lines = answers.map(line);
|
||||
if (!answers.some((rr) => rr.type === 28)) {
|
||||
// No IPv6 address. Show that this is a real answer, not a failure,
|
||||
// and what the name does have.
|
||||
const v4 = await resolve(name, "A");
|
||||
lines.push(`<span class="dim">no AAAA record: this name has no IPv6 address</span>`);
|
||||
(v4.Answer || []).filter((rr) => rr.type === 1).forEach((rr) => lines.push(line(rr)));
|
||||
}
|
||||
out.innerHTML = lines.join("\n");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="bad">lookup failed:</span> ${esc(e.message)} <span class="dim">(is there a network?)</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RIPE database ----------------------------------------------------
|
||||
|
||||
// Objects as apps.db.ripe.net shows them: one card per object, one row per
|
||||
// attribute, references in blue, rows the RIPE NCC manages shaded. The
|
||||
// API does not say which rows those are, so they are the ones the website
|
||||
// shades for an assignment: the key, org, status, source, sponsoring-org
|
||||
// and the RIPE NCC's own maintainers.
|
||||
const RIPE_MANAGED = new Set(["inetnum", "inet6num", "aut-num", "org", "status", "source", "sponsoring-org"]);
|
||||
const managed = (a) => RIPE_MANAGED.has(a.name) || (a.name === "mnt-by" && /^RIPE-NCC-/.test(a.value));
|
||||
|
||||
async function ripe(form) {
|
||||
const out = form.querySelector(".ripe-result");
|
||||
const query = form.elements.query.value.trim();
|
||||
if (!query) return;
|
||||
out.innerHTML = `<div class="ripe-msg">Searching the RIPE database for ${esc(query)} ...</div>`;
|
||||
try {
|
||||
const url = `https://rest.db.ripe.net/search.json?query-string=${encodeURIComponent(query)}&flags=no-referenced&flags=no-irt&source=ripe`;
|
||||
const r = await fetch(url, { headers: { accept: "application/json" } });
|
||||
if (r.status === 404) {
|
||||
out.innerHTML = `<div class="ripe-msg">No results found for ${esc(query)}.</div>`;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`rest.db.ripe.net answered HTTP ${r.status}`);
|
||||
const objects = ((await r.json()).objects || {}).object || [];
|
||||
out.innerHTML = objects
|
||||
.map((o) => {
|
||||
const rows = o.attributes.attribute.map((a) => {
|
||||
const value = a.link || a.name === o.type ? `<span class="ref">${esc(a.value)}</span>` : esc(a.value);
|
||||
return `<tr class="${managed(a) ? "managed" : ""}"><th>${esc(a.name)}:</th><td>${value}${a.comment ? `<span class="comment"># ${esc(a.comment)}</span>` : ""}</td></tr>`;
|
||||
});
|
||||
return `<div class="ripe-card"><table>${rows.join("")}</table></div>`;
|
||||
})
|
||||
.join("");
|
||||
out.scrollTop = 0;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="ripe-msg">Lookup failed: ${esc(e.message)}. Is there a network?</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The answer changes the slide's height; have reveal centre it again.
|
||||
const relayout = () => window.deck && window.deck.layout();
|
||||
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.dns-lookup, form.ripe-lookup");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
await (form.classList.contains("ripe-lookup") ? ripe(form) : lookup(form));
|
||||
relayout();
|
||||
});
|
||||
})();
|
||||
+200
-134
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hands-on BGP</title>
|
||||
<!--
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hands-on BGP</title>
|
||||
<!--
|
||||
Deck structure and look follow Matt Godbolt's newer talks (e.g.
|
||||
github.com/mattgodbolt/what-happens-beneath-accu-2026): reveal.js with
|
||||
no stock theme, a custom dark theme (theme.css), monokai code blocks,
|
||||
@@ -15,150 +15,216 @@
|
||||
stylesheets are inserted BEFORE ours by the loader below, so theme.css
|
||||
and style.css always have the last word.
|
||||
-->
|
||||
<link rel="stylesheet" href="theme.css" id="deck-style" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="shortcut icon" href="images/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
<!-- Title slide. For a full-bleed image instead, empty it and add
|
||||
<link rel="stylesheet" href="theme.css?v=2" id="deck-style" />
|
||||
<link rel="stylesheet" href="style.css?v=10" />
|
||||
<link rel="icon" href="images/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="shortcut icon" href="images/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
<!-- Title slide. For a full-bleed image instead, empty it and add
|
||||
data-background="images/title.png" like Matt does. -->
|
||||
<section class="title-slide">
|
||||
<h1>Hands-on BGP</h1>
|
||||
<h3>Announce your own prefix to the Internet</h3>
|
||||
<p class="byline">Rayhaan Jaufeerally · Network Security · ETH Zürich, 2026</p>
|
||||
</section>
|
||||
<section class="title-slide">
|
||||
<h1>Hands-on BGP</h1>
|
||||
<h3>Insights into real world operations</h3>
|
||||
<p class="byline">
|
||||
Rayhaan Jaufeerally · 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/01-addresses.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
|
||||
<section data-markdown="sections/02-registries.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
|
||||
<section data-markdown="sections/03-bgp-update.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
|
||||
<section data-markdown="sections/04-transit-and-ix.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
|
||||
<section data-markdown="sections/05-lab.md" data-separator="^-----" data-separator-vertical="^---" data-separator-notes="^Notes:"></section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits where Matt's decks carry the employer logo. -->
|
||||
<div class="wordmark"><b>ferrix</b>_bgp · netsec 2026</div>
|
||||
<section
|
||||
data-markdown="sections/00-intro.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/01-resources.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/03-bgp-update.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/04-transit-and-ix.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
<section
|
||||
data-markdown="sections/05-lab.md"
|
||||
data-separator="^-----"
|
||||
data-separator-vertical="^---"
|
||||
data-separator-notes="^Notes:"
|
||||
></section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits where Matt's decks carry the employer logo. -->
|
||||
<div class="wordmark"><b>AS210036</b> · netsec 2026</div>
|
||||
|
||||
<div id="deck-help" hidden>
|
||||
<h2>The slides did not load</h2>
|
||||
<p id="deck-help-why"></p>
|
||||
<pre>python3 -m http.server 8000 --directory slides</pre>
|
||||
<p>then open <code>http://localhost:8000</code>. Details in <code>slides/README.md</code>.</p>
|
||||
</div>
|
||||
<!-- A classic script: it still runs when the browser refuses to run the
|
||||
<div id="deck-help" hidden>
|
||||
<h2>The slides did not load</h2>
|
||||
<p id="deck-help-why"></p>
|
||||
<pre>python3 -m http.server 8000 --directory slides</pre>
|
||||
<p>
|
||||
then open <code>http://localhost:8000</code>. Details in
|
||||
<code>slides/README.md</code>.
|
||||
</p>
|
||||
</div>
|
||||
<!-- A classic script: it still runs when the browser refuses to run the
|
||||
module below, which is exactly the case it has to explain. -->
|
||||
<script>
|
||||
(function () {
|
||||
function help(why) {
|
||||
document.getElementById("deck-help-why").textContent = why;
|
||||
document.getElementById("deck-help").hidden = false;
|
||||
}
|
||||
if (location.protocol === "file:") {
|
||||
help("This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:");
|
||||
return;
|
||||
}
|
||||
window.addEventListener("deck-failed", function (e) { help(e.detail + " Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:"); });
|
||||
setTimeout(function () {
|
||||
if (!window.deck) help("reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:");
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
function help(why) {
|
||||
document.getElementById("deck-help-why").textContent = why;
|
||||
document.getElementById("deck-help").hidden = false;
|
||||
}
|
||||
if (location.protocol === "file:") {
|
||||
help(
|
||||
"This page was opened straight from disk. Browsers block a page opened that way from loading reveal.js and the section files, so serve the folder over HTTP:",
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.addEventListener("deck-failed", function (e) {
|
||||
help(
|
||||
e.detail +
|
||||
" Serve the folder over HTTP, and run slides/vendor-reveal.sh if there is no network:",
|
||||
);
|
||||
});
|
||||
setTimeout(function () {
|
||||
if (!window.deck)
|
||||
help(
|
||||
"reveal.js has not started after 10 seconds. Without a network it needs the local copy from slides/vendor-reveal.sh. Serve the folder like this:",
|
||||
);
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
const VERSION = "6.0.2";
|
||||
const LOCAL = "./reveal.js/dist";
|
||||
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
|
||||
<script src="dns-lookup.js?v=3"></script>
|
||||
<script src="prefix-explorer.js?v=4"></script>
|
||||
|
||||
// Prefer the vendored copy so the deck works without a network.
|
||||
async function pickBase() {
|
||||
try {
|
||||
const r = await fetch(`${LOCAL}/reveal.css`, { method: "HEAD" });
|
||||
if (r.ok) return LOCAL;
|
||||
} catch (_) {}
|
||||
return CDN;
|
||||
}
|
||||
const base = await pickBase();
|
||||
const fail = (what) => (e) => {
|
||||
window.dispatchEvent(new CustomEvent("deck-failed", { detail: `${what} (${e && e.message ? e.message : e}).` }));
|
||||
throw e;
|
||||
};
|
||||
<script type="module">
|
||||
const VERSION = "6.0.2";
|
||||
const LOCAL = "./reveal.js/dist";
|
||||
const CDN = `https://cdn.jsdelivr.net/npm/reveal.js@${VERSION}/dist`;
|
||||
|
||||
const deckStyle = document.getElementById("deck-style");
|
||||
await Promise.all(
|
||||
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
|
||||
(href) =>
|
||||
new Promise((resolve) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${base}/${href}`;
|
||||
link.onload = link.onerror = resolve;
|
||||
deckStyle.before(link);
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Prefer the vendored copy so the deck works without a network.
|
||||
async function pickBase() {
|
||||
try {
|
||||
const r = await fetch(`${LOCAL}/reveal.css`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (r.ok) return LOCAL;
|
||||
} catch (_) {}
|
||||
return CDN;
|
||||
}
|
||||
const base = await pickBase();
|
||||
const fail = (what) => (e) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("deck-failed", {
|
||||
detail: `${what} (${e && e.message ? e.message : e}).`,
|
||||
}),
|
||||
);
|
||||
throw e;
|
||||
};
|
||||
|
||||
const [{ default: Reveal }, { default: Markdown }, { default: Highlight }, { default: Notes }] =
|
||||
await Promise.all(
|
||||
["reveal.mjs", "plugin/markdown.mjs", "plugin/highlight.mjs", "plugin/notes.mjs"].map(
|
||||
(m) => import(/* @vite-ignore */ `${base}/${m}`),
|
||||
),
|
||||
).catch(fail(`Could not load reveal.js from ${base}`));
|
||||
const deckStyle = document.getElementById("deck-style");
|
||||
await Promise.all(
|
||||
["reset.css", "reveal.css", "plugin/highlight/monokai.css"].map(
|
||||
(href) =>
|
||||
new Promise((resolve) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = `${base}/${href}`;
|
||||
link.onload = link.onerror = resolve;
|
||||
deckStyle.before(link);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// BIRD configuration, so router config on slides is highlighted. Same
|
||||
// trick Matt uses for the assembly dialects in his talks.
|
||||
function bird() {
|
||||
return {
|
||||
name: "bird",
|
||||
keywords: {
|
||||
keyword:
|
||||
"protocol template filter function define table if then else case return accept reject " +
|
||||
"import export where none all limit action block neighbor local as direct multihop passive " +
|
||||
"description ipv4 ipv6 bgp kernel device static pipe include router id",
|
||||
built_in:
|
||||
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
|
||||
"bgp_med bgp_local_pref bgp_next_hop",
|
||||
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
|
||||
},
|
||||
contains: [
|
||||
{ className: "comment", begin: "#", end: "$" },
|
||||
{ className: "string", begin: '"', end: '"' },
|
||||
{ className: "number", begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b" },
|
||||
{ className: "number", begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b" },
|
||||
{ className: "number", begin: "\\b[0-9]+\\b" },
|
||||
],
|
||||
};
|
||||
}
|
||||
const [
|
||||
{ default: Reveal },
|
||||
{ default: Markdown },
|
||||
{ default: Highlight },
|
||||
{ default: Notes },
|
||||
] = await Promise.all(
|
||||
[
|
||||
"reveal.mjs",
|
||||
"plugin/markdown.mjs",
|
||||
"plugin/highlight.mjs",
|
||||
"plugin/notes.mjs",
|
||||
].map((m) => import(/* @vite-ignore */ `${base}/${m}`)),
|
||||
).catch(fail(`Could not load reveal.js from ${base}`));
|
||||
|
||||
// ?accent=green|amber|magenta previews another accent; blue is the default.
|
||||
const accent = new URLSearchParams(location.search).get("accent");
|
||||
if (accent) document.documentElement.dataset.accent = accent;
|
||||
// BIRD configuration, so router config on slides is highlighted. Same
|
||||
// trick Matt uses for the assembly dialects in his talks.
|
||||
function bird() {
|
||||
return {
|
||||
name: "bird",
|
||||
keywords: {
|
||||
keyword:
|
||||
"protocol template filter function define table if then else case return accept reject " +
|
||||
"import export where none all limit action block neighbor local as direct multihop passive " +
|
||||
"description ipv4 ipv6 bgp kernel device static pipe include router id",
|
||||
built_in:
|
||||
"net proto source dest bgp_path bgp_community bgp_large_community bgp_ext_community " +
|
||||
"bgp_med bgp_local_pref bgp_next_hop",
|
||||
literal: "true false on off RTD_UNREACHABLE RTS_BGP",
|
||||
},
|
||||
contains: [
|
||||
{ className: "comment", begin: "#", end: "$" },
|
||||
{ className: "string", begin: '"', end: '"' },
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9a-fA-F:]*::?[0-9a-fA-F:]*(/[0-9]+)?(\\{[0-9]+,[0-9]+\\})?\\b",
|
||||
},
|
||||
{
|
||||
className: "number",
|
||||
begin: "\\b[0-9]+(\\.[0-9]+){3}(/[0-9]+)?\\b",
|
||||
},
|
||||
{ className: "number", begin: "\\b[0-9]+\\b" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
|
||||
const scaleFactor = 0.65;
|
||||
const deck = new Reveal({
|
||||
hash: true,
|
||||
transition: "fade",
|
||||
slideNumber: false,
|
||||
margin: 0.15,
|
||||
width: 1920 * scaleFactor,
|
||||
height: 1080 * scaleFactor,
|
||||
markdown: { smartypants: true },
|
||||
highlight: {
|
||||
beforeHighlight: (hljs) => hljs.registerLanguage("bird", bird),
|
||||
},
|
||||
plugins: [Markdown, Highlight, Notes],
|
||||
});
|
||||
// ?accent=green|amber|magenta previews another accent; blue is the default.
|
||||
const accent = new URLSearchParams(location.search).get("accent");
|
||||
if (accent) document.documentElement.dataset.accent = accent;
|
||||
|
||||
await deck.initialize().catch(fail("reveal.js failed to start"));
|
||||
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
|
||||
</script>
|
||||
</body>
|
||||
// 16:9. Nudge scaleFactor to fit the projector, as Matt does.
|
||||
const scaleFactor = 0.65;
|
||||
const deck = new Reveal({
|
||||
hash: true,
|
||||
transition: "fade",
|
||||
slideNumber: false,
|
||||
margin: 0.15,
|
||||
width: 1920 * scaleFactor,
|
||||
height: 1080 * scaleFactor,
|
||||
markdown: { smartypants: true },
|
||||
highlight: {
|
||||
beforeHighlight: (hljs) =>
|
||||
hljs.registerLanguage("bird", bird),
|
||||
},
|
||||
plugins: [Markdown, Highlight, Notes],
|
||||
});
|
||||
|
||||
await deck.initialize().catch(fail("reveal.js failed to start"));
|
||||
window.deck = deck; // handy in the console: deck.slide(3), deck.getTotalSlides()
|
||||
// The slides exist now: scripts that fill live boxes wait for this.
|
||||
window.dispatchEvent(new Event("deck-ready"));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
// A live prefix calculator on a slide. Any <form class="prefix-explorer">
|
||||
// with <input name="prefix"> and <input type="range" name="len"> gets its
|
||||
// .px-bits, .px-range and .px-facts filled in as the reader types.
|
||||
(function () {
|
||||
const ALL = (1n << 128n) - 1n;
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// "2001:db8::1" -> BigInt, or null.
|
||||
function parse(text) {
|
||||
const s = text.trim().toLowerCase();
|
||||
if (!/^[0-9a-f:]+$/.test(s) || s.split("::").length > 2) return null;
|
||||
const [left, right = ""] = s.split("::");
|
||||
const l = left ? left.split(":") : [], r = right ? right.split(":") : [];
|
||||
if (s.includes("::") ? l.length + r.length > 7 : l.length !== 8) return null;
|
||||
const groups = [...l, ...Array(8 - l.length - r.length).fill("0"), ...r];
|
||||
let v = 0n;
|
||||
for (const g of groups) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
||||
v = (v << 16n) | BigInt(parseInt(g, 16));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function groupsOf(v) {
|
||||
const g = [];
|
||||
for (let i = 7; i >= 0; i--) g.push(Number((v >> BigInt(i * 16)) & 0xffffn));
|
||||
return g;
|
||||
}
|
||||
|
||||
// Shortest form, RFC 5952 style.
|
||||
function format(v) {
|
||||
const g = groupsOf(v).map((x) => x.toString(16));
|
||||
let best = -1, bestLen = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let n = 0;
|
||||
while (i + n < 8 && g[i + n] === "0") n++;
|
||||
if (n > bestLen && n > 1) { best = i; bestLen = n; }
|
||||
}
|
||||
if (best < 0) return g.join(":");
|
||||
return g.slice(0, best).join(":") + "::" + g.slice(best + bestLen).join(":");
|
||||
}
|
||||
|
||||
const sup = (k) => String(k).replace(/\d/g, (d) => "⁰¹²³⁴⁵⁶⁷⁸⁹"[d]);
|
||||
// 2^k as a number people can read: exact up to 2^50, else a power.
|
||||
const pow2 = (k) => (k <= 50 ? Number(1n << BigInt(k)).toLocaleString("en") : `2${sup(k)}`);
|
||||
|
||||
function render(form) {
|
||||
const input = form.elements.prefix, slider = form.elements.len;
|
||||
const bits = form.querySelector(".px-bits"), range = form.querySelector(".px-range"), facts = form.querySelector(".px-facts");
|
||||
let [addrText, lenText] = input.value.split("/");
|
||||
let len = lenText === undefined ? Number(slider.value) : parseInt(lenText, 10);
|
||||
const addr = parse(addrText || "");
|
||||
if (addr === null || !(len >= 0 && len <= 128)) {
|
||||
bits.innerHTML = '<span class="px-bad">not an IPv6 prefix: write address/length, e.g. 2001:db8::/32</span>';
|
||||
range.innerHTML = ""; facts.textContent = "";
|
||||
return;
|
||||
}
|
||||
slider.value = len;
|
||||
const host = 128 - len;
|
||||
const first = addr & ((ALL << BigInt(host)) & ALL);
|
||||
const last = first | ((1n << BigInt(host)) - 1n);
|
||||
|
||||
// The 32 hex digits with the fixed part in the accent colour; a nibble
|
||||
// that straddles the boundary is shown in binary.
|
||||
const hex = groupsOf(first).map((x) => x.toString(16).padStart(4, "0")).join("");
|
||||
const fullNibbles = Math.floor(len / 4), partBits = len % 4;
|
||||
const withColons = (s, offset) => s.replace(/./g, (c, i) => ((offset + i) % 4 === 3 && offset + i < 31 ? c + ":" : c));
|
||||
let html = `<span class="fixed">${withColons(hex.slice(0, fullNibbles), 0)}</span>`;
|
||||
let rest = fullNibbles;
|
||||
if (partBits) {
|
||||
const nib = parseInt(hex[fullNibbles], 16).toString(2).padStart(4, "0");
|
||||
// One hex digit, four bits, with the boundary inside it.
|
||||
html += `<span class="part" title="this hex digit straddles the boundary, shown as its four bits">${nib.slice(0, partBits)}<i>${"x".repeat(4 - partBits)}</i></span>`;
|
||||
rest++;
|
||||
}
|
||||
html += `<span class="free">${withColons("x".repeat(32 - rest), rest)}</span>`;
|
||||
bits.innerHTML = html;
|
||||
|
||||
// Every shorter prefix contains this one; show three of them, at byte
|
||||
// boundaries, with the range's position and share marked in each, so
|
||||
// that no single one looks like "the" parent. Then the range itself.
|
||||
const levels = [];
|
||||
for (let pl = len === 0 ? -1 : 8 * Math.floor((len - 1) / 8); pl >= 0 && levels.length < 3; pl -= 8) {
|
||||
const slots = 1n << BigInt(len - pl);
|
||||
const index = (first >> BigInt(host)) & (slots - 1n);
|
||||
const enclosing = first & ((ALL << BigInt(128 - pl)) & ALL);
|
||||
// Position and width as fractions; the slice is kept visible.
|
||||
const left = Number((index * 100000n) / slots) / 1000, width = Math.max(100 / Number(slots), 0.3);
|
||||
levels.push(`<div class="px-level"><span class="px-level-name">${esc(format(enclosing))}/${pl}</span>
|
||||
<span class="px-track"><i style="left:${left}%;width:${width}%"></i></span>
|
||||
<span class="px-level-count">1 of ${pow2(len - pl)}</span></div>`);
|
||||
}
|
||||
range.innerHTML = `
|
||||
<div class="px-levels">${levels.length ? `<div class="px-levels-title">every shorter prefix contains it; three of them:</div>${levels.join("")}` : '<div class="px-levels-title">nothing is shorter than /0: this is every address there is</div>'}</div>
|
||||
<div class="px-bar"><span class="px-end px-first">${esc(format(first))}</span><span class="px-mid">${pow2(host)} addresses</span><span class="px-end px-last">${esc(format(last))}</span></div>`;
|
||||
|
||||
const parts = [`The first <b>${len}</b> bits are fixed; the other ${host} are for use within the network.`];
|
||||
if (len < 64) parts.push(`That is <b>${pow2(64 - len)}</b> /64s, one per LAN.`);
|
||||
else if (len === 64) parts.push(`Exactly one LAN.`);
|
||||
else parts.push(`Smaller than a LAN: ${host === 0 ? "a single host" : `part of one /64`}.`);
|
||||
if (len < 48) parts.push(`Or ${pow2(48 - len)} /48s, one per site.`);
|
||||
facts.innerHTML = parts.join(" ");
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
const form = e.target.closest && e.target.closest("form.prefix-explorer");
|
||||
if (!form) return;
|
||||
if (e.target.name === "len") {
|
||||
// The slider rewrites the length in the text field.
|
||||
const addr = form.elements.prefix.value.split("/")[0];
|
||||
form.elements.prefix.value = `${addr}/${e.target.value}`;
|
||||
}
|
||||
render(form);
|
||||
if (window.deck) window.deck.layout();
|
||||
});
|
||||
document.addEventListener("submit", (e) => {
|
||||
if (e.target.closest && e.target.closest("form.prefix-explorer")) e.preventDefault();
|
||||
});
|
||||
// Fill in the initial values once the slides exist.
|
||||
const init = () => document.querySelectorAll("form.prefix-explorer").forEach(render);
|
||||
window.addEventListener("deck-ready", init);
|
||||
if (document.readyState === "complete") setTimeout(init, 500);
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
## About the speaker
|
||||
|
||||
**Rayhaan Jaufeerally**
|
||||
|
||||
- Software engineer at a hyperscaler, working on security and reliability.
|
||||
- Studied at ETH from 2016 to 2019, and took the network security course.
|
||||
- Has been running **AS210036** since 2018, for personal use and research
|
||||
|
||||
<p class="note">Today we'll get to use some connectivity through this network.</p>
|
||||
|
||||
Notes:
|
||||
|
||||
- Working at a large company with some offices near the main train station
|
||||
- Former ETH masters student and took the network security course
|
||||
- After my degree I signed up to be a LIR at RIPE, and got my own Autonomous System and started peering, for the fun of running my own network.
|
||||
@@ -1,92 +0,0 @@
|
||||
# IP addresses and prefixes
|
||||
|
||||
Notes:
|
||||
Section 1. IPv6 only: the whole lab is IPv6, and the ideas carry over to IPv4
|
||||
unchanged (32 bits, dotted decimal, /24 where we say /48).
|
||||
|
||||
-----
|
||||
|
||||
## An IPv6 address is 128 bits
|
||||
|
||||
```text
|
||||
2a0d:d741:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
|
||||
2a0d:d741:1:0:0:0:0:1 drop leading zeros in a group
|
||||
2a0d:d741:1::1 one run of zero groups becomes ::
|
||||
```
|
||||
|
||||
`::` may appear once, or the address would be ambiguous.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Ask: why only once? Because 2a0d::1::1 could put the zeros in several places.
|
||||
|
||||
-----
|
||||
|
||||
## A prefix is a range of addresses
|
||||
|
||||
`2a0d:d741:1::/48`
|
||||
|
||||
<p class="bits"><span class="fixed">2a0d:d741:0001</span><span class="free">xxxx:xxxx:xxxx:xxxx:xxxx</span></p>
|
||||
|
||||
The first **48** bits are fixed. The other 80 are yours.
|
||||
|
||||
Notes:
|
||||
2^80 addresses in one student prefix. About 1.2 million billion billion.
|
||||
The /n is a count of leading bits, nothing more.
|
||||
|
||||
-----
|
||||
|
||||
## Prefixes nest
|
||||
|
||||
| Prefix | Who | Holds |
|
||||
|---|---|---|
|
||||
| `2a0d:d740::/29` | the network we borrow from | 524,288 /48s |
|
||||
| `2a0d:d741::/40` | this lab | 256 /48s |
|
||||
| `2a0d:d741:<n>::/48` | **you** | 65,536 /64s |
|
||||
| `…/64` | one LAN | the hosts on it |
|
||||
|
||||
`<n>` is your student number, written *in hex*.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Student 10 is 2a0d:d741:a::/48, student 16 is 2a0d:d741:10::/48.
|
||||
A /40 holds exactly 256 /48s, which caps the class size at 255.
|
||||
|
||||
-----
|
||||
|
||||
## The most specific route wins
|
||||
|
||||
```text [3]
|
||||
2a0d:d740::/29 via AS210036 the aggregate
|
||||
2a0d:d741::/40 via AS210036 the lab
|
||||
2a0d:d741:1::/48 via AS210036 AS… student 1 <-- chosen for 2a0d:d741:1::1
|
||||
```
|
||||
|
||||
Routers forward on the *longest matching prefix*, before anything else is compared.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
This is the single most important forwarding rule for today. It is also why a
|
||||
hijacker announces a more specific prefix: it wins everywhere it is accepted,
|
||||
no matter how good the legitimate route is.
|
||||
|
||||
-----
|
||||
|
||||
## How small can a prefix be?
|
||||
|
||||
<div class="boxes">
|
||||
<div class="ok">IPv6: up to <b>/48</b></div>
|
||||
<div class="ok">IPv4: up to <b>/24</b></div>
|
||||
<div class="bad">anything longer: filtered by almost everyone</div>
|
||||
</div>
|
||||
|
||||
Not a protocol rule. A convention, so the global table stays finite.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Which is why the lab hands out /48s: the smallest thing the Internet at large
|
||||
will carry. The global IPv6 table is around 230k routes, IPv4 around 1M.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Internet Numbering Resources
|
||||
|
||||
Notes:
|
||||
Before we look at what's going on in the internet, let's refresh on how the Internet is even addressed.
|
||||
|
||||
---
|
||||
|
||||
## Packets are sent to IP addresses
|
||||
|
||||
<form class="dns-lookup" autocomplete="off">
|
||||
<input name="domain" value="ethz.ch" spellcheck="false" aria-label="domain name"><button type="submit">lookup AAAA</button>
|
||||
<div class="dns-result"><span class="dim">Ask a resolver which IPv6 address this name stands for.</span></div>
|
||||
</form>
|
||||
|
||||
Your computer cannot send a packet to a name. Every time you use a domain name, it is resolved into an IP address.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Live: type a few names the students use. ethz.ch has an AAAA record;
|
||||
github.com has none (only A), which shows that the two address families
|
||||
are separate. The answer comes from Google Public DNS over HTTPS
|
||||
(dns.google), so it needs the lecture-hall network.
|
||||
|
||||
---
|
||||
|
||||
## Who does the address belong to?
|
||||
|
||||
<form class="ripe-lookup" autocomplete="off">
|
||||
<input name="query" value="2001:67c:10ec:254::216" spellcheck="false" aria-label="address, prefix or AS number"><button type="submit">whois</button>
|
||||
<div class="ripe-result"><div class="ripe-msg">Paste the address from the last slide.</div></div>
|
||||
</form>
|
||||
|
||||
The **inet6num** says who was assigned the block; the **route6** says which AS is allowed to originate it.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Paste the AAAA answer from the previous slide. ETH's /48 is ASSIGNED PI
|
||||
(provider independent, sponsored by SWITCH) and its route6 names AS559,
|
||||
SWITCH. Try 2a0d:d741:1::1 as well: the lab prefix, origin AS210036. The
|
||||
box takes AS numbers too (AS559). Same source as whois -h whois.ripe.net,
|
||||
over the database's REST API.
|
||||
|
||||
---
|
||||
|
||||
## An IPv6 address is 128 bits
|
||||
|
||||
```text
|
||||
2001:0db8:0001:0000:0000:0000:0000:0001 eight groups of 16 bits, in hex
|
||||
2001:db8:1:0:0:0:0:1 drop leading zeros in a group
|
||||
2001:db8:1::1 one run of zero groups becomes ::
|
||||
```
|
||||
|
||||
`::` may appear once, or the address would be ambiguous. `2001:db8::/32` is reserved for documentation, so it never appears on the real Internet.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Ask: why only once? Because 2001::1::1 could put the zeros in several places.
|
||||
2001:db8::/32 is RFC 3849: set aside for examples, never routed.
|
||||
|
||||
---
|
||||
|
||||
## A prefix is a range of addresses
|
||||
|
||||
<form class="prefix-explorer" autocomplete="off">
|
||||
<input name="prefix" value="2001:db8:1::/48" spellcheck="false" aria-label="IPv6 prefix"><input type="range" name="len" min="0" max="128" value="48" aria-label="prefix length">
|
||||
<p class="bits px-bits"></p>
|
||||
<div class="px-range"></div>
|
||||
<p class="note px-facts"></p>
|
||||
</form>
|
||||
|
||||
Notes:
|
||||
Live. Drag the slider: the fixed part grows and the range shrinks by half per
|
||||
bit. Try /47 or /50 to see a hex digit split in two. The three rows above the
|
||||
bar are three of the shorter prefixes that contain it, with its position and
|
||||
share in each: there is no such thing as "the" parent prefix, only whatever
|
||||
someone chose to allocate from.
|
||||
2^80 addresses in one /48, about 1.2 million billion billion. The /n is a
|
||||
count of leading bits, nothing more.
|
||||
|
||||
---
|
||||
|
||||
## The global IPv6 address space
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="Nested boxes: all of IPv6, inside it global unicast 2000::/3, inside that the documentation prefix 2001:db8::/32, inside that one /64 LAN with its hosts">
|
||||
<g class="nest l0"><rect x="8" y="8" width="984" height="454"/></g>
|
||||
<text x="28" y="42" class="left accent nest-label">::/0</text><text x="112" y="42" class="left nest-desc">every IPv6 address there is: 2¹²⁸ of them</text>
|
||||
<g class="nest l1"><rect x="80" y="70" width="880" height="366"/></g>
|
||||
<text x="100" y="104" class="left accent nest-label">2000::/3</text><text x="230" y="104" class="left nest-desc">global unicast: the eighth of the space that is routed on the Internet</text>
|
||||
<g class="nest l2"><rect x="160" y="134" width="760" height="278"/></g>
|
||||
<text x="180" y="168" class="left accent nest-label">2001:db8::/32</text><text x="376" y="168" class="left nest-desc">one provider's allocation (this one is reserved for documentation)</text>
|
||||
<g class="nest l3"><rect x="260" y="200" width="580" height="188"/></g>
|
||||
<text x="280" y="234" class="left accent nest-label">2001:db8:1:2::/64</text><text x="530" y="234" class="left nest-desc">one LAN: 2⁶⁴ addresses for its hosts</text>
|
||||
<circle class="host" cx="330" cy="310" r="12"/><text x="330" y="352" class="host-addr">::1</text>
|
||||
<circle class="host" cx="430" cy="310" r="12"/><text x="430" y="352" class="host-addr">::2</text>
|
||||
<circle class="host" cx="530" cy="310" r="12"/><text x="530" y="352" class="host-addr">::3</text>
|
||||
<text x="630" y="318" class="host-addr">…</text>
|
||||
<circle class="host" cx="740" cy="310" r="12"/><text x="740" y="352" class="host-addr">::ffff:ffff:ffff:ffff</text>
|
||||
<text x="976" y="452" class="small" style="text-anchor:end">not to scale</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Not to scale: a /64 is 2^-64 of the space. Only the first ring matters for
|
||||
routing; the others are how the space is handed down, which is next.
|
||||
|
||||
---
|
||||
|
||||
## Who hands out the addresses
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 470" role="img" aria-label="IANA at the top hands blocks to five regional registries; RIPE NCC hands smaller blocks to local registries such as SWITCH and an ISP, which assign to end users such as ETH Zurich and their customers">
|
||||
<g class="hot"><rect x="400" y="14" width="200" height="56" rx="8"/></g><text x="500" y="48">IANA</text>
|
||||
<text x="30" y="48" class="left small">the root: one pool for the whole world</text>
|
||||
<line class="link" x1="500" y1="70" x2="96" y2="130"/><line class="link" x1="500" y1="70" x2="298" y2="130"/><line class="link" x1="500" y1="70" x2="500" y2="130"/><line class="link" x1="500" y1="70" x2="702" y2="130"/><line class="link" x1="500" y1="70" x2="904" y2="130"/>
|
||||
<text x="30" y="108" class="left small">a /12 to each region</text>
|
||||
<rect x="16" y="130" width="160" height="56" rx="8"/><text x="96" y="164">AFRINIC</text>
|
||||
<rect x="218" y="130" width="160" height="56" rx="8"/><text x="298" y="164">APNIC</text>
|
||||
<rect x="420" y="130" width="160" height="56" rx="8"/><text x="500" y="164">ARIN</text>
|
||||
<rect x="622" y="130" width="160" height="56" rx="8"/><text x="702" y="164">LACNIC</text>
|
||||
<g class="hot"><rect x="824" y="130" width="160" height="56" rx="8"/></g><text x="904" y="164" class="accent">RIPE NCC</text>
|
||||
<text x="30" y="222" class="left small">RIRs: five regional registries, one per part of the world</text>
|
||||
<line class="link" x1="904" y1="186" x2="690" y2="256"/><line class="link" x1="904" y1="186" x2="904" y2="256"/>
|
||||
<text x="700" y="238" class="left small">/29 and up to a member</text>
|
||||
<rect x="600" y="256" width="180" height="56" rx="8"/><text x="690" y="290">SWITCH</text>
|
||||
<rect x="814" y="256" width="180" height="56" rx="8"/><text x="904" y="290">an ISP</text>
|
||||
<text x="30" y="298" class="left small">LIRs: members of an RIR, usually providers</text>
|
||||
<line class="link" x1="690" y1="312" x2="690" y2="382"/><line class="link" x1="904" y1="312" x2="904" y2="382"/>
|
||||
<text x="700" y="360" class="left small">a /48 to a site</text>
|
||||
<rect x="600" y="382" width="180" height="56" rx="8"/><text x="690" y="416">ETH Zürich</text>
|
||||
<rect x="814" y="382" width="180" height="56" rx="8"/><text x="904" y="416">its customers</text>
|
||||
<text x="30" y="424" class="left small">end users: companies, universities, you</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
|
||||
- The Internet Assigned Numbers Authority comes up with allocations of IP
|
||||
addresses from the global space
|
||||
- Blocks are assigned to Regional Internet Registries (RIRs), which allocate
|
||||
them to Local Internet Registries (LIRs), who assign to end users
|
||||
- The RIPE lookup two slides back showed exactly this chain: ETH's /48 is
|
||||
ASSIGNED PI, sponsored by SWITCH (the LIR), registered at RIPE NCC (the
|
||||
RIR). Nobody owns addresses; they are registered to you.
|
||||
|
||||
---
|
||||
|
||||
## AS numbers
|
||||
|
||||
- Used to identify networks under a single oeprator, and required for exchanging BGP traffic
|
||||
- Managed by IANA and the RIRs
|
||||
- 16 bits at first: `1` to `65535`
|
||||
- 32 bits since RFC 6793: up to `4294967295`
|
||||
- In order to have a smooth transition, `AS23456` is used as a placeholder when the AS number is > 16 bits
|
||||
- 32 bit AS numbers are then exchanged using a BGP capability message to be backwards compatible
|
||||
- Private use: `64512–65534` and `4200000000–4294967294`
|
||||
|
||||
AS numbers come down the same chain: IANA to the RIRs, an RIR to whoever runs a network. The lab daemon is `AS4200000001`, a private one, stripped before the Internet sees your route.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Nobody "owns" numbers or addresses; they are registered to you. RFC 6996
|
||||
reserves the private ranges. The upstream router removes the private AS from
|
||||
the path, so the origin everybody else sees is AS210036.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Where numbers come from
|
||||
|
||||
Notes:
|
||||
Section 2. Nobody "owns" addresses or AS numbers; they are registered to you.
|
||||
|
||||
-----
|
||||
|
||||
## A hierarchy of registries
|
||||
|
||||
<div class="boxes">
|
||||
<div>IANA<br><span class="small">the root</span></div>
|
||||
<div class="us">RIPE NCC<br><span class="small">Europe, Middle East</span></div>
|
||||
<div>ARIN</div>
|
||||
<div>APNIC</div>
|
||||
<div>LACNIC</div>
|
||||
<div>AFRINIC</div>
|
||||
</div>
|
||||
|
||||
IANA hands large blocks to five regional registries. They hand smaller ones to members (LIRs), who assign to end users.
|
||||
|
||||
Notes:
|
||||
RIR = Regional Internet Registry. LIR = Local Internet Registry, a member of
|
||||
an RIR, typically an ISP. Our space comes from RIPE NCC via an LIR.
|
||||
|
||||
-----
|
||||
|
||||
## AS numbers
|
||||
|
||||
* 16 bits at first: `1` to `65535`
|
||||
* 32 bits since RFC 6793: up to `4294967295`
|
||||
* Old routers see `AS23456` (AS_TRANS) in place of a big one
|
||||
* Private use: `64512–65534` and `4200000000–4294967294`
|
||||
|
||||
The lab daemon is `AS4200000001`. It is stripped before the Internet sees your route.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
RFC 6996 reserves the private ranges. The upstream router removes the private
|
||||
AS from the path, so the origin everybody else sees is AS210036.
|
||||
|
||||
-----
|
||||
|
||||
## The RIPE database says who may announce what
|
||||
|
||||
```text [3]
|
||||
route6: 2a0d:d741:1::/48
|
||||
descr: ETHZ Netsec 2026 BGP lab - student 1
|
||||
origin: AS210036
|
||||
mnt-by: mnt-ch-ray-1
|
||||
source: RIPE
|
||||
```
|
||||
|
||||
One of these exists for every student prefix. Networks build their filters from them.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
This is a real object; try the whois on the next slide. `mnt-by` names the
|
||||
maintainer: whoever holds its credentials can change the object. An `inet6num`
|
||||
object records the address block itself, a `route6` object who may originate it.
|
||||
|
||||
-----
|
||||
|
||||
## Two ways to say "this origin is allowed"
|
||||
|
||||
| | IRR (`route6`) | RPKI (ROA) |
|
||||
|---|---|---|
|
||||
| What it is | an entry in a database | a signed certificate |
|
||||
| Who can create it | whoever holds the maintainer | only the holder of the addresses |
|
||||
| Says | prefix + origin AS | prefix + origin AS + **max length** |
|
||||
| Used for | building prefix filters | dropping "invalid" routes |
|
||||
|
||||
Notes:
|
||||
Max length matters for us: a ROA for the /40 with max length 40 would make
|
||||
every student /48 RPKI-invalid, and many networks would drop it.
|
||||
|
||||
-----
|
||||
|
||||
## Look it up yourself
|
||||
|
||||
```bash
|
||||
whois -h whois.ripe.net 2a0d:d741:1::/48
|
||||
whois -h whois.ripe.net AS210036
|
||||
```
|
||||
|
||||
Or in a browser: RIPEstat, bgp.tools, or a looking glass at an exchange.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
@@ -3,53 +3,175 @@
|
||||
Notes:
|
||||
Section 3. The aim: by the end, the bytes view on the lab page reads like text.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## BGP in one slide
|
||||
|
||||
* Runs over TCP, port `179`, between neighbours you configure by hand
|
||||
* Each side tells the other its best route per prefix
|
||||
* Only *changes* are sent: no periodic refresh
|
||||
* If the session drops, every route learned over it is gone
|
||||
- Runs over TCP, port `179`, and is exchanged typically between peering routers
|
||||
- Each side tells the other its best route per prefix
|
||||
- Only _changes_ are sent: no periodic refresh
|
||||
- If the session drops, after a timeout the routes learned over that session are dropped
|
||||
|
||||
Notes:
|
||||
No discovery, no authentication of content, no notion of truth. A neighbour
|
||||
says "I can reach X" and you believe it, subject to your filters.
|
||||
BGP is a very old protocol, and a lot of it's design predates the internet being something with adversarial participants.
|
||||
Security and extensions have had to be bolted on, in perhaps strange ways.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## Four kinds of message
|
||||
## The four message types
|
||||
|
||||
| Type | Name | Says |
|
||||
|---|---|---|
|
||||
| 1 | OPEN | who I am: AS number, router ID, capabilities |
|
||||
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
|
||||
| 3 | NOTIFICATION | something is wrong, I am closing the session |
|
||||
| 4 | KEEPALIVE | still here |
|
||||
| Type | Name | Says |
|
||||
| ---- | ------------ | ------------------------------------------------- |
|
||||
| 1 | OPEN | who I am: AS number, router ID, capabilities |
|
||||
| 2 | **UPDATE** | these prefixes are reachable, those no longer are |
|
||||
| 3 | NOTIFICATION | something is wrong, I am closing the session |
|
||||
| 4 | KEEPALIVE | still here |
|
||||
|
||||
OPEN, OPEN, KEEPALIVE, KEEPALIVE, and then it is all UPDATEs.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
There is a fifth, ROUTE-REFRESH: "send me everything again, my policy changed".
|
||||
There is actually a fifth type, ROUTE-REFRESH: "send me everything again, my policy changed".
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## OPEN: a handshake between peers.
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 340" role="img" aria-label="An OPEN message: the common header, then version, AS number, hold time, BGP identifier, and optional parameters that carry the capabilities">
|
||||
<text x="20" y="22" class="left small pkt-title">header, the same on every message: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">OPEN body: who I am</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="169" class="pkt-name">version</text>
|
||||
<text x="95" y="191" class="pkt-size">1 byte: 4</text>
|
||||
<g class="pkt hl"><rect x="174" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="274" y="169" class="pkt-name">my AS</text>
|
||||
<text x="274" y="191" class="pkt-size">2 bytes, or 23456</text>
|
||||
<g class="pkt hl"><rect x="378" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="478" y="169" class="pkt-name">hold time</text>
|
||||
<text x="478" y="191" class="pkt-size">2 bytes, seconds</text>
|
||||
<g class="pkt hl"><rect x="582" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="702" y="169" class="pkt-name">BGP identifier</text>
|
||||
<text x="702" y="191" class="pkt-size">4 bytes, the router ID</text>
|
||||
<g class="pkt hl"><rect x="826" y="140" width="154" height="64" rx="6"/></g>
|
||||
<text x="903" y="169" class="pkt-name">opt. length</text>
|
||||
<text x="903" y="191" class="pkt-size">1 byte</text>
|
||||
<path class="pkt-expand" d="M 826 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 980 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">optional parameters: today always one, of type 2 = capabilities, each a (code, length, value)</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="120" height="64" rx="6"/></g>
|
||||
<text x="80" y="287" class="pkt-name">type 2</text>
|
||||
<text x="80" y="309" class="pkt-size">capabilities</text>
|
||||
<g class="pkt ha"><rect x="144" y="258" width="110" height="64" rx="6"/></g>
|
||||
<text x="199" y="287" class="pkt-name">length</text>
|
||||
<text x="199" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha2"><rect x="258" y="258" width="200" height="64" rx="6"/></g>
|
||||
<text x="358" y="287" class="pkt-name">IPv6 unicast</text>
|
||||
<text x="358" y="309" class="pkt-size">code 1: AFI 2, SAFI 1</text>
|
||||
<g class="pkt ha2"><rect x="462" y="258" width="190" height="64" rx="6"/></g>
|
||||
<text x="557" y="287" class="pkt-name">4-byte AS</text>
|
||||
<text x="557" y="309" class="pkt-size">code 65: 210036</text>
|
||||
<g class="pkt ha2"><rect x="656" y="258" width="170" height="64" rx="6"/></g>
|
||||
<text x="741" y="287" class="pkt-name">route refresh</text>
|
||||
<text x="741" y="309" class="pkt-size">code 2, empty</text>
|
||||
<g class="pkt ha2"><rect x="830" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="905" y="287" class="pkt-name">…</text>
|
||||
<text x="905" y="309" class="pkt-size">more capabilities</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
Sent once, by each side, as soon as the TCP connection is up. The two sides
|
||||
must agree: the AS must be the one configured for the neighbour, the hold
|
||||
time becomes the smaller of the two, and only capabilities both announced are
|
||||
used. A 4-byte AS does not fit in "my AS", so that field carries 23456 and the
|
||||
real number rides in a capability, which is how our AS210036 arrives.
|
||||
|
||||
---
|
||||
|
||||
## An OPEN message example
|
||||
|
||||
<div class="legend"><span class="hh">header</span><span class="hl">who I am</span><span class="ha">capabilities</span></div>
|
||||
|
||||
<pre class="bytes"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 37</span> <span class="hh">01</span> <span class="hl">04</span> <span class="hl">5b a0</span> <span class="hl">00 f0</span> <span class="hl">0a ff 00 01</span> <span class="hl">1a</span> <span class="ha">02 18</span> <span class="ha2">01 04 00 02 00 01</span> <span class="ha2">02 00</span> <span class="ha2">06 00</span> <span class="ha2">40 02 00 00</span> <span class="ha2">41 04 00 03 34 74</span> <span class="ha2">46 00</span> <span class="ha2">47 00</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>04 · 5b a0 · 00 f0 · 0a ff 00 01 · 1a</td><td>version 4; AS 23456 (AS_TRANS, see below); hold time 240 s; router ID 10.255.0.1; 26 bytes of parameters</td></tr><tr><td>02 18</td><td>parameter type 2, capabilities, 24 bytes</td></tr><tr><td>01 04 00 02 00 01</td><td>multiprotocol: AFI 2 (IPv6), SAFI 1 (unicast). Without this, no IPv6 routes</td></tr><tr><td>41 04 00 03 34 74</td><td>4-byte AS: the real number, 210036</td></tr><tr><td>02 00 · 06 00 · 40 02 00 00 · 46 00 · 47 00</td><td>route refresh, extended message, graceful restart, enhanced refresh, long-lived GR</td></tr></table>
|
||||
|
||||
Notes:
|
||||
What BIRD on our upstream router sends the lab daemon, byte for byte. Hold
|
||||
time 240 is BIRD's default; keepalives go every third of it.
|
||||
|
||||
---
|
||||
|
||||
## The shape of an UPDATE
|
||||
|
||||
| Bytes | Field |
|
||||
|---|---|
|
||||
| 19 | header: marker, length, type |
|
||||
| 2 + n | withdrawn routes: prefixes that are no longer reachable |
|
||||
| 2 + n | path attributes: everything *about* the route |
|
||||
| the rest | NLRI: the IPv4 prefixes being announced |
|
||||
<svg class="diagram" viewBox="0 0 1000 460" role="img" aria-label="An UPDATE message: the common header, then withdrawn routes, path attributes and NLRI, with one attribute and one prefix expanded">
|
||||
<text x="20" y="22" class="left small pkt-title">header: 19 bytes</text>
|
||||
<g class="pkt hh"><rect x="20" y="30" width="470" height="64" rx="6"/></g>
|
||||
<text x="255" y="59" class="pkt-name">marker</text>
|
||||
<text x="255" y="81" class="pkt-size">16 bytes, all ff</text>
|
||||
<g class="pkt hh"><rect x="494" y="30" width="240" height="64" rx="6"/></g>
|
||||
<text x="614" y="59" class="pkt-name">length</text>
|
||||
<text x="614" y="81" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hh"><rect x="738" y="30" width="242" height="64" rx="6"/></g>
|
||||
<text x="859" y="59" class="pkt-name">type</text>
|
||||
<text x="859" y="81" class="pkt-size">1 byte</text>
|
||||
<text x="20" y="132" class="left small pkt-title">UPDATE body: two lengths, three variable parts</text>
|
||||
<g class="pkt hl"><rect x="20" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="105" y="169" class="pkt-name">withdrawn length</text>
|
||||
<text x="105" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt hn"><rect x="194" y="140" width="200" height="64" rx="6"/></g>
|
||||
<text x="294" y="169" class="pkt-name">withdrawn routes</text>
|
||||
<text x="294" y="191" class="pkt-size">IPv4 prefixes gone</text>
|
||||
<g class="pkt hl"><rect x="398" y="140" width="170" height="64" rx="6"/></g>
|
||||
<text x="483" y="169" class="pkt-name">attributes length</text>
|
||||
<text x="483" y="191" class="pkt-size">2 bytes</text>
|
||||
<g class="pkt ha"><rect x="572" y="140" width="240" height="64" rx="6"/></g>
|
||||
<text x="692" y="169" class="pkt-name">path attributes</text>
|
||||
<text x="692" y="191" class="pkt-size">everything about the route</text>
|
||||
<g class="pkt hn"><rect x="816" y="140" width="172" height="64" rx="6"/></g>
|
||||
<text x="902" y="169" class="pkt-name">NLRI</text>
|
||||
<text x="902" y="191" class="pkt-size">IPv4 prefixes, new</text>
|
||||
<path class="pkt-expand" d="M 572 204 L 20 250"/>
|
||||
<path class="pkt-expand" d="M 812 204 L 980 250"/>
|
||||
<text x="20" y="250" class="left small pkt-title">one path attribute; repeated for multiple</text>
|
||||
<g class="pkt ha"><rect x="20" y="258" width="150" height="64" rx="6"/></g>
|
||||
<text x="95" y="287" class="pkt-name">flags</text>
|
||||
<text x="95" y="309" class="pkt-size">1 byte</text>
|
||||
<g class="pkt ha"><rect x="174" y="258" width="330" height="64" rx="6"/></g>
|
||||
<text x="339" y="287" class="pkt-name">type</text>
|
||||
<text x="339" y="309" class="pkt-size">1 byte: ORIGIN = 1, AS_PATH = 2 …</text>
|
||||
<g class="pkt ha"><rect x="508" y="258" width="180" height="64" rx="6"/></g>
|
||||
<text x="598" y="287" class="pkt-name">length</text>
|
||||
<text x="598" y="309" class="pkt-size">1 or 2 bytes</text>
|
||||
<g class="pkt ha2"><rect x="692" y="258" width="292" height="64" rx="6"/></g>
|
||||
<text x="838" y="287" class="pkt-name">value</text>
|
||||
<text x="838" y="309" class="pkt-size">as many bytes as it said</text>
|
||||
<path class="pkt-expand" d="M 979 204 L 979 376"/>
|
||||
<text x="20" y="368" class="left small pkt-title">one prefix, in NLRI and in withdrawn routes: a bit count, then only the bytes that count needs</text>
|
||||
<g class="pkt hn"><rect x="20" y="376" width="300" height="64" rx="6"/></g>
|
||||
<text x="170" y="405" class="pkt-name">length</text>
|
||||
<text x="170" y="427" class="pkt-size">1 byte: 24 for a /24</text>
|
||||
<g class="pkt hn"><rect x="324" y="376" width="656" height="64" rx="6"/></g>
|
||||
<text x="652" y="405" class="pkt-name">prefix</text>
|
||||
<text x="652" y="427" class="pkt-size">3 bytes for a /24, 6 for a /48 …</text>
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
One UPDATE carries one set of attributes, and every prefix that shares them.
|
||||
The withdrawn and NLRI fields can only hold IPv4; the next slides show where
|
||||
IPv6 goes. Lengths everywhere: a receiver can walk the message without
|
||||
understanding any of it, which is what lets unknown attributes pass through.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## A real one: 89 bytes
|
||||
|
||||
@@ -73,7 +195,7 @@ empty. Hold that thought.
|
||||
|
||||
<pre class="bytes focus"><span class="hh on">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh on">00 59</span> <span class="hh on">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
<table class="decode"><tr><td>ff × 16</td><td>marker. Once carried authentication, now just filler to find message boundaries</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
|
||||
<table class="decode"><tr><td>ff × 16</td><td>Start of message marker.</td></tr><tr><td>00 59</td><td>length of the whole message: 89 bytes (maximum 4096)</td></tr><tr><td>02</td><td>type 2 = UPDATE</td></tr></table>
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +213,7 @@ empty. Hold that thought.
|
||||
|
||||
<table class="decode"><tr><td>40</td><td>flags: well-known, transitive</td></tr><tr><td>01</td><td>type 1 = ORIGIN</td></tr><tr><td>01</td><td>1 byte of value</td></tr><tr><td>00</td><td>IGP: the route was originated inside this AS</td></tr></table>
|
||||
|
||||
Every attribute is *flags, type, length, value*.
|
||||
Every attribute is _flags, type, length, value_.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
@@ -103,7 +225,7 @@ Every attribute is *flags, type, length, value*.
|
||||
|
||||
<table class="decode"><tr><td>40 02 06</td><td>well-known, transitive; type 2; 6 bytes</td></tr><tr><td>02</td><td>an AS_SEQUENCE, order matters</td></tr><tr><td>01</td><td>of 1 AS number</td></tr><tr><td>00 03 34 74</td><td>210036, the origin. Four bytes each (RFC 6793)</td></tr></table>
|
||||
|
||||
Each network prepends itself on the way out. The length of this list is what best-path selection compares.
|
||||
Every BGP router prepends its own AS number before announcing a route to an external peer, creating a map of which networks the announcement travelled through. This allows selecting the shortest path.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
@@ -119,13 +241,13 @@ does not add itself. Seen from further away it reads e.g. 6939 210036.
|
||||
|
||||
<table class="decode"><tr><td>80 04 04</td><td>optional, non-transitive; type 4; 4 bytes</td></tr><tr><td>00 00 00 00</td><td>MED 0. Lower is preferred, among routes from the same neighbour AS</td></tr></table>
|
||||
|
||||
A hint to a neighbour about which of *your* entrances to use. Non-transitive: it goes one hop and no further.
|
||||
A hint to a neighbour about which path you prefer traffic to come in on. Non-transitive: it goes one hop and no further.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
---
|
||||
|
||||
## MP_REACH_NLRI: where IPv6 lives
|
||||
## MP_REACH_NLRI: announcing paths for non-IPv4 protocols
|
||||
|
||||
<pre class="bytes focus"><span class="hh">ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff</span> <span class="hh">00 59</span> <span class="hh">02</span> <span class="hl">00 00</span> <span class="hl">00 42</span> <span class="ha">40 01 01 00</span> <span class="ha2">40 02 06 02 01 00 03 34 74</span> <span class="ha">80 04 04 00 00 00 00</span> <span class="ha2 on">80 0e 1c 00 02 01 10 20 01 07 f8 00 24 00 00 00 00 00 00 00 00 00 c9 00 30 2a 0d d7 40 00 00</span> <span class="ha">c0 20 0c 00 03 34 74 00 00 00 0a 00 00 00 02</span></pre>
|
||||
|
||||
@@ -152,17 +274,17 @@ Three 32-bit numbers. They mean whatever the network named in the first one says
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## The flags byte
|
||||
|
||||
| Bit | Set means | Clear means |
|
||||
|---|---|---|
|
||||
| `0x80` | optional | well-known: every BGP speaker must understand it |
|
||||
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
|
||||
| `0x20` | partial: someone on the way did not understand it | |
|
||||
| `0x10` | length field is 2 bytes | 1 byte |
|
||||
| Bit | Set means | Clear means |
|
||||
| ------ | ------------------------------------------------- | ------------------------------------------------ |
|
||||
| `0x80` | optional | well-known: every BGP speaker must understand it |
|
||||
| `0x40` | transitive: pass it on, even if not understood | drop it at the next hop |
|
||||
| `0x20` | partial: someone on the way did not understand it | |
|
||||
| `0x10` | length field is 2 bytes | 1 byte |
|
||||
|
||||
Transitive is why a community you set can act three networks away.
|
||||
Path attribute transitivity allows adding instructions for networks far away from direct neighbours.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
@@ -4,7 +4,7 @@ Notes:
|
||||
Section 4. Two very different ways of getting routes, and what an Internet
|
||||
exchange physically is.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## Two ways to reach other networks
|
||||
|
||||
@@ -13,18 +13,18 @@ exchange physically is.
|
||||
|
||||
### IP transit
|
||||
|
||||
* You **pay** a provider
|
||||
* They give you routes to *everyone*
|
||||
* They announce you to *everyone*
|
||||
- You **pay** a provider
|
||||
- They give you routes to _everyone_
|
||||
- They announce you to _everyone_
|
||||
|
||||
</div>
|
||||
<div>
|
||||
|
||||
### Peering
|
||||
|
||||
* Usually **free**, by mutual benefit
|
||||
* You exchange *your own* routes, and your customers'
|
||||
* Traffic between the two of you only
|
||||
- Usually **free**, by mutual benefit
|
||||
- You exchange _your own_ routes, and your customers'
|
||||
- Traffic between the two of you only
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ Transit is a customer-provider relationship; peering is between equals. A peer
|
||||
does not carry your traffic to third parties. Most networks have both: transit
|
||||
as the fallback, peering to keep traffic cheap and close.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## An exchange is a big Ethernet switch
|
||||
|
||||
@@ -45,8 +45,8 @@ as the fallback, peering to keep traffic cheap and close.
|
||||
<g class="hot"><rect x="70" y="40" width="180" height="70" rx="8"/></g>
|
||||
<text x="160" y="70">AS210036</text><text x="160" y="92" class="small">::c9</text>
|
||||
<rect x="300" y="40" width="180" height="70" rx="8"/><text x="390" y="70">AS559 SWITCH</text><text x="390" y="92" class="small">::35</text>
|
||||
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS6939 HE</text><text x="620" y="92" class="small">::aa</text>
|
||||
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335</text><text x="850" y="92" class="small">Cloudflare</text>
|
||||
<rect x="530" y="40" width="180" height="70" rx="8"/><text x="620" y="70">AS2906 Netflix</text><text x="620" y="92" class="small">::55</text>
|
||||
<rect x="760" y="40" width="180" height="70" rx="8"/><text x="850" y="70">AS13335 Cloudflare</text><text x="850" y="92" class="small">::c0</text>
|
||||
<line class="link" x1="160" y1="110" x2="160" y2="175"/><line class="link" x1="390" y1="110" x2="390" y2="175"/>
|
||||
<line class="link" x1="620" y1="110" x2="620" y2="175"/><line class="link" x1="850" y1="110" x2="850" y2="175"/>
|
||||
<rect x="385" y="300" width="230" height="70" rx="8"/><text x="500" y="330">route servers</text><text x="500" y="352" class="small">AS42476 ::a5ec:a:1</text>
|
||||
@@ -64,52 +64,32 @@ address in a shared subnet. No routing inside the exchange: just Ethernet.
|
||||
Dashed blue is a BGP session, green is where the packets actually go.
|
||||
a5ec is 42476 in hex: the route server's address spells its AS number.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## What that implies
|
||||
|
||||
* Your neighbour is *on your LAN*: one hop, found by neighbour discovery
|
||||
* A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
|
||||
* The exchange never routes your packets. It switches frames
|
||||
* So the LAN is policed hard: one MAC address per port, no stray broadcasts, no proxy ARP
|
||||
- Your neighbour is _on your LAN_: one hop, found by neighbour discovery
|
||||
- A route's next hop is the peer's LAN address, so traffic goes **directly** member to member
|
||||
- The exchange is a simple layer 2 switch, allowing for a large capacity at low cost
|
||||
- Strict(ish) rules on what traffic is allowed on the peering lan (e.g. no DHCP or broadcast packets)
|
||||
|
||||
Notes:
|
||||
A misbehaving port can hurt every member, which is why exchanges publish strict
|
||||
rules for what may appear on the peering LAN and quarantine ports that break them.
|
||||
|
||||
-----
|
||||
- IPv6 neighbour discovery and ARP are used to discover the MAC addresses of peers
|
||||
- after which traffic can flow directly
|
||||
|
||||
---
|
||||
|
||||
## Route servers
|
||||
|
||||
Without one, peering with 200 members is 200 BGP sessions.
|
||||
|
||||
* One session to the route server brings routes from everyone who uses it
|
||||
* It is **not** in the traffic path, and leaves the next hop alone
|
||||
* It does not add its own AS to the path
|
||||
* You steer it with communities: `42476:0:<as>` "not to them", `42476:1:<as>` "only to them"
|
||||
- One session to the route server brings routes from everyone who uses it
|
||||
- It is **not** in the traffic path, and leaves the next hop alone
|
||||
- It does not add its own AS to the path
|
||||
- You steer it with communities: `42476:0:<as>` "not to them", `42476:1:<as>` "only to them"
|
||||
|
||||
Notes:
|
||||
A route server is a BGP speaker that only redistributes. Because it is
|
||||
transparent, the AS path looks exactly as if you peered with each member
|
||||
directly. We will use those communities in the lab.
|
||||
|
||||
-----
|
||||
|
||||
## Our upstream router, for real
|
||||
|
||||
| Where | BGP sessions | Neighbour networks |
|
||||
|---|---|---|
|
||||
| Transit (CommunityRack) | 2 | 1 |
|
||||
| SwissIX, Zürich | 28 | 12 |
|
||||
| DE-CIX, Frankfurt | 26 | 8 |
|
||||
| France-IX, Paris | 23 | 10 |
|
||||
| NL-ix, Amsterdam | 22 | 6 |
|
||||
|
||||
One small router, `pr01.rue`, AS210036. IPv4 and IPv6 are separate sessions.
|
||||
|
||||
<!-- .element: class="note" -->
|
||||
|
||||
Notes:
|
||||
Counted from its BIRD configuration. It is also on two smaller Swiss exchanges.
|
||||
The same network (Cloudflare, Apple, Hurricane Electric) often appears at
|
||||
several exchanges, which matters when you choose where to announce.
|
||||
+12
-10
@@ -1,16 +1,18 @@
|
||||
# The lab
|
||||
|
||||
### <a href="https://bgplab.rayhaan.net">bgplab.rayhaan.net</a>
|
||||
|
||||
Notes:
|
||||
Section 5. What the students are about to touch, and what stands between a
|
||||
typo and the Internet.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## What you are connected to
|
||||
|
||||
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to ferrix to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
|
||||
<svg class="diagram" viewBox="0 0 1000 430" role="img" aria-label="Browser to the lab BGP speaker to the BIRD router pr01.rue, which peers at four exchanges and buys transit">
|
||||
<rect x="20" y="170" width="150" height="80" rx="8"/><text x="95" y="205">you</text><text x="95" y="228" class="small">a browser</text>
|
||||
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">ferrix</text><text x="330" y="226" class="small">AS4200000001</text>
|
||||
<g class="hot"><rect x="230" y="170" width="200" height="80" rx="8"/></g><text x="330" y="202" class="accent">lab speaker</text><text x="330" y="226" class="small">AS4200000001</text>
|
||||
<rect x="490" y="170" width="200" height="80" rx="8"/><text x="590" y="202">pr01.rue (BIRD)</text><text x="590" y="226" class="small">AS210036</text>
|
||||
<line class="link" x1="170" y1="210" x2="230" y2="210"/><text x="200" y="160" class="small">HTTPS</text>
|
||||
<line class="bgp" x1="430" y1="210" x2="490" y2="210"/><text x="460" y="160" class="small accent">BGP</text>
|
||||
@@ -26,11 +28,11 @@ typo and the Internet.
|
||||
</svg>
|
||||
|
||||
Notes:
|
||||
ferrix is a small BGP daemon written for this course; it runs on the same
|
||||
machine as the router. Both dashed hops are real BGP sessions. Everything to
|
||||
The lab speaker is a small BGP daemon written for this course; it runs on
|
||||
the same machine as the router. Both dashed hops are real BGP sessions. Everything to
|
||||
the right of pr01 is the actual Internet.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## Your prefix
|
||||
|
||||
@@ -44,11 +46,11 @@ Notes:
|
||||
Put the URL and the class password on the board. The page is a snapshot by
|
||||
default; turn on auto-refresh at the top right to watch things change.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## What happens when you press *Announce*
|
||||
|
||||
1. ferrix builds an UPDATE. Click *bytes* to see it, hover to decode it
|
||||
1. The lab speaker builds an UPDATE. Click *bytes* to see it, hover to decode it
|
||||
2. pr01 checks it against its import filter
|
||||
3. pr01 decides, per neighbour, whether to pass it on
|
||||
4. Within a minute or so, the Internet can see your prefix
|
||||
@@ -58,7 +60,7 @@ Notes:
|
||||
Step 5: pr01 answers for every address in the lab space, so reachability of
|
||||
your prefix depends only on whether your announcement got out.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## Steering your route
|
||||
|
||||
@@ -79,7 +81,7 @@ Notes:
|
||||
same by exchange (1 SwissIX, 2 NL-ix, 3 France-IX, 4 DE-CIX). A "never" always
|
||||
wins. The preset menu next to the field has all of these.
|
||||
|
||||
-----
|
||||
---
|
||||
|
||||
## Your turn
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve the deck for editing: like `python3 -m http.server`, but every
|
||||
response says Cache-Control: no-store, so a plain reload always shows the
|
||||
files as they are on disk. (The stock server sends Last-Modified and nothing
|
||||
else, and browsers then keep the section files and the CSS for a while.)
|
||||
|
||||
python3 slides/serve.py # http://localhost:8765
|
||||
python3 slides/serve.py 8000
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # quiet; errors still go through log_error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8765
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
print(f"serving {os.getcwd()} on http://localhost:{port}/")
|
||||
ThreadingHTTPServer(("", port), NoCacheHandler).serve_forever()
|
||||
@@ -157,6 +157,138 @@
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
|
||||
/* ---- live DNS and RIPE database lookups ------------------------------------ */
|
||||
|
||||
.reveal form.dns-lookup,
|
||||
.reveal form.ripe-lookup {
|
||||
margin: 0.4em auto 0;
|
||||
max-width: 34em;
|
||||
text-align: left;
|
||||
font-family: var(--r-code-font);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input,
|
||||
.reveal .ripe-lookup input {
|
||||
width: 62%;
|
||||
padding: 0.3em 0.5em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reveal .dns-lookup input:focus,
|
||||
.reveal .ripe-lookup input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .dns-lookup button,
|
||||
.reveal .ripe-lookup button {
|
||||
margin-left: 0.4em;
|
||||
padding: 0.3em 0.9em;
|
||||
font: inherit;
|
||||
font-size: 0.8em;
|
||||
color: var(--ground);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reveal .dns-result {
|
||||
min-height: 3.2em;
|
||||
max-height: 13em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--rule) transparent;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em 0.7em;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .dns-result .ttl,
|
||||
.reveal .dns-result .type,
|
||||
.reveal .dns-result .dim { color: var(--muted); }
|
||||
.reveal .dns-result .addr { color: var(--accent); font-weight: 500; }
|
||||
.reveal .dns-result .bad { color: #ff7a7a; }
|
||||
|
||||
/* RIPE database objects, laid out like apps.db.ripe.net: a light page with
|
||||
one card per object, one row per attribute, references in blue, rows the
|
||||
RIPE NCC manages shaded. */
|
||||
.reveal .ripe-result {
|
||||
/* A fixed height, not a maximum: the box must not grow when the answer
|
||||
arrives, or the slide overflows (reveal centres a slide only when it
|
||||
is shown). */
|
||||
height: 13.5em;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
font-size: 0.62em;
|
||||
text-align: left;
|
||||
background: #f4f5f7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.reveal .ripe-card {
|
||||
margin-bottom: 0.7em;
|
||||
padding: 0.2em 0.5em;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.reveal .ripe-card:last-child { margin-bottom: 0; }
|
||||
|
||||
.reveal .ripe-card table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: inherit;
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
.reveal .ripe-card th,
|
||||
.reveal .ripe-card td {
|
||||
padding: 0.28em 0.6em;
|
||||
border: 0;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
background: transparent;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reveal .ripe-card table th {
|
||||
width: 8.5em;
|
||||
color: #1c1c28;
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted #9a9aa6;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.reveal .ripe-card td { word-break: break-all; }
|
||||
.reveal .ripe-card tr.managed th,
|
||||
.reveal .ripe-card tr.managed td { background: #dde5f0; }
|
||||
.reveal .ripe-card .ref { color: #2f6fe4; }
|
||||
.reveal .ripe-card .comment { margin-left: 0.4em; color: #6b6b78; }
|
||||
|
||||
.reveal .ripe-msg {
|
||||
padding: 0.6em;
|
||||
font-family: var(--r-code-font);
|
||||
color: #1c1c28;
|
||||
}
|
||||
|
||||
/* A framed live page on a slide. */
|
||||
.reveal iframe.live {
|
||||
border: 1px solid var(--rule);
|
||||
@@ -174,6 +306,18 @@
|
||||
box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.9);
|
||||
}
|
||||
|
||||
/* Packet layouts: field boxes in the colours of the bytes view. */
|
||||
.diagram .pkt rect { stroke: none; }
|
||||
.diagram .pkt.hh rect { fill: #34406e; }
|
||||
.diagram .pkt.hl rect { fill: #6e3440; }
|
||||
.diagram .pkt.ha rect { fill: #245c3a; }
|
||||
.diagram .pkt.ha2 rect { fill: #2f7a4b; }
|
||||
.diagram .pkt.hn rect { fill: #6b5a1f; }
|
||||
.diagram text.pkt-name { font-size: 18px; font-weight: 500; }
|
||||
.diagram text.pkt-size { font-size: 13px; font-family: "IBM Plex Mono", monospace; fill: var(--ink); opacity: 0.75; }
|
||||
.diagram text.pkt-title { paint-order: stroke; stroke: var(--panel); stroke-width: 8px; stroke-linejoin: round; }
|
||||
.diagram .pkt-expand { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 4 5; fill: none; }
|
||||
|
||||
/* Small two-column explanation under a bytes block. */
|
||||
.reveal table.decode {
|
||||
font-size: 58%;
|
||||
@@ -186,6 +330,67 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- prefix explorer: type a prefix, see its range ------------------------ */
|
||||
|
||||
.reveal form.prefix-explorer {
|
||||
margin: 0 auto;
|
||||
max-width: 36em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"] {
|
||||
width: 15em;
|
||||
padding: 0.25em 0.5em;
|
||||
font: inherit;
|
||||
font-family: var(--r-code-font);
|
||||
font-size: 0.8em;
|
||||
color: var(--r-main-color);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer input[name="prefix"]:focus { border-color: var(--accent); }
|
||||
|
||||
.reveal .prefix-explorer input[name="len"] {
|
||||
width: 12em;
|
||||
margin-left: 1em;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.reveal .prefix-explorer .px-bits { margin: 0.5em 0 0.4em; min-height: 1.6em; white-space: nowrap; }
|
||||
.reveal .prefix-explorer .px-bad { color: #ff7a7a; font-size: 70%; }
|
||||
|
||||
.reveal .px-range { font-family: var(--r-code-font); font-size: 50%; min-height: 10.5em; }
|
||||
|
||||
.reveal .px-levels { text-align: left; margin-bottom: 1em; }
|
||||
.reveal .px-levels-title { color: var(--muted); margin-bottom: 0.35em; }
|
||||
.reveal .px-level { display: flex; align-items: center; gap: 0.7em; margin-bottom: 0.3em; }
|
||||
.reveal .px-level-name { width: 13em; text-align: right; color: var(--accent-soft); white-space: nowrap; }
|
||||
.reveal .px-level-count { width: 8em; color: var(--muted); white-space: nowrap; }
|
||||
.reveal .px-track { flex: 1; position: relative; height: 0.9em; background: var(--ground); border: 1px solid var(--rule); border-radius: 2px; }
|
||||
.reveal .px-track i { position: absolute; top: 0; bottom: 0; min-width: 2px; background: var(--accent); }
|
||||
|
||||
.reveal .px-bar {
|
||||
position: relative;
|
||||
height: 2.6em;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.45), rgba(var(--accent-rgb), 0.12));
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.reveal .px-bar .px-mid {
|
||||
position: absolute; left: 0; right: 0; top: 0.6em;
|
||||
text-align: center; color: var(--ink-strong); font-weight: 500;
|
||||
}
|
||||
.reveal .px-bar .px-end { position: absolute; top: 3em; color: var(--accent-soft); }
|
||||
.reveal .px-bar .px-first { left: 0; }
|
||||
.reveal .px-bar .px-last { right: 0; }
|
||||
|
||||
.reveal .prefix-explorer .px-facts { margin-top: 2.6em; }
|
||||
|
||||
/* ---- bits of an address: fixed part vs. free part ------------------------------ */
|
||||
|
||||
.bits {
|
||||
@@ -201,6 +406,20 @@
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.bits .part {
|
||||
color: var(--accent-soft);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
margin: 0 2px;
|
||||
font-size: 85%;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.bits .part::before { content: "₂"; color: var(--muted); letter-spacing: 0; margin-right: 2px; }
|
||||
|
||||
.bits .part i { font-style: normal; color: var(--muted); }
|
||||
|
||||
.bits .free {
|
||||
color: var(--ink);
|
||||
background: var(--ground);
|
||||
@@ -266,6 +485,22 @@
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Nested address ranges: each level a little brighter than the one around it. */
|
||||
.diagram .nest rect {
|
||||
stroke: var(--accent);
|
||||
rx: 14;
|
||||
}
|
||||
.diagram .nest.l0 rect { fill: rgba(var(--accent-rgb), 0.04); stroke-opacity: 0.35; }
|
||||
.diagram .nest.l1 rect { fill: rgba(var(--accent-rgb), 0.09); stroke-opacity: 0.55; }
|
||||
.diagram .nest.l2 rect { fill: rgba(var(--accent-rgb), 0.16); stroke-opacity: 0.8; }
|
||||
.diagram .nest.l3 rect { fill: rgba(var(--accent-rgb), 0.28); stroke-opacity: 1; }
|
||||
.diagram text.left { text-anchor: start; }
|
||||
.diagram text.mono { font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .nest-label { font-family: "IBM Plex Mono", monospace; font-size: 22px; font-weight: 500; }
|
||||
.diagram .nest-desc { font-size: 17px; fill: var(--ink); opacity: 0.85; }
|
||||
.diagram text.host-addr { font-size: 15px; fill: var(--accent-soft); font-family: "IBM Plex Mono", monospace; }
|
||||
.diagram .host { fill: var(--accent-soft); stroke: none; }
|
||||
|
||||
.diagram .traffic {
|
||||
stroke: #4fd98a;
|
||||
stroke-width: 4;
|
||||
|
||||
Reference in new issue
Block a user