From b1606527375c7567523d99c8e57d4c210cc84705 Mon Sep 17 00:00:00 2001 From: "E. Kaparulin" Date: Thu, 16 Jul 2026 14:39:57 +0300 Subject: [PATCH] chore: sync konduit-platform v0.1.0-beta.8 --- konduit-platform/src/routes/linux.rs | 155 +++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 21 deletions(-) diff --git a/konduit-platform/src/routes/linux.rs b/konduit-platform/src/routes/linux.rs index efaecab..e09e779 100644 --- a/konduit-platform/src/routes/linux.rs +++ b/konduit-platform/src/routes/linux.rs @@ -206,20 +206,7 @@ impl RouteManager { self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0)); - // 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. - match std::process::Command::new("ip") - .args(["rule", "add", "priority", &VPN_RULE_PRIORITY.to_string(), "lookup", "main"]) - .status() - { - Ok(s) if s.success() => { - self.added_policy_rule = true; - info!("Added ip rule priority {} → main table", VPN_RULE_PRIORITY); - } - Ok(_) => warn!("ip rule add returned non-zero (rule may already exist)"), - Err(e) => warn!("Failed to run ip rule add: {}", e), - } + self.install_policy_rule().await; } else { info!( "Split-tunnel mode: adding {} routes via VPN...", @@ -281,6 +268,52 @@ impl RouteManager { 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() { @@ -425,14 +458,28 @@ 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 { - match std::process::Command::new("ip") - .args(["rule", "del", "priority", &VPN_RULE_PRIORITY.to_string(), "lookup", "main"]) - .status() - { - Ok(s) if s.success() => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY), - Ok(_) => warn!("ip rule del returned non-zero"), - Err(e) => warn!("Failed to run ip rule del: {}", e), + 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; } @@ -506,3 +553,69 @@ impl Drop for RouteManager { } } } + +#[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()"); + } +}