chore: sync konduit-platform v0.1.0-beta.7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if route.header.destination_prefix_length != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut metric = 0u32;
|
||||
let mut gateway: Option<Ipv4Addr> = None;
|
||||
for nla in route.attributes.iter() {
|
||||
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,14 +463,21 @@ impl RouteManager {
|
||||
|
||||
if let Some(ip) = dest_ip {
|
||||
if self.added_routes.contains(&(ip, prefix_len)) {
|
||||
let is_ours = route.attributes.iter().any(|nla| {
|
||||
matches!(
|
||||
nla,
|
||||
netlink_packet_route::route::RouteAttribute::Priority(p)
|
||||
if *p == VPN_ROUTE_METRIC
|
||||
)
|
||||
});
|
||||
if is_ours {
|
||||
// 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::Oif(idx)
|
||||
if Some(*idx) == self.tun_index
|
||||
)
|
||||
});
|
||||
if is_ours {
|
||||
routes_to_delete.push(route);
|
||||
}
|
||||
} else {
|
||||
routes_to_delete.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user