Peer at link-local addresses: fe80::1%eth0

A peer's endpoint may be a link-local IPv6 address with the interface it
is on. Such a peer is the address and the interface together, wherever
peers are told apart: admission, collisions, hold-downs, the refusal log,
the status page. The interface is looked up by name each time it is
needed, so one that appears later or is made anew is found.

Our routes go out with our link-local address in both places of
MP_REACH_NLRI's next hop, as FRR sends its own. A peer's link-local next
hop is taken alone, twice, or after "::" (BIRD), and resolved in the
kernel on the interface of the session rather than by looking fe80::/64
up, which every interface has.

The lab has a new phase, linklocal: sessions with FRR (by interface, with
radvd beside the daemon) and BIRD over a LAN with link-local addresses
only, both families, pings across, the interface down and up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 18:24:13 +02:00
1 parent 92e013ca01
commit fd2248feb5
38 files changed
+975 -151

No files matched your search

+7 -4
View File
@@ -30,7 +30,7 @@ use crate::netlink::{
/// "Input/output error": for what should not happen.
const EIO: i32 = 5;
use crate::plan::{Op, Plan, Target, Unresolved, resolve};
use crate::plan::{Neighbor, Op, Plan, Target, Unresolved, resolve};
use crate::{Fib, FibConfig, FibError, InterfaceStatus};
/// How many best routes are taken from the routing table at a time.
@@ -305,7 +305,7 @@ async fn write(
}
/// What a next hop is resolved for: see [`Task::resolved`].
type Resolving = (Option<NlriNextHop>, IpVersion, Option<IpAddr>);
type Resolving = (Option<NlriNextHop>, IpVersion, Option<Neighbor>);
/// A change to the best routes, as taken out of the routing table to be
/// dealt with once the table is let go of.
@@ -689,10 +689,13 @@ impl Task {
next_hop,
Some(NlriNextHop::Ipv6(_) | NlriNextHop::Ipv6WithLl { .. })
);
let peer: Option<IpAddr> = needs_peer
let peer: Option<Neighbor> = needs_peer
.then(|| self.rib.peer(peer))
.flatten()
.map(|peer| peer.address);
.map(|peer| Neighbor {
address: peer.address,
interface: peer.interface,
});
let interfaces = &self.interfaces;
let target = *self
.resolved
+129 -12
View File
@@ -60,8 +60,27 @@ fn is_link_local(address: &Ipv6Addr) -> bool {
address.segments()[0] & 0xffc0 == 0xfe80
}
/// The peer a route was learned from, as far as its next hop is concerned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Neighbor {
pub address: IpAddr,
/// The interface the session with the peer is on, where the peer's
/// address is a link-local one and says nothing without it; zero
/// otherwise.
pub interface: u32,
}
impl From<IpAddr> for Neighbor {
fn from(address: IpAddr) -> Self {
Self {
address,
interface: 0,
}
}
}
/// Works out where the kernel is to send packets for a route to `prefix`
/// with the next hop `next_hop`, learned from the peer at `peer`.
/// with the next hop `next_hop`, learned from `peer`.
///
/// Only a next hop that is a neighbor on one of our interfaces is
/// resolved. A link-local next hop means something only on one link, and
@@ -70,7 +89,7 @@ pub fn resolve(
interfaces: &Interfaces,
prefix: &IpPrefix,
next_hop: Option<&NlriNextHop>,
peer: Option<IpAddr>,
peer: Option<Neighbor>,
) -> Result<Target, Unresolved> {
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
let version = match next_hop {
@@ -97,12 +116,29 @@ pub fn resolve(
};
// The interface the peer is a neighbor on, for a link-local gateway.
let on_peers_link = |gateway: Ipv6Addr| {
peer.and_then(|peer| interfaces.connected(peer))
.map(|connected| Target {
gateway: gateway.into(),
interface: connected.interface,
})
.ok_or(Unresolved::NotConnected)
let interface = match peer.ok_or(Unresolved::NotConnected)? {
// The session is on a link-local address itself, and so says
// which link: every interface has fe80::/64, and looking the
// peer up there would find any of them.
Neighbor { interface, .. } if interface != 0 => interfaces
.link(interface)
.filter(|link| link.up)
.map(|link| link.index),
Neighbor { address, .. } => interfaces
.connected(address)
.map(|connected| connected.interface),
}
.ok_or(Unresolved::NotConnected)?;
if interfaces
.addresses_of(interface)
.any(|ours| ours.local == IpAddr::V6(gateway))
{
return Err(Unresolved::Ourselves);
}
Ok(Target {
gateway: gateway.into(),
interface,
})
};
match *next_hop {
NlriNextHop::Ipv4(gateway) => direct(gateway.into()),
@@ -110,6 +146,13 @@ pub fn resolve(
NlriNextHop::Ipv6(gateway) => direct(gateway.into()),
// The global address if it is a neighbor; else the link-local one,
// which is there for exactly that case (RFC 2545 §3).
// Some put the link-local address, or nothing, where the global
// one goes, having no global one to give.
NlriNextHop::Ipv6WithLl { global, link_local }
if global.is_unspecified() || is_link_local(&global) =>
{
on_peers_link(link_local)
}
NlriNextHop::Ipv6WithLl { global, link_local } => match direct(global.into()) {
Err(Unresolved::NotConnected) => on_peers_link(link_local),
resolved => resolved,
@@ -405,12 +448,16 @@ mod tests {
interfaces
}
fn neighbor(address: &str) -> Neighbor {
address.parse::<IpAddr>().unwrap().into()
}
#[test]
fn a_next_hop_resolves_if_it_is_a_neighbor() {
let interfaces = interfaces();
let v4 = prefix("203.0.113.0/24");
let v6 = prefix("2001:db8:100::/48");
let peer = Some("192.0.2.2".parse().unwrap());
let peer = Some(neighbor("192.0.2.2"));
let v4_hop = |s: &str| NlriNextHop::Ipv4(s.parse().unwrap());
let v6_hop = |s: &str| NlriNextHop::Ipv6(s.parse().unwrap());
@@ -452,7 +499,7 @@ mod tests {
global: "2001:db8:ffff::2".parse().unwrap(),
link_local: "fe80::2".parse().unwrap(),
}),
Some("2001:db8::2".parse().unwrap()),
Some(neighbor("2001:db8::2")),
),
Ok(target("fe80::2", 2))
);
@@ -462,7 +509,7 @@ mod tests {
fn a_link_local_next_hop_is_on_the_peers_link() {
let interfaces = interfaces();
let v6 = prefix("2001:db8:100::/48");
let peer = Some("2001:db8::2".parse().unwrap());
let peer = Some(neighbor("2001:db8::2"));
let link_local: Ipv6Addr = "fe80::2".parse().unwrap();
let both = |global: &str| NlriNextHop::Ipv6WithLl {
global: global.parse().unwrap(),
@@ -483,7 +530,7 @@ mod tests {
Ok(target("fe80::2", 2))
);
// A peer that is not a neighbor says nothing about the link.
let far = Some("2001:db8:ffff::9".parse().unwrap());
let far = Some(neighbor("2001:db8:ffff::9"));
assert_eq!(
resolve(&interfaces, &v6, Some(&NlriNextHop::Ipv6(link_local)), far),
Err(Unresolved::NotConnected)
@@ -494,6 +541,76 @@ mod tests {
);
}
/// A session on link-local addresses: which link is known from the
/// session and from nothing else, every interface having fe80::/64.
#[test]
fn a_link_local_peer_is_on_the_interface_of_its_session() {
let mut interfaces = interfaces();
for (index, local) in [(2, "fe80::a"), (3, "fe80::b")] {
interfaces.apply(&Message::Address {
present: true,
address: Address {
interface: index,
local: local.parse().unwrap(),
network: prefix("fe80::/64"),
},
});
}
let v4 = prefix("203.0.113.0/24");
let v6 = prefix("2001:db8:100::/48");
let link_local: Ipv6Addr = "fe80::2".parse().unwrap();
let on = |interface| {
Some(Neighbor {
address: link_local.into(),
interface,
})
};
// However the peer says it: the address alone, or twice, or after
// nothing where a global one would go.
for next_hop in [
NlriNextHop::Ipv6(link_local),
NlriNextHop::Ipv6WithLl {
global: link_local,
link_local,
},
NlriNextHop::Ipv6WithLl {
global: Ipv6Addr::UNSPECIFIED,
link_local,
},
] {
for interface in [2, 3] {
for route in [&v4, &v6] {
assert_eq!(
resolve(&interfaces, route, Some(&next_hop), on(interface)),
Ok(target("fe80::2", interface)),
"{next_hop:?} on {interface}"
);
}
}
// An interface that is not there, or is down.
assert_eq!(
resolve(&interfaces, &v6, Some(&next_hop), on(9)),
Err(Unresolved::NotConnected)
);
}
// Our own address on that link, which on another is a neighbor's.
let ours = NlriNextHop::Ipv6("fe80::a".parse().unwrap());
assert_eq!(
resolve(&interfaces, &v6, Some(&ours), on(2)),
Err(Unresolved::Ourselves)
);
assert_eq!(
resolve(&interfaces, &v6, Some(&ours), on(3)),
Ok(target("fe80::a", 3))
);
// A global next hop on such a session is looked up as any other.
let global = NlriNextHop::Ipv6("2001:db8::2".parse().unwrap());
assert_eq!(
resolve(&interfaces, &v6, Some(&global), on(3)),
Ok(target("2001:db8::2", 2))
);
}
#[test]
fn the_kernel_is_asked_only_for_what_changes() {
let mut plan = Plan::default();
+2
View File
@@ -120,6 +120,7 @@ impl Machine {
asn: 65000 + u32::from(host),
router_id: self.neighbor(host),
address: self.neighbor(host).into(),
interface: 0,
ibgp: false,
})
.unwrap()
@@ -616,6 +617,7 @@ async fn an_ipv4_route_goes_by_way_of_an_ipv6_neighbor() {
asn: 65002,
router_id: machine.neighbor(2),
address: neighbor,
interface: 0,
ibgp: false,
})
.unwrap();