6 Commits

10 changed files with 682 additions and 167 deletions

View File

@@ -80,7 +80,7 @@ Extract the zip and run `konduit.exe` (CLI) or `Konduit.exe` (GUI). `wintun.dll`
### Android ### Android
Download from [Google Play](https://play.google.com/store/apps/details?id=eu.kaparulin.konduit) or sideload the `.aab` using [bundletool](https://github.com/google/bundletool). Download from [Google Play](https://play.google.com/store/apps/details?id=eu.k_ops.konduit) or sideload the `.aab` using [bundletool](https://github.com/google/bundletool).
### macOS · iOS ### macOS · iOS

View File

@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 415" width="620" height="415">
<defs>
<marker id="stealth-ah" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#546e7a"/>
</marker>
<marker id="stealth-ahb" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L8,3 z" fill="#1565c0"/>
</marker>
</defs>
<!-- Background -->
<rect width="620" height="415" rx="10" fill="#fafafa" stroke="#dee2e6" stroke-width="1"/>
<!-- Title -->
<text x="310" y="27" text-anchor="middle" font-family="monospace" font-size="14" font-weight="bold" fill="#263238">Stealth Mode: Real TLS Bypass via HAProxy</text>
<!-- ── Client ── -->
<rect x="185" y="42" width="250" height="62" rx="8" fill="#e0f7fa" stroke="#00838f" stroke-width="2"/>
<text x="310" y="68" text-anchor="middle" font-family="monospace" font-size="13" font-weight="bold" fill="#00695c">Client</text>
<text x="310" y="87" text-anchor="middle" font-family="monospace" font-size="11" fill="#00695c">stealth = true (or tls = true)</text>
<!-- Arrow: Client → HAProxy -->
<line x1="310" y1="104" x2="310" y2="150" stroke="#546e7a" stroke-width="2" marker-end="url(#stealth-ah)"/>
<!-- Arrow label (right side) -->
<rect x="318" y="109" width="148" height="34" rx="3" fill="#eceff1"/>
<text x="392" y="122" text-anchor="middle" font-family="monospace" font-size="10" fill="#37474f">TLS 1.3 · port 443</text>
<text x="392" y="136" text-anchor="middle" font-family="monospace" font-size="10" fill="#37474f">real cert (certbot)</text>
<!-- DPI callout (left side of arrow) -->
<text x="302" y="120" text-anchor="end" font-family="monospace" font-size="10" font-weight="bold" fill="#e65100">DPI/TSPU:</text>
<text x="302" y="134" text-anchor="end" font-family="monospace" font-size="10" fill="#e65100">sees valid HTTPS ✓</text>
<!-- ── HAProxy ── -->
<rect x="108" y="150" width="404" height="78" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="2"/>
<text x="310" y="176" text-anchor="middle" font-family="monospace" font-size="13" font-weight="bold" fill="#1b5e20">HAProxy :443</text>
<text x="310" y="194" text-anchor="middle" font-family="monospace" font-size="11" fill="#1b5e20">TLS termination · inspect first byte of payload</text>
<text x="310" y="213" text-anchor="middle" font-family="monospace" font-size="10" fill="#388e3c">HTTP method bytes → web backend · binary → konduit</text>
<!-- Arrow: HAProxy → Web (down-left) -->
<line x1="208" y1="228" x2="168" y2="275" stroke="#78909c" stroke-width="1.5" marker-end="url(#stealth-ah)"/>
<text x="163" y="258" text-anchor="end" font-family="monospace" font-size="10" fill="#546e7a">HTTP</text>
<!-- Arrow: HAProxy → Konduit (down-right) -->
<line x1="412" y1="228" x2="452" y2="275" stroke="#1565c0" stroke-width="2" marker-end="url(#stealth-ahb)"/>
<text x="456" y="258" font-family="monospace" font-size="10" fill="#1565c0">binary</text>
<!-- ── Web backend ── -->
<rect x="78" y="275" width="184" height="58" rx="8" fill="#f5f5f5" stroke="#9e9e9e" stroke-width="1.5"/>
<text x="170" y="301" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#424242">Web backend</text>
<text x="170" y="319" text-anchor="middle" font-family="monospace" font-size="10" fill="#616161">:8080 (nginx / any HTTP)</text>
<!-- ── Konduit ── -->
<rect x="358" y="275" width="184" height="58" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="2"/>
<text x="450" y="301" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#0d47a1">Konduit</text>
<text x="450" y="319" text-anchor="middle" font-family="monospace" font-size="10" fill="#1565c0">:8443 plain TCP (stealth off)</text>
<!-- Arrow: Konduit → VPN -->
<line x1="450" y1="333" x2="450" y2="357" stroke="#1565c0" stroke-width="2" marker-end="url(#stealth-ahb)"/>
<!-- ── VPN Tunnel ── -->
<rect x="358" y="357" width="184" height="40" rx="8" fill="#1565c0"/>
<text x="450" y="382" text-anchor="middle" font-family="monospace" font-size="12" font-weight="bold" fill="#ffffff">VPN Tunnel</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

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,105 +1,146 @@
# Stealth Mode: HAProxy + Konduit # Stealth Mode: Real TLS Bypass via HAProxy
Konduit's stealth mode makes the VPN server indistinguishable from a normal HTTPS site. It implements a fake TLS handshake — embedding the client's ephemeral key inside the TLS `SessionID` field. For this to work, **konduit must see the raw TCP stream from the client**. Any proxy that terminates TLS before konduit breaks stealth mode. Stealth mode makes VPN traffic indistinguishable from normal HTTPS by wrapping it in
real TLS with a valid certificate. Deep packet inspection sees a standard TLS 1.3
connection to a legitimate domain — not a VPN.
## Architecture ![Stealth mode architecture](diagrams/stealth-architecture.svg)
``` ## How It Works
Client
│ raw TCP (looks like TLS to observers) 1. **Client** opens a TLS 1.3 connection to port 443 using the system's trusted
certificate store — the same TLS stack as any browser.
HAProxy :443 ── TCP passthrough ──► konduit-server :8443 2. **HAProxy** terminates TLS using a real certbot certificate. It then inspects the
first byte of the decrypted payload to route traffic:
valid handshake │ handshake fails (real browser) - HTTP method bytes (`GET`, `POST`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`) → web backend
│ ▼ - Any other byte → konduit VPN backend
│ HAProxy :4443 (SSL termination) 3. **Konduit** receives a plain TCP connection and runs its normal handshake.
│ │ Stealth mode must be **disabled** on the server side — HAProxy already handled TLS.
│ ▼
│ real HTTPS backend The `stealth = true` and `tls = true` config flags are equivalent. Both activate TLS
VPN tunnel wrapping on the client. Existing configs with `stealth = true` continue to work without
``` modification.
## Server Requirements
- A domain with a valid TLS certificate (Let's Encrypt / certbot)
- HAProxy 2.4+
- Konduit server on a non-public port (e.g. `8443`)
- Web server on a local port (e.g. `8080`) — for non-VPN HTTP requests
## HAProxy Configuration ## HAProxy Configuration
Combine the certificate and private key into a single PEM file for HAProxy:
```bash
cat /etc/letsencrypt/live/your-domain.com/fullchain.pem \
/etc/letsencrypt/live/your-domain.com/privkey.pem \
> /etc/haproxy/ssl/your-domain.pem
chmod 600 /etc/haproxy/ssl/your-domain.pem
```
```haproxy ```haproxy
# VPN ingress — raw TCP passthrough, no SSL termination frontend https-ingress
frontend vpn-ingress bind *:443 ssl crt /etc/haproxy/ssl/your-domain.pem
bind *:443
mode tcp mode tcp
option tcplog option tcplog
tcp-request inspect-delay 3s
# Accept as soon as the first byte arrives — VPN connections never send a FIN,
# so WAIT_END would stall every connection for the full inspect-delay.
tcp-request content accept if { req.payload(0,1) -m found }
use_backend web if { req.payload(0,3) -m str GET }
use_backend web if { req.payload(0,4) -m str POST }
use_backend web if { req.payload(0,4) -m str HEAD }
use_backend web if { req.payload(0,3) -m str PUT }
use_backend web if { req.payload(0,6) -m str DELETE }
use_backend web if { req.payload(0,7) -m str OPTIONS }
default_backend konduit-vpn default_backend konduit-vpn
backend konduit-vpn backend konduit-vpn
mode tcp mode tcp
server konduit 127.0.0.1:8443 server konduit 127.0.0.1:8443
# Camouflage backend — receives failed handshakes from konduit via PROXY protocol
# SSL is terminated here, not on the ingress
frontend camouflage
bind *:4443 ssl crt /etc/haproxy/ssl/your-domain.pem accept-proxy
mode http
http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Proto https
default_backend web
backend web backend web
mode http mode http
server web1 127.0.0.1:80 check server web 127.0.0.1:8080
``` ```
## Konduit server.toml Reload after changes:
```bash
haproxy -c -f /etc/haproxy/haproxy.cfg # validate first
systemctl reload haproxy
```
## Konduit Server (`server.toml`)
Disable stealth on the server — HAProxy terminated TLS before the connection arrives:
```toml ```toml
[server] [server]
listen_addr = "0.0.0.0" listen_addr = "0.0.0.0"
listen_port = 8443 listen_port = 8443
public_addr = "your-domain.com" public_addr = "your-domain.com"
public_port = 443 # port clients connect to (HAProxy front) public_port = 443
[stealth] [stealth]
enabled = true enabled = false
camouflage = "127.0.0.1:4443" # where to proxy non-konduit connections
``` ```
Bootstrap with `--public-port` so generated client configs reference port 443: Bootstrap peers with `--public-port` so generated client configs reference port 443:
```bash ```bash
echo "your mantra" | ./konduit-ctl bootstrap -l your-domain.com:8443 --public-port 443 -p - echo "your-mantra" | ./konduit-ctl bootstrap \
-l your-domain.com:8443 --public-port 443 -p -
``` ```
## How It Works ## Client Configuration (`client.toml`)
**Konduit client (stealth handshake):** ```toml
1. Client sends fake TLS ClientHello with identity proof embedded [client]
2. HAProxy passes raw TCP to konduit on port 8443 server_endpoint = "your-domain.com:443"
3. Konduit verifies identity, completes handshake, establishes VPN tunnel tls = true
4. Traffic looks like TLS Application Data to any observer peer_id = "..."
identity_key = "..."
**Real browser or censor probe:** server_public_key = "..."
1. Browser sends a real TLS ClientHello
2. HAProxy passes it raw to konduit
3. Konduit cannot verify identity — proxies the connection to `127.0.0.1:4443` with a PROXY protocol header preserving the real client IP
4. HAProxy at 4443 terminates TLS and serves your website
5. Observer sees a normal HTTPS site
## Common Mistakes
```
# WRONG — TLS terminated by HAProxy before konduit, fake handshake never works
Client → HAProxy SSL termination → konduit
# CORRECT — raw TCP passed through, konduit handles the fake TLS
Client → HAProxy TCP passthrough → konduit → HAProxy SSL termination (on failure)
``` ```
The camouflage frontend uses `accept-proxy` — do not use it as the VPN ingress. The legacy flag is identical:
```toml
[stealth]
enabled = true
```
## Flutter / Mobile App
Enable **Stealth Mode** in the app settings. The toggle maps to `stealth = true` in
the connection config and activates TLS wrapping on all platforms (Linux, Android,
Windows).
For QR-code-based provisioning, the `t: true` field in the QR payload enables stealth.
## Verify ## Verify
```bash ```bash
# Browser should see your real website, not an error # A browser must see your real website not a TLS error or empty response
curl -sk https://your-domain.com/ curl -s https://your-domain.com/ | head -5
# Check konduit logs for stealth handshakes # Connect the VPN client — should stay connected without 20-second drops
journalctl -u konduit-server -f | grep -E "Stealth|Authenticated|camouflage" ./konduit --config client.toml
# HAProxy serves both roles: check access log
journalctl -u haproxy -f
``` ```
## Common Mistakes
| Mistake | Effect | Fix |
|---------|--------|-----|
| `WAIT_END` in inspect rule | 5-second stall on every connect | Use `req.payload(0,1) -m found` |
| Stealth enabled on server | Handshake mismatch after HAProxy strips TLS | Set `[stealth] enabled = false` |
| Port 8443 exposed to internet | Bypass HAProxy, no DPI camouflage | Firewall port 8443 to localhost only |
| Expired/self-signed cert | TLS error on client | Use Let's Encrypt; renew via certbot |

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(())
}
} }