diff --git a/konduit-platform/src/routes/linux.rs b/konduit-platform/src/routes/linux.rs index 51eab52..02eab17 100644 --- a/konduit-platform/src/routes/linux.rs +++ b/konduit-platform/src/routes/linux.rs @@ -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 { + 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 { + 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> { + 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> { + 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> { + 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> { 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"); + } }