Harden import, the kernel task and the RTR client after review

Three independent reviews (hijack resistance, reliability, code health)
of the whole tree. This is what they found that could let a bad route in
or take the daemon down.

Hijacks:
- A route had two paths on a two-octet session, AS_PATH and AS4_PATH,
  read in two places by two rules. A peer could make the origin checked
  on import differ from the one checked at revalidation, and get an
  RPKI-invalid route into the table. AS4_PATH is now merged into AS_PATH
  once, on receipt (RFC 6793 §4.2.3), and nothing else knows of it.
- The next hop is judged on import: it must be usable, agreed on
  (RFC 8950), and by default the peer itself.
- Our own originated prefixes are not taken from other networks.
- A route for a network the machine is on is never put in the kernel.
- Paths with an AS_SET are refused (RFC 9774).
- A configured peer gets one session, not one per BGP Identifier.
- More IPv6 special-purpose prefixes are bogons.

Reliability:
- A batch of requests the kernel refused could overflow the netlink
  socket and end the daemon. The socket has room now, a lost answer or a
  silent kernel means asking what is there, and neither is fatal.
- Working the routes out again within the grace period after a restart
  no longer drops the routes being kept.
- That work can no longer be put off for ever by interface changes.
- Peers whose sessions keep failing are slowed down.
- Unused attribute sets are swept when they pile up, not only each
  minute.
- Log lines a peer can cause are said once in a while.
- An RTR answer is bounded in size and time; data cannot expire between
  two refreshes.
- The shard of a key is taken from a stirred hash: neighbouring bits of
  the hash proved alike for prefixes in a row. The test that showed it,
  failing one run in sixteen, is deterministic now.

The lab has a phase, hijacks, in which FRR tries the first three.
route::apply is a struct with methods instead of nested closures, and
the kernel task's loop is shorter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 19:13:40 +02:00
1 parent fd2248feb5
commit 9ca86ede2f
34 files changed
+1696 -470

No files matched your search

+42 -4
View File
@@ -36,14 +36,20 @@ pub enum Unresolved {
/// Finding the way to such a next hop through other routes is not
/// done.
NotConnected,
/// 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; 4] = [
pub const ALL: [Self; 5] = [
Self::Missing,
Self::OtherFamily,
Self::Ourselves,
Self::NotConnected,
Self::OwnNetwork,
];
pub fn as_str(self) -> &'static str {
@@ -52,6 +58,7 @@ impl Unresolved {
Self::OtherFamily => "IPv4 next hop for an IPv6 route",
Self::Ourselves => "next hop is this machine",
Self::NotConnected => "next hop not directly connected",
Self::OwnNetwork => "inside a network of this machine",
}
}
}
@@ -91,6 +98,9 @@ pub fn resolve(
next_hop: Option<&NlriNextHop>,
peer: Option<Neighbor>,
) -> Result<Target, Unresolved> {
if interfaces.has_network_around(prefix) {
return Err(Unresolved::OwnNetwork);
}
let next_hop = next_hop.ok_or(Unresolved::Missing)?;
let version = match next_hop {
NlriNextHop::Ipv4(_) => IpVersion::Ipv4,
@@ -208,7 +218,7 @@ pub struct Plan {
unresolved: HashMap<IpPrefix, Unresolved>,
/// How many of them for each reason, in the order of
/// [`Unresolved::ALL`].
unresolved_counts: [usize; 4],
unresolved_counts: [usize; Unresolved::ALL.len()],
/// Prefixes whose route the kernel refused.
refused: HashSet<IpPrefix>,
}
@@ -308,7 +318,7 @@ impl Plan {
entry.confirmed = false;
}
self.unresolved.clear();
self.unresolved_counts = [0; 4];
self.unresolved_counts = [0; Unresolved::ALL.len()];
self.refused.clear();
}
@@ -394,7 +404,7 @@ impl Plan {
/// Everything goes: the changes that empty the kernel of our routes.
pub fn clear(&mut self) -> Vec<Op> {
self.unresolved.clear();
self.unresolved_counts = [0; 4];
self.unresolved_counts = [0; Unresolved::ALL.len()];
self.refused.clear();
self.installed
.drain()
@@ -485,6 +495,34 @@ mod tests {
resolve(&interfaces, &v6, Some(&v4_hop("192.0.2.2")), peer),
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),
Err(Unresolved::OwnNetwork),
"{ours}"
);
}
assert_eq!(
resolve(
&interfaces,
&prefix("192.0.0.0/22"),
Some(&v4_hop("192.0.2.2")),
peer
),
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!(