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 { 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 = 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 { 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: 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()); } }