From 678ed158a66a8fde7b56b0aba5571ce62cbc6fcb Mon Sep 17 00:00:00 2001 From: "E. Kaparulin" Date: Thu, 23 Jul 2026 18:33:05 +0300 Subject: [PATCH] chore: sync konduit-platform --- konduit-platform/src/dns/linux.rs | 119 +++++++++++++++++++++++ konduit-platform/src/dns/mod.rs | 24 ++++- konduit-platform/src/routes/linux.rs | 128 +++++++++++++++++++++---- konduit-platform/src/routes/macos.rs | 19 +++- konduit-platform/src/routes/windows.rs | 93 +++++++++--------- 5 files changed, 318 insertions(+), 65 deletions(-) diff --git a/konduit-platform/src/dns/linux.rs b/konduit-platform/src/dns/linux.rs index 565a40e..bf2c895 100644 --- a/konduit-platform/src/dns/linux.rs +++ b/konduit-platform/src/dns/linux.rs @@ -31,6 +31,31 @@ trait Resolve1Manager { fn revert_link(&self, ifindex: i32) -> zbus::Result<()>; } +// Per-link object (/org/freedesktop/resolve1/link/_), used only to +// read back the physical interface's current DNS servers before we override +// them, so they can be restored byte-for-byte on disconnect. +// +// SetLinkDefaultRoute (the "correct" API for demoting a link as a default DNS +// route) requires interactive polkit auth (auth_admin_keep) — unlike +// SetLinkDNS/SetLinkDomains, it does NOT get a CAP_NET_ADMIN bypass in +// systemd-resolved, so it silently fails for this capability-only (non-root) +// process. Reusing SetLinkDNS on the physical link's own object instead stays +// on the bypass path. +#[zbus::proxy(interface = "org.freedesktop.resolve1.Link", default_service = "org.freedesktop.resolve1")] +trait Resolve1Link { + #[zbus(property, name = "DNS")] + fn dns(&self) -> zbus::Result)>>; +} + +fn link_object_path(if_index: u32) -> String { + let mut path = String::from("/org/freedesktop/resolve1/link/"); + for c in if_index.to_string().chars() { + path.push_str("_3"); + path.push(c); + } + path +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -38,6 +63,7 @@ trait Resolve1Manager { pub async fn set_dns( interface: &str, if_index: Option, + physical_if_index: Option, dns_servers: &[IpAddr], _assigned_ip: IpAddr, state: &mut DnsState, @@ -59,6 +85,31 @@ pub async fn set_dns( match result { Ok(_) => { state.configured = true; + // Both the physical link and the tunnel link end up with + // Default Route: yes in systemd-resolved (the physical link + // never loses that flag just because another link gains it). + // With two "default route" resolvers, systemd-resolved can + // race queries between the VPN's DNS server and the ISP's, + // leaking lookups outside the tunnel and returning + // inconsistent/partial answers (e.g. AAAA-only responses). + // Blank out the physical link's own DNS server list so it has + // nothing to answer with, leaving the tunnel as the sole + // practical resolver; its original servers are saved and + // restored on disconnect. + if let Some(phys_idx) = physical_if_index { + match clear_physical_link_dns(phys_idx).await { + Ok(original) => { + state.demoted_physical_if_index = Some(phys_idx); + state.physical_dns_backup = original; + } + Err(e) => { + warn!( + "Failed to clear physical interface {} DNS servers: {}", + phys_idx, e + ); + } + } + } } Err(e) => { warn!( @@ -112,6 +163,18 @@ pub async fn restore( // Always run resolvectl revert as a second pass. revert_resolvectl(interface).await.ok(); + // Restore the physical interface's original DNS servers we blanked out + // in set_dns, or it's left with no resolver after the VPN disconnects. + if let Some(phys_idx) = state.demoted_physical_if_index.take() { + let backup = std::mem::take(&mut state.physical_dns_backup); + if let Err(e) = restore_physical_link_dns(phys_idx, backup).await { + warn!( + "Failed to restore physical interface {} DNS servers: {}", + phys_idx, e + ); + } + } + state.configured = false; Ok(()) } @@ -165,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> { Ok(()) } +/// Reads the physical link's current DNS servers, then blanks them out so it +/// stops acting as a competing resolver. Returns the original list to restore +/// later. Both the read and the clear go through the same CAP_NET_ADMIN +/// bypass path as the tunnel's own SetLinkDNS call. +async fn clear_physical_link_dns(if_index: u32) -> Result)>> { + let conn = zbus::Connection::system() + .await + .context("Failed to connect to D-Bus system bus")?; + + let link_proxy = Resolve1LinkProxy::builder(&conn) + .path(link_object_path(if_index)) + .context("Invalid link object path")? + .build() + .await + .context("Failed to create resolve1 link proxy")?; + + let original = link_proxy + .dns() + .await + .context("Failed to read physical link DNS servers")?; + + let manager_proxy = Resolve1ManagerProxy::new(&conn) + .await + .context("Failed to create resolve1 proxy")?; + + manager_proxy + .set_link_dns(if_index as i32, vec![]) + .await + .context("SetLinkDNS (clear) failed")?; + + info!( + "Cleared physical interface {} DNS servers ({} saved for restore)", + if_index, + original.len() + ); + Ok(original) +} + +async fn restore_physical_link_dns(if_index: u32, original: Vec<(i32, Vec)>) -> Result<()> { + let conn = zbus::Connection::system() + .await + .context("Failed to connect to D-Bus system bus")?; + + let proxy = Resolve1ManagerProxy::new(&conn) + .await + .context("Failed to create resolve1 proxy")?; + + proxy + .set_link_dns(if_index as i32, original) + .await + .context("SetLinkDNS (restore) failed")?; + + info!("Restored physical interface {} DNS servers", if_index); + Ok(()) +} + async fn revert_link_dbus(if_index: u32) -> Result<()> { let conn = zbus::Connection::system() .await diff --git a/konduit-platform/src/dns/mod.rs b/konduit-platform/src/dns/mod.rs index dddd306..d3a4f54 100644 --- a/konduit-platform/src/dns/mod.rs +++ b/konduit-platform/src/dns/mod.rs @@ -16,6 +16,9 @@ pub struct DnsManager { interface: String, /// Interface index (if available/relevant) if_index: Option, + /// Pre-VPN default-gateway interface index, demoted as DNS default route + /// while connected (Linux only) + physical_if_index: Option, /// IP address assigned to the TUN interface assigned_ip: IpAddr, /// State to track if we modified DNS @@ -26,14 +29,29 @@ pub struct DnsManager { struct DnsState { configured: bool, _original_dns: Vec, + // Physical interface demoted from systemd-resolved's default DNS route + // (Linux only) while the tunnel is the default; restored on disconnect. + demoted_physical_if_index: Option, + // Physical interface's original DNS servers (address_family, raw_bytes), + // saved before blanking them out; restored verbatim on disconnect. + physical_dns_backup: Vec<(i32, Vec)>, } impl DnsManager { - /// Create a new DNS manager for a specific interface - pub fn new(interface: String, if_index: Option, assigned_ip: IpAddr) -> Self { + /// Create a new DNS manager for a specific interface. + /// `physical_if_index` is the pre-VPN default-gateway interface (Linux + /// only) — needed so the tunnel can become the sole default DNS + /// resolver instead of racing systemd-resolved queries against it. + pub fn new( + interface: String, + if_index: Option, + physical_if_index: Option, + assigned_ip: IpAddr, + ) -> Self { Self { interface, if_index, + physical_if_index, assigned_ip, state: Arc::new(Mutex::new(DnsState::default())), } @@ -51,7 +69,7 @@ impl DnsManager { // Platform specific implementation #[cfg(target_os = "linux")] { - linux::set_dns(&self.interface, self.if_index, dns_servers, self.assigned_ip, &mut state).await?; + linux::set_dns(&self.interface, self.if_index, self.physical_if_index, dns_servers, self.assigned_ip, &mut state).await?; } #[cfg(target_os = "windows")] diff --git a/konduit-platform/src/routes/linux.rs b/konduit-platform/src/routes/linux.rs index e09e779..6e2429d 100644 --- a/konduit-platform/src/routes/linux.rs +++ b/konduit-platform/src/routes/linux.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use futures::stream::TryStreamExt; -use netlink_packet_route::route::RouteProtocol; +use netlink_packet_route::route::{RouteProtocol, RouteType}; use rtnetlink::{new_connection, Handle, IpVersion}; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use tracing::{info, warn}; // Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001). @@ -25,10 +25,21 @@ pub struct RouteManager { // Saved during setup for re-adding the server host route after resume. server_ip: Option, wifi_gateway: Option, + // Physical interface the server host route must be pinned to. Without this, + // if the VPN's assigned tun subnet happens to contain the physical gateway + // IP (e.g. both are 10.x.0.1), the kernel resolves the gateway via tun0's + // own directly-connected route instead of the physical NIC, looping the + // server route back into the tunnel it's meant to bypass. + wifi_iface_index: 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, + // True when we installed an IPv6 "unreachable" default route to block + // IPv6 leaks (full-tunnel mode only pushes an IPv4 default route via + // tun0, so without this, any AAAA-resolved destination would route out + // the physical NIC directly, bypassing the tunnel entirely). + added_ipv6_blackhole: bool, } impl RouteManager { @@ -43,11 +54,13 @@ impl RouteManager { 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)), + dns_manager: crate::dns::DnsManager::new(tun_interface, None, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)), server_ip: None, wifi_gateway: None, + wifi_iface_index: None, vpn_gateway: None, added_policy_rule: false, + added_ipv6_blackhole: false, }) } @@ -66,14 +79,18 @@ impl RouteManager { } } - async fn get_default_gateway(&self) -> Result { + /// Returns the physical default gateway and the interface it's reachable on. + /// The interface index must be used to pin any route meant to bypass the + /// tunnel — relying on gateway-only resolution lets the kernel route it via + /// tun0 instead if the VPN's assigned subnet happens to contain this gateway IP. + async fn get_default_gateway(&self) -> Result<(Ipv4Addr, u32)> { 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(); + let mut candidates: Vec<(u32, Ipv4Addr, u32)> = Vec::new(); while let Some(route) = routes.try_next().await? { if route.header.destination_prefix_length != 0 { @@ -81,25 +98,27 @@ impl RouteManager { } let mut metric = 0u32; let mut gateway: Option = None; + let mut oif: 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, + netlink_packet_route::route::RouteAttribute::Oif(idx) => oif = Some(*idx), _ => {} } } - if let Some(gw) = gateway { - candidates.push((metric, gw)); + if let (Some(gw), Some(idx)) = (gateway, oif) { + candidates.push((metric, gw, idx)); } } // Highest metric = physical/DHCP interface (not VPN). candidates .into_iter() - .max_by_key(|(m, _)| *m) - .map(|(_, gw)| gw) + .max_by_key(|(m, _, _)| *m) + .map(|(_, gw, idx)| (gw, idx)) .ok_or_else(|| anyhow::anyhow!("No default gateway found")) } @@ -126,11 +145,15 @@ impl RouteManager { self.tun_index.unwrap() ); - let current_gateway = self + let (current_gateway, wifi_iface_index) = self .get_default_gateway() .await .context("Failed to get current gateway")?; - info!("Current gateway: {}", current_gateway); + self.wifi_iface_index = Some(wifi_iface_index); + info!( + "Current gateway: {} (interface index {})", + current_gateway, wifi_iface_index + ); // Resolve server address to IP let server_ip = if let Ok(ip) = server_addr.parse::() { @@ -160,6 +183,7 @@ impl RouteManager { .v4() .destination_prefix(server_v4, 32) .gateway(current_gateway) + .output_interface(wifi_iface_index) .priority(VPN_ROUTE_METRIC) .execute() .await; @@ -207,6 +231,7 @@ impl RouteManager { self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0)); self.install_policy_rule().await; + self.block_ipv6_leak().await; } else { info!( "Split-tunnel mode: adding {} routes via VPN...", @@ -314,14 +339,59 @@ impl RouteManager { } } + /// Block outbound IPv6 in full-tunnel mode by installing an "unreachable" + /// default route (::/0). Full-tunnel only pushes an IPv4 default route + /// via the VPN gateway (VPN servers here don't provide IPv6 transport), + /// so without this, any destination that resolves to an AAAA record + /// leaks straight out the physical interface instead of the tunnel — + /// bypassing it entirely rather than merely failing closed. Failing the + /// IPv6 attempt fast also lets Happy-Eyeballs clients fall back to IPv4 + /// (through the tunnel) quickly instead of hanging on an IPv6 path that + /// silently goes nowhere. + async fn block_ipv6_leak(&mut self) { + let result = self + .handle + .route() + .add() + .v6() + .destination_prefix(Ipv6Addr::UNSPECIFIED, 0) + .kind(RouteType::Unreachable) + .priority(VPN_ROUTE_METRIC) + .execute() + .await; + + match result { + Ok(_) => { + self.added_ipv6_blackhole = true; + info!("✅ IPv6 default route blocked (prevents leaking outside tunnel)"); + } + 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_ipv6_blackhole = true; + warn!("IPv6 blackhole route already exists, continuing..."); + } else { + warn!("Failed to block IPv6 default route: {:?}", 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 = crate::dns::DnsManager::new( + self.tun_interface.clone(), + self.tun_index, + self.wifi_iface_index, + assigned_ip, + ); self.dns_manager.set_dns(dns_servers).await } @@ -344,16 +414,18 @@ impl RouteManager { _ => return Ok(()), }; - let result = self + let mut req = self .handle .route() .add() .v4() .destination_prefix(server_ip, 32) .gateway(gateway) - .priority(VPN_ROUTE_METRIC) - .execute() - .await; + .priority(VPN_ROUTE_METRIC); + if let Some(idx) = self.wifi_iface_index { + req = req.output_interface(idx); + } + let result = req.execute().await; match result { Ok(_) => { @@ -484,6 +556,28 @@ impl RouteManager { 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 { + 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"), + } + self.added_ipv6_blackhole = false; + } + if self.added_routes.is_empty() { return Ok(()); } diff --git a/konduit-platform/src/routes/macos.rs b/konduit-platform/src/routes/macos.rs index 10346bd..b59f57d 100644 --- a/konduit-platform/src/routes/macos.rs +++ b/konduit-platform/src/routes/macos.rs @@ -7,6 +7,12 @@ pub struct MacOsNetManager { server_host: String, original_gateway: String, primary_service: String, + // Physical interface the server host route must be pinned to via -ifscope. + // Without this, if the VPN's assigned tun subnet happens to contain the + // physical gateway IP, the OS can resolve the gateway via the tun + // interface's own directly-connected route instead of the physical NIC, + // looping the server route back into the tunnel it's meant to bypass. + wifi_iface: String, } impl MacOsNetManager { @@ -42,13 +48,18 @@ impl MacOsNetManager { server_host, original_gateway, primary_service, + wifi_iface: iface, }) } // Always adds split-default (0/1 + 128/1) for full-tunnel mode. // Split-tunnel (per-route from ServerConfig) is not yet supported on macOS. pub async fn setup_vpn_routes(&self) -> Result<()> { - run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await; + run( + "route", + &["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface], + ) + .await; run("route", &["add", "-net", "0.0.0.0/1", "-interface", &self.tun_name]).await; run("route", &["add", "-net", "128.0.0.0/1", "-interface", &self.tun_name]).await; info!("macOS VPN routes added via {}", self.tun_name); @@ -89,7 +100,11 @@ impl MacOsNetManager { // the wrong gateway until the next reconnect that creates a new MacOsNetManager. pub async fn ensure_server_route(&self) -> Result<()> { run("route", &["delete", "-host", &self.server_host]).await; - run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await; + run( + "route", + &["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface], + ) + .await; Ok(()) } diff --git a/konduit-platform/src/routes/windows.rs b/konduit-platform/src/routes/windows.rs index 3822ee9..2303d5c 100644 --- a/konduit-platform/src/routes/windows.rs +++ b/konduit-platform/src/routes/windows.rs @@ -13,6 +13,12 @@ pub struct RouteManager { tun_routes: Vec<(Ipv4Addr, u8)>, server_ip: Option, wifi_gateway: Option, + // Physical interface name the server host route must be pinned to. Without + // this, if the VPN's assigned tun subnet happens to contain the physical + // gateway IP, `route add`'s gateway-only resolution can pick the tun + // interface instead of the physical NIC, looping the server route back + // into the tunnel it's meant to bypass. + wifi_iface: Option, } impl RouteManager { @@ -27,17 +33,23 @@ impl RouteManager { tun_routes: Vec::new(), server_ip: None, wifi_gateway: None, + wifi_iface: None, }) } - fn get_default_gateway() -> Result { + /// Returns the physical default gateway and the friendly name of the + /// adapter it's reachable on. The interface name must be used to pin any + /// route meant to bypass the tunnel — relying on gateway-only resolution + /// lets `route add` route it via the TUN adapter instead if the VPN's + /// assigned subnet happens to contain this gateway IP. + fn get_default_gateway() -> Result<(Ipv4Addr, String)> { let adapters = ipconfig::get_adapters() .map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?; for adapter in &adapters { for gw in adapter.gateways() { if let IpAddr::V4(gw4) = gw { if !gw4.is_loopback() && !gw4.is_unspecified() { - return Ok(*gw4); + return Ok((*gw4, adapter.friendly_name().to_string())); } } } @@ -45,29 +57,26 @@ impl RouteManager { anyhow::bail!("No default gateway found") } - fn prefix_to_mask(prefix: u8) -> Ipv4Addr { - let bits = if prefix == 0 { - 0u32 - } else { - !0u32 << (32 - prefix) - }; - let [a, b, c, d] = bits.to_be_bytes(); - Ipv4Addr::new(a, b, c, d) - } - - // Add/delete server host route through WiFi gateway via `route` command. - // Specifying gateway on delete ensures we only remove our entry, not any other path. - fn wifi_route_add(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) { - let mask = Self::prefix_to_mask(prefix); - let out = std::process::Command::new("route") + // Add/delete the server host route through the physical WiFi/Ethernet + // interface, pinned explicitly by adapter name via `netsh` rather than + // `route add`'s gateway-only resolution — same rationale and technique as + // `tun_route_add` below: without an explicit interface, an IP collision + // between the VPN's assigned tun subnet and the physical gateway can make + // this route resolve onto the TUN adapter instead, looping the server + // route back into the tunnel it's meant to bypass. + fn wifi_route_add(iface: &str, dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) { + let prefix_str = format!("{}/{}", dest, prefix); + let out = std::process::Command::new("netsh") .args([ + "interface", + "ipv4", "add", - &dest.to_string(), - "MASK", - &mask.to_string(), - &gateway.to_string(), - "METRIC", - "1", + "route", + &prefix_str, + iface, + &format!("nexthop={}", gateway), + "metric=1", + "store=active", ]) .creation_flags(CREATE_NO_WINDOW) .output(); @@ -76,25 +85,19 @@ impl RouteManager { let stdout = String::from_utf8_lossy(&o.stdout); if !o.status.success() { // "already exists" is acceptable — a previous run may have left it - warn!("route add {}/{} via {}: {}", dest, prefix, gateway, stdout.trim()); + warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim()); } } - Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, e), + Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e), } } - fn wifi_route_delete(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) { - let mask = Self::prefix_to_mask(prefix); - let _ = std::process::Command::new("route") - .args([ - "delete", - &dest.to_string(), - "MASK", - &mask.to_string(), - &gateway.to_string(), - ]) + fn wifi_route_delete(iface: &str, dest: Ipv4Addr, prefix: u8) { + let prefix_str = format!("{}/{}", dest, prefix); + let _ = std::process::Command::new("netsh") + .args(["interface", "ipv4", "delete", "route", &prefix_str, iface]) .creation_flags(CREATE_NO_WINDOW) - .status(); + .output(); } // Add/delete routes through the TUN interface using netsh. @@ -169,8 +172,9 @@ impl RouteManager { ) -> Result<()> { info!("Setting up Windows VPN routes..."); - let current_gateway = Self::get_default_gateway()?; - info!("Current gateway: {}", current_gateway); + let (current_gateway, wifi_iface) = Self::get_default_gateway()?; + info!("Current gateway: {} (interface {})", current_gateway, wifi_iface); + self.wifi_iface = Some(wifi_iface.clone()); let server_ip: Ipv4Addr = { let host = server_addr.split(':').next().unwrap_or(server_addr); @@ -189,7 +193,7 @@ impl RouteManager { // even if the add below fails because the route already exists. self.server_ip = Some(server_ip); self.wifi_gateway = Some(current_gateway); - Self::wifi_route_add(server_ip, 32, current_gateway); + Self::wifi_route_add(&wifi_iface, server_ip, 32, current_gateway); let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0"); @@ -242,8 +246,10 @@ impl RouteManager { } pub async fn ensure_server_route(&mut self) -> Result<()> { - if let (Some(server_ip), Some(gateway)) = (self.server_ip, self.wifi_gateway) { - Self::wifi_route_add(server_ip, 32, gateway); + if let (Some(server_ip), Some(gateway), Some(iface)) = + (self.server_ip, self.wifi_gateway, self.wifi_iface.as_deref()) + { + Self::wifi_route_add(iface, server_ip, 32, gateway); } Ok(()) } @@ -280,8 +286,9 @@ impl RouteManager { } // Always clean up the server host route — a previous unclean exit may have // left it behind even if our add was skipped. - if let (Some(server_ip), Some(gateway)) = (self.server_ip.take(), self.wifi_gateway.take()) { - Self::wifi_route_delete(server_ip, 32, gateway); + self.wifi_gateway.take(); + if let (Some(server_ip), Some(iface)) = (self.server_ip.take(), self.wifi_iface.take()) { + Self::wifi_route_delete(&iface, server_ip, 32); } info!("Windows VPN routes removed"); Ok(())