use anyhow::Result; use std::net::{IpAddr, Ipv4Addr}; use std::os::windows::process::CommandExt; use tracing::{info, warn}; const CREATE_NO_WINDOW: u32 = 0x0800_0000; use crate::dns::DnsManager; pub struct RouteManager { tun_interface: String, dns_manager: DnsManager, 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 { pub async fn new(tun_interface: String) -> Result { Ok(Self { dns_manager: DnsManager::new( tun_interface.clone(), None, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED), ), tun_interface, tun_routes: Vec::new(), server_ip: None, wifi_gateway: None, wifi_iface: None, }) } /// 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, adapter.friendly_name().to_string())); } } } } anyhow::bail!("No default gateway found") } // 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", "route", &prefix_str, iface, &format!("nexthop={}", gateway), "metric=1", "store=active", ]) .creation_flags(CREATE_NO_WINDOW) .output(); match out { Ok(o) => { let stdout = String::from_utf8_lossy(&o.stdout); if !o.status.success() { // "already exists" is acceptable — a previous run may have left it warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim()); } } Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e), } } 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) .output(); } // Add/delete routes through the TUN interface using netsh. // // `netsh interface ipv4 add route` binds routes to a named interface explicitly, // avoiding the gateway-resolution ambiguity of the `route` command and ensuring // cleanup never touches WiFi routing state. fn tun_route_add(tun_name: &str, dest: Ipv4Addr, prefix: u8) { let prefix_str = format!("{}/{}", dest, prefix); let out = std::process::Command::new("netsh") .args([ "interface", "ipv4", "add", "route", &prefix_str, tun_name, "nexthop=0.0.0.0", "metric=1", "store=active", ]) .creation_flags(CREATE_NO_WINDOW) .output(); match out { Ok(o) => { let stdout = String::from_utf8_lossy(&o.stdout); if o.status.success() { info!("Added TUN route {}/{}", dest, prefix); } else { warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, stdout.trim()); } } Err(e) => warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, e), } } fn tun_route_delete(tun_name: &str, dest: Ipv4Addr, prefix: u8) { let prefix_str = format!("{}/{}", dest, prefix); let out = std::process::Command::new("netsh") .args([ "interface", "ipv4", "delete", "route", &prefix_str, tun_name, ]) .creation_flags(CREATE_NO_WINDOW) .output(); match out { Ok(o) => { let stdout = String::from_utf8_lossy(&o.stdout); if !o.status.success() { warn!( "netsh delete route {}/{} on {}: {}", dest, prefix, tun_name, stdout.trim() ); } } Err(e) => warn!("netsh delete route {}/{} on {}: {}", dest, prefix, tun_name, e), } } pub async fn setup_vpn_routes( &mut self, server_addr: &str, _vpn_gateway: IpAddr, routes: &[String], ) -> Result<()> { info!("Setting up Windows VPN routes..."); 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); if let Ok(IpAddr::V4(v4)) = host.parse::() { v4 } else { let addrs = tokio::net::lookup_host(format!("{}:0", host)).await?; match addrs.map(|a| a.ip()).next() { Some(IpAddr::V4(v4)) => v4, _ => anyhow::bail!("Could not resolve server address to IPv4"), } } }; // Always record server + gateway so cleanup can delete the host route // 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(&wifi_iface, server_ip, 32, current_gateway); let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0"); if is_full_tunnel { // Two /1 routes on the TUN interface cover the entire address space and // always beat the existing WiFi /0 default route regardless of metric. // netsh with explicit interface name avoids gateway-resolution ambiguity. info!("Full-tunnel mode: adding /1 cover routes on {}...", self.tun_interface); let lower = Ipv4Addr::new(0, 0, 0, 0); let upper = Ipv4Addr::new(128, 0, 0, 0); Self::tun_route_add(&self.tun_interface, lower, 1); self.tun_routes.push((lower, 1)); Self::tun_route_add(&self.tun_interface, upper, 1); self.tun_routes.push((upper, 1)); } else { info!("Split-tunnel mode: adding {} routes via TUN...", 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 Ok(dest) = dest_str.parse::() else { warn!("Invalid route destination: {}", dest_str); continue; }; let Ok(prefix_len) = prefix_str.parse::() else { warn!("Invalid prefix length: {}", prefix_str); continue; }; Self::tun_route_add(&self.tun_interface, dest, prefix_len); self.tun_routes.push((dest, prefix_len)); info!("Added split-tunnel route: {}", route_str); } } info!("Windows VPN routes configured"); Ok(()) } pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> { if dns_servers.is_empty() { return Ok(()); } self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, None, assigned_ip); self.dns_manager.set_dns(dns_servers).await } pub async fn restore_dns(&self) -> Result<()> { self.dns_manager.restore().await } pub async fn ensure_server_route(&mut self) -> Result<()> { 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(()) } pub async fn suspend_default_route(&mut self) -> Result<()> { let lower = Ipv4Addr::new(0, 0, 0, 0); if !self.tun_routes.contains(&(lower, 1)) { return Ok(()); } for (dest, prefix) in [(lower, 1u8), (Ipv4Addr::new(128, 0, 0, 0), 1u8)] { Self::tun_route_delete(&self.tun_interface, dest, prefix); } info!("VPN cover routes suspended — traffic falls to WiFi during reconnect"); Ok(()) } pub async fn resume_default_route(&mut self) -> Result<()> { let lower = Ipv4Addr::new(0, 0, 0, 0); if !self.tun_routes.contains(&(lower, 1)) { return Ok(()); } let upper = Ipv4Addr::new(128, 0, 0, 0); for (dest, prefix) in [(lower, 1u8), (upper, 1u8)] { Self::tun_route_add(&self.tun_interface, dest, prefix); } info!("VPN cover routes restored after reconnect"); Ok(()) } pub async fn restore_routes(&mut self) -> Result<()> { info!("Removing {} tracked VPN routes...", self.tun_routes.len()); for (dest, prefix) in self.tun_routes.drain(..) { Self::tun_route_delete(&self.tun_interface, dest, prefix); } // Always clean up the server host route — a previous unclean exit may have // left it behind even if our add was skipped. 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(()) } } impl Drop for RouteManager { fn drop(&mut self) { if !self.tun_routes.is_empty() { warn!( "RouteManager dropped with {} unrestored TUN routes — call restore_routes() before dropping", self.tun_routes.len() ); } } }