diff --git a/crates/integration-tests/tests/interop.rs b/crates/integration-tests/tests/interop.rs new file mode 100644 index 0000000..414d612 --- /dev/null +++ b/crates/integration-tests/tests/interop.rs @@ -0,0 +1,119 @@ +//! Interoperability with what real routers put on the wire. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::message::{Message, PathAttribute}; +use bgp_packet::open::{Capability, OpenOption}; +use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use eyre::Result; + +const TIMEOUT: Duration = Duration::from_secs(5); + +/// The OPEN a BIRD 2 router sends for an IPv6-only session from AS210036: +/// one optional parameter carrying MP(IPv6 unicast), route refresh, +/// extended message, graceful restart (aware only), 4-octet AS, enhanced +/// route refresh and long-lived graceful restart (aware only). +#[rustfmt::skip] +const BIRD_OPEN: &[u8] = &[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x37, 0x01, // length 55, type OPEN + 0x04, // version + 0x5b, 0xa0, // AS_TRANS + 0x00, 0xf0, // hold time 240 + 0x0a, 0xff, 0x00, 0x01, // router id 10.255.0.1 + 0x1a, // optional parameters length 26 + 0x02, 0x18, // capabilities, 24 bytes + 0x01, 0x04, 0x00, 0x02, 0x00, 0x01, // MP: IPv6 unicast + 0x02, 0x00, // route refresh + 0x06, 0x00, // extended message + 0x40, 0x02, 0x00, 0x00, // graceful restart, no address families + 0x41, 0x04, 0x00, 0x03, 0x34, 0x74, // 4-octet AS 210036 + 0x46, 0x00, // enhanced route refresh + 0x47, 0x00, // long-lived graceful restart, none +]; + +/// IPv6 End-of-RIB: an UPDATE whose only attribute is an empty +/// MP_UNREACH_NLRI for IPv6 unicast (RFC 4724 §2). +#[rustfmt::skip] +const IPV6_END_OF_RIB: &[u8] = &[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x1e, 0x02, // length 30, type UPDATE + 0x00, 0x00, // no withdrawn routes + 0x00, 0x07, // 7 bytes of path attributes + 0x90, 0x0f, 0x00, 0x03, // optional + extended length, MP_UNREACH_NLRI, len 3 + 0x00, 0x02, 0x01, // IPv6 unicast, no prefixes +]; + +#[tokio::test] +async fn test_server_advertises_ipv4_and_ipv6_unicast() -> Result<()> { + let server = TestServer::start(65000).await?; + let mut peer = TestPeer::connect(server.addr).await?; + let open = peer.recv_open().await?; + let caps: Vec<&Capability> = open + .options + .iter() + .flat_map(|o| match o { + OpenOption::Capabilities(c) => c.iter(), + }) + .collect(); + for afi in [AddressFamilyId::Ipv4, AddressFamilyId::Ipv6] { + assert!( + caps.iter().any(|c| matches!(c, Capability::MultiProtocol { afi: a, safi: SubsequentAfi::Unicast } if *a == afi)), + "missing MP capability for {afi:?}: {caps:?}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> { + let server = TestServer::start(4200000001).await?; + let mut peer = TestPeer::connect(server.addr).await?; + + peer.send_raw(BIRD_OPEN).await?; + peer.recv_open().await?; + match tokio::time::timeout(TIMEOUT, peer.recv()).await?? { + Message::KeepAlive => {} + other => eyre::bail!("server did not accept BIRD's OPEN: {other:?}"), + } + peer.send(&Message::KeepAlive).await?; + + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + let session = server.registry.list().remove(0); + assert_eq!(session.peer.asn, 210036, "4-octet ASN must come from the capability, not AS_TRANS"); + assert!(session.four_octet_asn); + + // End-of-RIB must neither break the session nor create a route. + peer.send_raw(IPV6_END_OF_RIB).await?; + peer.send(&Message::KeepAlive).await?; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(server.registry.list().len(), 1, "session dropped after End-of-RIB"); + assert!(server.adj_rib_in.snapshot().await.is_empty()); + Ok(()) +} + +/// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next +/// hop), so this only checks the builder the advertiser uses: the UPDATE for +/// a lab /48 carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP. +#[tokio::test] +async fn test_ipv6_lab_prefix_update_shape() -> Result<()> { + let route = LocalRoute::new(IpPrefix::try_from("2a0d:d741:1::/48")?, "student1", LocalRouteAttrs::default()); + let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?; + let update = bgp_server::advertise::build_announce(&route, 4200000001, nh.into()).expect("ipv6 over ipv6"); + assert!(update.announced_routes.is_empty()); + assert!(!update.path_attributes.iter().any(|a| matches!(a, PathAttribute::NextHop(_)))); + assert!(update.path_attributes.iter().any(|a| matches!(a, PathAttribute::MpReachNlri(m) + if m.afi == AddressFamilyId::Ipv6 && m.prefixes[0].to_string() == "2a0d:d741:1::/48"))); + let _ = Ipv4Addr::LOCALHOST; + Ok(()) +} diff --git a/crates/packet/src/open.rs b/crates/packet/src/open.rs index 0d33fbd..827624e 100644 --- a/crates/packet/src/open.rs +++ b/crates/packet/src/open.rs @@ -311,6 +311,10 @@ impl Capability { ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { let (buf, typ) = be_u8(buf)?; let (buf, len) = be_u8(buf)?; + // `cap_buf` holds exactly this capability's value; `buf` is what + // follows it. Every arm must return `buf`, or the caller's list + // parser sees an empty remainder and silently drops all the + // capabilities after this one. let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?; macro_rules! require_empty { ($buf:expr, $cap_type:expr) => { @@ -390,11 +394,11 @@ impl Capability { let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?; require_empty!(cap_buf, "ExtendedNextHop"); - Ok((cap_buf, Self::ExtendedNextHop { entries })) + Ok((buf, Self::ExtendedNextHop { entries })) } v if v == CapabilityDiscriminants::ExtendedMessage as u8 => { require_empty!(cap_buf, "ExtendedMessage"); - Ok((cap_buf, Self::ExtendedMessage)) + Ok((buf, Self::ExtendedMessage)) } v if v == CapabilityDiscriminants::BgpSec as u8 => { let (cap_buf, header) = be_u8(cap_buf)?; @@ -402,7 +406,7 @@ impl Capability { let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; require_empty!(cap_buf, "BgpSec"); Ok(( - cap_buf, + buf, Self::BgpSec { header: BgpSecHeader(header), afi, @@ -416,7 +420,7 @@ impl Capability { let (cap_buf, role) = be_u8(cap_buf)?; let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?; require_empty!(cap_buf, "BgpRole"); - Ok((cap_buf, Self::BgpRole { role })) + Ok((buf, Self::BgpRole { role })) } v if v == CapabilityDiscriminants::GracefulRestart as u8 => { // flags_restart_time is: (4 bits: restart flags, 12 bits: restart time (s)). @@ -445,7 +449,7 @@ impl Capability { let (cap_buf, entries) = nom::multi::many0(parse_entry).parse(cap_buf)?; require_empty!(cap_buf, "GracefulRestart"); Ok(( - cap_buf, + buf, Self::GracefulRestart { restart_state, restart_time, @@ -467,7 +471,7 @@ impl Capability { let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?; require_empty!(cap_buf, "AddPath"); Ok(( - cap_buf, + buf, Self::AddPath { afi, safi, @@ -477,7 +481,7 @@ impl Capability { } v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => { require_empty!(cap_buf, "EnhancedRouteRefresh"); - Ok((cap_buf, Self::EnhancedRouteRefresh)) + Ok((buf, Self::EnhancedRouteRefresh)) } v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => { let parse_llgr = |buf: &'a [u8]| -> IResult< @@ -503,13 +507,13 @@ impl Capability { }; let (cap_buf, entries) = nom::multi::many0(parse_llgr).parse(cap_buf)?; require_empty!(cap_buf, "LongLivedGracefulRestart"); - Ok((cap_buf, Self::LongLivedGracefulRestart(entries))) + Ok((buf, Self::LongLivedGracefulRestart(entries))) } type_code => { // Handling an unknown capability, so just parse it out and store the value // as bytes. Ok(( - &[], + buf, Self::UnknownCapability { type_code, length: cap_buf.len() as u8, @@ -978,4 +982,56 @@ mod tests { assert_eq!(buf.to_vec(), out.to_vec()); Ok(()) } + /// Each capability must leave the bytes that follow it for the caller. + /// Returning the (empty) per-capability buffer instead makes the list + /// parser stop early and silently lose every later capability, e.g. the + /// 4-octet ASN that routers list after graceful restart. + #[test] + fn capability_parsers_leave_the_following_bytes() { + let ctx = ParserContext::default(); + let trailer: &[u8] = &[0x41, 0x04, 0x00, 0x03, 0x34, 0x74]; + let samples: &[(&str, &[u8])] = &[ + ("multiprotocol", &[0x01, 0x04, 0x00, 0x02, 0x00, 0x01]), + ("route refresh", &[0x02, 0x00]), + ("extended next hop", &[0x05, 0x06, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02]), + ("extended message", &[0x06, 0x00]), + ("role", &[0x09, 0x01, 0x00]), + ("graceful restart, aware only", &[0x40, 0x02, 0x00, 0x00]), + ("graceful restart, one family", &[0x40, 0x06, 0x00, 0x78, 0x00, 0x02, 0x01, 0x80]), + ("4-octet asn", &[0x41, 0x04, 0x00, 0x00, 0xfd, 0xe8]), + ("add-path", &[0x45, 0x04, 0x00, 0x02, 0x01, 0x03]), + ("enhanced route refresh", &[0x46, 0x00]), + ("long-lived graceful restart, none", &[0x47, 0x00]), + ("unknown (fqdn)", &[0x49, 0x02, b'a', b'b']), + ]; + for (name, bytes) in samples { + let mut input = bytes.to_vec(); + input.extend_from_slice(trailer); + let (rest, _) = Capability::from_wire(&ctx, &input) + .unwrap_or_else(|e| panic!("{name}: failed to parse: {e:?}")); + assert_eq!(rest, trailer, "{name}: did not return the bytes following the capability"); + } + } + + /// The capability list of a BIRD 2 OPEN, end to end. + #[test] + fn bird_capability_list_is_parsed_completely() { + let ctx = ParserContext::default(); + #[rustfmt::skip] + let param: &[u8] = &[ + 0x02, 0x18, + 0x01, 0x04, 0x00, 0x02, 0x00, 0x01, + 0x02, 0x00, + 0x06, 0x00, + 0x40, 0x02, 0x00, 0x00, + 0x41, 0x04, 0x00, 0x03, 0x34, 0x74, + 0x46, 0x00, + 0x47, 0x00, + ]; + let (rest, OpenOption::Capabilities(caps)) = OpenOption::from_wire(&ctx, param).unwrap(); + assert!(rest.is_empty()); + assert_eq!(caps.len(), 7, "{caps:?}"); + assert!(caps.contains(&Capability::FourByteAsn { asn: 210036 })); + } + } diff --git a/crates/server/src/fsm.rs b/crates/server/src/fsm.rs index f0debbe..b834e6f 100644 --- a/crates/server/src/fsm.rs +++ b/crates/server/src/fsm.rs @@ -444,6 +444,12 @@ fn build_open(config: &SessionConfig) -> Message { afi: AddressFamilyId::Ipv4, safi: SubsequentAfi::Unicast, }, + // Without this a peer will not accept the IPv6 routes we + // originate via MP_REACH_NLRI (RFC 4760 §8). + Capability::MultiProtocol { + afi: AddressFamilyId::Ipv6, + safi: SubsequentAfi::Unicast, + }, Capability::RouteRefresh, ])], }) diff --git a/docs/RFCs.md b/docs/RFCs.md index a1857b7..16a97c9 100644 --- a/docs/RFCs.md +++ b/docs/RFCs.md @@ -107,7 +107,7 @@ https://datatracker.ietf.org/doc/html/rfc2858 | Feature | Status | |---------|--------| -| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ | +| `MultiProtocol` capability advertisement (IPv4 and IPv6 unicast) | ✅ | | `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ | | `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ | | IPv4 and IPv6 next-hop encoding | ✅ | diff --git a/docs/lab/prod/bird-ferrix.conf b/docs/lab/prod/bird-ferrix.conf new file mode 100644 index 0000000..5b8c903 --- /dev/null +++ b/docs/lab/prod/bird-ferrix.conf @@ -0,0 +1,78 @@ +# BIRD 2.x / 3.x: peer the production router (AS210036) with ferrix_bgp over +# WireGuard. ferrix is announce-only: we accept the lab /48s from it and send +# it nothing. +# +# Include from bird.conf: include "/etc/bird/bird-ferrix.conf"; +# Check and apply: birdc configure check && birdc configure +# Inspect: birdc show protocols all ferrix +# birdc show route protocol ferrix all + +define FERRIX_ASN = 4200000001; +define LAB_PREFIXES = [ 2a0d:d741::/40{48,48} ]; + +filter ferrix_import +{ + # Only the students' /48s, nothing shorter or longer, nothing else. + if !(net ~ LAB_PREFIXES) then reject; + + # ferrix allows at most 16 prepends on top of its own ASN. + if bgp_path.len > 17 then reject; + if bgp_path.first != FERRIX_ASN then reject; + + # Students type communities freely into a web form with no login. Do not + # let those reach upstreams, where they could trigger real actions + # (blackholing, selective prepends, ...). Keep only the two well-known + # ones BIRD itself honours, so NO_EXPORT / NO_ADVERTISE visibly work. + # To let students use more, add them to this list deliberately. + bgp_community.filter([ (65535, 65281), (65535, 65282) ]); + bgp_large_community.empty; + bgp_ext_community.empty; + + # Announce-only: there is nothing behind these prefixes. Do not forward + # their traffic into the tunnel; answer with "unreachable" instead. + dest = RTD_UNREACHABLE; + + accept; +} + +protocol bgp ferrix +{ + description "ferrix_bgp teaching lab (announce only)"; + local fd00:179::1 as 210036; + neighbor fd00:179::2 as FERRIX_ASN; + direct; + + # ferrix dials us. Staying passive avoids log noise from failed attempts + # towards a laptop that is asleep or firewalled. + passive on; + + # ferrix supports none of these; leave them off. + # password "..."; (TCP MD5) + # ttl security on; (GTSM) + # bfd on; + + ipv6 { + import filter ferrix_import; + # 128 students, one /48 each. Extra routes are ignored, the session + # stays up. + import limit 130 action block; + # Send nothing: ferrix keeps received routes in memory and renders + # every one of them on its status page. + export none; + }; +} + +# --------------------------------------------------------------------------- +# Your upstream / IXP export filters must let these routes out, and must +# strip ferrix's private ASN so the origin seen by the Internet is AS210036, +# matching the route6 objects. Add, before the final reject of each such +# filter: +# +# if proto = "ferrix" then { +# bgp_path.delete([ 64512..65534, 4200000000..4294967294 ]); +# accept; +# } +# +# Consequence: AS-path prepends set by students are made of the private ASN +# and are stripped here too, so prepending has no effect beyond this router. +# --------------------------------------------------------------------------- diff --git a/docs/lab/prod/ferrix.textpb b/docs/lab/prod/ferrix.textpb new file mode 100644 index 0000000..98e4667 --- /dev/null +++ b/docs/lab/prod/ferrix.textpb @@ -0,0 +1,25 @@ +# ferrix_bgp on a laptop, peering with the production router over WireGuard. +# Counterpart of bird-ferrix.conf. Run: cargo run --release -- docs/lab/prod/ferrix.textpb --level info +router_id: "10.255.0.2" # must differ from the production router's +local_asn: 4200000001 # private; stripped by the production router + +# IPv6 prefixes are only announced over an IPv6 session, and the next hop is +# our end of it, so listen and dial on the tunnel's IPv6 address. +listen_endpoints { address: "fd00:179::2" port: 179 } + +peers { + name: "prod" + endpoint { address: "fd00:179::1" port: 179 } + remote_asn: 210036 + hold_time_seconds: 90 +} +connect_retry_seconds: 5 +collision_detect_established_state: true + +local_route_policy { + allowed_prefixes: "2a0d:d741::/40" + max_routes_per_owner: 1 +} + +# No authentication on this API: keep it on loopback or the classroom LAN. +http_endpoint { address: "127.0.0.1" port: 8080 }