19 Commits

Author SHA1 Message Date
E. Kaparulin
c2d7f8d8c6 chore: sync konduit-platform v0.1.0-beta.10 2026-07-23 18:44:50 +03:00
E. Kaparulin
678ed158a6 chore: sync konduit-platform 2026-07-23 18:33:05 +03:00
E. Kaparulin
56201f6b72 docs(public): fix README support section to match actual channels (contact form, no in-app reporting/issues) 2026-07-17 10:34:10 +03:00
E. Kaparulin
b160652737 chore: sync konduit-platform v0.1.0-beta.8 2026-07-16 14:39:57 +03:00
E. Kaparulin
7df5aa4317 chore: sync konduit-platform v0.1.0-beta.7 2026-07-10 07:42:05 +03:00
E. Kaparulin
99a2993ace docs(public): remove stealth-mode content, add SOCKS5 modes and connection tuning docs 2026-07-10 06:46:44 +03:00
E. Kaparulin
fb7ad7ec40 chore: bump version to 0.1.0-beta.7 2026-06-26 23:16:30 +03:00
E. Kaparulin
87bb99c10b fix: stealth=true implies TLS+WebSocket; update public docs 2026-06-26 23:13:22 +03:00
E. Kaparulin
ba8e2b0aa1 debug: log TUN read byte count 2026-06-26 19:19:20 +03:00
E. Kaparulin
e63d41c153 docs: rewrite stealth-setup for real-TLS HAProxy mode with architecture diagram 2026-06-25 12:32:36 +03:00
57249605fb Update README.md 2026-06-19 07:44:03 +00:00
E. Kaparulin
9eb9b39f77 chore: sync konduit-platform v0.1.0-beta.3 2026-06-19 10:41:56 +03:00
E. Kaparulin
b1afe43ef0 fix: connection troubleshooting — reference app config, not toml file 2026-06-17 12:26:03 +03:00
E. Kaparulin
de3534af67 fix: clarify config setup — manual entry from toml, not file import 2026-06-17 12:24:12 +03:00
E. Kaparulin
3d3c32aef3 fix: update config import instructions to reflect TOML/QR flow 2026-06-17 12:22:36 +03:00
E. Kaparulin
7be38783b5 chore: add GUI quickstart docs and update README for 0.1.0-beta.0 2026-06-17 12:00:52 +03:00
E. Kaparulin
10def914ab Update examples 2026-06-09 08:30:41 +03:00
E. Kaparulin
1e08ba34f0 Documentation update 2026-06-09 08:18:12 +03:00
Eugen Kaparulin
5cd882f74b Public website overhaul 2026-06-09 07:17:24 +03:00
22 changed files with 1493 additions and 300 deletions

View File

@@ -7,7 +7,7 @@
**TCP-Native VPN. Works Where UDP Doesn't.** **TCP-Native VPN. Works Where UDP Doesn't.**
[![License: Proprietary](https://img.shields.io/badge/license-Proprietary-red)](LICENSE) [![License: Proprietary](https://img.shields.io/badge/license-Proprietary-red)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Android%20%7C%20iOS-lightgrey)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20Android-lightgrey)]()
[![Status](https://img.shields.io/badge/status-Beta-orange)]() [![Status](https://img.shields.io/badge/status-Beta-orange)]()
</div> </div>
@@ -33,20 +33,24 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
- **Userspace implementation** — no kernel modules, no root required - **Userspace implementation** — no kernel modules, no root required
- **Hot config reload** — update server configuration without dropping connections - **Hot config reload** — update server configuration without dropping connections
- **QR code provisioning** — scan once, connect instantly - **QR code provisioning** — scan once, connect instantly
- **Cross-platform** — Linux, macOS, Android, iOS - **Cross-platform** — Linux, Windows, Android (macOS and iOS coming soon)
- **Modern cryptography** — X25519 key exchange, ChaCha20-Poly1305 data channel - **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 - **Memory safe** — written entirely in Rust
## Download ## Download
Releases are published on the [Releases](../../releases) page. Each release includes: Releases are published on the [Releases](../../releases) page. Each release includes:
| Binary | Purpose | | Artifact | Platform | Purpose |
|---|---| |---|---|---|
| `konduit-server` | VPN server | | `konduit-server-*-linux-x86_64` | Linux | VPN server |
| `konduit` | CLI client (Linux) | | `konduit-*-linux-x86_64` | Linux | CLI client |
| `konduit-ctl` | Server provisioning tool | | `konduit-ctl-*-linux-x86_64` | Linux | Server provisioning tool |
| `Konduit-*-x86_64.AppImage` | Linux | GUI client (AppImage) |
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll |
| `konduit-gui-*-windows-x86_64.zip` | Windows | GUI client |
| `konduit-*-android.aab` | Android | Android app (sideload via bundletool) |
### Linux (CLI) ### Linux (CLI)
@@ -61,14 +65,39 @@ echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
./konduit connect --config client.toml ./konduit connect --config client.toml
``` ```
### macOS · Android · iOS ### Linux (GUI)
Download the `.AppImage`, make it executable, and run it:
```bash
chmod +x Konduit-*-x86_64.AppImage
./Konduit-*-x86_64.AppImage
```
### Windows
Extract the zip and run `konduit.exe` (CLI) or `Konduit.exe` (GUI). `wintun.dll` must remain alongside the executable.
### Android
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
Coming soon. Coming soon.
## Server Setup ## Setup
**GUI clients**
- [Linux GUI Quickstart](docs/gui-quickstart-linux.md) — AppImage, privileges, autostart
- [Windows GUI Quickstart](docs/gui-quickstart-windows.md) — extract, UAC, WinTun, autostart
**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 - [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 ## Architecture
@@ -96,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 [`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 ## Security
@@ -104,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. **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 ## Support
**Bug reports:** Use the in-app reporting feature or open an issue in this repository. Bug reports, security vulnerabilities, and any other inquiries: use the contact form at [www.k-ops.eu](https://www.k-ops.eu).
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
**Contributing:** Core development is handled internally. We do not currently accept external pull requests. **Contributing:** Core development is handled internally. We do not currently accept external pull requests.
@@ -119,8 +144,9 @@ The VPN server, management UI, and stealth-mode protocol are proprietary. Keepin
## About ## About
Created by **Eugen Kaparulin**. Created by **Eugen Kaparulin**.
Official binaries distributed by **K-Ops Oy**. Official binaries distributed by **[K-Ops Oy](https://k-ops.eu)**.
© Eugen Kaparulin. All rights reserved. © Eugen Kaparulin. All rights reserved.
[`konduit-platform`](./konduit-platform) is published under the [PolyForm Noncommercial License 1.0.0](LICENSE). [`konduit-platform`](./konduit-platform) is published under the [PolyForm Noncommercial License 1.0.0](LICENSE).
All other parts of Konduit are proprietary. All other parts of Konduit are proprietary.
[Privacy Policy](docs/privacy-policy.md)

57
docs/client-quickstart.md Normal file
View File

@@ -0,0 +1,57 @@
# Konduit CLI Client Quickstart
## 1. Download
Download the `konduit` binary from the [Releases](../../releases) page and make it executable:
```bash
chmod +x konduit
sudo cp konduit /opt/konduit/konduit
```
## 2. Get a client config
Your server administrator will provide a `client.toml` generated by `konduit-ctl add-client`. Transfer it to the client machine:
```bash
sudo cp client.toml /opt/konduit/client.toml
sudo chmod 600 /opt/konduit/client.toml
```
## 3. Connect
```bash
/opt/konduit/konduit -c /opt/konduit/client.toml
```
A successful connection looks like:
```
→ resolving vpn.example.com ok
→ tcp handshake X25519 ok
→ tun device konduit0 up
→ routes applied by server policy ok
connected · no udp, no root, port 443
```
## 4. Run as a systemd service
To connect automatically on boot, use the provided systemd unit:
```bash
sudo cp /opt/konduit/docs/systemd/konduit.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now konduit
```
The unit runs as root (required for TUN device creation) and restarts automatically on failure.
## 5. Capabilities (alternative to root)
To run without root, grant the binary the required capability instead:
```bash
sudo setcap cap_net_admin=+ep /opt/konduit/konduit
```
Then change `User=root` to your user account in the systemd unit before enabling it.

20
docs/connection-tuning.md Normal file
View 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.

View File

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

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,66 @@
# Konduit GUI — Linux Quickstart
## 1. Download
Download the `Konduit-*-x86_64.AppImage` from the [Releases](../../releases) page.
## 2. Make it executable
```bash
chmod +x Konduit-*-x86_64.AppImage
```
## 3. Run
Double-click the AppImage in your file manager, or launch it from the terminal:
```bash
./Konduit-*-x86_64.AppImage
```
No installation required. The AppImage is self-contained.
## 4. Load your config
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
Konduit creates a TUN network device, which requires elevated network capability. The AppImage requests this automatically via a `pkexec` prompt on first run. If you see a password prompt, enter your user password to grant the capability.
If `pkexec` is not available on your system (some minimal desktop environments), grant the capability manually once:
```bash
sudo setcap cap_net_admin=+ep ~/.local/share/konduit/konduit-helper
```
## 6. Autostart on login (optional)
To start Konduit automatically when you log in, enable autostart in the app: **Settings → Start on login**.
Alternatively, copy the provided desktop entry to your autostart directory:
```bash
cp ~/.local/share/applications/konduit.desktop ~/.config/autostart/
```
## Troubleshooting
**AppImage won't launch** — your system may be missing FUSE. Install it:
```bash
# Debian / Ubuntu
sudo apt install libfuse2
# Fedora / RHEL
sudo dnf install fuse
```
**"Failed to create TUN device"** — the capability was not granted. Run the `setcap` command from step 5 and relaunch.
**Connection fails immediately** — check that the server address, port, and key values in the app match what your administrator provided. Contact your administrator if you are unsure.
**Config lost on relaunch** — re-enter your connection details or scan the QR code again.

View File

@@ -0,0 +1,65 @@
# Konduit GUI — Windows Quickstart
## Requirements
- Windows 10 or later (64-bit)
- Administrator account (required for VPN driver installation)
## 1. Download
Download `konduit-gui-*-windows-x86_64.zip` from the [Releases](../../releases) page.
## 2. Extract
Right-click the zip file and choose **Extract All**. Extract to a folder of your choice, for example `C:\Konduit`.
Keep all files together — `Konduit.exe` and `wintun.dll` must be in the same folder.
## 3. Run
Double-click `Konduit.exe`.
Windows will show a **UAC prompt** asking for administrator permission. This is required because Konduit uses the [WinTun](https://www.wintun.net/) driver to create a virtual network adapter. Click **Yes** to continue.
> If you dismiss the UAC prompt, Konduit will not be able to establish a VPN connection.
## 4. Load your config
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
Press **Connect**. A system tray icon will appear while the VPN is active. Right-click the tray icon to disconnect.
## 6. Windows Defender / antivirus warning
Some antivirus tools flag `wintun.dll` or new unsigned executables. If Windows Defender blocks the launch:
1. Open **Windows Security → Virus & threat protection**
2. Click **Protection history**
3. Find the Konduit entry and choose **Allow**
Alternatively, add the Konduit folder to your antivirus exclusion list.
## 7. Autostart on login (optional)
To start Konduit automatically when Windows starts, enable it in the app: **Settings → Start on login**.
Alternatively, create a shortcut to `Konduit.exe` and place it in your Startup folder:
```
Win + R → shell:startup → paste shortcut here
```
## Troubleshooting
**UAC prompt does not appear / app closes immediately** — make sure you are running as a user with administrator privileges. Standard (non-admin) accounts cannot install the WinTun driver.
**"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 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`.

71
docs/privacy-policy.md Normal file
View File

@@ -0,0 +1,71 @@
# Konduit Privacy Policy
_Last updated: 9 June 2026_
## Who we are
Konduit is a TCP-native VPN client developed and distributed by **[K-Ops Oy](https://k-ops.eu)**. Questions about this policy can be sent to: **[konduit@k-ops.eu](mailto:konduit@k-ops.eu)**
## What data Konduit processes
### On your device
The Konduit client stores the following data locally:
| Data | Purpose | Where it is stored |
|------|---------|-------------------|
| VPN server address and port | Connect to your VPN server | Local config file |
| Peer ID and pre-shared key (PSK) | Authenticate with your VPN server | Local config file |
| Session statistics (bytes sent/received, connection state) | Display connection status | In-memory only, not persisted |
### On the VPN server
When you connect, the VPN server you connect to processes:
| Data | Purpose |
|------|---------|
| Your IP address | Route return traffic to your device |
| Connection timestamps | Session management |
| Traffic volume (bytes in/out) | Capacity planning and abuse prevention |
| Destination IP addresses of tunnelled traffic | Route packets to their destination |
The content of tunnelled traffic is not inspected beyond what is necessary for routing.
## What we do not collect
- Konduit does **not** collect analytics, crash reports, usage statistics, or any telemetry.
- Konduit does **not** display advertising.
- Konduit does **not** sell or share connection metadata with third parties.
## Self-hosted deployments
Konduit is designed to be self-hosted. If you run your own Konduit server, all server-side data listed above stays under your control and is never transmitted to [K-Ops Oy](https://k-ops.eu).
## Data retention and deletion
All client-side data is stored in the local config file. To delete it, remove your configuration or uninstall Konduit.
Server-side connection logs are retained for a limited period for operational purposes and then deleted. The exact retention period depends on the server operator.
## Security
- 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.
## Children
Konduit is not directed at children under 13 and does not knowingly collect data from children.
## GDPR (EU residents)
When using a [K-Ops Oy](https://k-ops.eu) operated server, [K-Ops Oy](https://k-ops.eu) acts as data processor for connection metadata (IP address, timestamps, traffic volume) as described above. This data is processed on the legal basis of legitimate interest (providing the VPN service). You may request deletion of your connection metadata by contacting **[konduit@k-ops.eu](mailto:konduit@k-ops.eu)**.
When using a self-hosted server, [K-Ops Oy](https://k-ops.eu) does not process any of your data.
## Changes to this policy
If we update this policy, the new version will be published at this URL with an updated "Last updated" date.
## Contact
Privacy questions: **[konduit@k-ops.eu](mailto:konduit@k-ops.eu)**

View File

@@ -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 - 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 ## 3. Add a Client
@@ -62,7 +62,12 @@ Without this, konduit picks the first available `tunN`, which shifts if other VP
./konduit-server --config server.toml ./konduit-server --config server.toml
``` ```
For persistent operation, use the provided systemd unit (`setup/server/konduit-server.service`). For persistent operation, use the provided systemd units in [docs/systemd/](systemd/). Copy `konduit-server.service` to `/etc/systemd/system/`, then:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now konduit-server
```
## 6. NAT / Masquerade ## 6. NAT / Masquerade
@@ -97,8 +102,8 @@ sudo apt install iptables-persistent && sudo netfilter-persistent save
## 7. Verify ## 7. Verify
```bash ```bash
# Should return your website (camouflage) not an error # Connect a client and confirm the tunnel comes up
curl -sk https://your-server/ ./konduit --config client.toml
# Check konduit logs # Check konduit logs
journalctl -u konduit-server -f journalctl -u konduit-server -f

50
docs/socks-modes.md Normal file
View 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.

View File

@@ -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"
```

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Konduit Server
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/konduit
ExecStart=/opt/konduit/konduit-server -c /opt/konduit/server.toml
Restart=on-failure
RestartSec=5
# Optional hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Konduit Client
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/konduit
ExecStart=/opt/konduit/konduit -c /opt/konduit/client.toml
Restart=on-failure
RestartSec=5
# Optional hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

View File

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

View File

@@ -31,6 +31,31 @@ trait Resolve1Manager {
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>; 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 // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,6 +63,7 @@ trait Resolve1Manager {
pub async fn set_dns( pub async fn set_dns(
interface: &str, interface: &str,
if_index: Option<u32>, if_index: Option<u32>,
physical_if_index: Option<u32>,
dns_servers: &[IpAddr], dns_servers: &[IpAddr],
_assigned_ip: IpAddr, _assigned_ip: IpAddr,
state: &mut DnsState, state: &mut DnsState,
@@ -59,6 +85,31 @@ pub async fn set_dns(
match result { match result {
Ok(_) => { Ok(_) => {
state.configured = true; 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) => { Err(e) => {
warn!( warn!(
@@ -100,14 +151,28 @@ pub async fn restore(
info!("Restoring DNS settings..."); info!("Restoring DNS settings...");
let result = match if_index { // Always attempt both paths: D-Bus RevertLink clears per-link config in
Some(idx) => revert_link_dbus(idx).await, // systemd-resolved's in-memory state; resolvectl revert is a belt-and-suspenders
None => revert_resolvectl(interface).await, // 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) = result { if let Err(e) = revert_link_dbus(idx).await {
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e); warn!("D-Bus RevertLink({}) failed: {}", idx, e);
}
}
// Always run resolvectl revert as a second pass.
revert_resolvectl(interface).await.ok(); 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; state.configured = false;
@@ -163,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
Ok(()) 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<()> { async fn revert_link_dbus(if_index: u32) -> Result<()> {
let conn = zbus::Connection::system() let conn = zbus::Connection::system()
.await .await
@@ -172,6 +293,14 @@ async fn revert_link_dbus(if_index: u32) -> Result<()> {
.await .await
.context("Failed to create resolve1 proxy")?; .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 proxy
.revert_link(if_index as i32) .revert_link(if_index as i32)
.await .await

View File

@@ -16,6 +16,9 @@ pub struct DnsManager {
interface: String, interface: String,
/// Interface index (if available/relevant) /// Interface index (if available/relevant)
if_index: Option<u32>, 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 /// IP address assigned to the TUN interface
assigned_ip: IpAddr, assigned_ip: IpAddr,
/// State to track if we modified DNS /// State to track if we modified DNS
@@ -26,14 +29,29 @@ pub struct DnsManager {
struct DnsState { struct DnsState {
configured: bool, configured: bool,
_original_dns: Vec<IpAddr>, _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 { impl DnsManager {
/// Create a new DNS manager for a specific interface /// Create a new DNS manager for a specific interface.
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self { /// `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 { Self {
interface, interface,
if_index, if_index,
physical_if_index,
assigned_ip, assigned_ip,
state: Arc::new(Mutex::new(DnsState::default())), state: Arc::new(Mutex::new(DnsState::default())),
} }
@@ -51,7 +69,7 @@ impl DnsManager {
// Platform specific implementation // Platform specific implementation
#[cfg(target_os = "linux")] #[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")] #[cfg(target_os = "windows")]

View File

@@ -1,6 +1,10 @@
use super::DnsState; use super::DnsState;
use anyhow::Result; use anyhow::Result;
use std::net::IpAddr; use std::net::IpAddr;
use std::os::windows::process::CommandExt;
use tracing::info;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub async fn set_dns( pub async fn set_dns(
interface: &str, interface: &str,
@@ -8,92 +12,57 @@ pub async fn set_dns(
dns_servers: &[IpAddr], dns_servers: &[IpAddr],
state: &mut DnsState, state: &mut DnsState,
) -> Result<()> { ) -> Result<()> {
// Windows implementation using windows-rs crate if dns_servers.is_empty() {
// Note: This logic runs within unsafe blocks as it calls Win32 APIs return Ok(());
}
use std::ffi::c_void; let addrs: Vec<String> = dns_servers.iter().map(|ip| ip.to_string()).collect();
use windows::Win32::Foundation::NO_ERROR; let addrs_str = addrs.join(",");
use windows::Win32::NetworkManagement::IpHelper::{
SetInterfaceDnsSettings, DNS_INTERFACE_SETTINGS, DNS_INTERFACE_SETTINGS_FLAGS, let iface_arg = if let Some(idx) = if_index {
DNS_INTERFACE_SETTINGS_VERSION1, DNS_SETTING_NAMESERVER, format!("-InterfaceIndex {}", idx)
} else {
format!("-InterfaceAlias '{}'", interface)
}; };
// We need the Interface LUID (Locally Unique Identifier) or Index/GUID. info!("Setting DNS on {} to {}", interface, addrs_str);
// The user provided `interface` string might be a GUID or friendly name.
// If if_index is provided, that's easier for some APIs.
// Convert IP addresses to wide string (UTF-16) comma-separated let script = format!(
let mut dns_str = String::new(); "Set-DnsClientServerAddress {} -ServerAddresses {}",
for (i, ip) in dns_servers.iter().enumerate() { iface_arg, addrs_str
if i > 0 {
dns_str.push(',');
}
dns_str.push_str(&ip.to_string());
}
let dns_wide: Vec<u16> = dns_str.encode_utf16().chain(std::iter::once(0)).collect();
if let Some(idx) = if_index {
// Find existing settings to backup?
// Windows doesn't make it easy to "get current and restore later" without some work.
// For simplicity, we assume we can just clear settings on restore.
}
/*
Implementation note:
Since we cannot easily compile this on Linux to verify, we outline the logic.
Real implementation would need `GetInterfaceDnsSettings` to backup state.
*/
tracing::info!(
"Setting DNS on Windows for interface {}: {:?}",
interface,
dns_servers
); );
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.status()?;
/* if !status.success() {
unsafe { anyhow::bail!("PowerShell failed to set DNS on {}", interface);
let mut settings = DNS_INTERFACE_SETTINGS {
Version: DNS_INTERFACE_SETTINGS_VERSION1,
Flags: DNS_SETTING_NAMESERVER,
NameServer: windows::core::PCWSTR(dns_wide.as_ptr()),
..Default::default()
};
let guid = windows::core::GUID::from(interface)?; // Assuming interface is a GUID string
let result = SetInterfaceDnsSettings(guid, &mut settings);
if result != NO_ERROR {
anyhow::bail!("Failed to set DNS: {:?}", result);
} }
}
*/
// For now, since we can't test, we log a limitation
tracing::warn!("Windows DNS setting logic placeholder");
state.configured = true; state.configured = true;
Ok(()) Ok(())
} }
pub async fn restore( pub async fn restore(
_interface: &str, interface: &str,
_if_index: Option<u32>, if_index: Option<u32>,
_state: &mut DnsState, state: &mut DnsState,
) -> Result<()> { ) -> Result<()> {
tracing::info!("Restoring DNS on Windows..."); info!("Restoring DNS on {} to automatic", interface);
// Clear custom DNS settings (return to DHCP?) let iface_arg = if let Some(idx) = if_index {
/* format!("-InterfaceIndex {}", idx)
unsafe { } else {
let mut settings = DNS_INTERFACE_SETTINGS { format!("-InterfaceAlias '{}'", interface)
Version: DNS_INTERFACE_SETTINGS_VERSION1,
Flags: DNS_SETTING_NAMESERVER,
NameServer: windows::core::PCWSTR::null(),
..Default::default()
}; };
// Call SetInterfaceDnsSettings with empty NameServer or appropriate flags
}
*/
let script = format!("Set-DnsClientServerAddress {} -ResetServerAddresses", iface_arg);
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.status();
state.configured = false;
Ok(()) Ok(())
} }

View File

@@ -1,10 +1,16 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use futures::stream::TryStreamExt; use futures::stream::TryStreamExt;
use netlink_packet_route::route::RouteProtocol; use netlink_packet_route::route::{RouteProtocol, RouteType};
use rtnetlink::{new_connection, Handle, IpVersion}; use rtnetlink::{new_connection, Handle, IpVersion};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use tracing::{info, warn}; 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; const VPN_ROUTE_METRIC: u32 = 50;
/// Manages routing table for VPN connection using netlink. /// 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. // Saved during setup for re-adding the server host route after resume.
server_ip: Option<Ipv4Addr>, server_ip: Option<Ipv4Addr>,
wifi_gateway: 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. // Saved for re-adding the VPN default route after reconnect.
vpn_gateway: Option<Ipv4Addr>, 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 { impl RouteManager {
@@ -35,10 +54,13 @@ impl RouteManager {
added_routes: Vec::new(), added_routes: Vec::new(),
tun_interface: tun_interface.clone(), tun_interface: tun_interface.clone(),
tun_index: None, 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, server_ip: None,
wifi_gateway: None, wifi_gateway: None,
wifi_iface_index: None,
vpn_gateway: None, vpn_gateway: None,
added_policy_rule: false,
added_ipv6_blackhole: false,
}) })
} }
@@ -57,23 +79,47 @@ 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(); 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.
let mut candidates: Vec<(u32, Ipv4Addr, u32)> = Vec::new();
while let Some(route) = routes.try_next().await? { 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() { for nla in route.attributes.iter() {
if let netlink_packet_route::route::RouteAttribute::Gateway(addr) = nla { match nla {
use netlink_packet_route::route::RouteAddress; netlink_packet_route::route::RouteAttribute::Gateway(
if let RouteAddress::Inet(ipv4_addr) = addr { RouteAddress::Inet(ip),
return Ok(*ipv4_addr); ) => 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) {
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. /// Install routes for the VPN connection.
@@ -99,11 +145,15 @@ impl RouteManager {
self.tun_index.unwrap() self.tun_index.unwrap()
); );
let current_gateway = self let (current_gateway, wifi_iface_index) = self
.get_default_gateway() .get_default_gateway()
.await .await
.context("Failed to get current gateway")?; .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 // Resolve server address to IP
let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() { let server_ip = if let Ok(ip) = server_addr.parse::<IpAddr>() {
@@ -133,6 +183,7 @@ impl RouteManager {
.v4() .v4()
.destination_prefix(server_v4, 32) .destination_prefix(server_v4, 32)
.gateway(current_gateway) .gateway(current_gateway)
.output_interface(wifi_iface_index)
.priority(VPN_ROUTE_METRIC) .priority(VPN_ROUTE_METRIC)
.execute() .execute()
.await; .await;
@@ -178,6 +229,9 @@ impl RouteManager {
.map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?; .map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?;
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0)); self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
self.install_policy_rule().await;
self.block_ipv6_leak().await;
} else { } else {
info!( info!(
"Split-tunnel mode: adding {} routes via VPN...", "Split-tunnel mode: adding {} routes via VPN...",
@@ -239,14 +293,105 @@ impl RouteManager {
Ok(()) 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 /// Setup DNS configuration
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> { pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
if dns_servers.is_empty() { if dns_servers.is_empty() {
return Ok(()); return Ok(());
} }
self.dns_manager = self.dns_manager = crate::dns::DnsManager::new(
crate::dns::DnsManager::new(self.tun_interface.clone(), self.tun_index, assigned_ip); self.tun_interface.clone(),
self.tun_index,
self.wifi_iface_index,
assigned_ip,
);
self.dns_manager.set_dns(dns_servers).await self.dns_manager.set_dns(dns_servers).await
} }
@@ -269,16 +414,18 @@ impl RouteManager {
_ => return Ok(()), _ => return Ok(()),
}; };
let result = self let mut req = self
.handle .handle
.route() .route()
.add() .add()
.v4() .v4()
.destination_prefix(server_ip, 32) .destination_prefix(server_ip, 32)
.gateway(gateway) .gateway(gateway)
.priority(VPN_ROUTE_METRIC) .priority(VPN_ROUTE_METRIC);
.execute() if let Some(idx) = self.wifi_iface_index {
.await; req = req.output_interface(idx);
}
let result = req.execute().await;
match result { match result {
Ok(_) => { Ok(_) => {
@@ -312,11 +459,13 @@ impl RouteManager {
let mut to_delete = None; let mut to_delete = None;
while let Some(route) = routes.try_next().await? { while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0 { 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| { let is_ours = route.attributes.iter().any(|nla| {
matches!( matches!(
nla, nla,
netlink_packet_route::route::RouteAttribute::Priority(p) netlink_packet_route::route::RouteAttribute::Oif(idx)
if *p == VPN_ROUTE_METRIC if Some(*idx) == self.tun_index
) )
}); });
if is_ours { if is_ours {
@@ -380,6 +529,55 @@ impl RouteManager {
self.added_routes.len() 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() { if self.added_routes.is_empty() {
return Ok(()); return Ok(());
} }
@@ -406,16 +604,23 @@ impl RouteManager {
if let Some(ip) = dest_ip { if let Some(ip) = dest_ip {
if self.added_routes.contains(&(ip, prefix_len)) { 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| { let is_ours = route.attributes.iter().any(|nla| {
matches!( matches!(
nla, nla,
netlink_packet_route::route::RouteAttribute::Priority(p) netlink_packet_route::route::RouteAttribute::Oif(idx)
if *p == VPN_ROUTE_METRIC if Some(*idx) == self.tun_index
) )
}); });
if is_ours { if is_ours {
routes_to_delete.push(route); routes_to_delete.push(route);
} }
} else {
routes_to_delete.push(route);
}
} }
} }
} }
@@ -442,3 +647,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()");
}
}

View File

@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
server_host: String, server_host: String,
original_gateway: String, original_gateway: String,
primary_service: 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 { impl MacOsNetManager {
@@ -42,13 +48,18 @@ impl MacOsNetManager {
server_host, server_host,
original_gateway, original_gateway,
primary_service, primary_service,
wifi_iface: iface,
}) })
} }
// Always adds split-default (0/1 + 128/1) for full-tunnel mode. // Always adds split-default (0/1 + 128/1) for full-tunnel mode.
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS. // Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
pub async fn setup_vpn_routes(&self) -> Result<()> { 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", "0.0.0.0/1", "-interface", &self.tun_name]).await;
run("route", &["add", "-net", "128.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); 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. // the wrong gateway until the next reconnect that creates a new MacOsNetManager.
pub async fn ensure_server_route(&self) -> Result<()> { pub async fn ensure_server_route(&self) -> Result<()> {
run("route", &["delete", "-host", &self.server_host]).await; 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(()) Ok(())
} }

View File

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

View File

@@ -0,0 +1,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()
);
}
}
}

View File

@@ -247,35 +247,6 @@ mod tests {
assert_eq!(stats.download_bytes, 2048); 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] #[test]
fn test_ring_buffer() { fn test_ring_buffer() {
let mut buffer = RingBuffer::new(); let mut buffer = RingBuffer::new();

View File

@@ -1,14 +1,26 @@
use anyhow::Result; use anyhow::Result;
use bytes::{Bytes, BytesMut}; use bytes::Bytes;
#[cfg(not(target_os = "windows"))]
use bytes::BytesMut;
use std::net::IpAddr; use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, error, info}; use tracing::{error, info};
#[cfg(target_os = "linux")]
use tracing::debug;
// Windows uses wintun directly to avoid the tun crate's poll_read bug, which spawns
// a new OS thread on every Poll::Pending call, causing thread explosion and packet loss.
#[cfg(target_os = "windows")]
use std::sync::Arc;
/// Interface to the TUN device /// Interface to the TUN device
pub struct TunDevice { pub struct TunDevice {
pub name: String, pub name: String,
#[cfg(not(target_os = "windows"))]
device: tun::AsyncDevice, device: tun::AsyncDevice,
#[cfg(target_os = "windows")]
session: Arc<wintun::Session>,
#[cfg_attr(target_os = "windows", allow(dead_code))]
mtu: usize, mtu: usize,
} }
@@ -79,6 +91,44 @@ impl TunDevice {
} }
} }
/// Create a new TUN device (Windows — uses WinTun directly for correct async behavior)
#[cfg(target_os = "windows")]
pub fn new(assigned_ip: IpAddr, mtu: usize) -> Result<Self> {
use std::net::Ipv4Addr;
const TUN_NAME: &str = "konduit";
let wintun = unsafe { wintun::load().map_err(|e| anyhow::anyhow!("Failed to load wintun: {}", e))? };
let adapter = match wintun::Adapter::open(&wintun, TUN_NAME) {
Ok(a) => a,
Err(_) => wintun::Adapter::create(&wintun, TUN_NAME, TUN_NAME, None)
.map_err(|e| anyhow::anyhow!("Failed to create WinTun adapter: {}", e))?,
};
let ip = match assigned_ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => anyhow::bail!("IPv6 not supported for TUN device on Windows"),
};
let mask = Ipv4Addr::new(255, 255, 255, 0);
adapter
.set_network_addresses_tuple(IpAddr::V4(ip), IpAddr::V4(mask), None)
.map_err(|e| anyhow::anyhow!("Failed to set WinTun address: {}", e))?;
let session = Arc::new(
adapter
.start_session(wintun::MAX_RING_CAPACITY)
.map_err(|e| anyhow::anyhow!("Failed to start WinTun session: {}", e))?,
);
info!("Created TUN interface: {}", TUN_NAME);
Ok(Self {
name: TUN_NAME.to_string(),
session,
mtu,
})
}
/// Create a new TUN device from file descriptor (Android / iOS) /// Create a new TUN device from file descriptor (Android / iOS)
#[cfg(any(target_os = "android", target_os = "ios"))] #[cfg(any(target_os = "android", target_os = "ios"))]
pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> { pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> {
@@ -111,37 +161,33 @@ impl TunDevice {
} }
} }
/// Run the TUN device loop /// Run the TUN device loop (Linux / macOS / Android / iOS — uses tokio AsyncRead/Write)
#[cfg(not(target_os = "windows"))]
pub async fn run( pub async fn run(
self, self,
mut from_tcp_rx: mpsc::Receiver<Bytes>, // Packets received from TCP to be written to TUN mut from_tcp_rx: mpsc::Receiver<Bytes>,
to_tcp_tx: mpsc::Sender<Bytes>, // Packets read from TUN to be sent to TCP to_tcp_tx: mpsc::Sender<Bytes>,
) -> Result<()> { ) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
info!("TUN device loop started"); info!("TUN device loop started");
let (mut reader, mut writer) = tokio::io::split(self.device); let (mut reader, mut writer) = tokio::io::split(self.device);
let mtu = self.mtu; let mtu = self.mtu;
// Separate reader task so writes never block reads.
let (tun_pkt_tx, mut tun_pkt_rx) = mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
loop { loop {
// Buffer for reading from TUN
// We authorize up to MTU + overhead, but each read returns one packet.
let mut buf = BytesMut::with_capacity(mtu + 100); let mut buf = BytesMut::with_capacity(mtu + 100);
match reader.read_buf(&mut buf).await {
tokio::select! { Ok(0) => {
// 1. Packet from TUN (needs to be sent to server)
res = reader.read_buf(&mut buf) => {
match res {
Ok(n) => {
if n == 0 {
info!("TUN device closed"); info!("TUN device closed");
break; break;
} }
// Important: TUN read returns distinct packets. Ok(_) => {
// We must send exactly this packet. if tun_pkt_tx.send(buf.freeze()).await.is_err() {
let packet = buf.freeze(); // Consumes buf into Bytes break;
if let Err(_) = to_tcp_tx.send(packet).await {
break; // Channel closed
} }
} }
Err(e) => { Err(e) => {
@@ -150,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() => { 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); 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(()) Ok(())