chore: sync konduit-platform v0.1.0-beta.7

This commit is contained in:
E. Kaparulin
2026-07-10 07:42:05 +03:00
parent 99a2993ace
commit 7df5aa4317
4 changed files with 131 additions and 63 deletions

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,21 @@ 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));
// 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.
match std::process::Command::new("ip")
.args(["rule", "add", "priority", &VPN_RULE_PRIORITY.to_string(), "lookup", "main"])
.status()
{
Ok(s) if s.success() => {
self.added_policy_rule = true;
info!("Added ip rule priority {} → main table", VPN_RULE_PRIORITY);
}
Ok(_) => warn!("ip rule add returned non-zero (rule may already exist)"),
Err(e) => warn!("Failed to run ip rule add: {}", e),
}
} else {
info!(
"Split-tunnel mode: adding {} routes via VPN...",
@@ -312,11 +354,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 +424,19 @@ impl RouteManager {
self.added_routes.len()
);
// Remove the policy rule first so traffic stops using the VPN immediately.
if self.added_policy_rule {
match std::process::Command::new("ip")
.args(["rule", "del", "priority", &VPN_RULE_PRIORITY.to_string(), "lookup", "main"])
.status()
{
Ok(s) if s.success() => info!("Removed ip rule priority {}", VPN_RULE_PRIORITY),
Ok(_) => warn!("ip rule del returned non-zero"),
Err(e) => warn!("Failed to run ip rule del: {}", e),
}
self.added_policy_rule = false;
}
if self.added_routes.is_empty() {
return Ok(());
}
@@ -406,16 +463,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);
}
}
}
}

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

@@ -185,8 +185,7 @@ impl TunDevice {
info!("TUN device closed");
break;
}
Ok(n) => {
debug!("TUN read: {} bytes", n);
Ok(_) => {
if tun_pkt_tx.send(buf.freeze()).await.is_err() {
break;
}
@@ -201,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) => {
@@ -214,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);
}
}
}
}