use anyhow::{Context, Result}; use futures::stream::TryStreamExt; use netlink_packet_route::route::RouteProtocol; use rtnetlink::{new_connection, Handle, IpVersion}; use std::net::{IpAddr, Ipv4Addr}; use tracing::{info, warn}; // Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001). // Ensures konduit's main-table routes (tun0 default, server host-route) win in // full-tunnel mode without modifying any third-party routing table. const VPN_RULE_PRIORITY: u32 = 8000; const VPN_ROUTE_METRIC: u32 = 50; /// Manages routing table for VPN connection using netlink. /// Tracks every route it installs and removes exactly those on teardown. /// Pre-existing routes are never touched. pub struct RouteManager { handle: Handle, added_routes: Vec<(Ipv4Addr, u8)>, tun_interface: String, tun_index: Option, dns_manager: crate::dns::DnsManager, // Saved during setup for re-adding the server host route after resume. server_ip: Option, wifi_gateway: Option, // Saved for re-adding the VPN default route after reconnect. vpn_gateway: Option, // True when we installed an ip rule to override policy-routing stacks. added_policy_rule: bool, } impl RouteManager { pub async fn new(tun_interface: String) -> Result { let (connection, handle, _) = new_connection().context("Failed to create netlink connection")?; tokio::spawn(connection); Ok(Self { handle, added_routes: Vec::new(), tun_interface: tun_interface.clone(), tun_index: None, dns_manager: crate::dns::DnsManager::new(tun_interface, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)), server_ip: None, wifi_gateway: None, vpn_gateway: None, added_policy_rule: false, }) } async fn get_interface_index(&self, name: &str) -> Result { let mut links = self .handle .link() .get() .match_name(name.to_string()) .execute(); if let Some(link) = links.try_next().await? { Ok(link.header.index) } else { anyhow::bail!("Interface {} not found", name) } } async fn get_default_gateway(&self) -> Result { use netlink_packet_route::route::RouteAddress; let mut routes = self.handle.route().get(IpVersion::V4).execute(); // Collect all default-route gateways with their metrics. // VPN default routes have low metric (50); physical/DHCP routes have high metric (600+). // We want the physical gateway, so take the one with the highest metric. let mut candidates: Vec<(u32, Ipv4Addr)> = Vec::new(); while let Some(route) = routes.try_next().await? { if route.header.destination_prefix_length != 0 { continue; } let mut metric = 0u32; let mut gateway: Option = None; for nla in route.attributes.iter() { match nla { netlink_packet_route::route::RouteAttribute::Gateway( RouteAddress::Inet(ip), ) => gateway = Some(*ip), netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m, _ => {} } } if let Some(gw) = gateway { candidates.push((metric, gw)); } } // Highest metric = physical/DHCP interface (not VPN). candidates .into_iter() .max_by_key(|(m, _)| *m) .map(|(_, gw)| gw) .ok_or_else(|| anyhow::anyhow!("No default gateway found")) } /// Install routes for the VPN connection. /// /// Uses metric 50 (lower than typical DHCP default of 100) so VPN routes /// win without deleting the original default route. pub async fn setup_vpn_routes( &mut self, server_addr: &str, vpn_gateway: IpAddr, routes: &[String], ) -> Result<()> { info!("Setting up VPN routes..."); self.tun_index = Some( self.get_interface_index(&self.tun_interface) .await .context("Failed to get TUN interface index")?, ); info!( "TUN interface {} has index {}", self.tun_interface, self.tun_index.unwrap() ); let current_gateway = self .get_default_gateway() .await .context("Failed to get current gateway")?; info!("Current gateway: {}", current_gateway); // Resolve server address to IP let server_ip = if let Ok(ip) = server_addr.parse::() { ip } else { let host = server_addr.split(':').next().unwrap_or(server_addr); let addrs = tokio::net::lookup_host(format!("{}:0", host)) .await .context("Failed to resolve server hostname")?; addrs .map(|addr| addr.ip()) .next() .context("No IP address found for server")? }; info!("Server IP: {}", server_ip); // Host route for VPN server through current gateway so the tunnel itself // doesn't loop back through itself. if let IpAddr::V4(server_v4) = server_ip { self.server_ip = Some(server_v4); self.wifi_gateway = Some(current_gateway); let result = self .handle .route() .add() .v4() .destination_prefix(server_v4, 32) .gateway(current_gateway) .priority(VPN_ROUTE_METRIC) .execute() .await; match result { Ok(_) => self.added_routes.push((server_v4, 32)), Err(e) => { let err_msg = format!("{:?}", e); if err_msg.contains("File exists") || err_msg.contains("EEXIST") || err_msg.contains("code: Some(-17)") { warn!("Server route already exists, continuing..."); } else { return Err(anyhow::anyhow!("Failed to add server route: {:#}", e)); } } } } else { warn!("IPv6 server addresses not yet supported for routing"); } let IpAddr::V4(gateway_v4) = vpn_gateway else { return Err(anyhow::anyhow!("IPv6 VPN gateways not supported")); }; self.vpn_gateway = Some(gateway_v4); let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0"); if is_full_tunnel { info!("Full-tunnel mode: adding default route via VPN..."); self.handle .route() .add() .v4() .destination_prefix(Ipv4Addr::new(0, 0, 0, 0), 0) .gateway(gateway_v4) .output_interface(self.tun_index.unwrap()) .priority(VPN_ROUTE_METRIC) .protocol(RouteProtocol::Boot) .execute() .await .map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?; self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0)); self.install_policy_rule().await; } else { info!( "Split-tunnel mode: adding {} routes via VPN...", routes.len() ); for route_str in routes { let Some((dest_str, prefix_str)) = route_str.split_once('/') else { warn!("Invalid route format: {}", route_str); continue; }; let dest: Ipv4Addr = match dest_str.parse() { Ok(ip) => ip, Err(_) => { warn!("Invalid route destination: {}", dest_str); continue; } }; let prefix_len: u8 = match prefix_str.parse() { Ok(p) => p, Err(_) => { warn!("Invalid prefix length: {}", prefix_str); continue; } }; let result = self .handle .route() .add() .v4() .destination_prefix(dest, prefix_len) .gateway(gateway_v4) .output_interface(self.tun_index.unwrap()) .priority(VPN_ROUTE_METRIC) .execute() .await; match result { Ok(_) => { self.added_routes.push((dest, prefix_len)); info!("Added route: {}/{}", dest, prefix_len); } Err(e) => { let err_msg = format!("{:?}", e); if err_msg.contains("File exists") || err_msg.contains("EEXIST") || err_msg.contains("code: Some(-17)") { warn!("Route {} already exists, continuing...", route_str); } else { warn!("Failed to add route {}: {:?}", route_str, e); } } } } } info!("✅ VPN routes configured successfully"); Ok(()) } /// Add a high-priority ip rule so konduit's main-table routes override any /// policy-routing stack (e.g., xray, sing-box) that intercepts traffic via a /// separate routing table at priority ~9000. /// /// Uses the same netlink handle as every other route change here, rather /// than shelling out to `ip rule add` -- a child process spawned via /// std::process::Command does NOT inherit capabilities granted to this /// process via `setcap`/file capabilities (they'd need to be raised into /// the ambient set first, which nothing here does), so the subprocess /// silently failed with EPERM ("RTNETLINK answers: Operation not /// permitted") every time, regardless of environment. That meant this /// rule never actually got installed, so a competing policy-routing /// stack (e.g. Xray) could keep overriding the VPN's own routes. async fn install_policy_rule(&mut self) { match self .handle .rule() .add() .v4() .priority(VPN_RULE_PRIORITY) // RuleAddRequest::new() defaults the rule's action to Unspec, not // ToTable. A "from all" rule (matches every packet) with an // Unspec action is not a valid "jump to main table" rule and the // kernel does not fall through past it — it broke route lookups // for ALL traffic, not just VPN traffic, until reboot. .action(netlink_packet_route::rule::RuleAction::ToTable) .execute() .await { Ok(()) => { self.added_policy_rule = true; info!("Added ip rule priority {} → main table", VPN_RULE_PRIORITY); } Err(e) => { let err_msg = format!("{:?}", e); if err_msg.contains("File exists") || err_msg.contains("EEXIST") || err_msg.contains("code: Some(-17)") { self.added_policy_rule = true; warn!("ip rule priority {} already exists, continuing...", VPN_RULE_PRIORITY); } else { warn!("Failed to add ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e); } } } } /// Setup DNS configuration pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> { if dns_servers.is_empty() { return Ok(()); } self.dns_manager = crate::dns::DnsManager::new(self.tun_interface.clone(), self.tun_index, assigned_ip); self.dns_manager.set_dns(dns_servers).await } /// Restore DNS configuration pub async fn restore_dns(&self) -> Result<()> { self.dns_manager.restore().await } /// Re-add the server host route if it has been removed by the kernel. /// /// On Linux, suspending the machine brings wlan0 down, which causes the kernel /// to purge all routes installed on that interface — including our host route for /// the VPN server IP via the WiFi gateway. Without that route, reconnect SYNs /// follow the VPN default route into tun0, get dropped, and time out. This /// method must be called before each reconnect attempt. pub async fn ensure_server_route(&mut self) -> Result<()> { let (server_ip, gateway) = match (self.server_ip, self.wifi_gateway) { (Some(s), Some(g)) => (s, g), _ => return Ok(()), }; let result = self .handle .route() .add() .v4() .destination_prefix(server_ip, 32) .gateway(gateway) .priority(VPN_ROUTE_METRIC) .execute() .await; match result { Ok(_) => { info!( "Re-added server host route {}/32 via {} (was removed on suspend)", server_ip, gateway ); } Err(e) => { let msg = format!("{:?}", e); if msg.contains("File exists") || msg.contains("EEXIST") || msg.contains("code: Some(-17)") { // Route is already present — nothing to do. } else { return Err(anyhow::anyhow!("Failed to re-add server route: {:#}", e)); } } } Ok(()) } /// 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. pub async fn suspend_default_route(&mut self) -> Result<()> { if !self.added_routes.contains(&(Ipv4Addr::new(0, 0, 0, 0), 0)) { return Ok(()); } let mut routes = self.handle.route().get(IpVersion::V4).execute(); let mut to_delete = None; while let Some(route) = routes.try_next().await? { if route.header.destination_prefix_length == 0 { // Identify our default route by its output interface (tun0), not metric, // since the kernel may not round-trip the Priority attribute reliably. let is_ours = route.attributes.iter().any(|nla| { matches!( nla, netlink_packet_route::route::RouteAttribute::Oif(idx) if Some(*idx) == self.tun_index ) }); if is_ours { to_delete = Some(route); break; } } } if let Some(route) = to_delete { self.handle.route().del(route).execute().await.map_err(|e| { anyhow::anyhow!("Failed to suspend VPN default route: {:?}", e) })?; info!("VPN default route suspended — traffic falls to WiFi during reconnect"); } Ok(()) } /// Re-add the VPN default route after a successful reconnect. pub async fn resume_default_route(&mut self) -> Result<()> { let gateway = match self.vpn_gateway { Some(gw) => gw, None => return Ok(()), }; if !self.added_routes.contains(&(Ipv4Addr::new(0, 0, 0, 0), 0)) { return Ok(()); } let result = self .handle .route() .add() .v4() .destination_prefix(Ipv4Addr::new(0, 0, 0, 0), 0) .gateway(gateway) .output_interface(self.tun_index.unwrap_or(0)) .priority(VPN_ROUTE_METRIC) .protocol(RouteProtocol::Boot) .execute() .await; match result { Ok(_) => info!("VPN default route restored after reconnect"), Err(e) => { let msg = format!("{:?}", e); if msg.contains("File exists") || msg.contains("EEXIST") || msg.contains("code: Some(-17)") { // already present } else { return Err(anyhow::anyhow!("Failed to resume VPN default route: {:#}", e)); } } } Ok(()) } /// Remove every route that was added by `setup_vpn_routes`. /// Pre-existing routes are untouched. pub async fn restore_routes(&mut self) -> Result<()> { info!( "Removing {} tracked VPN routes...", self.added_routes.len() ); // 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 { 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), } self.added_policy_rule = false; } if self.added_routes.is_empty() { return Ok(()); } let mut routes_to_delete = Vec::new(); let mut routes = self.handle.route().get(IpVersion::V4).execute(); while let Some(route) = routes.try_next().await? { let prefix_len = route.header.destination_prefix_length; let dest_ip: Option = if prefix_len == 0 { Some(Ipv4Addr::new(0, 0, 0, 0)) } else { route.attributes.iter().find_map(|nla| { if let netlink_packet_route::route::RouteAttribute::Destination(addr) = nla { use netlink_packet_route::route::RouteAddress; if let RouteAddress::Inet(ipv4) = addr { return Some(*ipv4); } } None }) }; if let Some(ip) = dest_ip { if self.added_routes.contains(&(ip, prefix_len)) { // For the default route (0.0.0.0/0), match by output interface // (tun0), not just destination prefix — otherwise we also delete // the WiFi default route (same prefix, different OIF/metric). if prefix_len == 0 { let is_ours = route.attributes.iter().any(|nla| { matches!( nla, netlink_packet_route::route::RouteAttribute::Oif(idx) if Some(*idx) == self.tun_index ) }); if is_ours { routes_to_delete.push(route); } } else { routes_to_delete.push(route); } } } } 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(()) } } impl Drop for RouteManager { fn drop(&mut self) { if !self.added_routes.is_empty() { warn!( "RouteManager dropped with {} unrestored routes — call restore_routes() before dropping", self.added_routes.len() ); } } } #[cfg(test)] mod tests { use super::*; use netlink_packet_route::rule::RuleAction; const REEXEC_ENV_VAR: &str = "KORIDOR_ROUTE_TEST_IN_NETNS"; // `unshare(CLONE_NEWUSER)` fails with EINVAL if the calling process is // multithreaded (which `cargo test`'s harness always is). So instead of // calling the syscall in-process, re-exec this same test binary under // the external `unshare --user --net --map-root-user` command, which // starts a fresh, single-threaded process already inside an isolated // user+net namespace -- no real root needed, and the host's actual // routing rules are never touched. fn run_in_isolated_netns(test_name: &str) { let exe = std::env::current_exe().expect("current_exe"); let status = std::process::Command::new("unshare") .args(["--user", "--net", "--map-root-user", "--"]) .arg(&exe) .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) .env(REEXEC_ENV_VAR, "1") .status() .expect("failed to spawn unshare (is util-linux's `unshare` installed?)"); assert!(status.success(), "test failed inside isolated netns (see output above)"); } // Regression test for the bug where connecting broke ALL host networking // (not just VPN traffic) until a reboot: the policy rule installed to // make konduit's routes win over other policy-routing stacks was built // via `RuleAddRequest::new()`, which defaults `header.action` to // `RuleAction::Unspec` — a "from all" rule (matches every packet) with // no action is not a valid "jump to main table" rule, and the kernel // does not fall through to the next rule for it, breaking route lookups // system-wide. The fix must set the action explicitly to `ToTable`. #[tokio::test(flavor = "current_thread")] async fn policy_rule_uses_to_table_action() { if std::env::var(REEXEC_ENV_VAR).is_err() { run_in_isolated_netns("routes::linux::tests::policy_rule_uses_to_table_action"); return; } let mut mgr = RouteManager::new("lo".to_string()) .await .expect("failed to create RouteManager in isolated netns"); mgr.install_policy_rule().await; let mut rules = mgr.handle.rule().get(IpVersion::V4).execute(); let mut found = false; while let Some(rule) = rules.try_next().await.unwrap() { let is_ours = rule.attributes.iter().any(|attr| { matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY) }); if is_ours { assert_eq!( rule.header.action, RuleAction::ToTable, "policy rule must use the ToTable action, or the kernel treats it as a black hole for every packet it matches (i.e. everything)" ); found = true; } } assert!(found, "installed policy rule not found via rule().get()"); } }