Compare commits
16 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d5305d8c | ||
|
|
c2d7f8d8c6 | ||
|
|
678ed158a6 | ||
|
|
56201f6b72 | ||
|
|
b160652737 | ||
|
|
7df5aa4317 | ||
|
|
99a2993ace | ||
|
|
fb7ad7ec40 | ||
|
|
87bb99c10b | ||
|
|
ba8e2b0aa1 | ||
|
|
e63d41c153 | ||
| 57249605fb | |||
|
|
9eb9b39f77 | ||
|
|
b1afe43ef0 | ||
|
|
de3534af67 | ||
|
|
3d3c32aef3 |
15
README.md
15
README.md
@@ -35,7 +35,7 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
|
||||
- **QR code provisioning** — scan once, connect instantly
|
||||
- **Cross-platform** — Linux, Windows, Android (macOS and iOS coming soon)
|
||||
- **Modern cryptography** — X25519 key exchange, ChaCha20-Poly1305 data channel
|
||||
- **Stealth mode** — port 443 deployment with decoy proxy for hostile network environments
|
||||
- **SOCKS5 chaining** — egress-dial (`use_proxy`) to reach the server through your own proxy, or a post-tunnel listener (`listen_socks`) for per-app routing — see [SOCKS Modes](docs/socks-modes.md)
|
||||
- **Memory safe** — written entirely in Rust
|
||||
|
||||
## Download
|
||||
@@ -80,7 +80,7 @@ Extract the zip and run `konduit.exe` (CLI) or `Konduit.exe` (GUI). `wintun.dll`
|
||||
|
||||
### 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
|
||||
|
||||
@@ -95,7 +95,8 @@ Coming soon.
|
||||
**CLI & server**
|
||||
- [Client Quickstart](docs/client-quickstart.md) — download, configure, connect, run as a systemd service
|
||||
- [Server Quickstart](docs/server-quickstart.md) — install, provision, NAT setup for iptables and firewalld
|
||||
- [Stealth Mode Setup](docs/stealth-setup.md) — HAProxy TCP passthrough + camouflage configuration
|
||||
- [SOCKS5 Modes](docs/socks-modes.md) — reach the server through a proxy, or expose a local SOCKS5 listener
|
||||
- [Connection Tuning](docs/connection-tuning.md) — opt-in connection pooling for unstable links
|
||||
- [systemd units](docs/systemd/) — service files for konduit-server, konduit (client), and konduit-admin-ui
|
||||
|
||||
## Architecture
|
||||
@@ -124,7 +125,7 @@ Konduit engine (Rust)
|
||||
|
||||
The [`konduit-platform`](./konduit-platform) crate is published here for transparency and security audit. It contains the cryptographic primitives, connection statistics, and platform networking layer (TUN device, DNS, routes) — everything an auditor needs to verify what runs on your machine. It is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE) — free to read, study, and use for noncommercial purposes.
|
||||
|
||||
The VPN server, management UI, and stealth-mode protocol are proprietary. Keeping stealth mechanisms private makes automated DPI fingerprinting significantly harder. Source review under NDA is available for enterprise partners.
|
||||
The VPN server and management UI are proprietary. Source review under NDA is available for enterprise partners.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -132,13 +133,9 @@ The VPN server, management UI, and stealth-mode protocol are proprietary. Keepin
|
||||
|
||||
**Key storage:** Private keys are stored in the OS secure enclave on every platform (iOS Keychain, macOS Keychain, Android Keystore). They are never written to disk in plaintext.
|
||||
|
||||
**Stealth mode:** On port 443, failed or unrecognized handshakes are proxied transparently to a configurable decoy service. From the outside, the server is indistinguishable from a standard HTTPS endpoint.
|
||||
|
||||
## Support
|
||||
|
||||
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
|
||||
|
||||
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
|
||||
Bug reports, security vulnerabilities, and any other inquiries: use the contact form at [www.k-ops.eu](https://www.k-ops.eu).
|
||||
|
||||
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ sudo cp client.toml /opt/konduit/client.toml
|
||||
sudo chmod 600 /opt/konduit/client.toml
|
||||
```
|
||||
|
||||
If your server runs in stealth mode, the config already points to port 443. No additional client-side configuration is needed.
|
||||
|
||||
## 3. Connect
|
||||
|
||||
```bash
|
||||
|
||||
20
docs/connection-tuning.md
Normal file
20
docs/connection-tuning.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Connection Tuning
|
||||
|
||||
Konduit is a TCP-native VPN. On stable networks, a single TCP connection works fine. On unstable links (spotty Wi-Fi, mobile data, satellite), you can opt into a small pool of pre-warmed connections so a failing connection can be replaced without a full reconnect cascade.
|
||||
|
||||
## Config
|
||||
|
||||
```toml
|
||||
[connection]
|
||||
pool_enabled = false # default off; opt in for unstable links
|
||||
pool_size = 3 # pre-warmed idle connections when enabled
|
||||
carry_duration = 1.0 # seconds an active connection carries traffic before handoff
|
||||
```
|
||||
|
||||
- `pool_enabled` — when `true`, the client keeps `pool_size` extra pre-authenticated connections warm, so an in-use connection can fail over instantly instead of paying a fresh TCP-plus-handshake cost.
|
||||
- `pool_size` — number of pre-warmed idle connections to keep, in addition to the active one. Only used when `pool_enabled = true`.
|
||||
- `carry_duration` — how long (in seconds) an active connection carries traffic before handoff to a pre-warmed one. Only used when `pool_enabled = true`.
|
||||
|
||||
This is a resilience feature, not a stealth feature — it exists purely to make konduit more tolerant of flaky links, and has no effect on how konduit's traffic looks to a network observer.
|
||||
|
||||
Server-side, sticky DL-target routing keeps your active connection selected until it actually fails (detected via a write timeout), rather than rotating on a fixed timer. This needs no client-side configuration.
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
@@ -51,7 +51,6 @@ Server-side connection logs are retained for a limited period for operational pu
|
||||
|
||||
- All traffic between client and server is encrypted using **X25519** key exchange and **ChaCha20-Poly1305** AEAD.
|
||||
- The pre-shared key (PSK) is stored in the local config file with permissions restricted to the current user.
|
||||
- Stealth mode wraps the tunnel in a protocol that is indistinguishable from HTTPS, preventing deep-packet inspection from identifying Konduit traffic.
|
||||
|
||||
## Children
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.c
|
||||
echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.com:8443 --public-port 443 -p -
|
||||
```
|
||||
|
||||
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443. See [stealth-setup.md](stealth-setup.md) for the full HAProxy configuration.
|
||||
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443 — useful when running behind any TCP-passthrough reverse proxy (e.g. HAProxy, nginx `stream` module).
|
||||
|
||||
## 3. Add a Client
|
||||
|
||||
@@ -102,8 +102,8 @@ sudo apt install iptables-persistent && sudo netfilter-persistent save
|
||||
## 7. Verify
|
||||
|
||||
```bash
|
||||
# Should return your website (camouflage) not an error
|
||||
curl -sk https://your-server/
|
||||
# Connect a client and confirm the tunnel comes up
|
||||
./konduit --config client.toml
|
||||
|
||||
# Check konduit logs
|
||||
journalctl -u konduit-server -f
|
||||
|
||||
50
docs/socks-modes.md
Normal file
50
docs/socks-modes.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# SOCKS5 Modes
|
||||
|
||||
Konduit is a VPN for reaching your own resources over the public internet — it is not a censorship-circumvention tool, and it doesn't try to disguise its traffic. If you need to get through a network that's actively blocking or fingerprinting VPN traffic, that's a job for a dedicated, purpose-built tool — an SSH SOCKS proxy (`ssh -D`), Xray, sing-box, or similar — not something konduit reimplements.
|
||||
|
||||
What konduit does provide is standard SOCKS5 support on both sides of the connection, so you can chain it with whichever of those tools you already trust.
|
||||
|
||||
## Egress-dial: reaching the server through a proxy (`use_proxy`)
|
||||
|
||||
If you already have a SOCKS5 proxy that gets you out of a restrictive network (for example `ssh -D 1080 jumphost`), point konduit at it instead of dialing the server directly:
|
||||
|
||||
```toml
|
||||
[client]
|
||||
server_endpoint = "vpn.example.com:443"
|
||||
use_proxy = "socks5://127.0.0.1:1080"
|
||||
```
|
||||
|
||||
Or via the CLI:
|
||||
|
||||
```bash
|
||||
./konduit connect --config client.toml --use-proxy socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
Konduit dials the SOCKS5 proxy, asks it to `CONNECT` to your server, and then runs its normal protocol over that connection — the proxy is otherwise transparent to it. If the proxy is unreachable, or requires authentication konduit doesn't support, the connection attempt fails immediately; there is no silent fallback to a direct connection.
|
||||
|
||||
## Listener: exposing a SOCKS5 proxy after the tunnel is up (`listen_socks`)
|
||||
|
||||
Once connected, konduit can also expose a local SOCKS5 listener so other tools (browsers, curl, Xray outbounds) can route traffic through the tunnel without needing their own TUN-level integration:
|
||||
|
||||
```toml
|
||||
[client]
|
||||
listen_socks = "127.0.0.1:1080"
|
||||
```
|
||||
|
||||
Or via the CLI:
|
||||
|
||||
```bash
|
||||
./konduit connect --config client.toml --listen-socks 127.0.0.1:1080
|
||||
```
|
||||
|
||||
Connections accepted by the listener are plain outbound TCP connections, routed through the tunnel by the kernel's routing table — the same routes any other tunnelled traffic uses. The SOCKS code itself has no VPN-specific logic.
|
||||
|
||||
Notes:
|
||||
|
||||
- SOCKS5 only, `CONNECT` command only — no `BIND`, no `UDP ASSOCIATE`.
|
||||
- No authentication — bind it to localhost (the default) unless you understand the exposure of doing otherwise.
|
||||
- If no address is given, konduit falls back to `127.0.0.1:1080`. Override the fallback itself with the `KONDUIT_SOCKS_LISTEN_ADDR` environment variable.
|
||||
|
||||
## Using both together
|
||||
|
||||
`use_proxy` and `listen_socks` are independent and can be combined — for example, dial out through an SSH SOCKS proxy to reach your server, and also expose a local SOCKS5 listener once connected for other apps to use.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Stealth Mode: HAProxy + Konduit
|
||||
|
||||
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.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client
|
||||
│ raw TCP (looks like TLS to observers)
|
||||
▼
|
||||
HAProxy :443 ── TCP passthrough ──► konduit-server :8443
|
||||
│
|
||||
valid handshake │ handshake fails (real browser)
|
||||
│ ▼
|
||||
│ HAProxy :4443 (SSL termination)
|
||||
│ │
|
||||
│ ▼
|
||||
│ real HTTPS backend
|
||||
VPN tunnel
|
||||
```
|
||||
|
||||
## HAProxy Configuration
|
||||
|
||||
```haproxy
|
||||
# VPN ingress — raw TCP passthrough, no SSL termination
|
||||
frontend vpn-ingress
|
||||
bind *:443
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend konduit-vpn
|
||||
|
||||
backend konduit-vpn
|
||||
mode tcp
|
||||
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
|
||||
mode http
|
||||
server web1 127.0.0.1:80 check
|
||||
```
|
||||
|
||||
## Konduit server.toml
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen_addr = "0.0.0.0"
|
||||
listen_port = 8443
|
||||
public_addr = "your-domain.com"
|
||||
public_port = 443 # port clients connect to (HAProxy front)
|
||||
|
||||
[stealth]
|
||||
enabled = true
|
||||
camouflage = "127.0.0.1:4443" # where to proxy non-konduit connections
|
||||
```
|
||||
|
||||
Bootstrap with `--public-port` so generated client configs reference port 443:
|
||||
|
||||
```bash
|
||||
echo "your mantra" | ./konduit-ctl bootstrap -l your-domain.com:8443 --public-port 443 -p -
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
**Konduit client (stealth handshake):**
|
||||
1. Client sends fake TLS ClientHello with identity proof embedded
|
||||
2. HAProxy passes raw TCP to konduit on port 8443
|
||||
3. Konduit verifies identity, completes handshake, establishes VPN tunnel
|
||||
4. Traffic looks like TLS Application Data to any observer
|
||||
|
||||
**Real browser or censor probe:**
|
||||
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.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Browser should see your real website, not an error
|
||||
curl -sk https://your-domain.com/
|
||||
|
||||
# Check konduit logs for stealth handshakes
|
||||
journalctl -u konduit-server -f | grep -E "Stealth|Authenticated|camouflage"
|
||||
```
|
||||
@@ -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"
|
||||
|
||||
@@ -31,6 +31,31 @@ trait Resolve1Manager {
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
// Per-link object (/org/freedesktop/resolve1/link/_<ifindex>), used only to
|
||||
// read back the physical interface's current DNS servers before we override
|
||||
// them, so they can be restored byte-for-byte on disconnect.
|
||||
//
|
||||
// SetLinkDefaultRoute (the "correct" API for demoting a link as a default DNS
|
||||
// route) requires interactive polkit auth (auth_admin_keep) — unlike
|
||||
// SetLinkDNS/SetLinkDomains, it does NOT get a CAP_NET_ADMIN bypass in
|
||||
// systemd-resolved, so it silently fails for this capability-only (non-root)
|
||||
// process. Reusing SetLinkDNS on the physical link's own object instead stays
|
||||
// on the bypass path.
|
||||
#[zbus::proxy(interface = "org.freedesktop.resolve1.Link", default_service = "org.freedesktop.resolve1")]
|
||||
trait Resolve1Link {
|
||||
#[zbus(property, name = "DNS")]
|
||||
fn dns(&self) -> zbus::Result<Vec<(i32, Vec<u8>)>>;
|
||||
}
|
||||
|
||||
fn link_object_path(if_index: u32) -> String {
|
||||
let mut path = String::from("/org/freedesktop/resolve1/link/");
|
||||
for c in if_index.to_string().chars() {
|
||||
path.push_str("_3");
|
||||
path.push(c);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,6 +63,7 @@ trait Resolve1Manager {
|
||||
pub async fn set_dns(
|
||||
interface: &str,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
dns_servers: &[IpAddr],
|
||||
_assigned_ip: IpAddr,
|
||||
state: &mut DnsState,
|
||||
@@ -59,6 +85,31 @@ pub async fn set_dns(
|
||||
match result {
|
||||
Ok(_) => {
|
||||
state.configured = true;
|
||||
// Both the physical link and the tunnel link end up with
|
||||
// Default Route: yes in systemd-resolved (the physical link
|
||||
// never loses that flag just because another link gains it).
|
||||
// With two "default route" resolvers, systemd-resolved can
|
||||
// race queries between the VPN's DNS server and the ISP's,
|
||||
// leaking lookups outside the tunnel and returning
|
||||
// inconsistent/partial answers (e.g. AAAA-only responses).
|
||||
// Blank out the physical link's own DNS server list so it has
|
||||
// nothing to answer with, leaving the tunnel as the sole
|
||||
// practical resolver; its original servers are saved and
|
||||
// restored on disconnect.
|
||||
if let Some(phys_idx) = physical_if_index {
|
||||
match clear_physical_link_dns(phys_idx).await {
|
||||
Ok(original) => {
|
||||
state.demoted_physical_if_index = Some(phys_idx);
|
||||
state.physical_dns_backup = original;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to clear physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -100,14 +151,28 @@ pub async fn restore(
|
||||
|
||||
info!("Restoring DNS settings...");
|
||||
|
||||
let result = match if_index {
|
||||
Some(idx) => revert_link_dbus(idx).await,
|
||||
None => revert_resolvectl(interface).await,
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e);
|
||||
// Always attempt both paths: D-Bus RevertLink clears per-link config in
|
||||
// systemd-resolved's in-memory state; resolvectl revert is a belt-and-suspenders
|
||||
// pass that ensures the +DefaultRoute domain is removed even if D-Bus races
|
||||
// with interface teardown.
|
||||
if let Some(idx) = if_index {
|
||||
if let Err(e) = revert_link_dbus(idx).await {
|
||||
warn!("D-Bus RevertLink({}) failed: {}", idx, e);
|
||||
}
|
||||
}
|
||||
// Always run resolvectl revert as a second pass.
|
||||
revert_resolvectl(interface).await.ok();
|
||||
|
||||
// Restore the physical interface's original DNS servers we blanked out
|
||||
// in set_dns, or it's left with no resolver after the VPN disconnects.
|
||||
if let Some(phys_idx) = state.demoted_physical_if_index.take() {
|
||||
let backup = std::mem::take(&mut state.physical_dns_backup);
|
||||
if let Err(e) = restore_physical_link_dns(phys_idx, backup).await {
|
||||
warn!(
|
||||
"Failed to restore physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.configured = false;
|
||||
@@ -163,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the physical link's current DNS servers, then blanks them out so it
|
||||
/// stops acting as a competing resolver. Returns the original list to restore
|
||||
/// later. Both the read and the clear go through the same CAP_NET_ADMIN
|
||||
/// bypass path as the tunnel's own SetLinkDNS call.
|
||||
async fn clear_physical_link_dns(if_index: u32) -> Result<Vec<(i32, Vec<u8>)>> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let link_proxy = Resolve1LinkProxy::builder(&conn)
|
||||
.path(link_object_path(if_index))
|
||||
.context("Invalid link object path")?
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to create resolve1 link proxy")?;
|
||||
|
||||
let original = link_proxy
|
||||
.dns()
|
||||
.await
|
||||
.context("Failed to read physical link DNS servers")?;
|
||||
|
||||
let manager_proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
manager_proxy
|
||||
.set_link_dns(if_index as i32, vec![])
|
||||
.await
|
||||
.context("SetLinkDNS (clear) failed")?;
|
||||
|
||||
info!(
|
||||
"Cleared physical interface {} DNS servers ({} saved for restore)",
|
||||
if_index,
|
||||
original.len()
|
||||
);
|
||||
Ok(original)
|
||||
}
|
||||
|
||||
async fn restore_physical_link_dns(if_index: u32, original: Vec<(i32, Vec<u8>)>) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
proxy
|
||||
.set_link_dns(if_index as i32, original)
|
||||
.await
|
||||
.context("SetLinkDNS (restore) failed")?;
|
||||
|
||||
info!("Restored physical interface {} DNS servers", if_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
@@ -172,6 +293,14 @@ async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
// Clear DNS servers and routing domains explicitly before the full revert.
|
||||
// RevertLink alone can race with interface teardown in systemd-resolved;
|
||||
// zeroing each setting first ensures they are gone even if RevertLink races.
|
||||
let _ = proxy.set_link_dns(if_index as i32, vec![]).await;
|
||||
let _ = proxy
|
||||
.set_link_domains(if_index as i32, vec![])
|
||||
.await;
|
||||
|
||||
proxy
|
||||
.revert_link(if_index as i32)
|
||||
.await
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct DnsManager {
|
||||
interface: String,
|
||||
/// Interface index (if available/relevant)
|
||||
if_index: Option<u32>,
|
||||
/// Pre-VPN default-gateway interface index, demoted as DNS default route
|
||||
/// while connected (Linux only)
|
||||
physical_if_index: Option<u32>,
|
||||
/// IP address assigned to the TUN interface
|
||||
assigned_ip: IpAddr,
|
||||
/// State to track if we modified DNS
|
||||
@@ -26,14 +29,29 @@ pub struct DnsManager {
|
||||
struct DnsState {
|
||||
configured: bool,
|
||||
_original_dns: Vec<IpAddr>,
|
||||
// Physical interface demoted from systemd-resolved's default DNS route
|
||||
// (Linux only) while the tunnel is the default; restored on disconnect.
|
||||
demoted_physical_if_index: Option<u32>,
|
||||
// Physical interface's original DNS servers (address_family, raw_bytes),
|
||||
// saved before blanking them out; restored verbatim on disconnect.
|
||||
physical_dns_backup: Vec<(i32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl DnsManager {
|
||||
/// Create a new DNS manager for a specific interface
|
||||
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self {
|
||||
/// Create a new DNS manager for a specific interface.
|
||||
/// `physical_if_index` is the pre-VPN default-gateway interface (Linux
|
||||
/// only) — needed so the tunnel can become the sole default DNS
|
||||
/// resolver instead of racing systemd-resolved queries against it.
|
||||
pub fn new(
|
||||
interface: String,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
assigned_ip: IpAddr,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
if_index,
|
||||
physical_if_index,
|
||||
assigned_ip,
|
||||
state: Arc::new(Mutex::new(DnsState::default())),
|
||||
}
|
||||
@@ -51,7 +69,7 @@ impl DnsManager {
|
||||
// Platform specific implementation
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::set_dns(&self.interface, self.if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
linux::set_dns(&self.interface, self.if_index, self.physical_if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -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()
|
||||
let iface_arg = if let Some(idx) = if_index {
|
||||
format!("-InterfaceIndex {}", idx)
|
||||
} else {
|
||||
format!("-InterfaceAlias '{}'", interface)
|
||||
};
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures::stream::TryStreamExt;
|
||||
use netlink_packet_route::route::RouteProtocol;
|
||||
use netlink_packet_route::route::{RouteProtocol, RouteType};
|
||||
use rtnetlink::{new_connection, Handle, IpVersion};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use tracing::{info, warn};
|
||||
|
||||
// Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001).
|
||||
// Ensures konduit's main-table routes (tun0 default, server host-route) win in
|
||||
// full-tunnel mode without modifying any third-party routing table.
|
||||
const VPN_RULE_PRIORITY: u32 = 8000;
|
||||
|
||||
|
||||
const VPN_ROUTE_METRIC: u32 = 50;
|
||||
|
||||
/// Manages routing table for VPN connection using netlink.
|
||||
@@ -19,8 +25,21 @@ pub struct RouteManager {
|
||||
// Saved during setup for re-adding the server host route after resume.
|
||||
server_ip: Option<Ipv4Addr>,
|
||||
wifi_gateway: Option<Ipv4Addr>,
|
||||
// Physical interface the server host route must be pinned to. Without this,
|
||||
// if the VPN's assigned tun subnet happens to contain the physical gateway
|
||||
// IP (e.g. both are 10.x.0.1), the kernel resolves the gateway via tun0's
|
||||
// own directly-connected route instead of the physical NIC, looping the
|
||||
// server route back into the tunnel it's meant to bypass.
|
||||
wifi_iface_index: Option<u32>,
|
||||
// Saved for re-adding the VPN default route after reconnect.
|
||||
vpn_gateway: Option<Ipv4Addr>,
|
||||
// True when we installed an ip rule to override policy-routing stacks.
|
||||
added_policy_rule: bool,
|
||||
// True when we installed an IPv6 "unreachable" default route to block
|
||||
// IPv6 leaks (full-tunnel mode only pushes an IPv4 default route via
|
||||
// tun0, so without this, any AAAA-resolved destination would route out
|
||||
// the physical NIC directly, bypassing the tunnel entirely).
|
||||
added_ipv6_blackhole: bool,
|
||||
}
|
||||
|
||||
impl RouteManager {
|
||||
@@ -35,10 +54,13 @@ impl RouteManager {
|
||||
added_routes: Vec::new(),
|
||||
tun_interface: tun_interface.clone(),
|
||||
tun_index: None,
|
||||
dns_manager: crate::dns::DnsManager::new(tun_interface, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
|
||||
dns_manager: crate::dns::DnsManager::new(tun_interface, None, None, IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
|
||||
server_ip: None,
|
||||
wifi_gateway: None,
|
||||
wifi_iface_index: None,
|
||||
vpn_gateway: None,
|
||||
added_policy_rule: false,
|
||||
added_ipv6_blackhole: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,23 +79,58 @@ impl RouteManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_default_gateway(&self) -> Result<Ipv4Addr> {
|
||||
/// Returns the physical default gateway and the interface it's reachable on.
|
||||
/// The interface index must be used to pin any route meant to bypass the
|
||||
/// tunnel — relying on gateway-only resolution lets the kernel route it via
|
||||
/// tun0 instead if the VPN's assigned subnet happens to contain this gateway IP.
|
||||
async fn get_default_gateway(&self) -> Result<(Ipv4Addr, u32)> {
|
||||
use netlink_packet_route::route::RouteAddress;
|
||||
|
||||
let mut routes = self.handle.route().get(IpVersion::V4).execute();
|
||||
// Collect all default-route gateways with their metrics.
|
||||
// VPN default routes have low metric (50); physical/DHCP routes have high metric (600+).
|
||||
// We want the physical gateway, so take the one with the highest metric.
|
||||
//
|
||||
// The metric comparison alone is not reliable: the kernel does not always
|
||||
// round-trip the Priority attribute on route dumps (see the Oif-based match
|
||||
// in suspend_default_route() below, which exists for the same reason), and a
|
||||
// leftover default route on a reused tun ifindex from an imperfectly torn
|
||||
// down previous session can win the metric comparison by accident. Since we
|
||||
// already know our own tun interface's index at this point, exclude it
|
||||
// outright rather than trusting metric alone to steer around it.
|
||||
let mut candidates: Vec<(u32, Ipv4Addr, u32)> = Vec::new();
|
||||
|
||||
while let Some(route) = routes.try_next().await? {
|
||||
if route.header.destination_prefix_length == 0 {
|
||||
if route.header.destination_prefix_length != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut metric = 0u32;
|
||||
let mut gateway: Option<Ipv4Addr> = None;
|
||||
let mut oif: Option<u32> = None;
|
||||
for nla in route.attributes.iter() {
|
||||
if let netlink_packet_route::route::RouteAttribute::Gateway(addr) = nla {
|
||||
use netlink_packet_route::route::RouteAddress;
|
||||
if let RouteAddress::Inet(ipv4_addr) = addr {
|
||||
return Ok(*ipv4_addr);
|
||||
match nla {
|
||||
netlink_packet_route::route::RouteAttribute::Gateway(
|
||||
RouteAddress::Inet(ip),
|
||||
) => gateway = Some(*ip),
|
||||
netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
|
||||
netlink_packet_route::route::RouteAttribute::Oif(idx) => oif = Some(*idx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(gw), Some(idx)) = (gateway, oif) {
|
||||
if Some(idx) == self.tun_index {
|
||||
continue;
|
||||
}
|
||||
candidates.push((metric, gw, idx));
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("No default gateway found")
|
||||
// Highest metric = physical/DHCP interface (not VPN).
|
||||
candidates
|
||||
.into_iter()
|
||||
.max_by_key(|(m, _, _)| *m)
|
||||
.map(|(_, gw, idx)| (gw, idx))
|
||||
.ok_or_else(|| anyhow::anyhow!("No default gateway found"))
|
||||
}
|
||||
|
||||
/// Install routes for the VPN connection.
|
||||
@@ -99,11 +156,15 @@ impl RouteManager {
|
||||
self.tun_index.unwrap()
|
||||
);
|
||||
|
||||
let current_gateway = self
|
||||
let (current_gateway, wifi_iface_index) = self
|
||||
.get_default_gateway()
|
||||
.await
|
||||
.context("Failed to get current gateway")?;
|
||||
info!("Current gateway: {}", current_gateway);
|
||||
self.wifi_iface_index = Some(wifi_iface_index);
|
||||
info!(
|
||||
"Current gateway: {} (interface index {})",
|
||||
current_gateway, wifi_iface_index
|
||||
);
|
||||
|
||||
// Resolve server address to IP
|
||||
let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() {
|
||||
@@ -133,6 +194,7 @@ impl RouteManager {
|
||||
.v4()
|
||||
.destination_prefix(server_v4, 32)
|
||||
.gateway(current_gateway)
|
||||
.output_interface(wifi_iface_index)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
@@ -178,6 +240,9 @@ impl RouteManager {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?;
|
||||
|
||||
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
|
||||
|
||||
self.install_policy_rule().await;
|
||||
self.block_ipv6_leak().await;
|
||||
} else {
|
||||
info!(
|
||||
"Split-tunnel mode: adding {} routes via VPN...",
|
||||
@@ -239,14 +304,105 @@ impl RouteManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a high-priority ip rule so konduit's main-table routes override any
|
||||
/// policy-routing stack (e.g., xray, sing-box) that intercepts traffic via a
|
||||
/// separate routing table at priority ~9000.
|
||||
///
|
||||
/// Uses the same netlink handle as every other route change here, rather
|
||||
/// than shelling out to `ip rule add` -- a child process spawned via
|
||||
/// std::process::Command does NOT inherit capabilities granted to this
|
||||
/// process via `setcap`/file capabilities (they'd need to be raised into
|
||||
/// the ambient set first, which nothing here does), so the subprocess
|
||||
/// silently failed with EPERM ("RTNETLINK answers: Operation not
|
||||
/// permitted") every time, regardless of environment. That meant this
|
||||
/// rule never actually got installed, so a competing policy-routing
|
||||
/// stack (e.g. Xray) could keep overriding the VPN's own routes.
|
||||
async fn install_policy_rule(&mut self) {
|
||||
match self
|
||||
.handle
|
||||
.rule()
|
||||
.add()
|
||||
.v4()
|
||||
.priority(VPN_RULE_PRIORITY)
|
||||
// RuleAddRequest::new() defaults the rule's action to Unspec, not
|
||||
// ToTable. A "from all" rule (matches every packet) with an
|
||||
// Unspec action is not a valid "jump to main table" rule and the
|
||||
// kernel does not fall through past it — it broke route lookups
|
||||
// for ALL traffic, not just VPN traffic, until reboot.
|
||||
.action(netlink_packet_route::rule::RuleAction::ToTable)
|
||||
.execute()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.added_policy_rule = true;
|
||||
info!("Added ip rule priority {} → main table", VPN_RULE_PRIORITY);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", e);
|
||||
if err_msg.contains("File exists") || err_msg.contains("EEXIST") || err_msg.contains("code: Some(-17)")
|
||||
{
|
||||
self.added_policy_rule = true;
|
||||
warn!("ip rule priority {} already exists, continuing...", VPN_RULE_PRIORITY);
|
||||
} else {
|
||||
warn!("Failed to add ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block outbound IPv6 in full-tunnel mode by installing an "unreachable"
|
||||
/// default route (::/0). Full-tunnel only pushes an IPv4 default route
|
||||
/// via the VPN gateway (VPN servers here don't provide IPv6 transport),
|
||||
/// so without this, any destination that resolves to an AAAA record
|
||||
/// leaks straight out the physical interface instead of the tunnel —
|
||||
/// bypassing it entirely rather than merely failing closed. Failing the
|
||||
/// IPv6 attempt fast also lets Happy-Eyeballs clients fall back to IPv4
|
||||
/// (through the tunnel) quickly instead of hanging on an IPv6 path that
|
||||
/// silently goes nowhere.
|
||||
async fn block_ipv6_leak(&mut self) {
|
||||
let result = self
|
||||
.handle
|
||||
.route()
|
||||
.add()
|
||||
.v6()
|
||||
.destination_prefix(Ipv6Addr::UNSPECIFIED, 0)
|
||||
.kind(RouteType::Unreachable)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.added_ipv6_blackhole = true;
|
||||
info!("✅ IPv6 default route blocked (prevents leaking outside tunnel)");
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", e);
|
||||
if err_msg.contains("File exists")
|
||||
|| err_msg.contains("EEXIST")
|
||||
|| err_msg.contains("code: Some(-17)")
|
||||
{
|
||||
self.added_ipv6_blackhole = true;
|
||||
warn!("IPv6 blackhole route already exists, continuing...");
|
||||
} else {
|
||||
warn!("Failed to block IPv6 default route: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup DNS configuration
|
||||
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
|
||||
if dns_servers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.dns_manager =
|
||||
crate::dns::DnsManager::new(self.tun_interface.clone(), self.tun_index, assigned_ip);
|
||||
self.dns_manager = crate::dns::DnsManager::new(
|
||||
self.tun_interface.clone(),
|
||||
self.tun_index,
|
||||
self.wifi_iface_index,
|
||||
assigned_ip,
|
||||
);
|
||||
|
||||
self.dns_manager.set_dns(dns_servers).await
|
||||
}
|
||||
@@ -269,16 +425,18 @@ impl RouteManager {
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let result = self
|
||||
let mut req = self
|
||||
.handle
|
||||
.route()
|
||||
.add()
|
||||
.v4()
|
||||
.destination_prefix(server_ip, 32)
|
||||
.gateway(gateway)
|
||||
.priority(VPN_ROUTE_METRIC)
|
||||
.execute()
|
||||
.await;
|
||||
.priority(VPN_ROUTE_METRIC);
|
||||
if let Some(idx) = self.wifi_iface_index {
|
||||
req = req.output_interface(idx);
|
||||
}
|
||||
let result = req.execute().await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@@ -312,11 +470,13 @@ impl RouteManager {
|
||||
let mut to_delete = None;
|
||||
while let Some(route) = routes.try_next().await? {
|
||||
if route.header.destination_prefix_length == 0 {
|
||||
// Identify our default route by its output interface (tun0), not metric,
|
||||
// since the kernel may not round-trip the Priority attribute reliably.
|
||||
let is_ours = route.attributes.iter().any(|nla| {
|
||||
matches!(
|
||||
nla,
|
||||
netlink_packet_route::route::RouteAttribute::Priority(p)
|
||||
if *p == VPN_ROUTE_METRIC
|
||||
netlink_packet_route::route::RouteAttribute::Oif(idx)
|
||||
if Some(*idx) == self.tun_index
|
||||
)
|
||||
});
|
||||
if is_ours {
|
||||
@@ -380,6 +540,55 @@ impl RouteManager {
|
||||
self.added_routes.len()
|
||||
);
|
||||
|
||||
// Remove the policy rule first so traffic stops using the VPN immediately.
|
||||
// Same netlink-handle approach as the add side above -- `handle.rule().del()`
|
||||
// needs the exact existing RuleMessage, so look it up by priority first.
|
||||
if self.added_policy_rule {
|
||||
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
|
||||
let mut to_delete = None;
|
||||
while let Some(rule) = rules.try_next().await? {
|
||||
let matches_priority = rule
|
||||
.attributes
|
||||
.iter()
|
||||
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
|
||||
if matches_priority {
|
||||
to_delete = Some(rule);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match to_delete {
|
||||
Some(rule) => match self.handle.rule().del(rule).execute().await {
|
||||
Ok(()) => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
|
||||
Err(e) => warn!("Failed to delete ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e),
|
||||
},
|
||||
None => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
|
||||
}
|
||||
self.added_policy_rule = false;
|
||||
}
|
||||
|
||||
if self.added_ipv6_blackhole {
|
||||
let mut routes = self.handle.route().get(IpVersion::V6).execute();
|
||||
let mut to_delete = None;
|
||||
while let Some(route) = routes.try_next().await? {
|
||||
if route.header.destination_prefix_length == 0
|
||||
&& route.header.kind == RouteType::Unreachable
|
||||
{
|
||||
to_delete = Some(route);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match to_delete {
|
||||
Some(route) => match self.handle.route().del(route).execute().await {
|
||||
Ok(()) => info!("Removed IPv6 blackhole route"),
|
||||
Err(e) => warn!("Failed to delete IPv6 blackhole route: {:?}", e),
|
||||
},
|
||||
None => warn!("IPv6 blackhole route not found, nothing to remove"),
|
||||
}
|
||||
self.added_ipv6_blackhole = false;
|
||||
}
|
||||
|
||||
if self.added_routes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -406,16 +615,23 @@ impl RouteManager {
|
||||
|
||||
if let Some(ip) = dest_ip {
|
||||
if self.added_routes.contains(&(ip, prefix_len)) {
|
||||
// For the default route (0.0.0.0/0), match by output interface
|
||||
// (tun0), not just destination prefix — otherwise we also delete
|
||||
// the WiFi default route (same prefix, different OIF/metric).
|
||||
if prefix_len == 0 {
|
||||
let is_ours = route.attributes.iter().any(|nla| {
|
||||
matches!(
|
||||
nla,
|
||||
netlink_packet_route::route::RouteAttribute::Priority(p)
|
||||
if *p == VPN_ROUTE_METRIC
|
||||
netlink_packet_route::route::RouteAttribute::Oif(idx)
|
||||
if Some(*idx) == self.tun_index
|
||||
)
|
||||
});
|
||||
if is_ours {
|
||||
routes_to_delete.push(route);
|
||||
}
|
||||
} else {
|
||||
routes_to_delete.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,3 +658,69 @@ impl Drop for RouteManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use netlink_packet_route::rule::RuleAction;
|
||||
|
||||
const REEXEC_ENV_VAR: &str = "KORIDOR_ROUTE_TEST_IN_NETNS";
|
||||
|
||||
// `unshare(CLONE_NEWUSER)` fails with EINVAL if the calling process is
|
||||
// multithreaded (which `cargo test`'s harness always is). So instead of
|
||||
// calling the syscall in-process, re-exec this same test binary under
|
||||
// the external `unshare --user --net --map-root-user` command, which
|
||||
// starts a fresh, single-threaded process already inside an isolated
|
||||
// user+net namespace -- no real root needed, and the host's actual
|
||||
// routing rules are never touched.
|
||||
fn run_in_isolated_netns(test_name: &str) {
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let status = std::process::Command::new("unshare")
|
||||
.args(["--user", "--net", "--map-root-user", "--"])
|
||||
.arg(&exe)
|
||||
.args(["--exact", test_name, "--nocapture", "--test-threads=1"])
|
||||
.env(REEXEC_ENV_VAR, "1")
|
||||
.status()
|
||||
.expect("failed to spawn unshare (is util-linux's `unshare` installed?)");
|
||||
assert!(status.success(), "test failed inside isolated netns (see output above)");
|
||||
}
|
||||
|
||||
// Regression test for the bug where connecting broke ALL host networking
|
||||
// (not just VPN traffic) until a reboot: the policy rule installed to
|
||||
// make konduit's routes win over other policy-routing stacks was built
|
||||
// via `RuleAddRequest::new()`, which defaults `header.action` to
|
||||
// `RuleAction::Unspec` — a "from all" rule (matches every packet) with
|
||||
// no action is not a valid "jump to main table" rule, and the kernel
|
||||
// does not fall through to the next rule for it, breaking route lookups
|
||||
// system-wide. The fix must set the action explicitly to `ToTable`.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn policy_rule_uses_to_table_action() {
|
||||
if std::env::var(REEXEC_ENV_VAR).is_err() {
|
||||
run_in_isolated_netns("routes::linux::tests::policy_rule_uses_to_table_action");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut mgr = RouteManager::new("lo".to_string())
|
||||
.await
|
||||
.expect("failed to create RouteManager in isolated netns");
|
||||
|
||||
mgr.install_policy_rule().await;
|
||||
|
||||
let mut rules = mgr.handle.rule().get(IpVersion::V4).execute();
|
||||
let mut found = false;
|
||||
while let Some(rule) = rules.try_next().await.unwrap() {
|
||||
let is_ours = rule.attributes.iter().any(|attr| {
|
||||
matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY)
|
||||
});
|
||||
if is_ours {
|
||||
assert_eq!(
|
||||
rule.header.action,
|
||||
RuleAction::ToTable,
|
||||
"policy rule must use the ToTable action, or the kernel treats it as a black hole for every packet it matches (i.e. everything)"
|
||||
);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assert!(found, "installed policy rule not found via rule().get()");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
|
||||
server_host: String,
|
||||
original_gateway: String,
|
||||
primary_service: String,
|
||||
// Physical interface the server host route must be pinned to via -ifscope.
|
||||
// Without this, if the VPN's assigned tun subnet happens to contain the
|
||||
// physical gateway IP, the OS can resolve the gateway via the tun
|
||||
// interface's own directly-connected route instead of the physical NIC,
|
||||
// looping the server route back into the tunnel it's meant to bypass.
|
||||
wifi_iface: String,
|
||||
}
|
||||
|
||||
impl MacOsNetManager {
|
||||
@@ -42,13 +48,18 @@ impl MacOsNetManager {
|
||||
server_host,
|
||||
original_gateway,
|
||||
primary_service,
|
||||
wifi_iface: iface,
|
||||
})
|
||||
}
|
||||
|
||||
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
|
||||
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
|
||||
pub async fn setup_vpn_routes(&self) -> Result<()> {
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
run("route", &["add", "-net", "0.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
run("route", &["add", "-net", "128.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
info!("macOS VPN routes added via {}", self.tun_name);
|
||||
@@ -89,7 +100,11 @@ impl MacOsNetManager {
|
||||
// the wrong gateway until the next reconnect that creates a new MacOsNetManager.
|
||||
pub async fn ensure_server_route(&self) -> Result<()> {
|
||||
run("route", &["delete", "-host", &self.server_host]).await;
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
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;
|
||||
|
||||
308
konduit-platform/src/routes/windows.rs
Normal file
308
konduit-platform/src/routes/windows.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
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>,
|
||||
// Physical interface name the server host route must be pinned to. Without
|
||||
// this, if the VPN's assigned tun subnet happens to contain the physical
|
||||
// gateway IP, `route add`'s gateway-only resolution can pick the tun
|
||||
// interface instead of the physical NIC, looping the server route back
|
||||
// into the tunnel it's meant to bypass.
|
||||
wifi_iface: Option<String>,
|
||||
}
|
||||
|
||||
impl RouteManager {
|
||||
pub async fn new(tun_interface: String) -> Result<Self> {
|
||||
Ok(Self {
|
||||
dns_manager: DnsManager::new(
|
||||
tun_interface.clone(),
|
||||
None,
|
||||
None,
|
||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
),
|
||||
tun_interface,
|
||||
tun_routes: Vec::new(),
|
||||
server_ip: None,
|
||||
wifi_gateway: None,
|
||||
wifi_iface: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the physical default gateway and the friendly name of the
|
||||
/// adapter it's reachable on. The interface name must be used to pin any
|
||||
/// route meant to bypass the tunnel — relying on gateway-only resolution
|
||||
/// lets `route add` route it via the TUN adapter instead if the VPN's
|
||||
/// assigned subnet happens to contain this gateway IP.
|
||||
fn get_default_gateway() -> Result<(Ipv4Addr, String)> {
|
||||
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, adapter.friendly_name().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No default gateway found")
|
||||
}
|
||||
|
||||
// Add/delete the server host route through the physical WiFi/Ethernet
|
||||
// interface, pinned explicitly by adapter name via `netsh` rather than
|
||||
// `route add`'s gateway-only resolution — same rationale and technique as
|
||||
// `tun_route_add` below: without an explicit interface, an IP collision
|
||||
// between the VPN's assigned tun subnet and the physical gateway can make
|
||||
// this route resolve onto the TUN adapter instead, looping the server
|
||||
// route back into the tunnel it's meant to bypass.
|
||||
fn wifi_route_add(iface: &str, dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
|
||||
let prefix_str = format!("{}/{}", dest, prefix);
|
||||
let out = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"add",
|
||||
"route",
|
||||
&prefix_str,
|
||||
iface,
|
||||
&format!("nexthop={}", gateway),
|
||||
"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() {
|
||||
// "already exists" is acceptable — a previous run may have left it
|
||||
warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim());
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, e),
|
||||
}
|
||||
}
|
||||
|
||||
fn wifi_route_delete(iface: &str, dest: Ipv4Addr, prefix: u8) {
|
||||
let prefix_str = format!("{}/{}", dest, prefix);
|
||||
let _ = std::process::Command::new("netsh")
|
||||
.args(["interface", "ipv4", "delete", "route", &prefix_str, iface])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
|
||||
// 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, wifi_iface) = Self::get_default_gateway()?;
|
||||
info!("Current gateway: {} (interface {})", current_gateway, wifi_iface);
|
||||
self.wifi_iface = Some(wifi_iface.clone());
|
||||
|
||||
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(&wifi_iface, 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, 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), Some(iface)) =
|
||||
(self.server_ip, self.wifi_gateway, self.wifi_iface.as_deref())
|
||||
{
|
||||
Self::wifi_route_add(iface, 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.
|
||||
self.wifi_gateway.take();
|
||||
if let (Some(server_ip), Some(iface)) = (self.server_ip.take(), self.wifi_iface.take()) {
|
||||
Self::wifi_route_delete(&iface, server_ip, 32);
|
||||
}
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,35 +247,6 @@ mod tests {
|
||||
assert_eq!(stats.download_bytes, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_calculation() {
|
||||
let tracker = StatsTracker::new("utilbox.eu:8443".to_string(), "test".to_string());
|
||||
|
||||
// Record some traffic
|
||||
tracker.record_upload(1024);
|
||||
tracker.record_download(2048);
|
||||
|
||||
// Update to calculate speed
|
||||
tracker.update();
|
||||
|
||||
let stats = tracker.get_stats(false);
|
||||
assert_eq!(stats.upload_speed, 1024);
|
||||
assert_eq!(stats.download_speed, 2048);
|
||||
|
||||
// Record more traffic
|
||||
tracker.record_upload(512);
|
||||
tracker.record_download(1024);
|
||||
|
||||
// Update again
|
||||
tracker.update();
|
||||
|
||||
let stats = tracker.get_stats(false);
|
||||
assert_eq!(stats.upload_bytes, 1536);
|
||||
assert_eq!(stats.download_bytes, 3072);
|
||||
assert_eq!(stats.upload_speed, 512);
|
||||
assert_eq!(stats.download_speed, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ring_buffer() {
|
||||
let mut buffer = RingBuffer::new();
|
||||
|
||||
@@ -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,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(
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
// 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);
|
||||
|
||||
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 {
|
||||
match reader.read_buf(&mut buf).await {
|
||||
Ok(0) => {
|
||||
info!("TUN device closed");
|
||||
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
|
||||
Ok(_) => {
|
||||
if tun_pkt_tx.send(buf.freeze()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -150,14 +196,121 @@ impl TunDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Packet from TCP (needs to be written to TUN)
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
// Server→TUN first: deliver incoming VPN traffic immediately.
|
||||
//
|
||||
// Deliberately NOT write_all(): a TUN character device requires each
|
||||
// write() syscall to contain exactly one complete packet. write_all()
|
||||
// retries a short write by sending the *remaining* bytes in a follow-up
|
||||
// write() call -- correct for stream sockets, but for a TUN device that
|
||||
// turns one packet into two malformed ones (a truncated first packet,
|
||||
// plus a bogus "packet" made of leftover bytes with no valid IP header).
|
||||
// A single write() either succeeds atomically or it doesn't; there is no
|
||||
// safe way to "continue" a partial packet write, so treat anything short
|
||||
// of a full write as a dropped packet, not a retry target.
|
||||
Some(packet) = from_tcp_rx.recv() => {
|
||||
if let Err(e) = writer.write_all(&packet).await {
|
||||
match writer.write(&packet).await {
|
||||
Ok(n) if n == packet.len() => {}
|
||||
Ok(n) => {
|
||||
error!(
|
||||
"TUN short write: wrote {} of {} bytes -- packet dropped (TUN writes must be atomic per-packet)",
|
||||
n, packet.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("TUN write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TUN→TCP: forward outgoing packets from the kernel.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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