chore: sync konduit-platform v0.1.0-beta.12

This commit is contained in:
E. Kaparulin
2026-10-06 18:31:54 +03:00
parent 7c24351f4a
commit 21d8988ab7

View File

@@ -207,7 +207,18 @@ impl RouteManager {
|| err_msg.contains("EEXIST")
|| err_msg.contains("code: Some(-17)")
{
warn!("Server route already exists, continuing...");
// Same destination, our metric: left by an earlier run
// that never got to tear down. Adopt it, or teardown
// never removes it and every later run finds it again.
match self.is_our_physical_host_route(server_v4).await {
Ok(true) => {
warn!("Server route already exists; adopting it so teardown removes it");
self.added_routes.push((server_v4, 32));
}
// Someone else's route to the same host: use it,
// but it is not ours to delete.
_ => warn!("Server route already exists and is not ours; leaving it alone"),
}
} else {
return Err(anyhow::anyhow!("Failed to add server route: {:#}", e));
}
@@ -458,6 +469,169 @@ impl RouteManager {
Ok(())
}
/// Pins `ip` to the physical gateway with a tracked host route, so the
/// full-tunnel default does not capture it. For the endpoint of another VPN
/// that was already up when we connected: its carrier otherwise follows the
/// default route into our tunnel, and that VPN dies.
///
/// Returns `Ok(false)`, installing nothing, when the address does not need
/// it: our own server (already routed), an address under a more specific
/// route (the full tunnel only replaces the default, so it was never
/// captured -- and pinning it via the default gateway would hairpin it), or
/// a route that already exists. Only a route we created is tracked, so
/// teardown never removes one that was there before us.
///
/// Call after `setup_vpn_routes`, which is what finds the physical gateway.
pub async fn pin_to_physical(&mut self, ip: Ipv4Addr) -> Result<bool> {
let (Some(gateway), Some(oif)) = (self.wifi_gateway, self.wifi_iface_index) else {
return Err(anyhow::anyhow!(
"pin_to_physical called before setup_vpn_routes found the physical gateway"
));
};
if Some(ip) == self.server_ip || self.added_routes.contains(&(ip, 32)) {
return Ok(false);
}
// A pin left by a run that was killed before teardown. It would be
// "covered" by itself below and skipped, then never removed.
if self.is_our_physical_host_route(ip).await? {
self.added_routes.push((ip, 32));
info!("Adopted a stranded pin for {}/32; teardown will remove it", ip);
return Ok(true);
}
if let Some((net, len)) = self.covering_specific_route(ip).await? {
info!("{} is under {}/{}, which the tunnel does not capture; not pinning", ip, net, len);
return Ok(false);
}
let result = self
.handle
.route()
.add()
.v4()
.destination_prefix(ip, 32)
.gateway(gateway)
.output_interface(oif)
.priority(VPN_ROUTE_METRIC)
.execute()
.await;
match result {
Ok(()) => {
self.added_routes.push((ip, 32));
info!("Pinned {}/32 via {} so it stays off the tunnel", ip, gateway);
Ok(true)
}
Err(e) => {
let msg = format!("{:?}", e);
if msg.contains("File exists") || msg.contains("EEXIST") || msg.contains("code: Some(-17)") {
Ok(false)
} else {
Err(anyhow::anyhow!("Failed to pin {}/32: {:#}", ip, e))
}
}
}
}
/// Removes a pin `pin_to_physical` installed, before teardown. For when the
/// VPN it protected has gone away: from then on that endpoint is new
/// traffic, and follows the default route like anything else.
///
/// A no-op for an address we never pinned -- a route someone else created
/// is never ours to remove.
pub async fn unpin(&mut self, ip: Ipv4Addr) -> Result<()> {
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
if !self.added_routes.contains(&(ip, 32)) {
return Ok(());
}
let mut routes = self.handle.route().get(IpVersion::V4).execute();
while let Some(route) = routes.try_next().await? {
let ours = route.header.destination_prefix_length == 32
&& route
.attributes
.iter()
.any(|a| matches!(a, RouteAttribute::Destination(RouteAddress::Inet(d)) if *d == ip))
&& route
.attributes
.iter()
.any(|a| matches!(a, RouteAttribute::Priority(m) if *m == VPN_ROUTE_METRIC));
if ours {
self.handle
.route()
.del(route)
.execute()
.await
.map_err(|e| anyhow::anyhow!("Failed to unpin {}/32: {:?}", ip, e))?;
break;
}
}
// Tracking ends either way: if the route is already gone, there is
// nothing left for teardown to remove.
self.added_routes.retain(|r| *r != (ip, 32));
info!("Unpinned {}/32; it follows the default route again", ip);
Ok(())
}
/// Whether a main-table `ip/32` route exists that is unmistakably one of
/// ours: our metric, via the physical gateway, out the physical interface.
///
/// A run killed before teardown strands such a route. "A /32 at our metric
/// exists" alone is not proof it is ours -- another tool can hold one -- so
/// the gateway and device are checked too before taking ownership of it.
async fn is_our_physical_host_route(&self, ip: Ipv4Addr) -> Result<bool> {
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
const RT_TABLE_MAIN: u8 = 254;
let (Some(gateway), Some(oif)) = (self.wifi_gateway, self.wifi_iface_index) else {
return Ok(false);
};
let mut routes = self.handle.route().get(IpVersion::V4).execute();
while let Some(route) = routes.try_next().await? {
if route.header.table != RT_TABLE_MAIN || route.header.destination_prefix_length != 32 {
continue;
}
let has = |want: &dyn Fn(&RouteAttribute) -> bool| route.attributes.iter().any(want);
if has(&|a| matches!(a, RouteAttribute::Destination(RouteAddress::Inet(d)) if *d == ip))
&& has(&|a| matches!(a, RouteAttribute::Priority(m) if *m == VPN_ROUTE_METRIC))
&& has(&|a| matches!(a, RouteAttribute::Gateway(RouteAddress::Inet(g)) if *g == gateway))
&& has(&|a| matches!(a, RouteAttribute::Oif(i) if *i == oif))
{
return Ok(true);
}
}
Ok(false)
}
/// The most specific main-table route, other than a default and other than
/// one through our own TUN, whose prefix contains `ip`.
async fn covering_specific_route(&self, ip: Ipv4Addr) -> Result<Option<(Ipv4Addr, u8)>> {
use netlink_packet_route::route::{RouteAddress, RouteAttribute};
const RT_TABLE_MAIN: u8 = 254;
let mut prefixes = Vec::new();
let mut routes = self.handle.route().get(IpVersion::V4).execute();
while let Some(route) = routes.try_next().await? {
let len = route.header.destination_prefix_length;
if route.header.table != RT_TABLE_MAIN || len == 0 {
continue;
}
let through_tun = route
.attributes
.iter()
.any(|a| matches!(a, RouteAttribute::Oif(i) if Some(*i) == self.tun_index));
if through_tun {
continue;
}
let dst = route.attributes.iter().find_map(|a| match a {
RouteAttribute::Destination(RouteAddress::Inet(v4)) => Some(*v4),
_ => None,
});
if let Some(dst) = dst {
prefixes.push((dst, len));
}
}
Ok(most_specific_covering(ip, &prefixes))
}
/// Remove the VPN default route (0.0.0.0/0) during a reconnect window so
/// traffic falls through to the WiFi default route (metric 600).
/// Call `resume_default_route()` after the new session is established.
@@ -534,6 +708,20 @@ impl RouteManager {
/// Remove every route that was added by `setup_vpn_routes`.
/// Pre-existing routes are untouched.
/// Restores everything this manager installed: the policy rule, the IPv6
/// blackhole, and the tracked VPN routes.
///
/// Each step is independent and **none of them may skip the ones after
/// it**. A failure while *looking up* what to delete is not a reason to
/// skip deleting what we already know is installed -- this function exists
/// to put the host's network back, and abandoning it halfway does the
/// opposite, leaving a `default via` route pointing at a TUN device that no
/// longer exists and taking the machine offline entirely.
///
/// The deletions themselves have always been handled properly (logged and
/// continued); it was the lookups that used `?` and aborted the rest. That
/// is backwards, and it is what this signature now reflects: lookups return
/// `Result`, callers log the failure and continue.
pub async fn restore_routes(&mut self) -> Result<()> {
info!(
"Removing {} tracked VPN routes...",
@@ -541,58 +729,94 @@ impl RouteManager {
);
// Remove the policy rule first so traffic stops using the VPN immediately.
// Same netlink-handle approach as the add side above -- `handle.rule().del()`
// needs the exact existing RuleMessage, so look it up by priority first.
if self.added_policy_rule {
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
let mut to_delete = None;
while let Some(rule) = rules.try_next().await? {
let matches_priority = rule
.attributes
.iter()
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
if matches_priority {
to_delete = Some(rule);
break;
}
}
match to_delete {
Some(rule) => match self.handle.rule().del(rule).execute().await {
match self.find_vpn_rule().await {
Ok(Some(rule)) => match self.handle.rule().del(rule).execute().await {
Ok(()) => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
Err(e) => warn!("Failed to delete ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e),
},
None => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
Ok(None) => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
Err(e) => warn!(
"Failed to look up ip rule priority {}: {:?} -- continuing with the rest of the cleanup",
VPN_RULE_PRIORITY, e
),
}
self.added_policy_rule = false;
}
if self.added_ipv6_blackhole {
let mut routes = self.handle.route().get(IpVersion::V6).execute();
let mut to_delete = None;
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0
&& route.header.kind == RouteType::Unreachable
{
to_delete = Some(route);
break;
}
}
match to_delete {
Some(route) => match self.handle.route().del(route).execute().await {
match self.find_ipv6_blackhole().await {
Ok(Some(route)) => match self.handle.route().del(route).execute().await {
Ok(()) => info!("Removed IPv6 blackhole route"),
Err(e) => warn!("Failed to delete IPv6 blackhole route: {:?}", e),
},
None => warn!("IPv6 blackhole route not found, nothing to remove"),
Ok(None) => warn!("IPv6 blackhole route not found, nothing to remove"),
Err(e) => warn!(
"Failed to look up the IPv6 blackhole route: {:?} -- continuing with the rest of the cleanup",
e
),
}
self.added_ipv6_blackhole = false;
}
if self.added_routes.is_empty() {
return Ok(());
// No early return for an empty `added_routes` here. It looks like a
// harmless shortcut, but it also skips the bookkeeping and the final
// log, which is how "we removed everything" gets reported when some of
// it was never looked at. With nothing tracked the collect below finds
// nothing anyway.
match self.find_tracked_routes().await {
Ok(routes_to_delete) => {
for route in routes_to_delete {
if let Err(e) = self.handle.route().del(route).execute().await {
warn!("Failed to delete tracked route: {:?}", e);
}
}
}
Err(e) => warn!(
"Failed to look up tracked routes: {:?} -- {} may still be installed",
e,
self.added_routes.len()
),
}
self.added_routes.clear();
info!("✅ VPN routes removed");
Ok(())
}
/// Finds the policy rule this manager installed, by priority.
async fn find_vpn_rule(&self) -> Result<Option<netlink_packet_route::rule::RuleMessage>> {
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
while let Some(rule) = rules.try_next().await? {
let matches_priority = rule
.attributes
.iter()
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
if matches_priority {
// `rule().del()` needs the exact existing RuleMessage, which is
// why this is a lookup rather than a bare delete by priority.
return Ok(Some(rule));
}
}
Ok(None)
}
/// Finds the IPv6 `unreachable default` blackhole installed to stop IPv6
/// leaking outside the tunnel.
async fn find_ipv6_blackhole(&self) -> Result<Option<netlink_packet_route::route::RouteMessage>> {
let mut routes = self.handle.route().get(IpVersion::V6).execute();
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0
&& route.header.kind == RouteType::Unreachable
{
return Ok(Some(route));
}
}
Ok(None)
}
/// Collects the tracked routes that are still present, ready to delete.
async fn find_tracked_routes(&self) -> Result<Vec<netlink_packet_route::route::RouteMessage>> {
let mut routes_to_delete = Vec::new();
let mut routes = self.handle.route().get(IpVersion::V4).execute();
@@ -636,15 +860,7 @@ impl RouteManager {
}
}
for route in routes_to_delete {
if let Err(e) = self.handle.route().del(route).execute().await {
warn!("Failed to delete tracked route: {:?}", e);
}
}
self.added_routes.clear();
info!("✅ VPN routes removed");
Ok(())
Ok(routes_to_delete)
}
}
@@ -659,6 +875,20 @@ impl Drop for RouteManager {
}
}
/// The longest prefix in `prefixes` that contains `ip`. Pure, so the matching
/// is testable without a netlink socket.
fn most_specific_covering(ip: Ipv4Addr, prefixes: &[(Ipv4Addr, u8)]) -> Option<(Ipv4Addr, u8)> {
let contains = |net: Ipv4Addr, len: u8| {
let mask = if len == 0 { 0 } else { u32::MAX << (32 - u32::from(len.min(32))) };
u32::from(ip) & mask == u32::from(net) & mask
};
prefixes
.iter()
.copied()
.filter(|&(net, len)| contains(net, len))
.max_by_key(|&(_, len)| len)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -723,4 +953,334 @@ mod tests {
}
assert!(found, "installed policy rule not found via rule().get()");
}
// The teardown contract, which Taiga #165 is about: every step runs, and a
// failure in one never skips the ones after it. `restore_routes` used to
// carry three `?`s on netlink *lookups*, so a single hiccup returned `Err`
// having deleted nothing -- leaving the host with a `default via` route
// pointing at a TUN device that no longer exists, i.e. no network at all.
//
// This covers the happy path: the whole install/restore cycle really does
// clean up both the policy rule and the IPv6 blackhole, which guards the
// refactor into `find_*` helpers. The failure branch -- one step erroring
// and the later steps still running -- is covered by the separate
// `a_failing_lookup_does_not_skip_the_later_cleanup_steps` below, which
// induces a real netlink failure rather than mocking one.
#[tokio::test(flavor = "current_thread")]
async fn restore_routes_clears_every_step_it_installed() {
if std::env::var(REEXEC_ENV_VAR).is_err() {
run_in_isolated_netns("routes::linux::tests::restore_routes_clears_every_step_it_installed");
return;
}
let mut mgr = RouteManager::new("lo".to_string())
.await
.expect("failed to create RouteManager in isolated netns");
// Install both pieces of state that need no TUN device to exist. The
// tracked-route step is exercised by find_tracked_routes' own filtering
// logic; it is not asserted here because adding a route needs a real
// output device and there is deliberately no interface to use.
mgr.install_policy_rule().await;
assert!(mgr.added_policy_rule, "install should have recorded the rule");
mgr.added_ipv6_blackhole = true;
// Same construction as block_ipv6_leak, so the test asserts against the
// route shape the add path really produces.
mgr.handle
.route()
.add()
.v6()
.destination_prefix(Ipv6Addr::UNSPECIFIED, 0)
.kind(RouteType::Unreachable)
.priority(VPN_ROUTE_METRIC)
.execute()
.await
.expect("failed to install IPv6 blackhole in isolated netns");
mgr.restore_routes().await.expect("restore_routes must not error");
// Bookkeeping: a step that was never reached leaves its flag set, which
// is what made Drop warn about unrestored state.
assert!(!mgr.added_policy_rule, "policy-rule step was skipped");
assert!(!mgr.added_ipv6_blackhole, "IPv6-blackhole step was skipped");
// And the kernel actually agrees it is gone.
let mut rules = mgr.handle.rule().get(IpVersion::V4).execute();
while let Some(rule) = rules.try_next().await.unwrap() {
let still_there = rule.attributes.iter().any(|attr| {
matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY)
});
assert!(!still_there, "policy rule survived restore_routes");
}
let mut routes = mgr.handle.route().get(IpVersion::V6).execute();
while let Some(route) = routes.try_next().await.unwrap() {
let still_there = route.header.destination_prefix_length == 0
&& route.header.kind == RouteType::Unreachable;
assert!(!still_there, "IPv6 blackhole survived restore_routes");
}
}
// The actual #165 regression. `restore_routes` used to carry `?` on its
// netlink *lookups*, so one failing read returned `Err` having deleted
// nothing and left the host with routes pointing at a dead TUN device --
// no network at all. The fix is that each step handles its own lookup
// failure and the next one still runs.
//
// The failure is induced for real rather than mocked: the netlink socket is
// closed underneath the handle by aborting the connection task, so every
// subsequent request fails with EBADF. Merely never spawning the connection
// would instead hang -- the request would be sent and wait forever for a
// reply that nothing is listening for -- hence the timeout.
//
// No netns is needed: with the socket closed no netlink message can leave
// the process, so this cannot touch the host's routing table.
#[tokio::test(flavor = "current_thread")]
async fn a_failing_lookup_does_not_skip_the_later_cleanup_steps() {
let (connection, handle, _) =
new_connection().expect("failed to create netlink connection");
let task = tokio::spawn(connection);
// Aborting drops the Connection, which owns the socket and closes it.
task.abort();
let _ = task.await;
let mut mgr = RouteManager {
handle,
added_routes: vec![(Ipv4Addr::new(198, 18, 0, 0), 0)],
tun_interface: "lo".to_string(),
tun_index: None,
dns_manager: crate::dns::DnsManager::new(
"lo".to_string(),
None,
None,
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
),
server_ip: None,
wifi_gateway: None,
wifi_iface_index: None,
vpn_gateway: None,
added_policy_rule: true,
added_ipv6_blackhole: true,
};
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(10),
mgr.restore_routes(),
)
.await;
assert!(
outcome.is_ok(),
"restore_routes hung on a closed netlink socket; every request must error instead"
);
// Each flag is cleared only once its step has actually run, so this is
// what distinguishes "attempted every step" from "bailed after the
// first failure" -- which is the bug.
assert!(
!mgr.added_policy_rule,
"policy-rule step was skipped after the lookup failed"
);
assert!(
!mgr.added_ipv6_blackhole,
"IPv6-blackhole step was skipped after an earlier lookup failed"
);
assert!(
mgr.added_routes.is_empty(),
"tracked-route step was skipped after an earlier lookup failed"
);
}
// Another VPN that was already up when we connected must keep its carrier.
// A WireGuard endpoint with no host route of its own follows the default
// route, and our full-tunnel default would capture it -- which took down a
// WireGuard VPN (and the DNS it provided) on 2026-10-05. Pinning puts the
// endpoint back on the physical gateway, and teardown takes the pin away.
//
// An address already covered by a more specific route is left alone: the
// full tunnel only replaces the *default*, so it never captured that
// address, and a pin via the default gateway would hairpin it.
#[tokio::test(flavor = "current_thread")]
async fn pin_to_physical_keeps_an_endpoint_off_the_tunnel_until_teardown() {
if std::env::var(REEXEC_ENV_VAR).is_err() {
run_in_isolated_netns(
"routes::linux::tests::pin_to_physical_keeps_an_endpoint_off_the_tunnel_until_teardown",
);
return;
}
// A stand-in physical NIC, its default route, and a more specific
// route standing in for another VPN's subnet.
for cmd in [
"ip link add phys0 type dummy",
"ip link set phys0 up",
"ip addr add 192.0.2.10/24 dev phys0",
"ip route add default via 192.0.2.1 dev phys0",
"ip route add 10.60.0.0/16 via 192.0.2.1 dev phys0",
] {
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
assert!(ok, "netns setup failed: {cmd}");
}
let mut mgr = RouteManager::new("lo".to_string())
.await
.expect("failed to create RouteManager in isolated netns");
let (gateway, oif) = mgr.get_default_gateway().await.expect("default gateway");
mgr.wifi_gateway = Some(gateway);
mgr.wifi_iface_index = Some(oif);
let endpoint = Ipv4Addr::new(203, 0, 113, 7);
assert!(mgr.pin_to_physical(endpoint).await.unwrap(), "an endpoint on the default route must be pinned");
let route = |dst: &str| {
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
assert!(
route("203.0.113.7/32").contains("via 192.0.2.1 dev phys0"),
"pin missing or wrong: {:?}",
route("203.0.113.7/32")
);
let inside_vpn = Ipv4Addr::new(10, 60, 48, 10);
assert!(
!mgr.pin_to_physical(inside_vpn).await.unwrap(),
"an address under a more specific route was never captured, so must not be pinned"
);
assert_eq!(route("10.60.48.10/32"), "", "no pin should exist for a covered address");
assert!(
!mgr.pin_to_physical(endpoint).await.unwrap(),
"pinning twice must not track the route twice"
);
// The other VPN went away: the pin comes off now, not at teardown, so
// its next connection follows the default route like any new traffic.
mgr.unpin(endpoint).await.expect("unpin");
assert_eq!(route("203.0.113.7/32"), "", "unpin must remove the route");
assert!(!mgr.added_routes.contains(&(endpoint, 32)), "unpin must stop tracking it");
mgr.unpin(endpoint).await.expect("unpinning twice is a no-op, not an error");
// A route we never created is not ours to remove, even at our address.
let ok = std::process::Command::new("sh")
.args(["-c", "ip route add 203.0.113.8/32 via 192.0.2.1 dev phys0 metric 50"])
.status()
.unwrap()
.success();
assert!(ok);
mgr.unpin(Ipv4Addr::new(203, 0, 113, 8)).await.expect("unpin of an untracked address");
assert!(!route("203.0.113.8/32").is_empty(), "unpin removed a route it never installed");
assert!(mgr.pin_to_physical(endpoint).await.unwrap(), "pinning again after unpin");
mgr.restore_routes().await.expect("restore_routes");
assert_eq!(route("203.0.113.7/32"), "", "teardown must remove the pin");
assert!(
route("10.60.0.0/16").contains("via 192.0.2.1"),
"teardown must not touch the other VPN's own route"
);
}
#[test]
fn most_specific_covering_picks_the_longest_matching_prefix() {
let v4 = |a, b, c, d| Ipv4Addr::new(a, b, c, d);
let prefixes = [(v4(10, 0, 0, 0), 8), (v4(10, 60, 0, 0), 16), (v4(192, 168, 0, 0), 23)];
assert_eq!(most_specific_covering(v4(10, 60, 48, 10), &prefixes), Some((v4(10, 60, 0, 0), 16)));
assert_eq!(most_specific_covering(v4(10, 1, 2, 3), &prefixes), Some((v4(10, 0, 0, 0), 8)));
assert_eq!(most_specific_covering(v4(192, 168, 1, 255), &prefixes), Some((v4(192, 168, 0, 0), 23)));
assert_eq!(most_specific_covering(v4(192, 168, 2, 0), &prefixes), None);
assert_eq!(most_specific_covering(v4(34, 88, 176, 131), &prefixes), None);
assert_eq!(most_specific_covering(v4(34, 88, 176, 131), &[(v4(34, 88, 176, 131), 32)]), Some((v4(34, 88, 176, 131), 32)));
}
// A server host route stranded by an earlier run (a SIGKILL skips teardown)
// made every later connect log "already exists" and carry on without
// tracking it, so teardown never removed it -- self-perpetuating. Found
// 2026-10-05 as `89.167.29.223 via 192.168.0.1 metric 50` on a host with
// koridor down. Same destination, gateway and our metric: it is ours.
#[tokio::test(flavor = "current_thread")]
async fn a_stranded_server_route_is_adopted_and_removed_at_teardown() {
if std::env::var(REEXEC_ENV_VAR).is_err() {
run_in_isolated_netns("routes::linux::tests::a_stranded_server_route_is_adopted_and_removed_at_teardown");
return;
}
for cmd in [
"ip link add phys0 type dummy",
"ip link set phys0 up",
"ip addr add 192.0.2.10/24 dev phys0",
"ip route add default via 192.0.2.1 dev phys0",
"ip link add tun0 type dummy",
"ip link set tun0 up",
"ip addr add 198.18.0.2/24 dev tun0",
// The stranded route, exactly as a previous run installed it.
"ip route add 203.0.113.9/32 via 192.0.2.1 dev phys0 metric 50",
] {
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
assert!(ok, "netns setup failed: {cmd}");
}
let route = |dst: &str| {
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let mut mgr = RouteManager::new("tun0".to_string()).await.expect("RouteManager");
mgr.setup_vpn_routes("203.0.113.9", IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1)), &["0.0.0.0/0".to_string()])
.await
.expect("setup_vpn_routes");
assert!(
mgr.added_routes.contains(&(Ipv4Addr::new(203, 0, 113, 9), 32)),
"the stranded server route was left untracked"
);
mgr.restore_routes().await.expect("restore_routes");
assert_eq!(route("203.0.113.9/32"), "", "teardown must remove the adopted server route");
assert!(route("default").contains("192.0.2.1"), "the physical default must survive teardown");
}
// Stranded routes are adopted only when they are unmistakably ours. A SIGKILL
// skips teardown and leaves a pin behind; the next connect used to see it,
// skip it, and never remove it. But "a /32 at our metric already exists" is
// not enough to take ownership: another tool can hold one, and deleting it at
// teardown would break that tool.
#[tokio::test(flavor = "current_thread")]
async fn only_our_own_stranded_routes_are_adopted() {
if std::env::var(REEXEC_ENV_VAR).is_err() {
run_in_isolated_netns("routes::linux::tests::only_our_own_stranded_routes_are_adopted");
return;
}
for cmd in [
"ip link add phys0 type dummy",
"ip link set phys0 up",
"ip addr add 192.0.2.10/24 dev phys0",
"ip route add default via 192.0.2.1 dev phys0",
"ip link add other0 type dummy",
"ip link set other0 up",
"ip addr add 198.51.100.10/24 dev other0",
// Ours, stranded by a killed run: physical gateway, our metric.
"ip route add 203.0.113.30/32 via 192.0.2.1 dev phys0 metric 50",
// Someone else's: same destination shape and metric, another device.
"ip route add 203.0.113.40/32 via 198.51.100.1 dev other0 metric 50",
] {
let ok = std::process::Command::new("sh").args(["-c", cmd]).status().unwrap().success();
assert!(ok, "netns setup failed: {cmd}");
}
let route = |dst: &str| {
let out = std::process::Command::new("ip").args(["route", "show", dst]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let mut mgr = RouteManager::new("lo".to_string()).await.expect("RouteManager");
let (gateway, oif) = mgr.get_default_gateway().await.expect("default gateway");
mgr.wifi_gateway = Some(gateway);
mgr.wifi_iface_index = Some(oif);
let ours = Ipv4Addr::new(203, 0, 113, 30);
let theirs = Ipv4Addr::new(203, 0, 113, 40);
assert!(mgr.pin_to_physical(ours).await.unwrap(), "our stranded pin must be adopted");
assert!(mgr.added_routes.contains(&(ours, 32)), "adopted but not tracked");
assert!(!mgr.pin_to_physical(theirs).await.unwrap(), "another tool's route must not be taken");
assert!(!mgr.added_routes.contains(&(theirs, 32)), "another tool's route got tracked");
mgr.restore_routes().await.expect("restore_routes");
assert_eq!(route("203.0.113.30/32"), "", "the adopted pin must go at teardown");
assert!(!route("203.0.113.40/32").is_empty(), "teardown deleted a route that was never ours");
}
}