Files
konduit-public/konduit-platform/src/routes/windows.rs
2026-07-23 18:44:50 +03:00

309 lines
12 KiB
Rust

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<Ipv4Addr>,
wifi_gateway: Option<Ipv4Addr>,
// 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<String>,
}
impl RouteManager {
pub async fn new(tun_interface: String) -> Result<Self> {
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::<IpAddr>() {
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::<Ipv4Addr>() else {
warn!("Invalid route destination: {}", dest_str);
continue;
};
let Ok(prefix_len) = prefix_str.parse::<u8>() 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()
);
}
}
}