fix: capability list parsing; advertise IPv6 unicast; BIRD peering config

Preparing to peer with a production BIRD router turned up two problems.

- packet: most Capability::from_wire arms returned the per-capability
  buffer (always empty by then) instead of the bytes following the
  capability. The list parser then saw an empty remainder and silently
  dropped every later capability. Any OPEN listing extended message, role,
  graceful restart, add-path, enhanced route refresh, LLGR, extended next
  hop or an unknown capability before the 4-octet ASN capability lost the
  ASN, so the peer appeared as AS_TRANS (23456) and a configured
  remote_asn was refused with Bad Peer AS. BIRD and FRR both do this.
  Single-capability round-trip tests could not catch it; new tests parse
  every capability with another following it, and a full BIRD capability
  list.
- server: advertise MultiProtocol IPv6 unicast alongside IPv4. Without it
  a peer will not accept the IPv6 routes we originate via MP_REACH_NLRI.
- Interop test: byte-for-byte BIRD 2 OPEN is accepted with the right ASN,
  and an IPv6 End-of-RIB neither drops the session nor creates a route.
- docs/lab/prod: BIRD config for an announce-only ferrix neighbour
  (strict /48 import filter, community scrubbing, unreachable next hop,
  import limit, export none, passive) and the matching ferrix config.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-19 15:57:23 +02:00
1 parent 87f96f9389
commit 160e28690a
6 files changed
+294 -10

No files matched your search

+119
View File
@@ -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(())
}
+65 -9
View File
@@ -311,6 +311,10 @@ impl Capability {
) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> {
let (buf, typ) = be_u8(buf)?; let (buf, typ) = be_u8(buf)?;
let (buf, len) = 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)?; let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?;
macro_rules! require_empty { macro_rules! require_empty {
($buf:expr, $cap_type:expr) => { ($buf:expr, $cap_type:expr) => {
@@ -390,11 +394,11 @@ impl Capability {
let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?; let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?;
require_empty!(cap_buf, "ExtendedNextHop"); require_empty!(cap_buf, "ExtendedNextHop");
Ok((cap_buf, Self::ExtendedNextHop { entries })) Ok((buf, Self::ExtendedNextHop { entries }))
} }
v if v == CapabilityDiscriminants::ExtendedMessage as u8 => { v if v == CapabilityDiscriminants::ExtendedMessage as u8 => {
require_empty!(cap_buf, "ExtendedMessage"); require_empty!(cap_buf, "ExtendedMessage");
Ok((cap_buf, Self::ExtendedMessage)) Ok((buf, Self::ExtendedMessage))
} }
v if v == CapabilityDiscriminants::BgpSec as u8 => { v if v == CapabilityDiscriminants::BgpSec as u8 => {
let (cap_buf, header) = be_u8(cap_buf)?; 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))?; let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "BgpSec"); require_empty!(cap_buf, "BgpSec");
Ok(( Ok((
cap_buf, buf,
Self::BgpSec { Self::BgpSec {
header: BgpSecHeader(header), header: BgpSecHeader(header),
afi, afi,
@@ -416,7 +420,7 @@ impl Capability {
let (cap_buf, role) = be_u8(cap_buf)?; let (cap_buf, role) = be_u8(cap_buf)?;
let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?; let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "BgpRole"); require_empty!(cap_buf, "BgpRole");
Ok((cap_buf, Self::BgpRole { role })) Ok((buf, Self::BgpRole { role }))
} }
v if v == CapabilityDiscriminants::GracefulRestart as u8 => { v if v == CapabilityDiscriminants::GracefulRestart as u8 => {
// flags_restart_time is: (4 bits: restart flags, 12 bits: restart time (s)). // 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)?; let (cap_buf, entries) = nom::multi::many0(parse_entry).parse(cap_buf)?;
require_empty!(cap_buf, "GracefulRestart"); require_empty!(cap_buf, "GracefulRestart");
Ok(( Ok((
cap_buf, buf,
Self::GracefulRestart { Self::GracefulRestart {
restart_state, restart_state,
restart_time, restart_time,
@@ -467,7 +471,7 @@ impl Capability {
let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?; let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?;
require_empty!(cap_buf, "AddPath"); require_empty!(cap_buf, "AddPath");
Ok(( Ok((
cap_buf, buf,
Self::AddPath { Self::AddPath {
afi, afi,
safi, safi,
@@ -477,7 +481,7 @@ impl Capability {
} }
v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => { v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => {
require_empty!(cap_buf, "EnhancedRouteRefresh"); require_empty!(cap_buf, "EnhancedRouteRefresh");
Ok((cap_buf, Self::EnhancedRouteRefresh)) Ok((buf, Self::EnhancedRouteRefresh))
} }
v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => { v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => {
let parse_llgr = |buf: &'a [u8]| -> IResult< 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)?; let (cap_buf, entries) = nom::multi::many0(parse_llgr).parse(cap_buf)?;
require_empty!(cap_buf, "LongLivedGracefulRestart"); require_empty!(cap_buf, "LongLivedGracefulRestart");
Ok((cap_buf, Self::LongLivedGracefulRestart(entries))) Ok((buf, Self::LongLivedGracefulRestart(entries)))
} }
type_code => { type_code => {
// Handling an unknown capability, so just parse it out and store the value // Handling an unknown capability, so just parse it out and store the value
// as bytes. // as bytes.
Ok(( Ok((
&[], buf,
Self::UnknownCapability { Self::UnknownCapability {
type_code, type_code,
length: cap_buf.len() as u8, length: cap_buf.len() as u8,
@@ -978,4 +982,56 @@ mod tests {
assert_eq!(buf.to_vec(), out.to_vec()); assert_eq!(buf.to_vec(), out.to_vec());
Ok(()) 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 }));
}
} }
+6
View File
@@ -444,6 +444,12 @@ fn build_open(config: &SessionConfig) -> Message {
afi: AddressFamilyId::Ipv4, afi: AddressFamilyId::Ipv4,
safi: SubsequentAfi::Unicast, 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, Capability::RouteRefresh,
])], ])],
}) })
+1 -1
View File
@@ -107,7 +107,7 @@ https://datatracker.ietf.org/doc/html/rfc2858
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ | | `MultiProtocol` capability advertisement (IPv4 and IPv6 unicast) | ✅ |
| `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ | | `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ |
| `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ | | `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ |
| IPv4 and IPv6 next-hop encoding | ✅ | | IPv4 and IPv6 next-hop encoding | ✅ |
+78
View File
@@ -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.
# ---------------------------------------------------------------------------
+25
View File
@@ -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 }