Compare commits
4 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb9b39f77 | ||
|
|
b1afe43ef0 | ||
|
|
de3534af67 | ||
|
|
3d3c32aef3 |
@@ -22,7 +22,10 @@ No installation required. The AppImage is self-contained.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -25,7 +25,10 @@ Windows will show a **UAC prompt** asking for administrator permission. This is
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "konduit-platform"
|
||||
version = "0.1.0"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
authors = ["Eugen Kaparulin <e.kaparulin@gmail.com>"]
|
||||
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"
|
||||
|
||||
@@ -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<String> = 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<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 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<u32>,
|
||||
_state: &mut DnsState,
|
||||
interface: &str,
|
||||
if_index: Option<u32>,
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
300
konduit-platform/src/routes/windows.rs
Normal file
300
konduit-platform/src/routes/windows.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<wintun::Session>,
|
||||
#[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<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)
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> {
|
||||
@@ -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<Bytes>, // Packets received from TCP to be written to TUN
|
||||
to_tcp_tx: mpsc::Sender<Bytes>, // Packets read from TUN to be sent to TCP
|
||||
mut from_tcp_rx: mpsc::Receiver<Bytes>,
|
||||
to_tcp_tx: mpsc::Sender<Bytes>,
|
||||
) -> 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::<Bytes>(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<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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user