chore: sync konduit-platform
This commit is contained in:
198
konduit-platform/src/routes/macos.rs
Normal file
198
konduit-platform/src/routes/macos.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use anyhow::Result;
|
||||
use std::net::IpAddr;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct MacOsNetManager {
|
||||
pub tun_name: String,
|
||||
server_host: String,
|
||||
original_gateway: String,
|
||||
primary_service: String,
|
||||
}
|
||||
|
||||
impl MacOsNetManager {
|
||||
/// Discover the current default gateway and primary network service, then return a manager.
|
||||
/// Uses blocking `std::process::Command` — call from a sync context or `spawn_blocking`.
|
||||
pub fn new(tun_name: String, server_endpoint: &str) -> Result<Self> {
|
||||
let server_host = server_endpoint
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(server_endpoint)
|
||||
.to_string();
|
||||
|
||||
let route_out = std::process::Command::new("route")
|
||||
.args(["-n", "get", "default"])
|
||||
.output()?;
|
||||
let route_text = String::from_utf8_lossy(&route_out.stdout).into_owned();
|
||||
let (original_gateway, iface) = Self::parse_default_route(&route_text)?;
|
||||
|
||||
let svc_out = std::process::Command::new("networksetup")
|
||||
.args(["-listnetworkserviceorder"])
|
||||
.output()?;
|
||||
let svc_text = String::from_utf8_lossy(&svc_out.stdout).into_owned();
|
||||
let primary_service = Self::parse_primary_service(&svc_text, &iface)
|
||||
.unwrap_or_else(|_| "Wi-Fi".to_string());
|
||||
|
||||
info!(
|
||||
"MacOsNetManager: gateway={}, service={}, tun={}",
|
||||
original_gateway, primary_service, tun_name
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
tun_name,
|
||||
server_host,
|
||||
original_gateway,
|
||||
primary_service,
|
||||
})
|
||||
}
|
||||
|
||||
// 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", "-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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure DNS for the primary network service.
|
||||
pub async fn setup_dns(&self, dns_ips: &[IpAddr], _assigned_ip: IpAddr) -> Result<()> {
|
||||
if dns_ips.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ip_strings: Vec<String> = dns_ips.iter().map(|ip| ip.to_string()).collect();
|
||||
let mut args: Vec<&str> = vec!["-setdnsservers", self.primary_service.as_str()];
|
||||
args.extend(ip_strings.iter().map(|s| s.as_str()));
|
||||
run("networksetup", &args).await;
|
||||
info!("macOS DNS configured: {:?}", dns_ips);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove split-default routes so traffic falls through to WiFi during reconnect.
|
||||
pub async fn suspend_default_route(&self) -> Result<()> {
|
||||
run("route", &["delete", "-net", "0.0.0.0/1"]).await;
|
||||
run("route", &["delete", "-net", "128.0.0.0/1"]).await;
|
||||
info!("macOS default route suspended");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-add split-default routes after successful reconnect.
|
||||
pub async fn resume_default_route(&self) -> Result<()> {
|
||||
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 default route resumed via {}", self.tun_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// NOTE: original_gateway is captured once at construction time.
|
||||
// If the WiFi gateway changes (network roaming), this route may go via
|
||||
// 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;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove all VPN routes.
|
||||
pub async fn restore_routes(&self) -> Result<()> {
|
||||
run("route", &["delete", "-host", &self.server_host]).await;
|
||||
run("route", &["delete", "-net", "0.0.0.0/1"]).await;
|
||||
run("route", &["delete", "-net", "128.0.0.0/1"]).await;
|
||||
info!("macOS VPN routes removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revert DNS to automatic (empty = use DHCP-provided DNS).
|
||||
pub async fn restore_dns(&self) -> Result<()> {
|
||||
run("networksetup", &["-setdnsservers", &self.primary_service, "Empty"]).await;
|
||||
info!("macOS DNS restored to automatic");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_default_route(output: &str) -> Result<(String, String)> {
|
||||
let gateway = output
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("gateway:"))
|
||||
.and_then(|l| l.splitn(2, ':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find default gateway in route output"))?;
|
||||
|
||||
let iface = output
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("interface:"))
|
||||
.and_then(|l| l.splitn(2, ':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find default interface in route output"))?;
|
||||
|
||||
Ok((gateway, iface))
|
||||
}
|
||||
|
||||
fn parse_primary_service(output: &str, iface: &str) -> Result<String> {
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
for i in 0..lines.len().saturating_sub(1) {
|
||||
if lines[i + 1].contains(&format!("Device: {}", iface)) {
|
||||
let name = lines[i].trim();
|
||||
let name = name.splitn(2, ") ").nth(1).unwrap_or(name.trim());
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Could not find network service for interface {}", iface)
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(cmd: &str, args: &[&str]) {
|
||||
match tokio::process::Command::new(cmd).args(args).output().await {
|
||||
Ok(out) if out.status.success() => {}
|
||||
Ok(out) => warn!(
|
||||
"{} {} failed: {}",
|
||||
cmd,
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => warn!("Failed to run {}: {}", cmd, e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ROUTE_OUTPUT: &str = " route to: default
|
||||
destination: default
|
||||
mask: default
|
||||
gateway: 192.168.1.1
|
||||
interface: en0
|
||||
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
|
||||
recvpipe sendpipe ssthresh rtt,msec rtt,var hopcount mtu expire
|
||||
0 0 0 35 128 0 1500 0 ";
|
||||
|
||||
const SERVICES_OUTPUT: &str = "An asterisk (*) denotes that a network service is disabled.
|
||||
(1) Wi-Fi
|
||||
(Hardware Port: Wi-Fi, Device: en0)
|
||||
|
||||
(2) Bluetooth PAN
|
||||
(Hardware Port: Bluetooth PAN, Device: en1)
|
||||
|
||||
(3) Thunderbolt Bridge
|
||||
(Hardware Port: Thunderbolt Bridge, Device: bridge0)
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parses_gateway_and_interface() {
|
||||
let (gw, iface) = MacOsNetManager::parse_default_route(ROUTE_OUTPUT).unwrap();
|
||||
assert_eq!(gw, "192.168.1.1");
|
||||
assert_eq!(iface, "en0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_primary_service() {
|
||||
let svc = MacOsNetManager::parse_primary_service(SERVICES_OUTPUT, "en0").unwrap();
|
||||
assert_eq!(svc, "Wi-Fi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_service_unknown_iface_returns_err() {
|
||||
let result = MacOsNetManager::parse_primary_service(SERVICES_OUTPUT, "en99");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user