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, } impl RouteManager { pub async fn new(tun_interface: String) -> Result { Ok(Self { dns_manager: DnsManager::new( tun_interface.clone(), None, IpAddr::V4(Ipv4Addr::UNSPECIFIED), ), tun_interface, tun_routes: Vec::new(), server_ip: None, wifi_gateway: None, }) } fn get_default_gateway() -> Result { 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); } } } } 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") .args([ "add", &dest.to_string(), "MASK", &mask.to_string(), &gateway.to_string(), "METRIC", "1", ]) .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!("route add {}/{} via {}: {}", dest, prefix, gateway, stdout.trim()); } } Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, 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(), ]) .creation_flags(CREATE_NO_WINDOW) .status(); } // 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 = Self::get_default_gateway()?; info!("Current gateway: {}", current_gateway); 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(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, 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)) = (self.server_ip, self.wifi_gateway) { Self::wifi_route_add(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. if let (Some(server_ip), Some(gateway)) = (self.server_ip.take(), self.wifi_gateway.take()) { Self::wifi_route_delete(server_ip, 32, gateway); } 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() ); } } }