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..."); info!("Restoring DNS settings...");
let result = match if_index { // Always attempt both paths: D-Bus RevertLink clears per-link config in
Some(idx) => revert_link_dbus(idx).await, // systemd-resolved's in-memory state; resolvectl revert is a belt-and-suspenders
None => revert_resolvectl(interface).await, // pass that ensures the +DefaultRoute domain is removed even if D-Bus races
}; // with interface teardown.
if let Some(idx) = if_index {
if let Err(e) = result { if let Err(e) = revert_link_dbus(idx).await {
warn!("D-Bus DNS revert failed ({}), trying resolvectl", e); warn!("D-Bus RevertLink({}) failed: {}", idx, e);
revert_resolvectl(interface).await.ok();
} }
}
// Always run resolvectl revert as a second pass.
revert_resolvectl(interface).await.ok();
state.configured = false; state.configured = false;
Ok(()) Ok(())
@@ -172,6 +174,14 @@ async fn revert_link_dbus(if_index: u32) -> Result<()> {
.await .await
.context("Failed to create resolve1 proxy")?; .context("Failed to create resolve1 proxy")?;
// Clear DNS servers and routing domains explicitly before the full revert.
// RevertLink alone can race with interface teardown in systemd-resolved;
// zeroing each setting first ensures they are gone even if RevertLink races.
let _ = proxy.set_link_dns(if_index as i32, vec![]).await;
let _ = proxy
.set_link_domains(if_index as i32, vec![])
.await;
proxy proxy
.revert_link(if_index as i32) .revert_link(if_index as i32)
.await .await

View File

@@ -5,6 +5,12 @@ use rtnetlink::{new_connection, Handle, IpVersion};
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use tracing::{info, warn}; use tracing::{info, warn};
// Added with priority lower than typical policy-routing stacks (e.g., xray uses 9001).
// Ensures konduit's main-table routes (tun0 default, server host-route) win in
// full-tunnel mode without modifying any third-party routing table.
const VPN_RULE_PRIORITY: u32 = 8000;
const VPN_ROUTE_METRIC: u32 = 50; const VPN_ROUTE_METRIC: u32 = 50;
/// Manages routing table for VPN connection using netlink. /// Manages routing table for VPN connection using netlink.
@@ -21,6 +27,8 @@ pub struct RouteManager {
wifi_gateway: Option<Ipv4Addr>, wifi_gateway: Option<Ipv4Addr>,
// Saved for re-adding the VPN default route after reconnect. // Saved for re-adding the VPN default route after reconnect.
vpn_gateway: Option<Ipv4Addr>, vpn_gateway: Option<Ipv4Addr>,
// True when we installed an ip rule to override policy-routing stacks.
added_policy_rule: bool,
} }
impl RouteManager { impl RouteManager {
@@ -39,6 +47,7 @@ impl RouteManager {
server_ip: None, server_ip: None,
wifi_gateway: None, wifi_gateway: None,
vpn_gateway: None, vpn_gateway: None,
added_policy_rule: false,
}) })
} }
@@ -58,22 +67,40 @@ impl RouteManager {
} }
async fn get_default_gateway(&self) -> Result<Ipv4Addr> { async fn get_default_gateway(&self) -> Result<Ipv4Addr> {
use netlink_packet_route::route::RouteAddress;
let mut routes = self.handle.route().get(IpVersion::V4).execute(); let mut routes = self.handle.route().get(IpVersion::V4).execute();
// Collect all default-route gateways with their metrics.
// VPN default routes have low metric (50); physical/DHCP routes have high metric (600+).
// We want the physical gateway, so take the one with the highest metric.
let mut candidates: Vec<(u32, Ipv4Addr)> = Vec::new();
while let Some(route) = routes.try_next().await? { while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0 { if route.header.destination_prefix_length != 0 {
continue;
}
let mut metric = 0u32;
let mut gateway: Option<Ipv4Addr> = None;
for nla in route.attributes.iter() { for nla in route.attributes.iter() {
if let netlink_packet_route::route::RouteAttribute::Gateway(addr) = nla { match nla {
use netlink_packet_route::route::RouteAddress; netlink_packet_route::route::RouteAttribute::Gateway(
if let RouteAddress::Inet(ipv4_addr) = addr { RouteAddress::Inet(ip),
return Ok(*ipv4_addr); ) => gateway = Some(*ip),
} netlink_packet_route::route::RouteAttribute::Priority(m) => metric = *m,
_ => {}
} }
} }
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. /// Install routes for the VPN connection.
@@ -178,6 +205,21 @@ impl RouteManager {
.map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?; .map_err(|e| anyhow::anyhow!("Failed to add VPN default route: {:?}", e))?;
self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0)); self.added_routes.push((Ipv4Addr::new(0, 0, 0, 0), 0));
// 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 { } else {
info!( info!(
"Split-tunnel mode: adding {} routes via VPN...", "Split-tunnel mode: adding {} routes via VPN...",
@@ -312,11 +354,13 @@ impl RouteManager {
let mut to_delete = None; let mut to_delete = None;
while let Some(route) = routes.try_next().await? { while let Some(route) = routes.try_next().await? {
if route.header.destination_prefix_length == 0 { if route.header.destination_prefix_length == 0 {
// Identify our default route by its output interface (tun0), not metric,
// since the kernel may not round-trip the Priority attribute reliably.
let is_ours = route.attributes.iter().any(|nla| { let is_ours = route.attributes.iter().any(|nla| {
matches!( matches!(
nla, nla,
netlink_packet_route::route::RouteAttribute::Priority(p) netlink_packet_route::route::RouteAttribute::Oif(idx)
if *p == VPN_ROUTE_METRIC if Some(*idx) == self.tun_index
) )
}); });
if is_ours { if is_ours {
@@ -380,6 +424,19 @@ impl RouteManager {
self.added_routes.len() 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() { if self.added_routes.is_empty() {
return Ok(()); return Ok(());
} }
@@ -406,16 +463,23 @@ impl RouteManager {
if let Some(ip) = dest_ip { if let Some(ip) = dest_ip {
if self.added_routes.contains(&(ip, prefix_len)) { if self.added_routes.contains(&(ip, prefix_len)) {
// For the default route (0.0.0.0/0), match by output interface
// (tun0), not just destination prefix — otherwise we also delete
// the WiFi default route (same prefix, different OIF/metric).
if prefix_len == 0 {
let is_ours = route.attributes.iter().any(|nla| { let is_ours = route.attributes.iter().any(|nla| {
matches!( matches!(
nla, nla,
netlink_packet_route::route::RouteAttribute::Priority(p) netlink_packet_route::route::RouteAttribute::Oif(idx)
if *p == VPN_ROUTE_METRIC if Some(*idx) == self.tun_index
) )
}); });
if is_ours { if is_ours {
routes_to_delete.push(route); routes_to_delete.push(route);
} }
} else {
routes_to_delete.push(route);
}
} }
} }
} }

View File

@@ -247,35 +247,6 @@ mod tests {
assert_eq!(stats.download_bytes, 2048); assert_eq!(stats.download_bytes, 2048);
} }
#[test]
fn test_speed_calculation() {
let tracker = StatsTracker::new("utilbox.eu:8443".to_string(), "test".to_string());
// Record some traffic
tracker.record_upload(1024);
tracker.record_download(2048);
// Update to calculate speed
tracker.update();
let stats = tracker.get_stats(false);
assert_eq!(stats.upload_speed, 1024);
assert_eq!(stats.download_speed, 2048);
// Record more traffic
tracker.record_upload(512);
tracker.record_download(1024);
// Update again
tracker.update();
let stats = tracker.get_stats(false);
assert_eq!(stats.upload_bytes, 1536);
assert_eq!(stats.download_bytes, 3072);
assert_eq!(stats.upload_speed, 512);
assert_eq!(stats.download_speed, 1024);
}
#[test] #[test]
fn test_ring_buffer() { fn test_ring_buffer() {
let mut buffer = RingBuffer::new(); let mut buffer = RingBuffer::new();

View File

@@ -185,8 +185,7 @@ impl TunDevice {
info!("TUN device closed"); info!("TUN device closed");
break; break;
} }
Ok(n) => { Ok(_) => {
debug!("TUN read: {} bytes", n);
if tun_pkt_tx.send(buf.freeze()).await.is_err() { if tun_pkt_tx.send(buf.freeze()).await.is_err() {
break; break;
} }
@@ -201,6 +200,35 @@ impl TunDevice {
loop { loop {
tokio::select! { 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() => { packet = tun_pkt_rx.recv() => {
match packet { match packet {
Some(pkt) => { 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);
}
}
} }
} }