From 9eb9b39f774a15a187af232d6dd1357d181d14c9 Mon Sep 17 00:00:00 2001 From: "E. Kaparulin" Date: Fri, 19 Jun 2026 10:41:56 +0300 Subject: [PATCH] chore: sync konduit-platform v0.1.0-beta.3 --- konduit-platform/Cargo.toml | 3 +- konduit-platform/src/dns/windows.rs | 111 ++++----- konduit-platform/src/routes/mod.rs | 5 + konduit-platform/src/routes/windows.rs | 300 +++++++++++++++++++++++++ konduit-platform/src/tun.rs | 183 ++++++++++++--- 5 files changed, 503 insertions(+), 99 deletions(-) create mode 100644 konduit-platform/src/routes/windows.rs diff --git a/konduit-platform/Cargo.toml b/konduit-platform/Cargo.toml index 70a5761..892269c 100644 --- a/konduit-platform/Cargo.toml +++ b/konduit-platform/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "konduit-platform" -version = "0.1.0" +version.workspace = true edition = "2021" authors = ["Eugen Kaparulin "] license = "PolyForm-Noncommercial-1.0.0" @@ -40,6 +40,7 @@ windows = { version = "0.52", features = [ "Win32_Networking_WinSock", ] } ipconfig = "0.3" +wintun = "0.3" [target.'cfg(target_os = "macos")'.dependencies] system-configuration = "0.5" diff --git a/konduit-platform/src/dns/windows.rs b/konduit-platform/src/dns/windows.rs index cf7c94a..ac1c557 100644 --- a/konduit-platform/src/dns/windows.rs +++ b/konduit-platform/src/dns/windows.rs @@ -1,6 +1,10 @@ use super::DnsState; use anyhow::Result; use std::net::IpAddr; +use std::os::windows::process::CommandExt; +use tracing::info; + +const CREATE_NO_WINDOW: u32 = 0x0800_0000; pub async fn set_dns( interface: &str, @@ -8,92 +12,57 @@ pub async fn set_dns( dns_servers: &[IpAddr], state: &mut DnsState, ) -> Result<()> { - // Windows implementation using windows-rs crate - // Note: This logic runs within unsafe blocks as it calls Win32 APIs + if dns_servers.is_empty() { + return Ok(()); + } - use std::ffi::c_void; - use windows::Win32::Foundation::NO_ERROR; - use windows::Win32::NetworkManagement::IpHelper::{ - SetInterfaceDnsSettings, DNS_INTERFACE_SETTINGS, DNS_INTERFACE_SETTINGS_FLAGS, - DNS_INTERFACE_SETTINGS_VERSION1, DNS_SETTING_NAMESERVER, + let addrs: Vec = dns_servers.iter().map(|ip| ip.to_string()).collect(); + let addrs_str = addrs.join(","); + + let iface_arg = if let Some(idx) = if_index { + format!("-InterfaceIndex {}", idx) + } else { + format!("-InterfaceAlias '{}'", interface) }; - // We need the Interface LUID (Locally Unique Identifier) or Index/GUID. - // The user provided `interface` string might be a GUID or friendly name. - // If if_index is provided, that's easier for some APIs. + info!("Setting DNS on {} to {}", interface, addrs_str); - // Convert IP addresses to wide string (UTF-16) comma-separated - let mut dns_str = String::new(); - for (i, ip) in dns_servers.iter().enumerate() { - if i > 0 { - dns_str.push(','); - } - dns_str.push_str(&ip.to_string()); - } - let dns_wide: Vec = dns_str.encode_utf16().chain(std::iter::once(0)).collect(); - - if let Some(idx) = if_index { - // Find existing settings to backup? - // Windows doesn't make it easy to "get current and restore later" without some work. - // For simplicity, we assume we can just clear settings on restore. - } - - /* - Implementation note: - Since we cannot easily compile this on Linux to verify, we outline the logic. - Real implementation would need `GetInterfaceDnsSettings` to backup state. - */ - - tracing::info!( - "Setting DNS on Windows for interface {}: {:?}", - interface, - dns_servers + let script = format!( + "Set-DnsClientServerAddress {} -ServerAddresses {}", + iface_arg, addrs_str ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .creation_flags(CREATE_NO_WINDOW) + .status()?; - /* - unsafe { - let mut settings = DNS_INTERFACE_SETTINGS { - Version: DNS_INTERFACE_SETTINGS_VERSION1, - Flags: DNS_SETTING_NAMESERVER, - NameServer: windows::core::PCWSTR(dns_wide.as_ptr()), - ..Default::default() - }; - - let guid = windows::core::GUID::from(interface)?; // Assuming interface is a GUID string - - let result = SetInterfaceDnsSettings(guid, &mut settings); - if result != NO_ERROR { - anyhow::bail!("Failed to set DNS: {:?}", result); - } + if !status.success() { + anyhow::bail!("PowerShell failed to set DNS on {}", interface); } - */ - - // For now, since we can't test, we log a limitation - tracing::warn!("Windows DNS setting logic placeholder"); state.configured = true; Ok(()) } pub async fn restore( - _interface: &str, - _if_index: Option, - _state: &mut DnsState, + interface: &str, + if_index: Option, + state: &mut DnsState, ) -> Result<()> { - tracing::info!("Restoring DNS on Windows..."); + info!("Restoring DNS on {} to automatic", interface); - // Clear custom DNS settings (return to DHCP?) - /* - unsafe { - let mut settings = DNS_INTERFACE_SETTINGS { - Version: DNS_INTERFACE_SETTINGS_VERSION1, - Flags: DNS_SETTING_NAMESERVER, - NameServer: windows::core::PCWSTR::null(), - ..Default::default() - }; - // Call SetInterfaceDnsSettings with empty NameServer or appropriate flags - } - */ + let iface_arg = if let Some(idx) = if_index { + format!("-InterfaceIndex {}", idx) + } else { + format!("-InterfaceAlias '{}'", interface) + }; + let script = format!("Set-DnsClientServerAddress {} -ResetServerAddresses", iface_arg); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + + state.configured = false; Ok(()) } diff --git a/konduit-platform/src/routes/mod.rs b/konduit-platform/src/routes/mod.rs index 89f1500..ff965dc 100644 --- a/konduit-platform/src/routes/mod.rs +++ b/konduit-platform/src/routes/mod.rs @@ -7,3 +7,8 @@ pub use linux::RouteManager; mod macos; #[cfg(target_os = "macos")] pub use macos::MacOsNetManager; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub use windows::RouteManager; diff --git a/konduit-platform/src/routes/windows.rs b/konduit-platform/src/routes/windows.rs new file mode 100644 index 0000000..3822ee9 --- /dev/null +++ b/konduit-platform/src/routes/windows.rs @@ -0,0 +1,300 @@ +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() + ); + } + } +} diff --git a/konduit-platform/src/tun.rs b/konduit-platform/src/tun.rs index 53f71cb..8f6c1b8 100644 --- a/konduit-platform/src/tun.rs +++ b/konduit-platform/src/tun.rs @@ -1,14 +1,26 @@ use anyhow::Result; -use bytes::{Bytes, BytesMut}; +use bytes::Bytes; +#[cfg(not(target_os = "windows"))] +use bytes::BytesMut; use std::net::IpAddr; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; -use tracing::{debug, error, info}; +use tracing::{error, info}; +#[cfg(target_os = "linux")] +use tracing::debug; + +// Windows uses wintun directly to avoid the tun crate's poll_read bug, which spawns +// a new OS thread on every Poll::Pending call, causing thread explosion and packet loss. +#[cfg(target_os = "windows")] +use std::sync::Arc; /// Interface to the TUN device pub struct TunDevice { pub name: String, + #[cfg(not(target_os = "windows"))] device: tun::AsyncDevice, + #[cfg(target_os = "windows")] + session: Arc, + #[cfg_attr(target_os = "windows", allow(dead_code))] mtu: usize, } @@ -79,6 +91,44 @@ impl TunDevice { } } + /// Create a new TUN device (Windows — uses WinTun directly for correct async behavior) + #[cfg(target_os = "windows")] + pub fn new(assigned_ip: IpAddr, mtu: usize) -> Result { + use std::net::Ipv4Addr; + + const TUN_NAME: &str = "konduit"; + + let wintun = unsafe { wintun::load().map_err(|e| anyhow::anyhow!("Failed to load wintun: {}", e))? }; + let adapter = match wintun::Adapter::open(&wintun, TUN_NAME) { + Ok(a) => a, + Err(_) => wintun::Adapter::create(&wintun, TUN_NAME, TUN_NAME, None) + .map_err(|e| anyhow::anyhow!("Failed to create WinTun adapter: {}", e))?, + }; + + let ip = match assigned_ip { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => anyhow::bail!("IPv6 not supported for TUN device on Windows"), + }; + let mask = Ipv4Addr::new(255, 255, 255, 0); + + adapter + .set_network_addresses_tuple(IpAddr::V4(ip), IpAddr::V4(mask), None) + .map_err(|e| anyhow::anyhow!("Failed to set WinTun address: {}", e))?; + + let session = Arc::new( + adapter + .start_session(wintun::MAX_RING_CAPACITY) + .map_err(|e| anyhow::anyhow!("Failed to start WinTun session: {}", e))?, + ); + + info!("Created TUN interface: {}", TUN_NAME); + Ok(Self { + name: TUN_NAME.to_string(), + session, + mtu, + }) + } + /// Create a new TUN device from file descriptor (Android / iOS) #[cfg(any(target_os = "android", target_os = "ios"))] pub fn from_fd(fd: i32, mtu: usize) -> Result { @@ -111,47 +161,58 @@ impl TunDevice { } } - /// Run the TUN device loop + /// Run the TUN device loop (Linux / macOS / Android / iOS — uses tokio AsyncRead/Write) + #[cfg(not(target_os = "windows"))] pub async fn run( self, - mut from_tcp_rx: mpsc::Receiver, // Packets received from TCP to be written to TUN - to_tcp_tx: mpsc::Sender, // Packets read from TUN to be sent to TCP + mut from_tcp_rx: mpsc::Receiver, + to_tcp_tx: mpsc::Sender, ) -> Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + info!("TUN device loop started"); let (mut reader, mut writer) = tokio::io::split(self.device); let mtu = self.mtu; - loop { - // Buffer for reading from TUN - // We authorize up to MTU + overhead, but each read returns one packet. - let mut buf = BytesMut::with_capacity(mtu + 100); + // Separate reader task so writes never block reads. + let (tun_pkt_tx, mut tun_pkt_rx) = mpsc::channel::(64); + tokio::spawn(async move { + loop { + let mut buf = BytesMut::with_capacity(mtu + 100); + match reader.read_buf(&mut buf).await { + Ok(0) => { + info!("TUN device closed"); + break; + } + Ok(_) => { + if tun_pkt_tx.send(buf.freeze()).await.is_err() { + break; + } + } + Err(e) => { + error!("TUN read error: {}", e); + break; + } + } + } + }); + loop { tokio::select! { - // 1. Packet from TUN (needs to be sent to server) - res = reader.read_buf(&mut buf) => { - match res { - Ok(n) => { - if n == 0 { - info!("TUN device closed"); + packet = tun_pkt_rx.recv() => { + match packet { + Some(pkt) => { + if to_tcp_tx.send(pkt).await.is_err() { break; } - // Important: TUN read returns distinct packets. - // We must send exactly this packet. - let packet = buf.freeze(); // Consumes buf into Bytes - - if let Err(_) = to_tcp_tx.send(packet).await { - break; // Channel closed - } } - Err(e) => { - error!("TUN read error: {}", e); + None => { + info!("TUN reader task exited"); break; } } } - - // 2. Packet from TCP (needs to be written to TUN) Some(packet) = from_tcp_rx.recv() => { if let Err(e) = writer.write_all(&packet).await { error!("TUN write error: {}", e); @@ -162,4 +223,72 @@ impl TunDevice { Ok(()) } + + /// Run the TUN device loop (Windows — uses wintun directly with a dedicated reader thread) + /// + /// The tun crate's Windows AsyncRead implementation spawns a new OS thread on every + /// Poll::Pending, causing thread explosion and ~80% packet loss under normal VPN load. + /// We bypass it by using a single dedicated blocking thread for wintun reads. + #[cfg(target_os = "windows")] + pub async fn run( + self, + mut from_tcp_rx: mpsc::Receiver, + to_tcp_tx: mpsc::Sender, + ) -> Result<()> { + info!("TUN device loop started"); + + let session = self.session; + let reader_session = session.clone(); + + // One dedicated OS thread for blocking wintun reads. + // This avoids spawning a new thread per poll_read call. + let (read_tx, mut read_rx) = mpsc::channel::(64); + std::thread::spawn(move || loop { + match reader_session.receive_blocking() { + Ok(pkt) => { + let bytes = Bytes::copy_from_slice(pkt.bytes()); + drop(pkt); // release ring buffer slot before blocking on channel send + if read_tx.blocking_send(bytes).is_err() { + break; + } + } + Err(e) => { + error!("WinTun receive error: {:?}", e); + break; + } + } + }); + + loop { + tokio::select! { + // WinTun → TCP channel + packet = read_rx.recv() => { + match packet { + Some(pkt) => { + if to_tcp_tx.send(pkt).await.is_err() { + break; + } + } + None => { + info!("WinTun reader thread exited"); + break; + } + } + } + + // TCP channel → WinTun (send_packet is non-blocking ring-buffer op) + Some(packet) = from_tcp_rx.recv() => { + match session.allocate_send_packet(packet.len() as u16) { + Ok(mut send_pkt) => { + send_pkt.bytes_mut().copy_from_slice(&packet); + session.send_packet(send_pkt); + } + Err(e) => error!("WinTun allocate_send_packet error: {:?}", e), + } + } + } + } + + Ok(()) + } }