use super::*;
pub(super) fn macos_default_routes_from_netstat(output: &str) -> Vec<MacosRouteSpec> {
let mut routes = Vec::new();
for line in output.lines().map(str::trim) {
let tokens = line.split_whitespace().collect::<Vec<_>>();
if tokens.first().copied() != Some("default") || tokens.len() < 4 {
continue;
}
let iface_index = if tokens.last().copied() == Some("!") {
tokens.len().saturating_sub(2)
} else {
tokens.len().saturating_sub(1)
};
let Some(interface) = tokens.get(iface_index) else {
continue;
};
routes.push(MacosRouteSpec {
gateway: (!tokens[1].starts_with("link#")).then(|| tokens[1].to_string()),
interface: (*interface).to_string(),
});
}
routes
}
pub(crate) fn macos_has_underlay_default_route(output: &str) -> bool {
macos_underlay_default_route_from_routes(&macos_default_routes_from_netstat(output)).is_some()
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_has_tunnel_split_default_routes(output: &str) -> bool {
output.lines().map(str::trim).any(|line| {
let tokens = line.split_whitespace().collect::<Vec<_>>();
if tokens.len() < 4 {
return false;
}
let target = tokens[0];
let iface_index = if tokens.last().copied() == Some("!") {
tokens.len().saturating_sub(2)
} else {
tokens.len().saturating_sub(1)
};
let Some(interface) = tokens.get(iface_index).copied() else {
return false;
};
interface.starts_with("utun")
&& matches!(target, "0/1" | "0.0.0.0/1" | "128/1" | "128.0.0.0/1")
})
}
pub(super) fn macos_underlay_default_route_from_routes(
routes: &[MacosRouteSpec],
) -> Option<MacosRouteSpec> {
routes
.iter()
.find(|route| {
route.gateway.is_some()
&& !route.interface.starts_with("utun")
&& !route.interface.starts_with("bridge")
&& route.interface != "lo0"
})
.cloned()
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_interface_names_from_ifconfig_list(output: &str) -> Vec<String> {
output
.split_whitespace()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_ipconfig_router_from_output(output: &str) -> Option<Ipv4Addr> {
for line in output.lines().map(str::trim) {
let value = if let Some(value) = line.strip_prefix("router (ip):") {
value.trim()
} else if let Some(value) = line.strip_prefix("router (ip_mult):") {
value.trim().trim_start_matches('{').trim_end_matches('}')
} else {
continue;
};
if let Ok(router) = value.parse::<Ipv4Addr>() {
return Some(router);
}
}
None
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_ipconfig_ipv4_for_interface(iface: &str) -> Result<Option<Ipv4Addr>> {
match command_stdout_checked(ProcessCommand::new("ipconfig").arg("getifaddr").arg(iface)) {
Ok(output) => Ok(output.trim().parse::<Ipv4Addr>().ok()),
Err(error) => {
if error.to_string().to_ascii_lowercase().contains("not found") {
Ok(None)
} else {
Err(error)
}
}
}
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_ipconfig_router_for_interface(iface: &str) -> Result<Option<Ipv4Addr>> {
if let Ok(output) = command_stdout_checked(
ProcessCommand::new("ipconfig")
.arg("getoption")
.arg(iface)
.arg("router"),
) && let Ok(router) = output.trim().parse::<Ipv4Addr>()
{
return Ok(Some(router));
}
let output =
command_stdout_checked(ProcessCommand::new("ipconfig").arg("getpacket").arg(iface))?;
Ok(macos_ipconfig_router_from_output(&output))
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_underlay_default_route_from_system() -> Result<Option<MacosRouteSpec>> {
let output = command_stdout_checked(ProcessCommand::new("ifconfig").arg("-l"))?;
for iface in macos_interface_names_from_ifconfig_list(&output) {
if iface.starts_with("utun")
|| iface.starts_with("bridge")
|| iface == "lo0"
|| iface == "gif0"
|| iface == "stf0"
|| iface.starts_with("anpi")
|| iface.starts_with("awdl")
|| iface.starts_with("llw")
{
continue;
}
let Ok(Some(_ipv4)) = macos_ipconfig_ipv4_for_interface(&iface) else {
continue;
};
let Ok(Some(router)) = macos_ipconfig_router_for_interface(&iface) else {
continue;
};
return Ok(Some(MacosRouteSpec {
gateway: Some(router.to_string()),
interface: iface,
}));
}
Ok(None)
}
#[cfg(target_os = "macos")]
fn macos_unscoped_default_route_works() -> bool {
let Ok(output) = ProcessCommand::new("route")
.arg("-n")
.arg("get")
.arg("1.1.1.1")
.output()
else {
return false;
};
if !output.status.success() {
return false;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
!stderr.to_ascii_lowercase().contains("not in table")
&& (stdout.contains("gateway:") || stdout.contains("interface:"))
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_tunnel_interfaces_with_ipv4(tunnel_ip: Ipv4Addr) -> Result<Vec<String>> {
let output = command_stdout_checked(ProcessCommand::new("ifconfig").arg("-l"))?;
let mut matches = Vec::new();
for iface in macos_interface_names_from_ifconfig_list(&output) {
if !iface.starts_with("utun") {
continue;
}
if macos_iface_has_ipv4_address(&iface, tunnel_ip)? {
matches.push(iface);
}
}
Ok(matches)
}
#[cfg(target_os = "macos")]
pub(crate) fn renew_macos_interface_dhcp(iface: &str) -> Result<()> {
run_checked(
ProcessCommand::new("ipconfig")
.arg("set")
.arg(iface)
.arg("DHCP"),
)
}
#[cfg(target_os = "macos")]
pub(crate) fn ensure_macos_underlay_default_route() -> Result<bool> {
let output = command_stdout_checked(
ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet"),
)?;
if macos_has_tunnel_split_default_routes(&output)
|| (macos_has_underlay_default_route(&output) && macos_unscoped_default_route_works())
{
return Ok(false);
}
let Some(underlay) = macos_underlay_default_route_from_system()? else {
return Ok(false);
};
if restore_macos_default_route(&underlay).is_ok() {
let refreshed_output = command_stdout_checked(
ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet"),
)?;
if macos_has_underlay_default_route(&refreshed_output) {
return Ok(true);
}
}
let _ = renew_macos_interface_dhcp(&underlay.interface);
let refreshed_output = command_stdout_checked(
ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet"),
)?;
if macos_has_underlay_default_route(&refreshed_output)
|| macos_has_tunnel_split_default_routes(&refreshed_output)
{
return Ok(true);
}
restore_macos_default_route(&underlay)?;
Ok(true)
}
#[cfg(target_os = "macos")]
pub(super) fn macos_default_routes() -> Result<Vec<MacosRouteSpec>> {
let output = command_stdout_checked(
ProcessCommand::new("netstat")
.arg("-rn")
.arg("-f")
.arg("inet"),
)?;
Ok(macos_default_routes_from_netstat(&output))
}
#[cfg(target_os = "macos")]
pub(super) fn delete_macos_managed_route(
target: &str,
gateway: Option<&str>,
interface: Option<&str>,
) -> Result<()> {
if gateway.is_none()
&& let Some(iface) = interface
{
return delete_macos_direct_route_variants(target, iface);
}
delete_macos_route_spec(target, None)
}
#[cfg(target_os = "macos")]
pub(super) fn restore_macos_default_route(route: &MacosRouteSpec) -> Result<()> {
apply_macos_default_route(route.gateway.as_deref(), Some(route.interface.as_str()))
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_tunnel_default_route_targets() -> &'static [&'static str] {
&["0.0.0.0/1", "128.0.0.0/1"]
}
#[cfg(any(target_os = "macos", test))]
fn macos_gateway_route_args(action: &str, target: &str, gateway: &str) -> Vec<String> {
let target_ip = strip_cidr(target);
let is_host = target.ends_with("/32") || !target.contains('/');
let mut args = vec!["-n".to_string(), action.to_string()];
if is_host {
args.push("-host".to_string());
args.push(target_ip.to_string());
} else if target == "0.0.0.0/0" {
args.push("default".to_string());
} else {
args.push("-net".to_string());
args.push(target.to_string());
}
args.push(gateway.to_string());
args
}
#[cfg(test)]
pub(crate) fn macos_gateway_route_args_for_test(
action: &str,
target: &str,
gateway: &str,
) -> Vec<String> {
macos_gateway_route_args(action, target, gateway)
}
#[cfg(target_os = "macos")]
pub(super) fn apply_macos_default_route(
gateway: Option<&str>,
ifscope: Option<&str>,
) -> Result<()> {
if let Some(ifscope) = ifscope {
let _ = delete_macos_default_route_for_interface(ifscope);
let _ = ProcessCommand::new("route")
.arg("-n")
.arg("delete")
.arg("default")
.arg("-ifscope")
.arg(ifscope)
.status();
}
if gateway.is_none() {
let iface = ifscope.ok_or_else(|| anyhow!("missing interface for direct default route"))?;
for target in macos_tunnel_default_route_targets() {
apply_macos_route_spec(target, None, Some(iface)).with_context(|| {
format!("failed to install macOS default route target {target} on {iface}")
})?;
}
return Ok(());
}
let mut change = ProcessCommand::new("route");
change.arg("-n").arg("change").arg("default");
change.arg(gateway.expect("gateway checked above"));
match run_checked(&mut change) {
Ok(()) => Ok(()),
Err(_) => {
let mut add = ProcessCommand::new("route");
add.arg("-n").arg("add").arg("default");
add.arg(gateway.expect("gateway checked above"));
run_checked(&mut add)
}
}
}
#[cfg(target_os = "macos")]
pub(super) fn delete_macos_default_route_for_interface(iface: &str) -> Result<()> {
let mut failures = Vec::new();
for target in
std::iter::once("0.0.0.0/0").chain(macos_tunnel_default_route_targets().iter().copied())
{
if let Err(error) = delete_macos_direct_route_variants(target, iface) {
failures.push(format!("remove {target} on {iface}: {error}"));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(anyhow!(failures.join("; ")))
}
}
pub(super) fn macos_ifconfig_has_ipv4(output: &str, needle: Ipv4Addr) -> bool {
output.lines().map(str::trim).any(|line| {
line.strip_prefix("inet ")
.and_then(|rest| rest.split_whitespace().next())
.is_some_and(|value| value == needle.to_string())
})
}
#[cfg(target_os = "macos")]
pub(super) fn macos_iface_has_ipv4_address(iface: &str, needle: Ipv4Addr) -> Result<bool> {
let output = command_stdout_checked(ProcessCommand::new("ifconfig").arg(iface))?;
Ok(macos_ifconfig_has_ipv4(&output, needle))
}
#[cfg(target_os = "macos")]
pub(super) fn apply_macos_route_spec(
target: &str,
gateway: Option<&str>,
ifscope: Option<&str>,
) -> Result<()> {
let target_ip = strip_cidr(target);
let is_host = target.ends_with("/32") || !target.contains('/');
let mut add = ProcessCommand::new("route");
if let Some(gateway) = gateway {
add.args(macos_gateway_route_args("add", target, gateway));
} else {
if let Some(iface) = ifscope {
let _ = delete_macos_route_spec(target, Some(iface));
let _ = delete_macos_route_spec(target, None);
}
add.arg("-n").arg("add");
if is_host {
add.arg("-host").arg(target_ip);
} else if target == "0.0.0.0/0" {
add.arg("default");
} else {
add.arg("-net").arg(target);
}
let iface = ifscope.ok_or_else(|| anyhow!("missing interface for direct route"))?;
add.arg("-interface").arg(iface);
}
match run_checked(&mut add) {
Ok(()) => Ok(()),
Err(_) => {
let mut change = ProcessCommand::new("route");
if let Some(gateway) = gateway {
change.args(macos_gateway_route_args("change", target, gateway));
} else {
change.arg("-n").arg("change");
if is_host {
change.arg("-host").arg(target_ip);
} else if target == "0.0.0.0/0" {
change.arg("default");
} else {
change.arg("-net").arg(target);
}
let iface = ifscope.ok_or_else(|| anyhow!("missing interface for direct route"))?;
change.arg("-interface").arg(iface);
}
run_checked(&mut change)
}
}
}
#[cfg(target_os = "macos")]
fn delete_macos_route_spec(target: &str, ifscope: Option<&str>) -> Result<()> {
let target_ip = strip_cidr(target);
let is_host = target.ends_with("/32") || !target.contains('/');
let mut delete = ProcessCommand::new("route");
delete.arg("-n").arg("delete");
if let Some(ifscope) = ifscope {
delete.arg("-ifscope").arg(ifscope);
}
if is_host {
delete.arg("-host").arg(target_ip);
} else if target == "0.0.0.0/0" {
delete.arg("default");
} else {
delete.arg("-net").arg(target);
}
run_checked(&mut delete)
}
#[cfg(target_os = "macos")]
fn delete_macos_direct_route_variants(target: &str, iface: &str) -> Result<()> {
let mut failures = Vec::new();
for attempt in [Some(iface), None] {
if let Err(error) = delete_macos_route_spec(target, attempt)
&& !crate::daemon_runtime::macos_route_delete_error_is_absent(&error.to_string())
{
failures.push(error.to_string());
}
}
if failures.is_empty() {
Ok(())
} else {
Err(anyhow!(failures.join("; ")))
}
}
#[cfg(target_os = "macos")]
pub(super) fn read_macos_ip_forward() -> Result<bool> {
Ok(command_stdout_checked(
ProcessCommand::new("sysctl")
.arg("-n")
.arg("net.inet.ip.forwarding"),
)?
.trim()
== "1")
}
#[cfg(target_os = "macos")]
pub(super) fn write_macos_ip_forward(enabled: bool) -> Result<()> {
run_checked(ProcessCommand::new("sysctl").arg("-w").arg(format!(
"net.inet.ip.forwarding={}",
if enabled { "1" } else { "0" }
)))
}
#[cfg(target_os = "macos")]
const MACOS_PF_EXIT_ANCHOR: &str = "com.apple/to.nostrvpn/exit";
#[cfg(target_os = "macos")]
pub(super) fn cleanup_macos_pf_nat() -> Result<()> {
run_checked(
ProcessCommand::new("pfctl")
.arg("-a")
.arg(MACOS_PF_EXIT_ANCHOR)
.arg("-F")
.arg("nat"),
)
}