7 Commits

Author SHA1 Message Date
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
11 changed files with 321 additions and 221 deletions

View File

@@ -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
@@ -95,7 +95,8 @@ Coming soon.
**CLI & server**
- [Client Quickstart](docs/client-quickstart.md) — download, configure, connect, run as a systemd service
- [Server Quickstart](docs/server-quickstart.md) — install, provision, NAT setup for iptables and firewalld
- [Stealth Mode Setup](docs/stealth-setup.md) — HAProxy TCP passthrough + camouflage configuration
- [SOCKS5 Modes](docs/socks-modes.md) — reach the server through a proxy, or expose a local SOCKS5 listener
- [Connection Tuning](docs/connection-tuning.md) — opt-in connection pooling for unstable links
- [systemd units](docs/systemd/) — service files for konduit-server, konduit (client), and konduit-admin-ui
## Architecture
@@ -124,7 +125,7 @@ Konduit engine (Rust)
The [`konduit-platform`](./konduit-platform) crate is published here for transparency and security audit. It contains the cryptographic primitives, connection statistics, and platform networking layer (TUN device, DNS, routes) — everything an auditor needs to verify what runs on your machine. It is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE) — free to read, study, and use for noncommercial purposes.
The VPN server, management UI, and stealth-mode protocol are proprietary. Keeping stealth mechanisms private makes automated DPI fingerprinting significantly harder. Source review under NDA is available for enterprise partners.
The VPN server and management UI are proprietary. Source review under NDA is available for enterprise partners.
## Security
@@ -132,13 +133,9 @@ The VPN server, management UI, and stealth-mode protocol are proprietary. Keepin
**Key storage:** Private keys are stored in the OS secure enclave on every platform (iOS Keychain, macOS Keychain, Android Keystore). They are never written to disk in plaintext.
**Stealth mode:** On port 443, failed or unrecognized handshakes are proxied transparently to a configurable decoy service. From the outside, the server is indistinguishable from a standard HTTPS endpoint.
## Support
**Bug reports:** Use the in-app reporting feature or open an issue in this repository.
**Security vulnerabilities:** Do not open a public issue. Contact the maintainer directly at the address shown in the application's About screen.
Bug reports, security vulnerabilities, and any other inquiries: use the contact form at [www.k-ops.eu](https://www.k-ops.eu).
**Contributing:** Core development is handled internally. We do not currently accept external pull requests.

View File

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

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

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 -
```
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443. See [stealth-setup.md](stealth-setup.md) for the full HAProxy configuration.
`--public-port` sets the port written into client configs, so they connect to 443 even though konduit listens on 8443 — useful when running behind any TCP-passthrough reverse proxy (e.g. HAProxy, nginx `stream` module).
## 3. Add a Client
@@ -102,8 +102,8 @@ sudo apt install iptables-persistent && sudo netfilter-persistent save
## 7. Verify
```bash
# Should return your website (camouflage) not an error
curl -sk https://your-server/
# Connect a client and confirm the tunnel comes up
./konduit --config client.toml
# Check konduit logs
journalctl -u konduit-server -f

50
docs/socks-modes.md Normal file
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,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.
![Stealth mode architecture](diagrams/stealth-architecture.svg)
## 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 |

View File

@@ -100,15 +100,17 @@ pub async fn restore(
info!("Restoring DNS settings...");
let result = match if_index {
Some(idx) => revert_link_dbus(idx).await,
None => revert_resolvectl(interface).await,
};
if let Err(e) = result {
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e);
revert_resolvectl(interface).await.ok();
// 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();
state.configured = false;
Ok(())
@@ -172,6 +174,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

View File

@@ -5,6 +5,12 @@ use rtnetlink::{new_connection, Handle, IpVersion};
use std::net::{IpAddr, Ipv4Addr};
use tracing::{info, warn};
// Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001).
// Ensures konduit's main-table routes (tun0 default, server host-route) win in
// full-tunnel mode without modifying any third-party routing table.
const VPN_RULE_PRIORITY: u32 = 8000;
const VPN_ROUTE_METRIC: u32 = 50;
/// Manages routing table for VPN connection using netlink.
@@ -21,6 +27,8 @@ pub struct RouteManager {
wifi_gateway: Option<Ipv4Addr>,
// Saved for re-adding the VPN default route after reconnect.
vpn_gateway: Option<Ipv4Addr>,
// True when we installed an ip rule to override policy-routing stacks.
added_policy_rule: bool,
}
impl RouteManager {
@@ -39,6 +47,7 @@ impl RouteManager {
server_ip: None,
wifi_gateway: None,
vpn_gateway: None,
added_policy_rule: false,
})
}
@@ -58,22 +67,40 @@ impl RouteManager {
}
async fn get_default_gateway(&self) -> Result<Ipv4Addr> {
use netlink_packet_route::route::RouteAddress;
let mut routes = self.handle.route().get(IpVersion::V4).execute();
// Collect all default-route gateways with their metrics.
// VPN default routes have low metric (50); physical/DHCP routes have high metric (600+).
// We want the physical gateway, so take the one with the highest metric.
let mut candidates: Vec<(u32, Ipv4Addr)> = Vec::new();
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0 {
if route.header.destination_prefix_length != 0 {
continue;
}
let mut metric = 0u32;
let mut gateway: Option<Ipv4Addr> = None;
for nla in route.attributes.iter() {
if let netlink_packet_route::route::RouteAttribute::Gateway(addr) = nla {
use netlink_packet_route::route::RouteAddress;
if let RouteAddress::Inet(ipv4_addr) = addr {
return Ok(*ipv4_addr);
}
match nla {
netlink_packet_route::route::RouteAttribute::Gateway(
RouteAddress::Inet(ip),
) => gateway = Some(*ip),
netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
_ => {}
}
}
if let Some(gw) = gateway {
candidates.push((metric, gw));
}
}
anyhow::bail!("No default gateway found")
// Highest metric = physical/DHCP interface (not VPN).
candidates
.into_iter()
.max_by_key(|(m, _)| *m)
.map(|(_, gw)| gw)
.ok_or_else(|| anyhow::anyhow!("No default gateway found"))
}
/// Install routes for the VPN connection.
@@ -178,6 +205,8 @@ impl RouteManager {
.map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?;
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
self.install_policy_rule().await;
} else {
info!(
"Split-tunnel mode: adding {} routes via VPN...",
@@ -239,6 +268,52 @@ impl RouteManager {
Ok(())
}
/// Add a high-priority ip rule so konduit's main-table routes override any
/// policy-routing stack (e.g., xray, sing-box) that intercepts traffic via a
/// separate routing table at priority ~9000.
///
/// Uses the same netlink handle as every other route change here, rather
/// than shelling out to `ip rule add` -- a child process spawned via
/// std::process::Command does NOT inherit capabilities granted to this
/// process via `setcap`/file capabilities (they'd need to be raised into
/// the ambient set first, which nothing here does), so the subprocess
/// silently failed with EPERM ("RTNETLINK answers: Operation not
/// permitted") every time, regardless of environment. That meant this
/// rule never actually got installed, so a competing policy-routing
/// stack (e.g. Xray) could keep overriding the VPN's own routes.
async fn install_policy_rule(&mut self) {
match self
.handle
.rule()
.add()
.v4()
.priority(VPN_RULE_PRIORITY)
// RuleAddRequest::new() defaults the rule's action to Unspec, not
// ToTable. A "from all" rule (matches every packet) with an
// Unspec action is not a valid "jump to main table" rule and the
// kernel does not fall through past it — it broke route lookups
// for ALL traffic, not just VPN traffic, until reboot.
.action(netlink_packet_route::rule::RuleAction::ToTable)
.execute()
.await
{
Ok(()) => {
self.added_policy_rule = true;
info!("Added ip rule priority {} → main table", VPN_RULE_PRIORITY);
}
Err(e) => {
let err_msg = format!("{:?}", e);
if err_msg.contains("File exists") || err_msg.contains("EEXIST") || err_msg.contains("code: Some(-17)")
{
self.added_policy_rule = true;
warn!("ip rule priority {} already exists, continuing...", VPN_RULE_PRIORITY);
} else {
warn!("Failed to add ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e);
}
}
}
}
/// Setup DNS configuration
pub async fn setup_dns(&mut self, dns_servers: &[IpAddr], assigned_ip: IpAddr) -> Result<()> {
if dns_servers.is_empty() {
@@ -312,11 +387,13 @@ impl RouteManager {
let mut to_delete = None;
while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0 {
// Identify our default route by its output interface (tun0), not metric,
// since the kernel may not round-trip the Priority attribute reliably.
let is_ours = route.attributes.iter().any(|nla| {
matches!(
nla,
netlink_packet_route::route::RouteAttribute::Priority(p)
if *p == VPN_ROUTE_METRIC
netlink_packet_route::route::RouteAttribute::Oif(idx)
if Some(*idx) == self.tun_index
)
});
if is_ours {
@@ -380,6 +457,33 @@ impl RouteManager {
self.added_routes.len()
);
// Remove the policy rule first so traffic stops using the VPN immediately.
// Same netlink-handle approach as the add side above -- `handle.rule().del()`
// needs the exact existing RuleMessage, so look it up by priority first.
if self.added_policy_rule {
let mut rules = self.handle.rule().get(IpVersion::V4).execute();
let mut to_delete = None;
while let Some(rule) = rules.try_next().await? {
let matches_priority = rule
.attributes
.iter()
.any(|attr| matches!(attr, netlink_packet_route::rule::RuleAttribute::Priority(p) if *p == VPN_RULE_PRIORITY));
if matches_priority {
to_delete = Some(rule);
break;
}
}
match to_delete {
Some(rule) => match self.handle.rule().del(rule).execute().await {
Ok(()) => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
Err(e) => warn!("Failed to delete ip rule priority {}: {:?}", VPN_RULE_PRIORITY, e),
},
None => warn!("ip rule priority {} not found, nothing to remove", VPN_RULE_PRIORITY),
}
self.added_policy_rule = false;
}
if self.added_routes.is_empty() {
return Ok(());
}
@@ -406,16 +510,23 @@ impl RouteManager {
if let Some(ip) = dest_ip {
if self.added_routes.contains(&(ip, prefix_len)) {
// For the default route (0.0.0.0/0), match by output interface
// (tun0), not just destination prefix — otherwise we also delete
// the WiFi default route (same prefix, different OIF/metric).
if prefix_len == 0 {
let is_ours = route.attributes.iter().any(|nla| {
matches!(
nla,
netlink_packet_route::route::RouteAttribute::Priority(p)
if *p == VPN_ROUTE_METRIC
netlink_packet_route::route::RouteAttribute::Oif(idx)
if Some(*idx) == self.tun_index
)
});
if is_ours {
routes_to_delete.push(route);
}
} else {
routes_to_delete.push(route);
}
}
}
}
@@ -442,3 +553,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

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

View File

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