4 Commits

Author SHA1 Message Date
E. Kaparulin
9eb9b39f77 chore: sync konduit-platform v0.1.0-beta.3 2026-06-19 10:41:56 +03:00
E. Kaparulin
b1afe43ef0 fix: connection troubleshooting — reference app config, not toml file 2026-06-17 12:26:03 +03:00
E. Kaparulin
de3534af67 fix: clarify config setup — manual entry from toml, not file import 2026-06-17 12:24:12 +03:00
E. Kaparulin
3d3c32aef3 fix: update config import instructions to reflect TOML/QR flow 2026-06-17 12:22:36 +03:00
7 changed files with 515 additions and 103 deletions

View File

@@ -22,7 +22,10 @@ No installation required. The AppImage is self-contained.
## 4. Load your config ## 4. Load your config
Your server administrator will provide a `client.toml` config file. On first launch, tap **Open config** and select the file. Your server administrator will provide either a `client.toml` config file or a QR code image.
- **TOML config:** your administrator will provide a `client.toml` file. Open it in a text editor and enter the server address, port, and key values into the corresponding fields in the app.
- **QR code:** tap **Scan QR code** and point the camera at the QR code image, or use **Import QR image** to select an image file from disk.
## 5. Privileges ## 5. Privileges
@@ -58,4 +61,6 @@ sudo dnf install fuse
**"Failed to create TUN device"** — the capability was not granted. Run the `setcap` command from step 5 and relaunch. **"Failed to create TUN device"** — the capability was not granted. Run the `setcap` command from step 5 and relaunch.
**Config not found on relaunch**the app remembers the last config path. If you move the file, use **Open config** again to reselect it. **Connection fails immediately**check that the server address, port, and key values in the app match what your administrator provided. Contact your administrator if you are unsure.
**Config lost on relaunch** — re-enter your connection details or scan the QR code again.

View File

@@ -25,7 +25,10 @@ Windows will show a **UAC prompt** asking for administrator permission. This is
## 4. Load your config ## 4. Load your config
Your server administrator will provide a `client.toml` config file. On first launch, tap **Open config** and select the file. Your server administrator will provide either a `client.toml` config file or a QR code image.
- **TOML config:** your administrator will provide a `client.toml` file. Open it in a text editor and enter the server address, port, and key values into the corresponding fields in the app.
- **QR code:** tap **Import QR image** and select the QR code image file from disk.
## 5. Connect ## 5. Connect
@@ -57,6 +60,6 @@ Win + R → shell:startup → paste shortcut here
**"wintun.dll not found"** — `Konduit.exe` and `wintun.dll` must be in the same folder. Do not move them separately. **"wintun.dll not found"** — `Konduit.exe` and `wintun.dll` must be in the same folder. Do not move them separately.
**Connection fails immediately** — check that your `client.toml` points to the correct server address and port. Contact your server administrator if you are unsure. **Connection fails immediately** — check that the server address, port, and key values in the app match what your administrator provided. Contact your administrator if you are unsure.
**Tray icon disappears unexpectedly** — the app may have crashed. Check Windows Event Viewer or relaunch `Konduit.exe`. **Tray icon disappears unexpectedly** — the app may have crashed. Check Windows Event Viewer or relaunch `Konduit.exe`.

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "konduit-platform" name = "konduit-platform"
version = "0.1.0" version.workspace = true
edition = "2021" edition = "2021"
authors = ["Eugen Kaparulin <e.kaparulin@gmail.com>"] authors = ["Eugen Kaparulin <e.kaparulin@gmail.com>"]
license = "PolyForm-Noncommercial-1.0.0" license = "PolyForm-Noncommercial-1.0.0"
@@ -40,6 +40,7 @@ windows = { version = "0.52", features = [
"Win32_Networking_WinSock", "Win32_Networking_WinSock",
] } ] }
ipconfig = "0.3" ipconfig = "0.3"
wintun = "0.3"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
system-configuration = "0.5" system-configuration = "0.5"

View File

@@ -1,6 +1,10 @@
use super::DnsState; use super::DnsState;
use anyhow::Result; use anyhow::Result;
use std::net::IpAddr; 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( pub async fn set_dns(
interface: &str, interface: &str,
@@ -8,92 +12,57 @@ pub async fn set_dns(
dns_servers: &[IpAddr], dns_servers: &[IpAddr],
state: &mut DnsState, state: &mut DnsState,
) -> Result<()> { ) -> Result<()> {
// Windows implementation using windows-rs crate if dns_servers.is_empty() {
// Note: This logic runs within unsafe blocks as it calls Win32 APIs return Ok(());
}
use std::ffi::c_void; let addrs: Vec<String> = dns_servers.iter().map(|ip| ip.to_string()).collect();
use windows::Win32::Foundation::NO_ERROR; let addrs_str = addrs.join(",");
use windows::Win32::NetworkManagement::IpHelper::{
SetInterfaceDnsSettings, DNS_INTERFACE_SETTINGS, DNS_INTERFACE_SETTINGS_FLAGS, let iface_arg = if let Some(idx) = if_index {
DNS_INTERFACE_SETTINGS_VERSION1, DNS_SETTING_NAMESERVER, format!("-InterfaceIndex {}", idx)
} else {
format!("-InterfaceAlias '{}'", interface)
}; };
// We need the Interface LUID (Locally Unique Identifier) or Index/GUID. info!("Setting DNS on {} to {}", interface, addrs_str);
// The user provided `interface` string might be a GUID or friendly name.
// If if_index is provided, that's easier for some APIs.
// Convert IP addresses to wide string (UTF-16) comma-separated let script = format!(
let mut dns_str = String::new(); "Set-DnsClientServerAddress {} -ServerAddresses {}",
for (i, ip) in dns_servers.iter().enumerate() { iface_arg, addrs_str
if i > 0 {
dns_str.push(',');
}
dns_str.push_str(&ip.to_string());
}
let dns_wide: Vec<u16> = 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 status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.status()?;
/* if !status.success() {
unsafe { anyhow::bail!("PowerShell failed to set DNS on {}", interface);
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);
} }
}
*/
// For now, since we can't test, we log a limitation
tracing::warn!("Windows DNS setting logic placeholder");
state.configured = true; state.configured = true;
Ok(()) Ok(())
} }
pub async fn restore( pub async fn restore(
_interface: &str, interface: &str,
_if_index: Option<u32>, if_index: Option<u32>,
_state: &mut DnsState, state: &mut DnsState,
) -> Result<()> { ) -> Result<()> {
tracing::info!("Restoring DNS on Windows..."); info!("Restoring DNS on {} to automatic", interface);
// Clear custom DNS settings (return to DHCP?) let iface_arg = if let Some(idx) = if_index {
/* format!("-InterfaceIndex {}", idx)
unsafe { } else {
let mut settings = DNS_INTERFACE_SETTINGS { format!("-InterfaceAlias '{}'", interface)
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 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(()) Ok(())
} }

View File

@@ -7,3 +7,8 @@ pub use linux::RouteManager;
mod macos; mod macos;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub use macos::MacOsNetManager; pub use macos::MacOsNetManager;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::RouteManager;

View File

@@ -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<Ipv4Addr>,
wifi_gateway: Option<Ipv4Addr>,
}
impl RouteManager {
pub async fn new(tun_interface: String) -> Result<Self> {
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<Ipv4Addr> {
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::<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(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, 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()
);
}
}
}

View File

@@ -1,14 +1,26 @@
use anyhow::Result; use anyhow::Result;
use bytes::{Bytes, BytesMut}; use bytes::Bytes;
#[cfg(not(target_os = "windows"))]
use bytes::BytesMut;
use std::net::IpAddr; use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc; 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 /// Interface to the TUN device
pub struct TunDevice { pub struct TunDevice {
pub name: String, pub name: String,
#[cfg(not(target_os = "windows"))]
device: tun::AsyncDevice, device: tun::AsyncDevice,
#[cfg(target_os = "windows")]
session: Arc<wintun::Session>,
#[cfg_attr(target_os = "windows", allow(dead_code))]
mtu: usize, 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<Self> {
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) /// Create a new TUN device from file descriptor (Android / iOS)
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(any(target_os = "android", target_os = "ios"))]
pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> { pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> {
@@ -111,37 +161,33 @@ 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( pub async fn run(
self, self,
mut from_tcp_rx: mpsc::Receiver<Bytes>, // Packets received from TCP to be written to TUN mut from_tcp_rx: mpsc::Receiver<Bytes>,
to_tcp_tx: mpsc::Sender<Bytes>, // Packets read from TUN to be sent to TCP to_tcp_tx: mpsc::Sender<Bytes>,
) -> Result<()> { ) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
info!("TUN device loop started"); info!("TUN device loop started");
let (mut reader, mut writer) = tokio::io::split(self.device); let (mut reader, mut writer) = tokio::io::split(self.device);
let mtu = self.mtu; let mtu = self.mtu;
// Separate reader task so writes never block reads.
let (tun_pkt_tx, mut tun_pkt_rx) = mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
loop { 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); let mut buf = BytesMut::with_capacity(mtu + 100);
match reader.read_buf(&mut buf).await {
tokio::select! { Ok(0) => {
// 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"); info!("TUN device closed");
break; break;
} }
// Important: TUN read returns distinct packets. Ok(_) => {
// We must send exactly this packet. if tun_pkt_tx.send(buf.freeze()).await.is_err() {
let packet = buf.freeze(); // Consumes buf into Bytes break;
if let Err(_) = to_tcp_tx.send(packet).await {
break; // Channel closed
} }
} }
Err(e) => { Err(e) => {
@@ -150,8 +196,23 @@ impl TunDevice {
} }
} }
} }
});
// 2. Packet from TCP (needs to be written to TUN) loop {
tokio::select! {
packet = tun_pkt_rx.recv() => {
match packet {
Some(pkt) => {
if to_tcp_tx.send(pkt).await.is_err() {
break;
}
}
None => {
info!("TUN reader task exited");
break;
}
}
}
Some(packet) = from_tcp_rx.recv() => { Some(packet) = from_tcp_rx.recv() => {
if let Err(e) = writer.write_all(&packet).await { if let Err(e) = writer.write_all(&packet).await {
error!("TUN write error: {}", e); error!("TUN write error: {}", e);
@@ -162,4 +223,72 @@ impl TunDevice {
Ok(()) 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<Bytes>,
to_tcp_tx: mpsc::Sender<Bytes>,
) -> 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::<Bytes>(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(())
}
} }