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>
1328 lines
48 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|