Files
ferrix/crates/fib/src/plan.rs
T
Rayhaan JaufeerallyandClaude Fable 5.1 1bc71aaac5 fib, bench: profile the daemon taking in a full table; count blackholes instead of scanning
Before this change there was no way to see where the daemon's CPU time
goes while it receives a full Internet routing table: the benchmarks in
crates/bench time the routing table in-process, without the sessions,
the policy, the kernel task or the production allocator. Profiling the
daemon itself showed that a third of its CPU time during a full-table
load went into `Plan::blackholes()`, which walked every installed route
to count the blackhole ones each time the kernel task published its
status, after every batch of changes; with 1.2 million routes
installed that was 3.9 seconds from the first route to a synced kernel
instead of 2.0.

This change:

* Keeps counts of blackhole routes and of routes kept unconfirmed from
  an earlier run in `Plan`, in step with the map through `Plan::put`
  and `Plan::take`, so that `Plan::blackholes()` and `Plan::kept()`
  cost nothing. `kept()` had the same scan; it ran only while routes of
  an earlier run were being kept, which is exactly during a full-table
  load after a restart.
* Adds `scripts/profile/full-table.sh`, which builds the daemon with the
  new `profiling` Cargo profile (release with line tables) and frame
  pointers, runs it in a network namespace joined by a veth pair to a
  second namespace, feeds it the synthetic table from there as one or
  more neighbors, samples it with `perf`, and writes a flame graph
  (inferno), flat profiles by self and inclusive time, the time until
  the routes are in the table and in the kernel, and the daemon's
  resident memory. `PEERS`, `KERNEL`, `SCALE_DOWN`, `HOLD_SECS`, `FREQ`
  and `OUT` control it. From macOS it runs in the Linux VM through
  scripts/linux-vm.sh.
* Adds the load generator `crates/bench/examples/feed.rs`: it connects
  from the peers' addresses, completes the handshakes, writes each
  neighbor's table, drains what the daemon sends back and holds the
  sessions open with KEEPALIVEs. `TestPeer::into_stream` gives it the
  connection after the handshake.
* Updates the testing guide: the current `full_table` numbers, a
  "Profiling" section, and the daemon's numbers in the VM.

Measured in the Linux VM (8 vCPUs) with the synthetic table of 1.23
million prefixes: one neighbor's table is in the routing table after
0.7 s and in the kernel after 2.0 s (before: 3.9 s); four neighbors'
4.9 million routes are in the table after 1.1 s and the kernel is
synced after 3.0 s; 520 MB resident with one table, 680 MB with four.
Without the kernel one session takes in 2.0 million routes per second
on one core. A profile also attributed 14% of the session's time to
`Journal::tidy`; inlining its early return changed nothing in an A/B
run of the benchmark, so that was a sampling artefact and is left as it
is.

Testing: a unit test checks the counts against a scan of the entries
through adopt, best, reset, synced, release, withdrawal, refusal and
clear; the kernel test suite and the workspace tests pass; the script
was run with one and four neighbors, with and without the kernel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 00:03:43 +02:00

1328 lines
48 KiB
Rust

//! What should be in the kernel's table, what is, and what to do about the
//! difference. No I/O here: this is told what the routing table's best
//! routes are and what the kernel said, and answers with the changes to
//! ask the kernel for.
use std::net::{IpAddr, Ipv6Addr};
use bgp_packet::message::NlriNextHop;
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
use crate::netlink::KIND_UNICAST;
use crate::topology::Topology;
// Asked for every route: a quick hash matters here.
pub(crate) type HashMap<K, V> = std::collections::HashMap<K, V, foldhash::fast::RandomState>;
pub(crate) type HashSet<K> = std::collections::HashSet<K, foldhash::fast::RandomState>;
/// Where the kernel is to send packets for a prefix.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Target {
/// To a gateway, out of an interface.
Via {
gateway: IpAddr,
/// The index of the interface the gateway is on.
interface: u32,
},
/// Nowhere: the packets are dropped. For a route that asks for that
/// with the BLACKHOLE community (RFC 7999).
Discard,
}
/// Why a route's next hop is not something the kernel can be given.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Unresolved {
/// The route has no next hop.
Missing,
/// An IPv6 route with an IPv4 next hop.
OtherFamily,
/// The next hop is one of this machine's own addresses.
Ourselves,
/// The next hop is not in the network of any interface that is up,
/// and none of the kernel's routes that count says the way to it
/// (see `crate::routes`).
NoRoute,
/// The only route to the next hop is the default route, which is not
/// taken as the way to a next hop unless asked for
/// (`Topology::via_default`).
OnlyDefault,
/// The route to the next hop is one that discards packets: a
/// blackhole, unreachable or prohibit route.
Discarded,
/// The route is for a network this machine is on itself, or for part
/// of one. The kernel reaches that directly; a route from a peer that
/// said otherwise would send the neighbors' traffic, and the sessions
/// with them, wherever the peer liked.
OwnNetwork,
}
impl Unresolved {
pub const ALL: [Self; 7] = [
Self::Missing,
Self::OtherFamily,
Self::Ourselves,
Self::NoRoute,
Self::OnlyDefault,
Self::Discarded,
Self::OwnNetwork,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Missing => "no next hop",
Self::OtherFamily => "IPv4 next hop for an IPv6 route",
Self::Ourselves => "next hop is this machine",
Self::NoRoute => "no route to the next hop",
Self::OnlyDefault => "next hop reachable only by the default route",
Self::Discarded => "the route to the next hop discards packets",
Self::OwnNetwork => "inside a network of this machine",
}
}
}
// A reason's place in `ALL` is its number as declared, which is what
// [`Plan`] counts them by.
const _: () = {
let mut at = 0;
while at < Unresolved::ALL.len() {
assert!(Unresolved::ALL[at] as usize == at);
at += 1;
}
};
/// 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 `peer`. `blackhole` says
/// the route asks for its packets to be dropped (the BLACKHOLE community,
/// RFC 7999): then they go nowhere, and the next hop is not looked at.
///
/// A next hop that is a neighbor on one of our interfaces is where the
/// packets go. One that is not is looked up in the kernel's own routes,
/// and the packets go where the route that covers it sends them: to its
/// gateway, or onto its interface. A link-local next hop means something
/// only on one link, and that is taken to be the link the peer itself is
/// on.
pub fn resolve(
topology: &Topology,
prefix: &IpPrefix,
next_hop: Option<&NlriNextHop>,
peer: Option<Neighbor>,
blackhole: bool,
) -> Result<Target, Unresolved> {
if topology.interfaces.has_network_around(prefix) {
return Err(Unresolved::OwnNetwork);
}
if blackhole {
return Ok(Target::Discard);
}
resolve_next_hop(topology, prefix.address_family(), next_hop, peer)
}
/// The way to `gateway`, which is not a neighbor, by the kernel's routes
/// that count: where the route that covers it sends packets. One step
/// only: the kernel sees to it that a route's own gateway is a neighbor.
fn through_routes(
topology: &Topology,
routes: IpVersion,
gateway: IpAddr,
) -> Result<Target, Unresolved> {
let route = topology.routes.lookup(gateway).ok_or(Unresolved::NoRoute)?;
if route.prefix.length() == 0 && !topology.via_default {
return Err(Unresolved::OnlyDefault);
}
if route.kind != KIND_UNICAST {
return Err(Unresolved::Discarded);
}
let (hop, interface) = match route.nexthop {
Some(id) => {
// A group of nexthops has no gateway or interface of its own;
// which of its members packets take is the kernel's to say,
// and nothing to give it as a route.
let nexthop = topology.nexthops.get(&id).ok_or(Unresolved::NoRoute)?;
(
nexthop.gateway,
nexthop.interface.ok_or(Unresolved::NoRoute)?,
)
}
None => (route.gateway, route.interface.ok_or(Unresolved::NoRoute)?),
};
if !topology
.interfaces
.link(interface)
.is_some_and(|link| link.up)
{
return Err(Unresolved::NoRoute);
}
// A route without a gateway puts the destination on its interface:
// the next hop itself is the neighbor there.
let gateway = hop.unwrap_or(gateway);
// An IPv4 route may go by an IPv6 gateway (RFC 8950, and the kernel
// allows it); an IPv6 route by an IPv4 gateway is nothing.
if routes == IpVersion::Ipv6 && gateway.is_ipv4() {
return Err(Unresolved::NoRoute);
}
Ok(Target::Via { gateway, interface })
}
/// The part of [`resolve`] that is the same for every route of IP version
/// `routes` with one next hop from one peer: where the next hop is. For
/// those who ask it of many such routes, and ask of each only whether it
/// is for one of the machine's own networks.
pub fn resolve_next_hop(
topology: &Topology,
routes: IpVersion,
next_hop: Option<&NlriNextHop>,
peer: Option<Neighbor>,
) -> Result<Target, Unresolved> {
let interfaces = &topology.interfaces;
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
let version = match next_hop {
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
NlriNextHop::Ipv6(_) | NlriNextHop::Ipv6WithLl { .. } => IpVersion::Ipv6,
};
// 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 != routes && version == IpVersion::Ipv4 {
return Err(Unresolved::OtherFamily);
}
let direct = |gateway: IpAddr| {
if interfaces.is_local(gateway) {
return Err(Unresolved::Ourselves);
}
match interfaces.connected(gateway) {
Some(connected) => Ok(Target::Via {
gateway,
interface: connected.interface,
}),
None => through_routes(topology, routes, gateway),
}
};
// The interface the peer is a neighbor on, for a link-local gateway.
let on_peers_link = |gateway: Ipv6Addr| {
let interface = match peer.ok_or(Unresolved::NoRoute)? {
// 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::NoRoute)?;
if interfaces
.addresses_of(interface)
.any(|ours| ours.local == IpAddr::V6(gateway))
{
return Err(Unresolved::Ourselves);
}
Ok(Target::Via {
gateway: gateway.into(),
interface,
})
};
match *next_hop {
NlriNextHop::Ipv4(gateway) => direct(gateway.into()),
NlriNextHop::Ipv6(gateway) if gateway.is_unicast_link_local() => on_peers_link(gateway),
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() || global.is_unicast_link_local() =>
{
on_peers_link(link_local)
}
// Where there is no way to the global one, the link-local one is
// tried; a way that is there and is bad (ourselves, a blackhole)
// is an answer as it stands.
NlriNextHop::Ipv6WithLl { global, link_local } => match direct(global.into()) {
Err(Unresolved::NoRoute | Unresolved::OnlyDefault) => on_peers_link(link_local),
resolved => resolved,
},
}
}
/// A change to ask the kernel for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Op {
/// Put this route there, in place of ours for the prefix if there is
/// one.
Install {
prefix: IpPrefix,
target: Target,
},
Remove {
prefix: IpPrefix,
},
}
impl Op {
pub fn prefix(&self) -> &IpPrefix {
match self {
Self::Install { prefix, .. } | Self::Remove { prefix } => prefix,
}
}
}
struct Entry {
/// What the kernel has; `None` for a route found there at startup that
/// is not of a shape we make.
target: Option<Target>,
/// Whether the prefix was heard of since the last reset.
confirmed: bool,
/// Whether the route is one an earlier run left that is being kept
/// for now though not confirmed: see [`Plan::adopt`].
kept: bool,
}
/// The routes of ours in the kernel, kept in step with the best routes.
///
/// It follows what a reader of the routing table is given: the whole
/// table between a reset and its completion, and changes after. Whatever
/// is in the kernel and was not confirmed by the end of a whole table is
/// removed: that is how routes left by an earlier run, or made stale by a
/// change of interfaces, are cleared out.
#[derive(Default)]
pub struct Plan {
/// What the kernel has been asked to have, and has not refused.
installed: HashMap<IpPrefix, Entry>,
/// Prefixes whose best route is not installed, and why.
unresolved: HashMap<IpPrefix, Unresolved>,
/// How many of them for each reason, in the order of
/// [`Unresolved::ALL`].
unresolved_counts: [usize; Unresolved::ALL.len()],
/// Prefixes whose route the kernel refused.
refused: HashSet<IpPrefix>,
/// How many entries of `installed` are blackhole routes, and how many
/// are kept from an earlier run without having been confirmed. Kept in
/// step with the map by [`Plan::put`] and [`Plan::take`], so that
/// asking costs nothing however large the table: the kernel task asks
/// after every batch of changes, and a scan of a million entries each
/// time was a third of its work.
blackholes: usize,
kept_unconfirmed: usize,
}
impl Plan {
/// How many routes of ours the kernel has.
pub fn installed(&self) -> usize {
self.installed.len()
}
/// How many of the routes the kernel has drop their packets.
pub fn blackholes(&self) -> usize {
self.blackholes
}
/// What `entry` counts for: one if it is a blackhole route, and one if
/// it is kept from an earlier run without having been confirmed.
fn weigh(entry: &Entry) -> (usize, usize) {
(
usize::from(entry.target == Some(Target::Discard)),
usize::from(entry.kept && !entry.confirmed),
)
}
/// Puts `entry` in as the route of `prefix`, in place of what was
/// there, keeping the counts in step.
fn put(&mut self, prefix: IpPrefix, entry: Entry) {
let (blackhole, kept) = Self::weigh(&entry);
self.blackholes += blackhole;
self.kept_unconfirmed += kept;
if let Some(old) = self.installed.insert(prefix, entry) {
let (blackhole, kept) = Self::weigh(&old);
self.blackholes -= blackhole;
self.kept_unconfirmed -= kept;
}
}
/// Takes the route of `prefix` out, keeping the counts in step.
fn take(&mut self, prefix: &IpPrefix) -> Option<Entry> {
let old = self.installed.remove(prefix)?;
let (blackhole, kept) = Self::weigh(&old);
self.blackholes -= blackhole;
self.kept_unconfirmed -= kept;
Some(old)
}
/// How many prefixes have a best route that could not be given to the
/// kernel for want of a usable next hop.
pub fn unresolved(&self) -> usize {
self.unresolved.len()
}
/// The same, by reason, leaving out the reasons there are none of.
pub fn unresolved_by_reason(&self) -> impl Iterator<Item = (Unresolved, usize)> {
Unresolved::ALL
.into_iter()
.zip(self.unresolved_counts)
.filter(|(_, count)| *count > 0)
}
/// Notes why the best route of `prefix` is not installed, or with
/// `None` that this is no longer so.
fn set_unresolved(&mut self, prefix: IpPrefix, reason: Option<Unresolved>) {
let before = match reason {
Some(reason) => self.unresolved.insert(prefix, reason),
None => self.unresolved.remove(&prefix),
};
if let Some(before) = before {
self.unresolved_counts[before as usize] -= 1;
}
if let Some(reason) = reason {
self.unresolved_counts[reason as usize] += 1;
}
}
/// Forgets which prefixes are unresolved or refused, and why.
fn forget_unresolved_and_refused(&mut self) {
self.unresolved.clear();
self.unresolved_counts = [0; Unresolved::ALL.len()];
self.refused.clear();
}
/// How many prefixes have a best route that the kernel refused.
pub fn refused(&self) -> usize {
self.refused.len()
}
/// Notes a route of ours that the kernel already has. It stays only if
/// the routing table turns out to have the same.
///
/// With `keep` it is not removed for being absent from a whole table,
/// only by [`Plan::release`]. That is for the routes an earlier run
/// left: when the daemon starts, its table is empty until its peers
/// have sent theirs again, and to remove everything meanwhile would
/// stop the machine forwarding for as long as that takes.
pub fn adopt(&mut self, prefix: IpPrefix, target: Option<Target>, keep: bool) {
self.put(
prefix,
Entry {
target,
confirmed: false,
kept: keep,
},
);
}
/// How many routes are being kept from an earlier run without the
/// routing table having confirmed them.
pub fn kept(&self) -> usize {
self.kept_unconfirmed
}
/// The time for keeping unconfirmed routes of an earlier run is over:
/// those the routing table still does not have go.
pub fn release(&mut self) -> Vec<Op> {
let mut stale = Vec::new();
let mut blackholes_gone = 0;
self.installed.retain(|prefix, entry| {
let goes = entry.kept && !entry.confirmed;
entry.kept = false;
if goes {
stale.push(Op::Remove { prefix: *prefix });
blackholes_gone += usize::from(entry.target == Some(Target::Discard));
}
!goes
});
// What stays was confirmed, and is now not kept either.
self.kept_unconfirmed = 0;
self.blackholes -= blackholes_gone;
stale
}
/// The whole table follows: nothing is known to be wanted until it is
/// heard of again.
pub fn reset(&mut self) {
self.kept_unconfirmed = 0;
for entry in self.installed.values_mut() {
entry.confirmed = false;
self.kept_unconfirmed += usize::from(entry.kept);
}
self.forget_unresolved_and_refused();
}
/// The best route for `prefix` is now one that resolves as `target`.
pub fn best(&mut self, prefix: IpPrefix, target: Result<Target, Unresolved>) -> Option<Op> {
// Nearly always there is nothing to forget.
if !self.refused.is_empty() {
self.refused.remove(&prefix);
}
match target {
Ok(target) => {
if !self.unresolved.is_empty() {
self.set_unresolved(prefix, None);
}
let entry = self.installed.entry(prefix).or_insert(Entry {
target: None,
confirmed: true,
kept: false,
});
let before = Self::weigh(entry);
entry.confirmed = true;
let op = if entry.target == Some(target) {
None
} else {
entry.target = Some(target);
Some(Op::Install { prefix, target })
};
let after = Self::weigh(entry);
self.blackholes += after.0;
self.blackholes -= before.0;
self.kept_unconfirmed += after.1;
self.kept_unconfirmed -= before.1;
op
}
Err(reason) => {
self.set_unresolved(prefix, Some(reason));
// Packets must not go on following a route that is no
// longer the best.
self.take(&prefix).map(|_| Op::Remove { prefix })
}
}
}
/// `prefix` has no route any more.
pub fn withdrawn(&mut self, prefix: IpPrefix) -> Option<Op> {
self.set_unresolved(prefix, None);
self.refused.remove(&prefix);
self.take(&prefix).map(|_| Op::Remove { prefix })
}
/// The whole table has been given: what was not in it goes.
pub fn synced(&mut self) -> Vec<Op> {
let stale: Vec<IpPrefix> = self
.installed
.iter()
.filter(|(_, entry)| !entry.confirmed && !entry.kept)
.map(|(prefix, _)| *prefix)
.collect();
stale
.into_iter()
.map(|prefix| {
self.take(&prefix);
Op::Remove { prefix }
})
.collect()
}
/// The kernel refused `op`. Returns what to ask of it instead, if
/// anything.
pub fn refused_by_kernel(&mut self, op: &Op) -> Option<Op> {
match op {
Op::Install { prefix, target } => {
// If the plan has moved on since, this is old news.
if self.installed.get(prefix)?.target != Some(*target) {
return None;
}
self.take(prefix);
self.refused.insert(*prefix);
// What the kernel refused to replace, it still has.
Some(Op::Remove { prefix: *prefix })
}
// It was not there to remove: as wanted.
Op::Remove { .. } => None,
}
}
/// Everything goes: the changes that empty the kernel of our routes.
pub fn clear(&mut self) -> Vec<Op> {
self.forget_unresolved_and_refused();
self.blackholes = 0;
self.kept_unconfirmed = 0;
self.installed
.drain()
.map(|(prefix, _)| Op::Remove { prefix })
.collect()
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
use crate::netlink::{Address, Link, Message, Nexthop, PROTOCOL_BGP, Route, TABLE_MAIN};
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
fn target(gateway: &str, interface: u32) -> Target {
Target::Via {
gateway: gateway.parse().unwrap(),
interface,
}
}
fn interfaces() -> Topology {
let mut topology = Topology::new(TABLE_MAIN, false);
for (index, local, network) in [
(2, "192.0.2.1", "192.0.2.0/24"),
(2, "2001:db8::1", "2001:db8::/64"),
(3, "198.51.100.1", "198.51.100.0/24"),
] {
topology.apply(&Message::Link {
present: true,
link: Link {
index,
name: format!("eth{index}"),
up: true,
},
});
topology.apply(&Message::Address {
present: true,
address: Address {
interface: index,
local: local.parse().unwrap(),
network: prefix(network),
},
});
}
topology
}
/// A route of the kernel's main table, as a static route is.
fn kernel_route(prefix_text: &str, gateway: Option<&str>, interface: Option<u32>) -> Route {
Route {
prefix: prefix(prefix_text),
table: TABLE_MAIN,
protocol: 4,
metric: 0,
gateway: gateway.map(|g| g.parse().unwrap()),
interface,
nexthop: None,
kind: KIND_UNICAST,
}
}
fn with_route(topology: &mut Topology, route: Route) {
assert!(topology.apply(&Message::Route {
present: true,
route,
}));
}
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(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());
assert_eq!(
resolve(&interfaces, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
Ok(target("192.0.2.2", 2))
);
// A neighbor other than the peer: a route server's routes.
assert_eq!(
resolve(&interfaces, &v4, Some(&v4_hop("198.51.100.9")), peer, false),
Ok(target("198.51.100.9", 3))
);
assert_eq!(
resolve(&interfaces, &v6, Some(&v6_hop("2001:db8::2")), peer, false),
Ok(target("2001:db8::2", 2))
);
for (hop, why) in [
(Some(v4_hop("10.9.9.9")), Unresolved::NoRoute),
(Some(v4_hop("192.0.2.1")), Unresolved::Ourselves),
(None, Unresolved::Missing),
] {
assert_eq!(
resolve(&interfaces, &v4, hop.as_ref(), peer, false),
Err(why)
);
}
assert_eq!(
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer, false),
Err(Unresolved::OtherFamily)
);
// A route for part of a network we are on is not for the kernel,
// however good its next hop; one for more than the network is.
for ours in [
"192.0.2.0/24",
"192.0.2.128/25",
"192.0.2.7/32",
"2001:db8::/64",
] {
let ours = prefix(ours);
let hop = match ours.address_family() {
IpVersion::Ipv4 => v4_hop("192.0.2.2"),
IpVersion::Ipv6 => v6_hop("2001:db8::2"),
};
assert_eq!(
resolve(&interfaces, &ours, Some(&hop), peer, false),
Err(Unresolved::OwnNetwork),
"{ours}"
);
}
assert_eq!(
resolve(
&interfaces,
&prefix("192.0.0.0/22"),
Some(&v4_hop("192.0.2.2")),
peer,
false
),
Ok(target("192.0.2.2", 2))
);
// 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, false),
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(neighbor("2001:db8::2")),
false
),
Ok(target("fe80::2", 2))
);
}
#[test]
fn a_link_local_next_hop_is_on_the_peers_link() {
let interfaces = interfaces();
let v6 = prefix("2001:db8:100::/48");
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(),
link_local,
};
// The global address is a neighbor: it is used.
assert_eq!(
resolve(&interfaces, &v6, Some(&both("2001:db8::2")), peer, false),
Ok(target("2001:db8::2", 2))
);
// It is not: the link-local one, on the interface the peer is on.
assert_eq!(
resolve(
&interfaces,
&v6,
Some(&both("2001:db8:ffff::2")),
peer,
false
),
Ok(target("fe80::2", 2))
);
assert_eq!(
resolve(
&interfaces,
&v6,
Some(&NlriNextHop::Ipv6(link_local)),
peer,
false
),
Ok(target("fe80::2", 2))
);
// A peer that is not a neighbor says nothing about the link.
let far = Some(neighbor("2001:db8:ffff::9"));
assert_eq!(
resolve(
&interfaces,
&v6,
Some(&NlriNextHop::Ipv6(link_local)),
far,
false
),
Err(Unresolved::NoRoute)
);
assert_eq!(
resolve(&interfaces, &v6, Some(&both("2001:db8::1")), peer, false),
Err(Unresolved::Ourselves)
);
}
/// 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), false),
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), false),
Err(Unresolved::NoRoute)
);
}
// 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), false),
Err(Unresolved::Ourselves)
);
assert_eq!(
resolve(&interfaces, &v6, Some(&ours), on(3), false),
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), false),
Ok(target("2001:db8::2", 2))
);
}
/// A next hop that is no neighbor is reached the way the kernel's
/// own routes say, and the route goes into the kernel with that way's
/// gateway and interface.
#[test]
fn a_far_next_hop_resolves_by_the_kernels_routes() {
let mut topology = interfaces();
let v4 = prefix("203.0.113.0/24");
let v6 = prefix("2001:db8:100::/48");
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());
let far = v4_hop("10.9.9.9");
// Nothing says the way.
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Err(Unresolved::NoRoute)
);
// The default route does not count unless asked for.
with_route(
&mut topology,
kernel_route("0.0.0.0/0", Some("192.0.2.254"), Some(2)),
);
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Err(Unresolved::OnlyDefault)
);
topology.via_default = true;
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Ok(target("192.0.2.254", 2))
);
topology.via_default = false;
// A static route with a gateway: the gateway is where packets go.
with_route(
&mut topology,
kernel_route("10.9.0.0/16", Some("198.51.100.9"), Some(3)),
);
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Ok(target("198.51.100.9", 3))
);
// A more specific route onto an interface, as a tunnel's is: the
// next hop itself is the neighbor there.
topology.apply(&Message::Link {
present: true,
link: Link {
index: 7,
name: "wg0".into(),
up: true,
},
});
with_route(&mut topology, kernel_route("10.9.9.0/24", None, Some(7)));
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Ok(target("10.9.9.9", 7))
);
// The tunnel goes down: the kernel keeps such routes, we do not
// use them.
topology.apply(&Message::Link {
present: true,
link: Link {
index: 7,
name: "wg0".into(),
up: false,
},
});
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Err(Unresolved::NoRoute)
);
// A route that names a nexthop object goes where the object says.
with_route(
&mut topology,
Route {
nexthop: Some(77),
..kernel_route("10.9.9.9/32", None, None)
},
);
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Err(Unresolved::NoRoute),
"an object the kernel has not told of"
);
topology.apply(&Message::Nexthop {
present: true,
nexthop: Nexthop {
id: 77,
protocol: 4,
gateway: Some("192.0.2.77".parse().unwrap()),
interface: Some(2),
},
});
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Ok(target("192.0.2.77", 2))
);
// A route that discards packets is a way to nowhere.
with_route(
&mut topology,
Route {
kind: 6, // blackhole
..kernel_route("10.9.9.9/32", None, None)
},
);
assert_eq!(
resolve(&topology, &v4, Some(&far), peer, false),
Err(Unresolved::Discarded)
);
// Routes of BGP's never say the way, be the prefix ever so long.
assert!(!topology.apply(&Message::Route {
present: true,
route: Route {
protocol: PROTOCOL_BGP,
metric: 20,
..kernel_route("10.9.9.9/32", Some("198.51.100.9"), Some(3))
},
}));
// An IPv6 next hop by an IPv4 gateway is no way for an IPv6 route,
// but is one for an IPv4 route (RFC 8950 the other way about is
// what the kernel allows).
with_route(
&mut topology,
kernel_route("2001:db8:9::/48", Some("198.51.100.9"), Some(3)),
);
assert_eq!(
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
Err(Unresolved::NoRoute)
);
with_route(
&mut topology,
kernel_route("2001:db8:9::/48", Some("2001:db8::9"), Some(2)),
);
assert_eq!(
resolve(&topology, &v6, Some(&v6_hop("2001:db8:9::9")), peer, false),
Ok(target("2001:db8::9", 2))
);
assert_eq!(
resolve(&topology, &v4, Some(&v6_hop("2001:db8:9::9")), peer, false),
Ok(target("2001:db8::9", 2))
);
// A neighbor is a neighbor, whatever the routes say.
with_route(
&mut topology,
kernel_route("192.0.2.2/32", Some("198.51.100.9"), Some(3)),
);
assert_eq!(
resolve(&topology, &v4, Some(&v4_hop("192.0.2.2")), peer, false),
Ok(target("192.0.2.2", 2))
);
}
/// A route that asks for its packets to be dropped goes nowhere,
/// whatever its next hop; one for a network of ours does not.
#[test]
fn a_blackhole_route_resolves_to_nowhere() {
let topology = interfaces();
let far = NlriNextHop::Ipv4("10.9.9.9".parse().unwrap());
let peer = Some(neighbor("192.0.2.2"));
assert_eq!(
resolve(
&topology,
&prefix("203.0.113.9/32"),
Some(&far),
peer,
false
),
Err(Unresolved::NoRoute)
);
assert_eq!(
resolve(&topology, &prefix("203.0.113.9/32"), Some(&far), peer, true),
Ok(Target::Discard)
);
assert_eq!(
resolve(&topology, &prefix("203.0.113.9/32"), None, peer, true),
Ok(Target::Discard)
);
assert_eq!(
resolve(&topology, &prefix("192.0.2.9/32"), Some(&far), peer, true),
Err(Unresolved::OwnNetwork)
);
let mut plan = Plan::default();
assert_eq!(
plan.best(prefix("203.0.113.9/32"), Ok(Target::Discard)),
Some(Op::Install {
prefix: prefix("203.0.113.9/32"),
target: Target::Discard
})
);
assert_eq!((plan.installed(), plan.blackholes()), (1, 1));
}
#[test]
fn the_kernel_is_asked_only_for_what_changes() {
let mut plan = Plan::default();
let p = prefix("203.0.113.0/24");
let (a, b) = (target("192.0.2.2", 2), target("192.0.2.3", 2));
assert_eq!(
plan.best(p, Ok(a)),
Some(Op::Install {
prefix: p,
target: a
})
);
// The best route changes to one with the same next hop: nothing
// for the kernel.
assert_eq!(plan.best(p, Ok(a)), None);
assert_eq!(
plan.best(p, Ok(b)),
Some(Op::Install {
prefix: p,
target: b
})
);
assert_eq!(plan.installed(), 1);
// A best route that cannot be installed takes the old one out.
assert_eq!(
plan.best(p, Err(Unresolved::NoRoute)),
Some(Op::Remove { prefix: p })
);
assert_eq!((plan.installed(), plan.unresolved()), (0, 1));
assert_eq!(
plan.unresolved_by_reason().collect::<Vec<_>>(),
[(Unresolved::NoRoute, 1)]
);
assert_eq!(plan.withdrawn(p), None);
assert_eq!(plan.unresolved_by_reason().count(), 0);
assert_eq!(plan.unresolved(), 0);
plan.best(p, Ok(a));
assert_eq!(plan.withdrawn(p), Some(Op::Remove { prefix: p }));
assert_eq!(plan.withdrawn(p), None);
}
#[test]
fn what_a_whole_table_does_not_confirm_is_removed() {
let mut plan = Plan::default();
let (kept, changed, stale, odd) = (
prefix("203.0.113.0/24"),
prefix("198.18.0.0/15"),
prefix("192.88.99.0/24"),
prefix("2001:db8:5::/48"),
);
let a = target("192.0.2.2", 2);
// Left in the kernel by an earlier run.
plan.adopt(kept, Some(a), false);
plan.adopt(changed, Some(a), false);
plan.adopt(stale, Some(a), false);
plan.adopt(odd, None, false);
assert_eq!(plan.installed(), 4);
plan.reset();
// The same as the kernel has: nothing to do.
assert_eq!(plan.best(kept, Ok(a)), None);
let b = target("192.0.2.3", 2);
assert_eq!(
plan.best(changed, Ok(b)),
Some(Op::Install {
prefix: changed,
target: b
})
);
let mut removed = plan.synced();
removed.sort_by_key(|op| *op.prefix());
let mut expected = vec![Op::Remove { prefix: stale }, Op::Remove { prefix: odd }];
expected.sort_by_key(|op| *op.prefix());
assert_eq!(removed, expected);
assert_eq!(plan.installed(), 2);
// A second completion finds nothing more.
assert_eq!(plan.synced(), []);
}
/// When the daemon starts its table is empty, and stays so until its
/// peers have sent theirs. What an earlier run left in the kernel is
/// kept meanwhile.
#[test]
fn routes_of_an_earlier_run_are_kept_until_released() {
let mut plan = Plan::default();
let (still_right, moved, gone) = (
prefix("203.0.113.0/24"),
prefix("198.18.0.0/15"),
prefix("192.88.99.0/24"),
);
let (a, b) = (target("192.0.2.2", 2), target("192.0.2.3", 2));
for p in [still_right, moved, gone] {
plan.adopt(p, Some(a), true);
}
// The first whole table is the empty one: nothing goes.
plan.reset();
assert_eq!(plan.synced(), []);
assert_eq!((plan.installed(), plan.kept()), (3, 3));
// The peers come back.
assert_eq!(plan.best(still_right, Ok(a)), None);
assert_eq!(
plan.best(moved, Ok(b)),
Some(Op::Install {
prefix: moved,
target: b
})
);
assert_eq!(plan.kept(), 1);
// Time is up: what nobody announced again goes, and only that.
assert_eq!(plan.release(), [Op::Remove { prefix: gone }]);
assert_eq!((plan.installed(), plan.kept()), (2, 0));
assert_eq!(plan.release(), []);
// From then on a whole table decides as always.
plan.reset();
plan.best(moved, Ok(b));
assert_eq!(
plan.synced(),
[Op::Remove {
prefix: still_right
}]
);
// A route that is withdrawn while kept goes at once.
let mut plan = Plan::default();
plan.adopt(gone, Some(a), true);
plan.best(gone, Ok(a));
assert_eq!(plan.withdrawn(gone), Some(Op::Remove { prefix: gone }));
}
#[test]
fn a_route_the_kernel_refuses_is_not_counted_as_there() {
let mut plan = Plan::default();
let p = prefix("203.0.113.0/24");
let (a, b) = (target("192.0.2.2", 2), target("192.0.2.3", 2));
let install = plan.best(p, Ok(a)).unwrap();
// What it refused to put there may have left an older one of ours.
assert_eq!(
plan.refused_by_kernel(&install),
Some(Op::Remove { prefix: p })
);
assert_eq!((plan.installed(), plan.refused()), (0, 1));
// Refusing to remove what is not there is as wanted.
assert_eq!(plan.refused_by_kernel(&Op::Remove { prefix: p }), None);
// The next best route is tried afresh.
assert!(plan.best(p, Ok(b)).is_some());
assert_eq!((plan.installed(), plan.refused()), (1, 0));
// News of a refusal of what has since been replaced is ignored.
assert_eq!(plan.refused_by_kernel(&install), None);
assert_eq!(plan.installed(), 1);
let mut cleared = plan.clear();
assert_eq!(cleared.pop(), Some(Op::Remove { prefix: p }));
assert_eq!(plan.installed(), 0);
}
/// What happens to a table of best routes.
#[derive(Debug, Clone)]
enum Step {
Best(u8, Option<u8>),
Withdrawn(u8),
/// A reader that starts over: the whole table again.
Resync,
}
fn any_step() -> impl Strategy<Value = Step> {
prop_oneof![
4 => (0u8..12, proptest::option::weighted(0.8, 0u8..3)).prop_map(|(p, t)| Step::Best(p, t)),
2 => (0u8..12).prop_map(Step::Withdrawn),
1 => Just(Step::Resync),
]
}
/// What the counts would be if the entries were scanned.
fn scanned(plan: &Plan) -> (usize, usize) {
plan.installed
.values()
.map(Plan::weigh)
.fold((0, 0), |(b, k), (eb, ek)| (b + eb, k + ek))
}
#[test]
fn the_counts_keep_step_with_the_routes() {
let mut plan = Plan::default();
let p = |n: u8| prefix(&format!("203.0.113.{n}/32"));
let via = target("192.0.2.2", 2);
let check = |plan: &Plan| assert_eq!((plan.blackholes(), plan.kept()), scanned(plan));
// Adopted from an earlier run: two kept, one a blackhole.
plan.adopt(p(1), Some(Target::Discard), true);
plan.adopt(p(2), Some(via), true);
plan.adopt(p(3), Some(via), false);
check(&plan);
// Heard of again, as a blackhole and as a route by way of a
// gateway; one changes kind.
plan.best(p(1), Ok(Target::Discard));
plan.best(p(2), Ok(Target::Discard));
plan.best(p(4), Ok(Target::Discard));
check(&plan);
plan.best(p(4), Ok(via));
check(&plan);
// The whole table again: everything unconfirmed, then confirmed.
plan.reset();
check(&plan);
plan.best(p(1), Ok(Target::Discard));
plan.synced();
check(&plan);
plan.reset();
plan.release();
check(&plan);
// Gone for want of a next hop, withdrawn, refused, adopted over.
plan.best(p(1), Err(Unresolved::Missing));
plan.best(p(5), Ok(Target::Discard));
plan.withdrawn(p(5));
plan.best(p(6), Ok(Target::Discard));
plan.refused_by_kernel(&Op::Install {
prefix: p(6),
target: Target::Discard,
});
plan.adopt(p(7), Some(Target::Discard), true);
plan.adopt(p(7), Some(via), true);
check(&plan);
let had = plan.installed();
assert_eq!(plan.clear().len(), had);
assert_eq!((plan.blackholes(), plan.kept()), (0, 0));
}
proptest! {
/// Whatever happens to the best routes, and whatever an earlier
/// run left behind, a kernel that does as it is asked ends up with
/// exactly the best routes that resolve.
#[test]
fn the_kernel_ends_up_with_the_best_routes_that_resolve(
left_behind in proptest::collection::vec((0u8..16, 0u8..3), 0..6),
steps in proptest::collection::vec(any_step(), 0..60)
) {
let p = |n: u8| IpPrefix::new(IpVersion::Ipv4, [10, n, 0, 0], 16).unwrap();
let t = |n: u8| target(&format!("192.0.2.{}", n + 2), 2);
let mut kernel: HashMap<IpPrefix, Target> = HashMap::default();
let mut plan = Plan::default();
for (prefix, gateway) in left_behind {
kernel.insert(p(prefix), t(gateway));
plan.adopt(p(prefix), Some(t(gateway)), false);
}
// The best routes: the prefix's next hop, if it resolves.
let mut table: HashMap<u8, Option<u8>> = HashMap::default();
let apply = |kernel: &mut HashMap<IpPrefix, Target>, op: Option<Op>| match op {
Some(Op::Install { prefix, target }) => { kernel.insert(prefix, target); }
Some(Op::Remove { prefix }) => { kernel.remove(&prefix); }
None => {}
};
let resolved = |gateway: Option<u8>| gateway.map(t).ok_or(Unresolved::NoRoute);
// A new reader is first given the whole table.
let steps = std::iter::once(Step::Resync).chain(steps);
for step in steps {
match step {
Step::Best(prefix, gateway) => {
table.insert(prefix, gateway);
apply(&mut kernel, plan.best(p(prefix), resolved(gateway)));
}
Step::Withdrawn(prefix) => {
table.remove(&prefix);
apply(&mut kernel, plan.withdrawn(p(prefix)));
}
Step::Resync => {
plan.reset();
for (prefix, gateway) in &table {
apply(&mut kernel, plan.best(p(*prefix), resolved(*gateway)));
}
for op in plan.synced() {
apply(&mut kernel, Some(op));
}
}
}
let wanted: HashMap<IpPrefix, Target> = table
.iter()
.filter_map(|(prefix, gateway)| Some((p(*prefix), t((*gateway)?))))
.collect();
prop_assert_eq!(&kernel, &wanted);
prop_assert_eq!(plan.installed(), wanted.len());
prop_assert_eq!(plan.unresolved(), table.len() - wanted.len());
prop_assert_eq!(
plan.unresolved_by_reason().map(|(_, count)| count).sum::<usize>(),
plan.unresolved()
);
}
}
}
}