Carry IPv4 routes over IPv6 sessions with IPv6 next hops (RFC 8950)

For a link that has only IPv6 addresses.

- The Extended Next Hop capability is offered on every session over IPv6
  whose families include IPv4 unicast, and used if the peer offers it too.
  Nothing to configure.
- On such a session our IPv4 routes go out in MP_REACH_NLRI with the
  session's IPv6 address as next hop, and are withdrawn in
  MP_UNREACH_NLRI. Without the agreement they are not sent, as before.
- IPv4 routes are read with an IPv6 next hop, global or global with
  link-local.
- The kernel installer resolves an IPv4 route's IPv6 next hop like any
  other and installs it: by way of an IPv6 nexthop object, or with RTA_VIA
  where routes carry their own gateway.

Tested against a real kernel in both modes, with a link-local next hop
too, and in the lab against FRR and BIRD: the new `v6only` phase takes
the IPv4 addresses off the exchange LAN, runs one IPv6 session with each
router for both families, and pings IPv4 across.

Sessions between link-local addresses are not supported yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 17:58:03 +02:00
1 parent 321af51e4b
commit 92e013ca01
14 files changed
+511 -23

No files matched your search

+53 -1
View File
@@ -84,6 +84,9 @@ mod route_attribute {
pub const GATEWAY: u16 = 5;
pub const PRIORITY: u16 = 6;
pub const TABLE: u16 = 15;
/// A gateway of another family than the route's: the family, then
/// the address (`RTA_VIA`).
pub const VIA: u16 = 18;
pub const NEXTHOP_ID: u16 = 30;
}
@@ -380,6 +383,11 @@ fn route(fixed: &[u8], attributes_of_route: &[u8]) -> Option<Route> {
route.prefix = IpPrefix::new(version, value, destination_length).ok()?;
}
route_attribute::GATEWAY => route.gateway = Some(address(version, value)?),
route_attribute::VIA => {
let (family, gateway) = value.split_at_checked(2)?;
let family = version_of(u8::try_from(ne_u16(family)).ok()?)?;
route.gateway = Some(address(family, gateway)?);
}
route_attribute::OUT_INTERFACE => route.interface = Some(ne_u32(value.get(..4)?)),
route_attribute::PRIORITY => route.metric = ne_u32(value.get(..4)?),
route_attribute::TABLE => route.table = ne_u32(value.get(..4)?),
@@ -546,7 +554,20 @@ pub fn route_request(
}
if let Some(gateway) = &route.gateway {
let (bytes, length) = octets(gateway);
put_attribute(out, route_attribute::GATEWAY, &bytes[..length]);
let gateway_version = match gateway {
IpAddr::V4(_) => IpVersion::Ipv4,
IpAddr::V6(_) => IpVersion::Ipv6,
};
if gateway_version == version {
put_attribute(out, route_attribute::GATEWAY, &bytes[..length]);
} else {
// An IPv4 route by way of an IPv6 neighbor (Linux 5.2 and
// later).
let mut via = [0u8; 18];
via[..2].copy_from_slice(&u16::from(family_of(gateway_version)).to_ne_bytes());
via[2..2 + length].copy_from_slice(&bytes[..length]);
put_attribute(out, route_attribute::VIA, &via[..2 + length]);
}
}
if let Some(interface) = route.interface {
put_attribute(
@@ -723,6 +744,37 @@ mod tests {
);
}
#[test]
fn an_ipv4_route_by_way_of_an_ipv6_gateway_reads_back() {
let route = Route {
prefix: prefix("203.0.113.0/24"),
table: TABLE_MAIN,
protocol: PROTOCOL_BGP,
metric: 20,
gateway: Some("2001:db8::2".parse().unwrap()),
interface: Some(3),
nexthop: None,
kind: KIND_UNICAST,
};
let mut wire = BytesMut::new();
route_request(&mut wire, 1, Change::Replace, false, &route);
// RTA_VIA after destination, table and priority: AF_INET6, then
// the address.
let via = &wire[16 + 12 + 3 * 8..][..24];
// Four of header, two of family, sixteen of address; padded to 24.
assert_eq!(via[..2], 22u16.to_ne_bytes());
assert_eq!(via[2..4], 18u16.to_ne_bytes());
assert_eq!(via[4..6], 10u16.to_ne_bytes());
assert_eq!(via[6..8], [0x20, 0x01]);
assert_eq!(
read_back(&route, Change::Replace).1,
Message::Route {
present: true,
route
}
);
}
#[test]
fn nexthop_objects_are_asked_for_named_and_read() {
// The request: RTM_NEWNEXTHOP, REQUEST|ACK|ECHO|CREATE; nhmsg with
+24 -4
View File
@@ -28,7 +28,7 @@ pub struct Target {
pub enum Unresolved {
/// The route has no next hop.
Missing,
/// The next hop is of the other IP version than the prefix.
/// An IPv6 route with an IPv4 next hop.
OtherFamily,
/// The next hop is one of this machine's own addresses.
Ourselves,
@@ -49,7 +49,7 @@ impl Unresolved {
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "no next hop",
Self::OtherFamily => "next hop of the other IP version",
Self::OtherFamily => "IPv4 next hop for an IPv6 route",
Self::Ourselves => "next hop is this machine",
Self::NotConnected => "next hop not directly connected",
}
@@ -77,7 +77,10 @@ pub fn resolve(
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
NlriNextHop::Ipv6(_) | NlriNextHop::Ipv6WithLl { .. } => IpVersion::Ipv6,
};
if version != prefix.address_family() {
// An IPv4 route may have an IPv6 next hop: that is how routes go over
// a link with only IPv6 addresses (RFC 8950), and the kernel takes
// such a route. The other way about means nothing.
if version != prefix.address_family() && version == IpVersion::Ipv4 {
return Err(Unresolved::OtherFamily);
}
let direct = |gateway: IpAddr| {
@@ -427,7 +430,6 @@ mod tests {
for (hop, why) in [
(Some(v4_hop("10.9.9.9")), Unresolved::NotConnected),
(Some(v4_hop("192.0.2.1")), Unresolved::Ourselves),
(Some(v6_hop("2001:db8::2")), Unresolved::OtherFamily),
(None, Unresolved::Missing),
] {
assert_eq!(resolve(&interfaces, &v4, hop.as_ref(), peer), Err(why));
@@ -436,6 +438,24 @@ mod tests {
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer),
Err(Unresolved::OtherFamily)
);
// An IPv4 route by way of an IPv6 neighbor: a link with only IPv6
// addresses (RFC 8950).
assert_eq!(
resolve(&interfaces, &v4, Some(&v6_hop("2001:db8::2")), peer),
Ok(target("2001:db8::2", 2))
);
assert_eq!(
resolve(
&interfaces,
&v4,
Some(&NlriNextHop::Ipv6WithLl {
global: "2001:db8:ffff::2".parse().unwrap(),
link_local: "fe80::2".parse().unwrap(),
}),
Some("2001:db8::2".parse().unwrap()),
),
Ok(target("fe80::2", 2))
);
}
#[test]
+66
View File
@@ -594,3 +594,69 @@ async fn routes_of_an_earlier_run_are_kept_while_peers_come_back() {
fib.stop().await;
task.await.unwrap().unwrap();
}
/// An IPv4 route whose next hop is an IPv6 neighbor: routes over a link
/// that has only IPv6 addresses (RFC 8950). The kernel takes such a route,
/// by way of a nexthop object or with its gateway spelled out.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "changes the machine's routes; run scripts/kernel-tests.sh"]
async fn an_ipv4_route_goes_by_way_of_an_ipv6_neighbor() {
for (n, nexthop_objects) in [(8, true), (9, false)] {
let mut machine = Machine::new(n);
machine.nexthop_objects = nexthop_objects;
// Only IPv6 on the link.
ip(&format!(
"addr del 10.{n}.0.1/24 dev {}",
machine.interface()
));
let neighbor: IpAddr = format!("2001:db8:{n}::2").parse().unwrap();
let peer = machine
.rib
.add_peer(PeerInfo {
asn: 65002,
router_id: machine.neighbor(2),
address: neighbor,
ibgp: false,
})
.unwrap();
// Both families by way of the same neighbor.
let via = machine.via(neighbor, 0);
machine.rib.announce(
peer,
&via,
&[prefix("203.0.113.0/24"), prefix("2001:db8:100::/48")],
);
// And an IPv4 route whose next hop has a link-local address too,
// with a global one that is not on any link of ours.
let link_local = machine
.rib
.attrs()
.intern(
&[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![65002]),
],
Some(&NlriNextHop::Ipv6WithLl {
global: "2001:db8:ffff::2".parse().unwrap(),
link_local: "fe80::2".parse().unwrap(),
}),
)
.unwrap();
machine
.rib
.announce(peer, &link_local, &[prefix("198.18.0.0/15")]);
let (fib, task) = machine.start();
let interface = machine.interface();
machine.kernel_becomes(&[
format!("203.0.113.0/24 via inet6 {neighbor} dev {interface} proto bgp metric 20"),
format!("198.18.0.0/15 via inet6 fe80::2 dev {interface} proto bgp metric 20"),
machine.shown("2001:db8:100::/48", neighbor),
]);
assert_eq!(fib.status().errors, 0, "{:?}", fib.status().last_error);
fib.stop().await;
task.await.unwrap().unwrap();
machine.kernel_becomes(&[]);
}
}
@@ -127,6 +127,7 @@ async fn test_ipv6_prefix_update_shape() -> Result<()> {
four_octet_asn: true,
ibgp: false,
role: None,
extended_next_hop: false,
},
)
.expect("ipv6 over ipv6");
+5 -3
View File
@@ -1182,11 +1182,13 @@ impl MpReachNlriPathAttribute {
let (buf, next_hop_len) = be_u8(buf)?;
let (buf, next_hop) = nom::bytes::take(usize::from(next_hop_len)).parse(buf)?;
// An IPv4 address for IPv4 routes; for IPv6, a global address,
// optionally followed by a link-local one (RFC 2545).
// For IPv6 routes, a global address, optionally followed by a
// link-local one (RFC 2545). For IPv4 routes an IPv4 address, or
// the same as for IPv6: routes over a link that has only IPv6
// addresses (RFC 8950). Which of the two it is shows in its length.
let next_hop = NlriNextHop::from_bytes(next_hop)
.filter(|next_hop| {
matches!(next_hop, NlriNextHop::Ipv4(_)) == (version == IpVersion::Ipv4)
version == IpVersion::Ipv4 || !matches!(next_hop, NlriNextHop::Ipv4(_))
})
.ok_or_else(|| {
malformed(format!(
+100 -8
View File
@@ -233,6 +233,7 @@ impl Exporter {
four_octet_asn: session.four_octet_asn,
ibgp: session.ibgp,
role: session.role,
extended_next_hop: session.extended_next_hop,
};
let Some(update) = build_announce(route, &towards) else {
debug!(
@@ -255,7 +256,10 @@ impl Exporter {
}
debug!(peer = %self.session.peer, %prefix, "Withdrawing local route");
self.session
.send(Message::Update(build_withdraw(&prefix)))
.send(Message::Update(build_withdraw(
&prefix,
self.session.extended_next_hop,
)))
.await?;
Ok(())
}
@@ -276,13 +280,16 @@ pub struct Towards {
pub ibgp: bool,
/// What the peer is to us, if that is known.
pub role: Option<PeerRole>,
/// Whether the session takes IPv4 routes with IPv6 next hops
/// (RFC 8950).
pub extended_next_hop: bool,
}
/// Builds the UPDATE announcing `route` on a session.
///
/// Returns `None` when the prefix family does not match the next-hop family:
/// carrying IPv4 NLRI over an IPv6 next hop (RFC 8950) or vice versa is not
/// supported yet.
/// Returns `None` when the route cannot be given the session's address as
/// next hop: an IPv6 route on a session over IPv4, or an IPv4 route on a
/// session over IPv6 that did not agree on RFC 8950.
pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option<UpdateMessage> {
let attrs = &route.attrs;
@@ -328,6 +335,16 @@ pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option<UpdateMes
prefixes: vec![route.prefix.into()],
}))
}
// An IPv4 route over a link that has only IPv6 addresses: in
// MP_REACH_NLRI like an IPv6 route, with the IPv6 next hop
// (RFC 8950 §4).
(IpVersion::Ipv4, IpAddr::V6(next_hop)) if towards.extended_next_hop => {
Some(PathAttribute::MpReachNlri(MpReachNlriPathAttribute {
family: AfiSafi::IPV4_UNICAST,
next_hop: NlriNextHop::Ipv6(next_hop),
prefixes: vec![route.prefix.into()],
}))
}
_ => return None,
};
if let Some(med) = attrs.med {
@@ -378,9 +395,19 @@ pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option<UpdateMes
})
}
/// Builds the UPDATE withdrawing `prefix`.
pub fn build_withdraw(prefix: &IpPrefix) -> UpdateMessage {
/// Builds the UPDATE withdrawing `prefix`. With `extended_next_hop` the
/// session has IPv4 routes announced in MP_REACH_NLRI (RFC 8950), and they
/// are withdrawn the way they were announced.
pub fn build_withdraw(prefix: &IpPrefix, extended_next_hop: bool) -> UpdateMessage {
match prefix.address_family() {
IpVersion::Ipv4 if extended_next_hop => UpdateMessage {
withdrawn_routes: vec![],
path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
family: AfiSafi::IPV4_UNICAST,
prefixes: vec![Nlri::from(*prefix)],
})],
announced_routes: vec![],
},
IpVersion::Ipv4 => UpdateMessage {
withdrawn_routes: vec![Nlri::from(*prefix)],
path_attributes: vec![],
@@ -412,6 +439,69 @@ mod tests {
IpPrefix::try_from(s).unwrap()
}
/// RFC 8950: over a link that has only IPv6 addresses, an IPv4 route
/// goes in MP_REACH_NLRI with our IPv6 address as next hop, and is
/// withdrawn in MP_UNREACH_NLRI.
#[test]
fn an_ipv4_route_goes_over_an_ipv6_session_if_both_agreed() {
let route = LocalRoute::new(prefix("10.1.0.0/16"), LocalRouteAttrs::default());
let ours: Ipv6Addr = "2001:db8::1".parse().unwrap();
let over_ipv6 = towards(65000, IpAddr::V6(ours), true);
// Not agreed: the route has no next hop the peer could use.
assert!(build_announce(&route, &over_ipv6).is_none());
let agreed = Towards {
extended_next_hop: true,
..over_ipv6
};
let update = build_announce(&route, &agreed).unwrap();
assert!(update.announced_routes.is_empty(), "not in the body");
assert!(
!update
.path_attributes
.iter()
.any(|attribute| matches!(attribute, PathAttribute::NextHop(_))),
"no NEXT_HOP attribute"
);
assert!(update.path_attributes.contains(&PathAttribute::MpReachNlri(
MpReachNlriPathAttribute {
family: AfiSafi::IPV4_UNICAST,
next_hop: NlriNextHop::Ipv6(ours),
prefixes: vec![prefix("10.1.0.0/16").into()],
}
)));
// What is written is what a peer reads.
let ctx = ParserContext {
four_octet_asn: true,
ibgp: false,
add_path: Vec::new(),
};
let mut wire = BytesMut::new();
Message::Update(update.clone())
.to_wire(&ctx, &mut wire)
.unwrap();
assert_eq!(
Message::decode(&ctx, &wire).unwrap(),
Message::Update(update)
);
let withdrawal = build_withdraw(&prefix("10.1.0.0/16"), true);
assert!(withdrawal.withdrawn_routes.is_empty());
assert_eq!(
withdrawal.path_attributes,
[PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute {
family: AfiSafi::IPV4_UNICAST,
prefixes: vec![prefix("10.1.0.0/16").into()],
})]
);
// An IPv6 route is as it always was, agreed or not.
let v6 = LocalRoute::new(prefix("2001:db8:1::/48"), LocalRouteAttrs::default());
assert_eq!(
build_announce(&v6, &agreed),
build_announce(&v6, &over_ipv6)
);
}
/// RFC 9234 §5, the egress procedure for routes we originate.
#[test]
fn a_route_is_marked_only_to_customer_on_its_way_down_or_across() {
@@ -456,6 +546,7 @@ mod tests {
four_octet_asn,
ibgp: false,
role: None,
extended_next_hop: false,
}
}
@@ -599,11 +690,11 @@ mod tests {
#[test]
fn withdraw_by_family() {
let v4 = build_withdraw(&prefix("10.0.0.0/8"));
let v4 = build_withdraw(&prefix("10.0.0.0/8"), false);
assert_eq!(v4.withdrawn_routes, vec![prefix("10.0.0.0/8")]);
assert!(v4.path_attributes.is_empty());
let v6 = build_withdraw(&prefix("2001:db8::/32"));
let v6 = build_withdraw(&prefix("2001:db8::/32"), false);
assert!(v6.withdrawn_routes.is_empty());
assert!(
matches!(&v6.path_attributes[..], [PathAttribute::MpUnreachNlri(m)] if m.prefixes == vec![prefix("2001:db8::/32")])
@@ -731,6 +822,7 @@ mod tests {
add_path_receive: FamilySet::default(),
add_path_send: FamilySet::default(),
role_confirmed: false,
extended_next_hop: false,
};
let rib_peer = self
.table
+3
View File
@@ -401,6 +401,9 @@ impl Session {
add_path: peer.add_path,
role: peer.role,
require_role: peer.require_role,
// Over IPv6 our address is no next hop for an IPv4 route as
// BGP first had it; this is how such routes go all the same.
extended_next_hop: peer_addr.is_ipv6(),
});
// The peer goes by its configured name, or by its address.
let label = peer
+79 -1
View File
@@ -16,7 +16,10 @@ use std::time::Duration;
use bgp_packet::errors::{BgpError, DecodeError, OpenMsgSubcode};
use bgp_packet::message::{AS_TRANS, BGP4_VERSION, Message, UpdateMessage};
use bgp_packet::open::{AddPathEntry, AddPathSendRecv, Capability, OpenMessage, OpenOption};
use bgp_packet::open::{
AddPathEntry, AddPathSendRecv, Capability, ExtendedNextHop, OpenMessage, OpenOption,
};
use bgp_primitives::constants::{Afi, AfiSafi, Safi};
use crate::error::SessionError;
use crate::family::FamilySet;
@@ -73,6 +76,9 @@ pub struct FsmConfig {
/// Refuse a peer that does not say what it is (RFC 9234 §4.2, "strict
/// mode"). Means nothing without `role`.
pub require_role: bool,
/// Offer to exchange IPv4 routes with IPv6 next hops (RFC 8950): for a
/// session over a link that has only IPv6 addresses.
pub extended_next_hop: bool,
}
/// What the two OPENs settled.
@@ -101,6 +107,9 @@ pub struct Negotiated {
/// Whether the peer said what it is to us, and it is what we have it
/// configured as (RFC 9234).
pub role_confirmed: bool,
/// Whether IPv4 routes go with IPv6 next hops on this session
/// (RFC 8950): both sides offered it, and the session carries IPv4.
pub extended_next_hop: bool,
}
/// A message from the peer, with what the decoder found out besides.
@@ -377,7 +386,16 @@ impl Fsm {
}
}
// RFC 8950 §4: in use only if both said so.
let extended_next_hop = self.config.extended_next_hop
&& families.contains(AfiSafi::IPV4_UNICAST)
&& open.capabilities().any(|capability| {
matches!(capability, Capability::ExtendedNextHop { entries }
if entries.contains(&IPV4_WITH_IPV6_NEXT_HOP))
});
let negotiated = Negotiated {
extended_next_hop,
peer_asn,
ibgp: peer_asn == self.config.local_asn,
peer_identifier: open.identifier,
@@ -434,6 +452,14 @@ impl Fsm {
}
}
/// IPv4 unicast routes with next hops that are IPv6 addresses, as the
/// Extended Next Hop capability names it (RFC 8950 §3).
const IPV4_WITH_IPV6_NEXT_HOP: ExtendedNextHop = ExtendedNextHop {
afi: Afi::IPV4,
safi: Safi::UNICAST,
nh_afi: Afi::IPV6,
};
/// Any message from the peer shows it is alive (RFC 4271 §4.4).
fn heard_from_peer(negotiated: &Negotiated) -> Vec<Action> {
hold_time(negotiated)
@@ -464,6 +490,11 @@ fn build_open(config: &FsmConfig) -> Message {
if let Some(role) = config.role {
capabilities.push(Capability::BgpRole { role: role.ours() });
}
if config.extended_next_hop && config.families.contains(AfiSafi::IPV4_UNICAST) {
capabilities.push(Capability::ExtendedNextHop {
entries: vec![IPV4_WITH_IPV6_NEXT_HOP],
});
}
if let Some(send_recv) = config.add_path {
capabilities.push(Capability::AddPath {
entries: config
@@ -531,6 +562,7 @@ mod tests {
add_path: None,
role: None,
require_role: false,
extended_next_hop: false,
}
}
@@ -632,6 +664,7 @@ mod tests {
add_path_receive: FamilySet::default(),
add_path_send: FamilySet::default(),
role_confirmed: false,
extended_next_hop: false,
};
assert!(matches!(
&actions[..],
@@ -891,6 +924,51 @@ mod tests {
}
}
/// RFC 8950: IPv4 routes with IPv6 next hops are in use only if both
/// sides offer it, and we offer it only where asked to.
#[test]
fn ipv4_with_ipv6_next_hops_is_in_use_only_if_both_offer_it() {
let offer = Capability::ExtendedNextHop {
entries: vec![IPV4_WITH_IPV6_NEXT_HOP],
};
let outcome = |ours: bool, theirs: Vec<Capability>| {
let (mut fsm, first) = Fsm::new(FsmConfig {
extended_next_hop: ours,
..config()
});
let Action::Send(open) = &first[0] else {
panic!("expected an OPEN first, got {first:?}");
};
assert_eq!(capabilities(open).contains(&&offer), ours);
let mut offered = vec![
Capability::multiprotocol(AfiSafi::IPV4_UNICAST),
Capability::multiprotocol(AfiSafi::IPV6_UNICAST),
];
offered.extend(theirs);
let open = OpenMessage {
options: vec![OpenOption::Capabilities(offered)],
..peer_open(90)
};
match &fsm.handle(received(Message::Open(open)))[..] {
[Action::Admit(negotiated), ..] => negotiated.extended_next_hop,
other => panic!("expected the session to go on, got {other:?}"),
}
};
assert!(outcome(true, vec![offer.clone()]));
assert!(!outcome(true, vec![]));
assert!(!outcome(false, vec![offer.clone()]));
// Offered for something else: IPv6 routes with IPv4 next hops,
// say.
let other = Capability::ExtendedNextHop {
entries: vec![ExtendedNextHop {
afi: Afi::IPV6,
safi: Safi::UNICAST,
nh_afi: Afi::IPV4,
}],
};
assert!(!outcome(true, vec![other]));
}
/// RFC 9234 §4.1: our OPEN says what we are to the peer, which is the
/// other half of what the peer is to us.
#[test]
+4
View File
@@ -125,6 +125,9 @@ pub struct SessionHandle {
pub role: Option<PeerRole>,
/// Whether the peer said so itself in its OPEN (RFC 9234).
pub role_confirmed: bool,
/// Whether IPv4 routes go with IPv6 next hops on this session
/// (RFC 8950).
pub extended_next_hop: bool,
/// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how
/// AS_PATH is encoded on the wire for this session.
pub four_octet_asn: bool,
@@ -170,6 +173,7 @@ impl SessionHandle {
ibgp: negotiated.ibgp,
role: None,
role_confirmed: false,
extended_next_hop: negotiated.extended_next_hop,
four_octet_asn: negotiated.four_octet_asn,
families: negotiated.families.clone(),
add_path_receive: negotiated.add_path_receive.clone(),
+5
View File
@@ -64,6 +64,9 @@ pub struct PeerView {
pub established_at_unix: Option<u64>,
pub uptime_secs: Option<u64>,
pub four_octet_asn: Option<bool>,
/// Whether IPv4 routes go with IPv6 next hops on the session
/// (RFC 8950).
pub extended_next_hop: Option<bool>,
/// The address families negotiated with the peer, e.g. `IPv4 unicast`.
pub address_families: Option<Vec<String>>,
/// The families for which the peer sends us several routes for a
@@ -102,6 +105,7 @@ impl From<&SessionHandle> for PeerView {
established_at_unix: Some(unix_secs(s.established_at)),
uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)),
four_octet_asn: Some(s.four_octet_asn),
extended_next_hop: Some(s.extended_next_hop),
address_families: Some(s.families.iter().map(|f| f.to_string()).collect()),
add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()),
add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()),
@@ -135,6 +139,7 @@ impl PeerView {
established_at_unix: None,
uptime_secs: None,
four_octet_asn: None,
extended_next_hop: None,
address_families: None,
add_path_receive: None,
add_path_send: None,
+1 -1
View File
@@ -186,7 +186,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) + (p.extended_next_hop ? ' &middot; IPv4 with IPv6 next hops' : '') : DASH}</td>
<td>${up ? p.routes : DASH}</td>
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));