Compare commits
6 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e63d41c153 | ||
| 57249605fb | |||
|
|
9eb9b39f77 | ||
|
|
b1afe43ef0 | ||
|
|
de3534af67 | ||
|
|
3d3c32aef3 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
62
docs/diagrams/stealth-architecture.svg
Normal file
62
docs/diagrams/stealth-architecture.svg
Normal 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 |
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|

|
||||||
|
|
||||||
```
|
## 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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 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,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(
|
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;
|
||||||
|
|
||||||
loop {
|
// Separate reader task so writes never block reads.
|
||||||
// Buffer for reading from TUN
|
let (tun_pkt_tx, mut tun_pkt_rx) = mpsc::channel::<Bytes>(64);
|
||||||
// We authorize up to MTU + overhead, but each read returns one packet.
|
tokio::spawn(async move {
|
||||||
let mut buf = BytesMut::with_capacity(mtu + 100);
|
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! {
|
tokio::select! {
|
||||||
// 1. Packet from TUN (needs to be sent to server)
|
packet = tun_pkt_rx.recv() => {
|
||||||
res = reader.read_buf(&mut buf) => {
|
match packet {
|
||||||
match res {
|
Some(pkt) => {
|
||||||
Ok(n) => {
|
if to_tcp_tx.send(pkt).await.is_err() {
|
||||||
if n == 0 {
|
|
||||||
info!("TUN device closed");
|
|
||||||
break;
|
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) => {
|
None => {
|
||||||
error!("TUN read error: {}", e);
|
info!("TUN reader task exited");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Packet from TCP (needs to be written to TUN)
|
|
||||||
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user