Add an interoperability lab with FRR, BIRD and Routinator

scripts/interop/lab.sh runs the daemon against FRR, BIRD and Routinator,
each in a network namespace on one exchange LAN, and checks what every
side ends up with: sessions and routes over IPv4 and IPv6 with their
attributes, four-octet AS numbers, ADD-PATH, RFC 9234 roles and Only to
Customer, RTR version 2 and invalid routes, route refresh, session resets,
killed peers, a cut wire, the daemon stopped and killed, a table of a
million routes and a restart under it, role mismatch and the prefix limit.
All of it passes against FRR 10.5.1, BIRD 2.18 and Routinator 0.15.2.

scripts/interop/real-rpki.sh checks the RTR client against a Routinator
with the real RPKI: 713,258 IPv4 and 187,479 IPv6 VRPs and 3,379 ASPAs,
the same numbers on both sides.

What the lab found, fixed here:

- A daemon restarted with its routes in the kernel removed them all at
  once, its table being empty until the peers had sent theirs again, and
  then put them back: no forwarding meanwhile. Routes of an earlier run
  are now kept for `stale_seconds` (120 by default) and only those nobody
  announced again are removed. A restart under a million routes takes
  1.1 s to be in step, with nothing removed.
- FRR ends a session it loses to a collision with a state machine error
  without a subcode, which was shown as "error code 5 subcode 0".

CI gets a job that runs the lab.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 17:44:49 +02:00
1 parent 1de75fc610
commit 321af51e4b
16 files changed
+1024 -17

No files matched your search

+96 -9
View File
@@ -142,6 +142,9 @@ struct Entry {
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.
@@ -209,18 +212,49 @@ impl Plan {
self.installed.keys()
}
/// Notes a route of ours that the kernel already has, from before this
/// run. It stays only if the routing table turns out to have the same.
pub fn adopt(&mut self, prefix: IpPrefix, target: Option<Target>) {
/// 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.installed.insert(
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.installed
.values()
.filter(|entry| entry.kept && !entry.confirmed)
.count()
}
/// 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();
self.installed.retain(|prefix, entry| {
let goes = entry.kept && !entry.confirmed;
entry.kept = false;
if goes {
stale.push(Op::Remove { prefix: *prefix });
}
!goes
});
stale
}
/// The whole table follows: nothing is known to be wanted until it is
/// heard of again.
pub fn reset(&mut self) {
@@ -246,6 +280,7 @@ impl Plan {
let entry = self.installed.entry(prefix).or_insert(Entry {
target: None,
confirmed: true,
kept: false,
});
entry.confirmed = true;
if entry.target == Some(target) {
@@ -279,7 +314,7 @@ impl Plan {
let stale: Vec<IpPrefix> = self
.installed
.iter()
.filter(|(_, entry)| !entry.confirmed)
.filter(|(_, entry)| !entry.confirmed && !entry.kept)
.map(|(prefix, _)| *prefix)
.collect();
stale
@@ -493,10 +528,10 @@ mod tests {
);
let a = target("192.0.2.2", 2);
// Left in the kernel by an earlier run.
plan.adopt(kept, Some(a));
plan.adopt(changed, Some(a));
plan.adopt(stale, Some(a));
plan.adopt(odd, None);
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();
@@ -520,6 +555,58 @@ mod tests {
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();
@@ -578,7 +665,7 @@ mod tests {
let mut plan = Plan::default();
for (prefix, gateway) in left_behind {
kernel.insert(p(prefix), t(gateway));
plan.adopt(p(prefix), Some(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();