Compare commits
14 Commits
v0.1.0-bet
...
v0.1.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d96bdac7b | ||
|
|
7c24351f4a | ||
|
|
4a4ab9d781 | ||
|
|
3b212bbed9 | ||
|
|
11d5305d8c | ||
|
|
c2d7f8d8c6 | ||
|
|
678ed158a6 | ||
|
|
56201f6b72 | ||
|
|
b160652737 | ||
|
|
7df5aa4317 | ||
|
|
99a2993ace | ||
|
|
fb7ad7ec40 | ||
|
|
87bb99c10b | ||
|
|
ba8e2b0aa1 |
36
README.md
36
README.md
@@ -35,7 +35,7 @@ Most VPNs treat TCP as a fallback. Konduit is designed for TCP from the ground u
|
||||
- **QR code provisioning** — scan once, connect instantly
|
||||
- **Cross-platform** — Linux, Windows, Android (macOS and iOS coming soon)
|
||||
- **Modern cryptography** — X25519 key exchange, ChaCha20-Poly1305 data channel
|
||||
- **Stealth mode** — port 443 deployment with decoy proxy for hostile network environments
|
||||
- **SOCKS5 chaining** — egress-dial (`use_proxy`) to reach the server through your own proxy, or a post-tunnel listener (`listen_socks`) for per-app routing — see [SOCKS Modes](docs/socks-modes.md)
|
||||
- **Memory safe** — written entirely in Rust
|
||||
|
||||
## Download
|
||||
@@ -48,9 +48,14 @@ Releases are published on the [Releases](../../releases) page. Each release incl
|
||||
| `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) |
|
||||
| `konduit-gui-*-windows-x86_64-portable.zip` | Windows | GUI client |
|
||||
| `konduit-cli-*-windows-x86_64.zip` | Windows | CLI client + wintun.dll — **not in every release** |
|
||||
| `konduit-*-android.aab` | Android | Android app for bundletool sideload — **not in every release** |
|
||||
|
||||
Which Windows and Android assets are attached varies between releases: v0.1.0-beta.9
|
||||
and v0.1.0-beta.10 carried both, v0.1.0-beta.11 shipped the Windows GUI zip only (as
|
||||
`-portable.zip`). **The [Releases](../../releases) page is authoritative** — check it
|
||||
rather than assuming a row is present. Android is on Google Play either way.
|
||||
|
||||
### Linux (CLI)
|
||||
|
||||
@@ -62,7 +67,15 @@ chmod +x konduit konduit-ctl
|
||||
echo "your secret mantra" | ./konduit-ctl bootstrap -l vpn.example.com:443 -p -
|
||||
|
||||
# Connect a client
|
||||
./konduit connect --config client.toml
|
||||
./konduit --config client.toml
|
||||
```
|
||||
|
||||
`--config`/`-c` is a top-level flag, not a `connect` flag. The `connect`
|
||||
subcommand takes the peer details individually instead:
|
||||
|
||||
```bash
|
||||
./konduit connect --server vpn.example.com:443 \
|
||||
--peer-id <peer-id> --identity <key-hex> --server-pub <server-pub-hex>
|
||||
```
|
||||
|
||||
### Linux (GUI)
|
||||
@@ -95,7 +108,8 @@ Coming soon.
|
||||
**CLI & server**
|
||||
- [Client Quickstart](docs/client-quickstart.md) — download, configure, connect, run as a systemd service
|
||||
- [Server Quickstart](docs/server-quickstart.md) — install, provision, NAT setup for iptables and firewalld
|
||||
- [Stealth Mode Setup](docs/stealth-setup.md) — HAProxy TCP passthrough + camouflage configuration
|
||||
- [SOCKS5 Modes](docs/socks-modes.md) — reach the server through a proxy, or expose a local SOCKS5 listener
|
||||
- [Connection Tuning](docs/connection-tuning.md) — opt-in connection pooling for unstable links
|
||||
- [systemd units](docs/systemd/) — service files for konduit-server, konduit (client), and konduit-admin-ui
|
||||
|
||||
## Architecture
|
||||
@@ -124,21 +138,19 @@ Konduit engine (Rust)
|
||||
|
||||
The [`konduit-platform`](./konduit-platform) crate is published here for transparency and security audit. It contains the cryptographic primitives, connection statistics, and platform networking layer (TUN device, DNS, routes) — everything an auditor needs to verify what runs on your machine. It is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE) — free to read, study, and use for noncommercial purposes.
|
||||
|
||||
The VPN server, management UI, and stealth-mode protocol are proprietary. Keeping stealth mechanisms private makes automated DPI fingerprinting significantly harder. Source review under NDA is available for enterprise partners.
|
||||
The VPN server and management UI are proprietary. Source review under NDA is available for enterprise partners.
|
||||
|
||||
## Security
|
||||
|
||||
**No UDP dependency:** Konduit does not require UDP at any layer.
|
||||
|
||||
**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.
|
||||
**Key handling:** Your private identity key is generated locally by `konduit-ctl` and written into the client configuration file you pass with `--config` — it is never transmitted to K-Ops, and K-Ops operates no server. **That file is the secret.** Treat it like a private key file: readable only by its owner, and the only thing standing between an attacker and a tunnel into your VPN is its permissions. Do not commit it or paste it into a bug report.
|
||||
|
||||
## Support
|
||||
|
||||
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
|
||||
**Bug reports:** file an issue on [Gitea Issues](https://git.k-ops.eu/konduit/konduit-public/issues).
|
||||
|
||||
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
|
||||
**Vulnerabilities:** report directly to konduit@k-ops.eu.
|
||||
|
||||
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ sudo cp client.toml /opt/konduit/client.toml
|
||||
sudo chmod 600 /opt/konduit/client.toml
|
||||
```
|
||||
|
||||
If your server runs in stealth mode, the config already points to port 443. No additional client-side configuration is needed.
|
||||
|
||||
## 3. Connect
|
||||
|
||||
```bash
|
||||
|
||||
20
docs/connection-tuning.md
Normal file
20
docs/connection-tuning.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Connection Tuning
|
||||
|
||||
Konduit is a TCP-native VPN. On stable networks, a single TCP connection works fine. On unstable links (spotty Wi-Fi, mobile data, satellite), you can opt into a small pool of pre-warmed connections so a failing connection can be replaced without a full reconnect cascade.
|
||||
|
||||
## Config
|
||||
|
||||
```toml
|
||||
[connection]
|
||||
pool_enabled = false # default off; opt in for unstable links
|
||||
pool_size = 3 # pre-warmed idle connections when enabled
|
||||
carry_duration = 1.0 # seconds an active connection carries traffic before handoff
|
||||
```
|
||||
|
||||
- `pool_enabled` — when `true`, the client keeps `pool_size` extra pre-authenticated connections warm, so an in-use connection can fail over instantly instead of paying a fresh TCP-plus-handshake cost.
|
||||
- `pool_size` — number of pre-warmed idle connections to keep, in addition to the active one. Only used when `pool_enabled = true`.
|
||||
- `carry_duration` — how long (in seconds) an active connection carries traffic before handoff to a pre-warmed one. Only used when `pool_enabled = true`.
|
||||
|
||||
This is a resilience feature, not a stealth feature — it exists purely to make konduit more tolerant of flaky links, and has no effect on how konduit's traffic looks to a network observer.
|
||||
|
||||
Server-side, sticky DL-target routing keeps your active connection selected until it actually fails (detected via a write timeout), rather than rotating on a fixed timer. This needs no client-side configuration.
|
||||
@@ -51,7 +51,6 @@ Server-side connection logs are retained for a limited period for operational pu
|
||||
|
||||
- All traffic between client and server is encrypted using **X25519** key exchange and **ChaCha20-Poly1305** AEAD.
|
||||
- The pre-shared key (PSK) is stored in the local config file with permissions restricted to the current user.
|
||||
- Stealth mode wraps the tunnel in a protocol that is indistinguishable from HTTPS, preventing deep-packet inspection from identifying Konduit traffic.
|
||||
|
||||
## Children
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.c
|
||||
echo "your secret mantra phrase here" | ./konduit-ctl bootstrap -l vpn.example.com:8443 --public-port 443 -p -
|
||||
```
|
||||
|
||||
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443. See [stealth-setup.md](stealth-setup.md) for the full HAProxy configuration.
|
||||
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443 — useful when running behind any TCP-passthrough reverse proxy (e.g. HAProxy, nginx `stream` module).
|
||||
|
||||
## 3. Add a Client
|
||||
|
||||
@@ -102,8 +102,8 @@ sudo apt install iptables-persistent && sudo netfilter-persistent save
|
||||
## 7. Verify
|
||||
|
||||
```bash
|
||||
# Should return your website (camouflage) not an error
|
||||
curl -sk https://your-server/
|
||||
# Connect a client and confirm the tunnel comes up
|
||||
./konduit --config client.toml
|
||||
|
||||
# Check konduit logs
|
||||
journalctl -u konduit-server -f
|
||||
|
||||
62
docs/socks-modes.md
Normal file
62
docs/socks-modes.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 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, if you are passing everything on the command line rather than via a config file:
|
||||
|
||||
```bash
|
||||
./konduit connect \
|
||||
--server vpn.example.com:443 \
|
||||
--peer-id "$PEER_ID" \
|
||||
--identity "$IDENTITY_HEX" \
|
||||
--server-pub "$SERVER_PUB_HEX" \
|
||||
--use-proxy socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
`--config` and `--use-proxy` cannot be combined. `--config` is a top-level flag, while `--use-proxy` belongs to the `connect` subcommand, so a single invocation has to pick one style. Mixing them fails with `error: unexpected argument '--use-proxy' found`.
|
||||
|
||||
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 `connect` subcommand, as above — `--listen-socks` takes a bare address:
|
||||
|
||||
```bash
|
||||
./konduit connect \
|
||||
--server vpn.example.com:443 \
|
||||
--peer-id "$PEER_ID" \
|
||||
--identity "$IDENTITY_HEX" \
|
||||
--server-pub "$SERVER_PUB_HEX" \
|
||||
--listen-socks 127.0.0.1:1080
|
||||
```
|
||||
|
||||
Connections accepted by the listener are plain outbound TCP connections, routed through the tunnel by the kernel's routing table — the same routes any other tunnelled traffic uses. The SOCKS code itself has no VPN-specific logic.
|
||||
|
||||
Notes:
|
||||
|
||||
- SOCKS5 only, `CONNECT` command only — no `BIND`, no `UDP ASSOCIATE`.
|
||||
- No authentication — bind it to localhost (the default) unless you understand the exposure of doing otherwise.
|
||||
- If no address is given, konduit falls back to `127.0.0.1:1080`. Override the fallback itself with the `KONDUIT_SOCKS_LISTEN_ADDR` environment variable.
|
||||
|
||||
## Using both together
|
||||
|
||||
`use_proxy` and `listen_socks` are independent and can be combined — for example, dial out through an SSH SOCKS proxy to reach your server, and also expose a local SOCKS5 listener once connected for other apps to use.
|
||||
@@ -1,146 +0,0 @@
|
||||
# Stealth Mode: Real TLS Bypass via HAProxy
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
backend web
|
||||
mode http
|
||||
server web 127.0.0.1:8080
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
[stealth]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
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 -
|
||||
```
|
||||
|
||||
## Client Configuration (`client.toml`)
|
||||
|
||||
```toml
|
||||
[client]
|
||||
server_endpoint = "your-domain.com:443"
|
||||
tls = true
|
||||
peer_id = "..."
|
||||
identity_key = "..."
|
||||
server_public_key = "..."
|
||||
```
|
||||
|
||||
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
|
||||
# A browser must see your real website — not a TLS error or empty response
|
||||
curl -s https://your-domain.com/ | head -5
|
||||
|
||||
# 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 |
|
||||
@@ -31,6 +31,31 @@ trait Resolve1Manager {
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
// Per-link object (/org/freedesktop/resolve1/link/_<ifindex>), used only to
|
||||
// read back the physical interface's current DNS servers before we override
|
||||
// them, so they can be restored byte-for-byte on disconnect.
|
||||
//
|
||||
// SetLinkDefaultRoute (the "correct" API for demoting a link as a default DNS
|
||||
// route) requires interactive polkit auth (auth_admin_keep) — unlike
|
||||
// SetLinkDNS/SetLinkDomains, it does NOT get a CAP_NET_ADMIN bypass in
|
||||
// systemd-resolved, so it silently fails for this capability-only (non-root)
|
||||
// process. Reusing SetLinkDNS on the physical link's own object instead stays
|
||||
// on the bypass path.
|
||||
#[zbus::proxy(interface = "org.freedesktop.resolve1.Link", default_service = "org.freedesktop.resolve1")]
|
||||
trait Resolve1Link {
|
||||
#[zbus(property, name = "DNS")]
|
||||
fn dns(&self) -> zbus::Result<Vec<(i32, Vec<u8>)>>;
|
||||
}
|
||||
|
||||
fn link_object_path(if_index: u32) -> String {
|
||||
let mut path = String::from("/org/freedesktop/resolve1/link/");
|
||||
for c in if_index.to_string().chars() {
|
||||
path.push_str("_3");
|
||||
path.push(c);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,6 +63,7 @@ trait Resolve1Manager {
|
||||
pub async fn set_dns(
|
||||
interface: &str,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
dns_servers: &[IpAddr],
|
||||
_assigned_ip: IpAddr,
|
||||
state: &mut DnsState,
|
||||
@@ -59,6 +85,31 @@ pub async fn set_dns(
|
||||
match result {
|
||||
Ok(_) => {
|
||||
state.configured = true;
|
||||
// Both the physical link and the tunnel link end up with
|
||||
// Default Route: yes in systemd-resolved (the physical link
|
||||
// never loses that flag just because another link gains it).
|
||||
// With two "default route" resolvers, systemd-resolved can
|
||||
// race queries between the VPN's DNS server and the ISP's,
|
||||
// leaking lookups outside the tunnel and returning
|
||||
// inconsistent/partial answers (e.g. AAAA-only responses).
|
||||
// Blank out the physical link's own DNS server list so it has
|
||||
// nothing to answer with, leaving the tunnel as the sole
|
||||
// practical resolver; its original servers are saved and
|
||||
// restored on disconnect.
|
||||
if let Some(phys_idx) = physical_if_index {
|
||||
match clear_physical_link_dns(phys_idx).await {
|
||||
Ok(original) => {
|
||||
state.demoted_physical_if_index = Some(phys_idx);
|
||||
state.physical_dns_backup = original;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to clear physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -100,14 +151,28 @@ pub async fn restore(
|
||||
|
||||
info!("Restoring DNS settings...");
|
||||
|
||||
let result = match if_index {
|
||||
Some(idx) => revert_link_dbus(idx).await,
|
||||
None => revert_resolvectl(interface).await,
|
||||
};
|
||||
// Always attempt both paths: D-Bus RevertLink clears per-link config in
|
||||
// systemd-resolved's in-memory state; resolvectl revert is a belt-and-suspenders
|
||||
// pass that ensures the +DefaultRoute domain is removed even if D-Bus races
|
||||
// with interface teardown.
|
||||
if let Some(idx) = if_index {
|
||||
if let Err(e) = revert_link_dbus(idx).await {
|
||||
warn!("D-Bus RevertLink({}) failed: {}", idx, e);
|
||||
}
|
||||
}
|
||||
// Always run resolvectl revert as a second pass.
|
||||
revert_resolvectl(interface).await.ok();
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e);
|
||||
revert_resolvectl(interface).await.ok();
|
||||
// Restore the physical interface's original DNS servers we blanked out
|
||||
// in set_dns, or it's left with no resolver after the VPN disconnects.
|
||||
if let Some(phys_idx) = state.demoted_physical_if_index.take() {
|
||||
let backup = std::mem::take(&mut state.physical_dns_backup);
|
||||
if let Err(e) = restore_physical_link_dns(phys_idx, backup).await {
|
||||
warn!(
|
||||
"Failed to restore physical interface {} DNS servers: {}",
|
||||
phys_idx, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.configured = false;
|
||||
@@ -163,6 +228,62 @@ async fn set_dns_dbus(if_index: u32, dns_servers: &[IpAddr]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the physical link's current DNS servers, then blanks them out so it
|
||||
/// stops acting as a competing resolver. Returns the original list to restore
|
||||
/// later. Both the read and the clear go through the same CAP_NET_ADMIN
|
||||
/// bypass path as the tunnel's own SetLinkDNS call.
|
||||
async fn clear_physical_link_dns(if_index: u32) -> Result<Vec<(i32, Vec<u8>)>> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let link_proxy = Resolve1LinkProxy::builder(&conn)
|
||||
.path(link_object_path(if_index))
|
||||
.context("Invalid link object path")?
|
||||
.build()
|
||||
.await
|
||||
.context("Failed to create resolve1 link proxy")?;
|
||||
|
||||
let original = link_proxy
|
||||
.dns()
|
||||
.await
|
||||
.context("Failed to read physical link DNS servers")?;
|
||||
|
||||
let manager_proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
manager_proxy
|
||||
.set_link_dns(if_index as i32, vec![])
|
||||
.await
|
||||
.context("SetLinkDNS (clear) failed")?;
|
||||
|
||||
info!(
|
||||
"Cleared physical interface {} DNS servers ({} saved for restore)",
|
||||
if_index,
|
||||
original.len()
|
||||
);
|
||||
Ok(original)
|
||||
}
|
||||
|
||||
async fn restore_physical_link_dns(if_index: u32, original: Vec<(i32, Vec<u8>)>) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("Failed to connect to D-Bus system bus")?;
|
||||
|
||||
let proxy = Resolve1ManagerProxy::new(&conn)
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
proxy
|
||||
.set_link_dns(if_index as i32, original)
|
||||
.await
|
||||
.context("SetLinkDNS (restore) failed")?;
|
||||
|
||||
info!("Restored physical interface {} DNS servers", if_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
@@ -172,6 +293,14 @@ async fn revert_link_dbus(if_index: u32) -> Result<()> {
|
||||
.await
|
||||
.context("Failed to create resolve1 proxy")?;
|
||||
|
||||
// Clear DNS servers and routing domains explicitly before the full revert.
|
||||
// RevertLink alone can race with interface teardown in systemd-resolved;
|
||||
// zeroing each setting first ensures they are gone even if RevertLink races.
|
||||
let _ = proxy.set_link_dns(if_index as i32, vec![]).await;
|
||||
let _ = proxy
|
||||
.set_link_domains(if_index as i32, vec![])
|
||||
.await;
|
||||
|
||||
proxy
|
||||
.revert_link(if_index as i32)
|
||||
.await
|
||||
|
||||
@@ -16,6 +16,9 @@ pub struct DnsManager {
|
||||
interface: String,
|
||||
/// Interface index (if available/relevant)
|
||||
if_index: Option<u32>,
|
||||
/// Pre-VPN default-gateway interface index, demoted as DNS default route
|
||||
/// while connected (Linux only)
|
||||
physical_if_index: Option<u32>,
|
||||
/// IP address assigned to the TUN interface
|
||||
assigned_ip: IpAddr,
|
||||
/// State to track if we modified DNS
|
||||
@@ -26,14 +29,29 @@ pub struct DnsManager {
|
||||
struct DnsState {
|
||||
configured: bool,
|
||||
_original_dns: Vec<IpAddr>,
|
||||
// Physical interface demoted from systemd-resolved's default DNS route
|
||||
// (Linux only) while the tunnel is the default; restored on disconnect.
|
||||
demoted_physical_if_index: Option<u32>,
|
||||
// Physical interface's original DNS servers (address_family, raw_bytes),
|
||||
// saved before blanking them out; restored verbatim on disconnect.
|
||||
physical_dns_backup: Vec<(i32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl DnsManager {
|
||||
/// Create a new DNS manager for a specific interface
|
||||
pub fn new(interface: String, if_index: Option<u32>, assigned_ip: IpAddr) -> Self {
|
||||
/// Create a new DNS manager for a specific interface.
|
||||
/// `physical_if_index` is the pre-VPN default-gateway interface (Linux
|
||||
/// only) — needed so the tunnel can become the sole default DNS
|
||||
/// resolver instead of racing systemd-resolved queries against it.
|
||||
pub fn new(
|
||||
interface: String,
|
||||
if_index: Option<u32>,
|
||||
physical_if_index: Option<u32>,
|
||||
assigned_ip: IpAddr,
|
||||
) -> Self {
|
||||
Self {
|
||||
interface,
|
||||
if_index,
|
||||
physical_if_index,
|
||||
assigned_ip,
|
||||
state: Arc::new(Mutex::new(DnsState::default())),
|
||||
}
|
||||
@@ -51,7 +69,7 @@ impl DnsManager {
|
||||
// Platform specific implementation
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux::set_dns(&self.interface, self.if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
linux::set_dns(&self.interface, self.if_index, self.physical_if_index, dns_servers, self.assigned_ip, &mut state).await?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ pub struct MacOsNetManager {
|
||||
server_host: String,
|
||||
original_gateway: String,
|
||||
primary_service: String,
|
||||
// Physical interface the server host route must be pinned to via -ifscope.
|
||||
// Without this, if the VPN's assigned tun subnet happens to contain the
|
||||
// physical gateway IP, the OS can resolve the gateway via the tun
|
||||
// interface's own directly-connected route instead of the physical NIC,
|
||||
// looping the server route back into the tunnel it's meant to bypass.
|
||||
wifi_iface: String,
|
||||
}
|
||||
|
||||
impl MacOsNetManager {
|
||||
@@ -42,13 +48,18 @@ impl MacOsNetManager {
|
||||
server_host,
|
||||
original_gateway,
|
||||
primary_service,
|
||||
wifi_iface: iface,
|
||||
})
|
||||
}
|
||||
|
||||
// Always adds split-default (0/1 + 128/1) for full-tunnel mode.
|
||||
// Split-tunnel (per-route from ServerConfig) is not yet supported on macOS.
|
||||
pub async fn setup_vpn_routes(&self) -> Result<()> {
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
run("route", &["add", "-net", "0.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
run("route", &["add", "-net", "128.0.0.0/1", "-interface", &self.tun_name]).await;
|
||||
info!("macOS VPN routes added via {}", self.tun_name);
|
||||
@@ -89,7 +100,11 @@ impl MacOsNetManager {
|
||||
// the wrong gateway until the next reconnect that creates a new MacOsNetManager.
|
||||
pub async fn ensure_server_route(&self) -> Result<()> {
|
||||
run("route", &["delete", "-host", &self.server_host]).await;
|
||||
run("route", &["add", "-host", &self.server_host, &self.original_gateway]).await;
|
||||
run(
|
||||
"route",
|
||||
&["add", "-host", &self.server_host, &self.original_gateway, "-ifscope", &self.wifi_iface],
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct RouteManager {
|
||||
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 {
|
||||
@@ -21,23 +27,30 @@ impl RouteManager {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_default_gateway() -> Result<Ipv4Addr> {
|
||||
/// 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);
|
||||
return Ok((*gw4, adapter.friendly_name().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,29 +58,26 @@ impl RouteManager {
|
||||
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")
|
||||
// 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",
|
||||
&dest.to_string(),
|
||||
"MASK",
|
||||
&mask.to_string(),
|
||||
&gateway.to_string(),
|
||||
"METRIC",
|
||||
"1",
|
||||
"route",
|
||||
&prefix_str,
|
||||
iface,
|
||||
&format!("nexthop={}", gateway),
|
||||
"metric=1",
|
||||
"store=active",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
@@ -76,25 +86,19 @@ impl RouteManager {
|
||||
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());
|
||||
warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, stdout.trim());
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("route add {}/{} via {}: {}", dest, prefix, gateway, e),
|
||||
Err(e) => warn!("netsh add route {}/{} via {} on {}: {}", dest, prefix, gateway, iface, 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(),
|
||||
])
|
||||
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)
|
||||
.status();
|
||||
.output();
|
||||
}
|
||||
|
||||
// Add/delete routes through the TUN interface using netsh.
|
||||
@@ -169,8 +173,9 @@ impl RouteManager {
|
||||
) -> Result<()> {
|
||||
info!("Setting up Windows VPN routes...");
|
||||
|
||||
let current_gateway = Self::get_default_gateway()?;
|
||||
info!("Current gateway: {}", current_gateway);
|
||||
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);
|
||||
@@ -189,7 +194,7 @@ impl RouteManager {
|
||||
// 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);
|
||||
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");
|
||||
|
||||
@@ -233,7 +238,7 @@ impl RouteManager {
|
||||
if dns_servers.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, assigned_ip);
|
||||
self.dns_manager = DnsManager::new(self.tun_interface.clone(), None, None, assigned_ip);
|
||||
self.dns_manager.set_dns(dns_servers).await
|
||||
}
|
||||
|
||||
@@ -242,8 +247,10 @@ impl RouteManager {
|
||||
}
|
||||
|
||||
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);
|
||||
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(())
|
||||
}
|
||||
@@ -280,8 +287,9 @@ impl RouteManager {
|
||||
}
|
||||
// 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);
|
||||
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(())
|
||||
|
||||
@@ -247,35 +247,6 @@ mod tests {
|
||||
assert_eq!(stats.download_bytes, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_speed_calculation() {
|
||||
let tracker = StatsTracker::new("utilbox.eu:8443".to_string(), "test".to_string());
|
||||
|
||||
// Record some traffic
|
||||
tracker.record_upload(1024);
|
||||
tracker.record_download(2048);
|
||||
|
||||
// Update to calculate speed
|
||||
tracker.update();
|
||||
|
||||
let stats = tracker.get_stats(false);
|
||||
assert_eq!(stats.upload_speed, 1024);
|
||||
assert_eq!(stats.download_speed, 2048);
|
||||
|
||||
// Record more traffic
|
||||
tracker.record_upload(512);
|
||||
tracker.record_download(1024);
|
||||
|
||||
// Update again
|
||||
tracker.update();
|
||||
|
||||
let stats = tracker.get_stats(false);
|
||||
assert_eq!(stats.upload_bytes, 1536);
|
||||
assert_eq!(stats.download_bytes, 3072);
|
||||
assert_eq!(stats.upload_speed, 512);
|
||||
assert_eq!(stats.download_speed, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ring_buffer() {
|
||||
let mut buffer = RingBuffer::new();
|
||||
|
||||
@@ -200,6 +200,35 @@ impl TunDevice {
|
||||
|
||||
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() => {
|
||||
match writer.write(&packet).await {
|
||||
Ok(n) if n == packet.len() => {}
|
||||
Ok(n) => {
|
||||
error!(
|
||||
"TUN short write: wrote {} of {} bytes -- packet dropped (TUN writes must be atomic per-packet)",
|
||||
n, packet.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("TUN write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TUN→TCP: forward outgoing packets from the kernel.
|
||||
packet = tun_pkt_rx.recv() => {
|
||||
match packet {
|
||||
Some(pkt) => {
|
||||
@@ -213,11 +242,6 @@ impl TunDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(packet) = from_tcp_rx.recv() => {
|
||||
if let Err(e) = writer.write_all(&packet).await {
|
||||
error!("TUN write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user