10 Commits

Author SHA1 Message Date
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
15 changed files with 1013 additions and 174 deletions

View File

@@ -7,7 +7,7 @@
**TCP-Native VPN. Works Where UDP Doesn't.**
[![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)]()
</div>
@@ -33,7 +33,7 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
- **Userspace implementation** — no kernel modules, no root required
- **Hot config reload** — update server configuration without dropping connections
- **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
- **Stealth mode** — port 443 deployment with decoy proxy for hostile network environments
- **Memory safe** — written entirely in Rust
@@ -42,11 +42,15 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
Releases are published on the [Releases](../../releases) page. Each release includes:
| Binary | Purpose |
|---|---|
| `konduit-server` | VPN server |
| `konduit` | CLI client (Linux) |
| `konduit-ctl` | Server provisioning tool |
| Artifact | Platform | Purpose |
|---|---|---|
| `konduit-server-*-linux-x86_64` | Linux | VPN server |
| `konduit-*-linux-x86_64` | Linux | CLI client |
| `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)
@@ -61,14 +65,38 @@ echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
./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.
## 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
- [Stealth Mode Setup](docs/stealth-setup.md) — HAProxy TCP passthrough + camouflage configuration
- [systemd units](docs/systemd/) — service files for konduit-server, konduit (client), and konduit-admin-ui
## Architecture
@@ -119,8 +147,9 @@ The VPN server, management UI, and stealth-mode protocol are proprietary. Keepin
## About
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.
[`konduit-platform`](./konduit-platform) is published under the [PolyForm Noncommercial License 1.0.0](LICENSE).
All other parts of Konduit are proprietary.
[Privacy Policy](docs/privacy-policy.md)

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

@@ -0,0 +1,59 @@
# 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
```
If your server runs in stealth mode, the config already points to port 443. No additional client-side configuration is needed.
## 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.

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`.

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

@@ -0,0 +1,72 @@
# 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.
- Stealth mode wraps the tunnel in a protocol that is indistinguishable from HTTPS, preventing deep-packet inspection from identifying Konduit traffic.
## 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

@@ -62,7 +62,12 @@ Without this, konduit picks the first available `tunN`, which shifts if other VP
./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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -0,0 +1,300 @@
use anyhow::Result;
use std::net::{IpAddr, Ipv4Addr};
use std::os::windows::process::CommandExt;
use tracing::{info, warn};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
use crate::dns::DnsManager;
pub struct RouteManager {
tun_interface: String,
dns_manager: DnsManager,
tun_routes: Vec<(Ipv4Addr, u8)>,
server_ip: Option<Ipv4Addr>,
wifi_gateway: Option<Ipv4Addr>,
}
impl RouteManager {
pub async fn new(tun_interface: String) -> Result<Self> {
Ok(Self {
dns_manager: DnsManager::new(
tun_interface.clone(),
None,
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
),
tun_interface,
tun_routes: Vec::new(),
server_ip: None,
wifi_gateway: None,
})
}
fn get_default_gateway() -> Result<Ipv4Addr> {
let adapters = ipconfig::get_adapters()
.map_err(|e| anyhow::anyhow!("Failed to enumerate adapters: {}", e))?;
for adapter in &adapters {
for gw in adapter.gateways() {
if let IpAddr::V4(gw4) = gw {
if !gw4.is_loopback() && !gw4.is_unspecified() {
return Ok(*gw4);
}
}
}
}
anyhow::bail!("No default gateway found")
}
fn prefix_to_mask(prefix: u8) -> Ipv4Addr {
let bits = if prefix == 0 {
0u32
} else {
!0u32 << (32 - prefix)
};
let [a, b, c, d] = bits.to_be_bytes();
Ipv4Addr::new(a, b, c, d)
}
// Add/delete server host route through WiFi gateway via `route` command.
// Specifying gateway on delete ensures we only remove our entry, not any other path.
fn wifi_route_add(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
let mask = Self::prefix_to_mask(prefix);
let out = std::process::Command::new("route")
.args([
"add",
&dest.to_string(),
"MASK",
&mask.to_string(),
&gateway.to_string(),
"METRIC",
"1",
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match out {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
if !o.status.success() {
// "already exists" is acceptable — a previous run may have left it
warn!("route add {}/{} via {}: {}", dest, prefix, gateway, stdout.trim());
}
}
Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, e),
}
}
fn wifi_route_delete(dest: Ipv4Addr, prefix: u8, gateway: Ipv4Addr) {
let mask = Self::prefix_to_mask(prefix);
let _ = std::process::Command::new("route")
.args([
"delete",
&dest.to_string(),
"MASK",
&mask.to_string(),
&gateway.to_string(),
])
.creation_flags(CREATE_NO_WINDOW)
.status();
}
// Add/delete routes through the TUN interface using netsh.
//
// `netsh interface ipv4 add route` binds routes to a named interface explicitly,
// avoiding the gateway-resolution ambiguity of the `route` command and ensuring
// cleanup never touches WiFi routing state.
fn tun_route_add(tun_name: &str, dest: Ipv4Addr, prefix: u8) {
let prefix_str = format!("{}/{}", dest, prefix);
let out = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"add",
"route",
&prefix_str,
tun_name,
"nexthop=0.0.0.0",
"metric=1",
"store=active",
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match out {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
if o.status.success() {
info!("Added TUN route {}/{}", dest, prefix);
} else {
warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, stdout.trim());
}
}
Err(e) => warn!("netsh add route {}/{} on {}: {}", dest, prefix, tun_name, e),
}
}
fn tun_route_delete(tun_name: &str, dest: Ipv4Addr, prefix: u8) {
let prefix_str = format!("{}/{}", dest, prefix);
let out = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"delete",
"route",
&prefix_str,
tun_name,
])
.creation_flags(CREATE_NO_WINDOW)
.output();
match out {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
if !o.status.success() {
warn!(
"netsh delete route {}/{} on {}: {}",
dest,
prefix,
tun_name,
stdout.trim()
);
}
}
Err(e) => warn!("netsh delete route {}/{} on {}: {}", dest, prefix, tun_name, e),
}
}
pub async fn setup_vpn_routes(
&mut self,
server_addr: &str,
_vpn_gateway: IpAddr,
routes: &[String],
) -> Result<()> {
info!("Setting up Windows VPN routes...");
let current_gateway = Self::get_default_gateway()?;
info!("Current gateway: {}", current_gateway);
let server_ip: Ipv4Addr = {
let host = server_addr.split(':').next().unwrap_or(server_addr);
if let Ok(IpAddr::V4(v4)) = host.parse::<IpAddr>() {
v4
} else {
let addrs = tokio::net::lookup_host(format!("{}:0", host)).await?;
match addrs.map(|a| a.ip()).next() {
Some(IpAddr::V4(v4)) => v4,
_ => anyhow::bail!("Could not resolve server address to IPv4"),
}
}
};
// Always record server + gateway so cleanup can delete the host route
// even if the add below fails because the route already exists.
self.server_ip = Some(server_ip);
self.wifi_gateway = Some(current_gateway);
Self::wifi_route_add(server_ip, 32, current_gateway);
let is_full_tunnel = routes.iter().any(|r| r == "0.0.0.0/0");
if is_full_tunnel {
// Two /1 routes on the TUN interface cover the entire address space and
// always beat the existing WiFi /0 default route regardless of metric.
// netsh with explicit interface name avoids gateway-resolution ambiguity.
info!("Full-tunnel mode: adding /1 cover routes on {}...", self.tun_interface);
let lower = Ipv4Addr::new(0, 0, 0, 0);
let upper = Ipv4Addr::new(128, 0, 0, 0);
Self::tun_route_add(&self.tun_interface, lower, 1);
self.tun_routes.push((lower, 1));
Self::tun_route_add(&self.tun_interface, upper, 1);
self.tun_routes.push((upper, 1));
} else {
info!("Split-tunnel mode: adding {} routes via TUN...", routes.len());
for route_str in routes {
let Some((dest_str, prefix_str)) = route_str.split_once('/') else {
warn!("Invalid route format: {}", route_str);
continue;
};
let Ok(dest) = dest_str.parse::<Ipv4Addr>() else {
warn!("Invalid route destination: {}", dest_str);
continue;
};
let Ok(prefix_len) = prefix_str.parse::<u8>() else {
warn!("Invalid prefix length: {}", prefix_str);
continue;
};
Self::tun_route_add(&self.tun_interface, dest, prefix_len);
self.tun_routes.push((dest, prefix_len));
info!("Added split-tunnel route: {}", route_str);
}
}
info!("Windows VPN routes configured");
Ok(())
}
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
if dns_servers.is_empty() {
return Ok(());
}
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, assigned_ip);
self.dns_manager.set_dns(dns_servers).await
}
pub async fn restore_dns(&self) -> Result<()> {
self.dns_manager.restore().await
}
pub async fn ensure_server_route(&mut self) -> Result<()> {
if let (Some(server_ip), Some(gateway)) = (self.server_ip, self.wifi_gateway) {
Self::wifi_route_add(server_ip, 32, gateway);
}
Ok(())
}
pub async fn suspend_default_route(&mut self) -> Result<()> {
let lower = Ipv4Addr::new(0, 0, 0, 0);
if !self.tun_routes.contains(&(lower, 1)) {
return Ok(());
}
for (dest, prefix) in [(lower, 1u8), (Ipv4Addr::new(128, 0, 0, 0), 1u8)] {
Self::tun_route_delete(&self.tun_interface, dest, prefix);
}
info!("VPN cover routes suspended — traffic falls to WiFi during reconnect");
Ok(())
}
pub async fn resume_default_route(&mut self) -> Result<()> {
let lower = Ipv4Addr::new(0, 0, 0, 0);
if !self.tun_routes.contains(&(lower, 1)) {
return Ok(());
}
let upper = Ipv4Addr::new(128, 0, 0, 0);
for (dest, prefix) in [(lower, 1u8), (upper, 1u8)] {
Self::tun_route_add(&self.tun_interface, dest, prefix);
}
info!("VPN cover routes restored after reconnect");
Ok(())
}
pub async fn restore_routes(&mut self) -> Result<()> {
info!("Removing {} tracked VPN routes...", self.tun_routes.len());
for (dest, prefix) in self.tun_routes.drain(..) {
Self::tun_route_delete(&self.tun_interface, dest, prefix);
}
// Always clean up the server host route — a previous unclean exit may have
// left it behind even if our add was skipped.
if let (Some(server_ip), Some(gateway)) = (self.server_ip.take(), self.wifi_gateway.take()) {
Self::wifi_route_delete(server_ip, 32, gateway);
}
info!("Windows VPN routes removed");
Ok(())
}
}
impl Drop for RouteManager {
fn drop(&mut self) {
if !self.tun_routes.is_empty() {
warn!(
"RouteManager dropped with {} unrestored TUN routes — call restore_routes() before dropping",
self.tun_routes.len()
);
}
}
}

View File

@@ -1,14 +1,26 @@
use anyhow::Result;
use bytes::{Bytes, BytesMut};
use bytes::Bytes;
#[cfg(not(target_os = "windows"))]
use bytes::BytesMut;
use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
use tracing::{debug, error, info};
use tracing::{error, info};
#[cfg(target_os = "linux")]
use tracing::debug;
// Windows uses wintun directly to avoid the tun crate's poll_read bug, which spawns
// a new OS thread on every Poll::Pending call, causing thread explosion and packet loss.
#[cfg(target_os = "windows")]
use std::sync::Arc;
/// Interface to the TUN device
pub struct TunDevice {
pub name: String,
#[cfg(not(target_os = "windows"))]
device: tun::AsyncDevice,
#[cfg(target_os = "windows")]
session: Arc<wintun::Session>,
#[cfg_attr(target_os = "windows", allow(dead_code))]
mtu: usize,
}
@@ -79,6 +91,44 @@ impl TunDevice {
}
}
/// Create a new TUN device (Windows — uses WinTun directly for correct async behavior)
#[cfg(target_os = "windows")]
pub fn new(assigned_ip: IpAddr, mtu: usize) -> Result<Self> {
use std::net::Ipv4Addr;
const TUN_NAME: &str = "konduit";
let wintun = unsafe { wintun::load().map_err(|e| anyhow::anyhow!("Failed to load wintun: {}", e))? };
let adapter = match wintun::Adapter::open(&wintun, TUN_NAME) {
Ok(a) => a,
Err(_) => wintun::Adapter::create(&wintun, TUN_NAME, TUN_NAME, None)
.map_err(|e| anyhow::anyhow!("Failed to create WinTun adapter: {}", e))?,
};
let ip = match assigned_ip {
IpAddr::V4(v4) => v4,
IpAddr::V6(_) => anyhow::bail!("IPv6 not supported for TUN device on Windows"),
};
let mask = Ipv4Addr::new(255, 255, 255, 0);
adapter
.set_network_addresses_tuple(IpAddr::V4(ip), IpAddr::V4(mask), None)
.map_err(|e| anyhow::anyhow!("Failed to set WinTun address: {}", e))?;
let session = Arc::new(
adapter
.start_session(wintun::MAX_RING_CAPACITY)
.map_err(|e| anyhow::anyhow!("Failed to start WinTun session: {}", e))?,
);
info!("Created TUN interface: {}", TUN_NAME);
Ok(Self {
name: TUN_NAME.to_string(),
session,
mtu,
})
}
/// Create a new TUN device from file descriptor (Android / iOS)
#[cfg(any(target_os = "android", target_os = "ios"))]
pub fn from_fd(fd: i32, mtu: usize) -> Result<Self> {
@@ -111,47 +161,58 @@ impl TunDevice {
}
}
/// Run the TUN device loop
/// Run the TUN device loop (Linux / macOS / Android / iOS — uses tokio AsyncRead/Write)
#[cfg(not(target_os = "windows"))]
pub async fn run(
self,
mut from_tcp_rx: mpsc::Receiver<Bytes>, // Packets received from TCP to be written to TUN
to_tcp_tx: mpsc::Sender<Bytes>, // Packets read from TUN to be sent to TCP
mut from_tcp_rx: mpsc::Receiver<Bytes>,
to_tcp_tx: mpsc::Sender<Bytes>,
) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
info!("TUN device loop started");
let (mut reader, mut writer) = tokio::io::split(self.device);
let mtu = self.mtu;
loop {
// Buffer for reading from TUN
// We authorize up to MTU + overhead, but each read returns one packet.
let mut buf = BytesMut::with_capacity(mtu + 100);
// Separate reader task so writes never block reads.
let (tun_pkt_tx, mut tun_pkt_rx) = mpsc::channel::<Bytes>(64);
tokio::spawn(async move {
loop {
let mut buf = BytesMut::with_capacity(mtu + 100);
match reader.read_buf(&mut buf).await {
Ok(0) => {
info!("TUN device closed");
break;
}
Ok(_) => {
if tun_pkt_tx.send(buf.freeze()).await.is_err() {
break;
}
}
Err(e) => {
error!("TUN read error: {}", e);
break;
}
}
}
});
loop {
tokio::select! {
// 1. Packet from TUN (needs to be sent to server)
res = reader.read_buf(&mut buf) => {
match res {
Ok(n) => {
if n == 0 {
info!("TUN device closed");
packet = tun_pkt_rx.recv() => {
match packet {
Some(pkt) => {
if to_tcp_tx.send(pkt).await.is_err() {
break;
}
// Important: TUN read returns distinct packets.
// We must send exactly this packet.
let packet = buf.freeze(); // Consumes buf into Bytes
if let Err(_) = to_tcp_tx.send(packet).await {
break; // Channel closed
}
}
Err(e) => {
error!("TUN read error: {}", e);
None => {
info!("TUN reader task exited");
break;
}
}
}
// 2. Packet from TCP (needs to be written to TUN)
Some(packet) = from_tcp_rx.recv() => {
if let Err(e) = writer.write_all(&packet).await {
error!("TUN write error: {}", e);
@@ -162,4 +223,72 @@ impl TunDevice {
Ok(())
}
/// Run the TUN device loop (Windows — uses wintun directly with a dedicated reader thread)
///
/// The tun crate's Windows AsyncRead implementation spawns a new OS thread on every
/// Poll::Pending, causing thread explosion and ~80% packet loss under normal VPN load.
/// We bypass it by using a single dedicated blocking thread for wintun reads.
#[cfg(target_os = "windows")]
pub async fn run(
self,
mut from_tcp_rx: mpsc::Receiver<Bytes>,
to_tcp_tx: mpsc::Sender<Bytes>,
) -> Result<()> {
info!("TUN device loop started");
let session = self.session;
let reader_session = session.clone();
// One dedicated OS thread for blocking wintun reads.
// This avoids spawning a new thread per poll_read call.
let (read_tx, mut read_rx) = mpsc::channel::<Bytes>(64);
std::thread::spawn(move || loop {
match reader_session.receive_blocking() {
Ok(pkt) => {
let bytes = Bytes::copy_from_slice(pkt.bytes());
drop(pkt); // release ring buffer slot before blocking on channel send
if read_tx.blocking_send(bytes).is_err() {
break;
}
}
Err(e) => {
error!("WinTun receive error: {:?}", e);
break;
}
}
});
loop {
tokio::select! {
// WinTun → TCP channel
packet = read_rx.recv() => {
match packet {
Some(pkt) => {
if to_tcp_tx.send(pkt).await.is_err() {
break;
}
}
None => {
info!("WinTun reader thread exited");
break;
}
}
}
// TCP channel → WinTun (send_packet is non-blocking ring-buffer op)
Some(packet) = from_tcp_rx.recv() => {
match session.allocate_send_packet(packet.len() as u16) {
Ok(mut send_pkt) => {
send_pkt.bytes_mut().copy_from_slice(&packet);
session.send_packet(send_pkt);
}
Err(e) => error!("WinTun allocate_send_packet error: {:?}", e),
}
}
}
}
Ok(())
}
}