#[cfg(target_os = "linux")]
use std::fs;
#[cfg(target_os = "linux")]
use std::net::IpAddr;
#[cfg(any(target_os = "linux", target_os = "macos", test))]
use std::net::Ipv4Addr;
#[cfg(any(target_os = "linux", test))]
use std::net::Ipv6Addr;
#[cfg(target_os = "linux")]
use std::net::ToSocketAddrs;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use std::process::Command as ProcessCommand;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use anyhow::Context;
#[cfg(any(target_os = "linux", target_os = "macos", test))]
use anyhow::{Result, anyhow};
#[cfg(target_os = "linux")]
use netdev::get_interfaces;
#[cfg(target_os = "linux")]
use netdev::interface::interface::Interface as NetworkInterface;
#[cfg(target_os = "linux")]
use nostr_vpn_core::config::AppConfig;
#[cfg(any(target_os = "macos", test))]
use crate::MacosRouteSpec;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use crate::run_checked;
#[cfg(any(target_os = "linux", target_os = "macos", test))]
use crate::strip_cidr;
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LinuxRouteGetSpec {
pub(crate) gateway: Option<String>,
pub(crate) dev: String,
pub(crate) src: Option<String>,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
pub(crate) struct LinuxEndpointBypassRoute {
pub(crate) target: String,
pub(crate) gateway: Option<String>,
pub(crate) dev: String,
pub(crate) src: Option<String>,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
pub(crate) struct LinuxDefaultRouteSpec {
pub(crate) line: String,
pub(crate) dev: String,
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_default_route_device_from_output(output: &str) -> Option<String> {
output.lines().find_map(|line| {
let tokens = line.split_whitespace().collect::<Vec<_>>();
tokens
.windows(2)
.find(|window| window[0] == "dev")
.map(|window| window[1].to_string())
})
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_route_get_spec_from_output(output: &str) -> Option<LinuxRouteGetSpec> {
let line = output.lines().find(|line| !line.trim().is_empty())?.trim();
let tokens = line.split_whitespace().collect::<Vec<_>>();
let mut gateway = None;
let mut dev = None;
let mut src = None;
let mut index = 0;
while index < tokens.len() {
match tokens[index] {
"via" => {
gateway = tokens.get(index + 1).map(|value| (*value).to_string());
index += 2;
}
"dev" => {
dev = tokens.get(index + 1).map(|value| (*value).to_string());
index += 2;
}
"src" => {
src = tokens.get(index + 1).map(|value| (*value).to_string());
index += 2;
}
_ => {
index += 1;
}
}
}
Some(LinuxRouteGetSpec {
gateway,
dev: dev?,
src,
})
}
#[cfg(target_os = "linux")]
fn linux_default_route_from_output(output: &str) -> Option<LinuxDefaultRouteSpec> {
let line = output.lines().find(|line| !line.trim().is_empty())?.trim();
Some(LinuxDefaultRouteSpec {
line: line.to_string(),
dev: linux_default_route_device_from_output(line)?,
})
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn command_stdout_checked(command: &mut ProcessCommand) -> Result<String> {
let display = format!("{command:?}");
let output = command
.output()
.with_context(|| format!("failed to execute {display}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(anyhow!(
"command failed: {display}\nstdout: {}\nstderr: {}",
stdout.trim(),
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_default_route() -> Result<LinuxDefaultRouteSpec> {
linux_default_route_for_family("-4", "IPv4")
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_default_ipv6_route() -> Result<LinuxDefaultRouteSpec> {
linux_default_route_for_family("-6", "IPv6")
}
#[cfg(target_os = "linux")]
fn linux_default_route_for_family(
family_flag: &str,
family_label: &str,
) -> Result<LinuxDefaultRouteSpec> {
let output = command_stdout_checked(
ProcessCommand::new("ip")
.arg(family_flag)
.arg("route")
.arg("show")
.arg("default"),
)?;
linux_default_route_from_output(&output)
.ok_or_else(|| anyhow!("failed to resolve default {family_label} route"))
}
#[cfg(target_os = "linux")]
pub(crate) fn restore_linux_default_route(route: &str) -> Result<()> {
restore_linux_default_route_for_family("-4", route)
}
#[cfg(target_os = "linux")]
pub(crate) fn restore_linux_default_ipv6_route(route: &str) -> Result<()> {
restore_linux_default_route_for_family("-6", route)
}
#[cfg(target_os = "linux")]
fn restore_linux_default_route_for_family(family_flag: &str, route: &str) -> Result<()> {
let mut command = ProcessCommand::new("ip");
command.arg(family_flag).arg("route").arg("replace");
for token in route.split_whitespace() {
command.arg(token);
}
run_checked(&mut command)
}
#[cfg(target_os = "linux")]
pub(crate) fn delete_linux_default_route() -> Result<()> {
run_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("del")
.arg("default"),
)
}
#[cfg(target_os = "linux")]
pub(crate) fn delete_linux_default_ipv6_route() -> Result<()> {
run_checked(
ProcessCommand::new("ip")
.arg("-6")
.arg("route")
.arg("del")
.arg("default"),
)
}
#[cfg(target_os = "linux")]
pub(crate) fn flush_linux_route_cache() -> Result<()> {
run_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("flush")
.arg("cache"),
)
}
#[cfg(target_os = "linux")]
fn relay_bypass_ipv4_hosts(app: &AppConfig) -> Vec<Ipv4Addr> {
let mut hosts = app
.nostr
.relays
.iter()
.flat_map(|relay| relay_ipv4_hosts(relay))
.collect::<Vec<_>>();
hosts.sort_unstable();
hosts.dedup();
hosts
}
#[cfg(target_os = "linux")]
fn relay_ipv4_hosts(relay: &str) -> Vec<Ipv4Addr> {
let Some((host, port)) = relay_host_port(relay) else {
return Vec::new();
};
if let Ok(ip) = host.parse::<Ipv4Addr>() {
return vec![ip];
}
if host.parse::<IpAddr>().is_ok() {
return Vec::new();
}
(host.as_str(), port)
.to_socket_addrs()
.map(|addrs| {
addrs
.filter_map(|addr| match addr.ip() {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
fn relay_host_port(relay: &str) -> Option<(String, u16)> {
let relay = relay.trim();
if relay.is_empty() {
return None;
}
let (scheme, remainder) = relay
.split_once("://")
.map_or(("", relay), |(scheme, rest)| (scheme, rest));
let authority = remainder.split('/').next().unwrap_or(remainder);
let default_port = match scheme {
"wss" | "https" => 443,
_ => 80,
};
split_host_port(authority, default_port)
}
#[cfg(target_os = "linux")]
pub(crate) fn stun_host_port(server: &str) -> Option<(String, u16)> {
let server = server.trim();
if server.is_empty() {
return None;
}
let authority = server
.strip_prefix("stun://")
.or_else(|| server.strip_prefix("stun:"))
.unwrap_or(server);
split_host_port(authority, 3478)
}
#[cfg(target_os = "linux")]
fn stun_ipv4_hosts(app: &AppConfig) -> Vec<Ipv4Addr> {
let mut hosts = app
.nat
.stun_servers
.iter()
.filter_map(|server| stun_host_port(server))
.flat_map(|(host, port)| {
if let Ok(ip) = host.parse::<Ipv4Addr>() {
return vec![ip];
}
if host.parse::<IpAddr>().is_ok() {
return Vec::new();
}
(host.as_str(), port)
.to_socket_addrs()
.map(|addrs| {
addrs
.filter_map(|addr| match addr.ip() {
IpAddr::V4(ip) => Some(ip),
IpAddr::V6(_) => None,
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
})
.collect::<Vec<_>>();
hosts.sort_unstable();
hosts.dedup();
hosts
}
#[cfg(target_os = "linux")]
fn management_ipv4_hosts_from_interfaces(interfaces: &[NetworkInterface]) -> Vec<Ipv4Addr> {
let mut hosts = interfaces
.iter()
.filter(|interface| interface.is_up() && !interface.is_loopback() && !interface.is_tun())
.flat_map(|interface| {
let gateways = interface
.gateway
.iter()
.flat_map(|gateway| gateway.ipv4.iter().copied());
let dns_servers = interface
.dns_servers
.iter()
.filter_map(|server| match server {
IpAddr::V4(ip) => Some(*ip),
IpAddr::V6(_) => None,
});
gateways.chain(dns_servers).collect::<Vec<_>>()
})
.collect::<Vec<_>>();
hosts.sort_unstable();
hosts.dedup();
hosts
}
#[cfg(target_os = "linux")]
pub(crate) fn control_plane_bypass_ipv4_hosts_from_interfaces(
app: &AppConfig,
interfaces: &[NetworkInterface],
) -> Vec<Ipv4Addr> {
let mut hosts = relay_bypass_ipv4_hosts(app);
hosts.extend(stun_ipv4_hosts(app));
hosts.extend(management_ipv4_hosts_from_interfaces(interfaces));
hosts.sort_unstable();
hosts.dedup();
hosts
}
#[cfg(target_os = "linux")]
pub(crate) fn control_plane_bypass_ipv4_hosts(app: &AppConfig) -> Vec<Ipv4Addr> {
control_plane_bypass_ipv4_hosts_from_interfaces(app, &get_interfaces())
}
#[cfg(target_os = "linux")]
pub(crate) fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
let authority = authority.trim();
if authority.is_empty() {
return None;
}
if let Some(rest) = authority.strip_prefix('[') {
let (host, after_host) = rest.split_once(']')?;
let port = after_host
.strip_prefix(':')
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(default_port);
return Some((host.to_string(), port));
}
match authority.rsplit_once(':') {
Some((host, port))
if !host.contains(':') && !host.is_empty() && port.parse::<u16>().is_ok() =>
{
Some((host.to_string(), port.parse::<u16>().ok()?))
}
_ => Some((authority.to_string(), default_port)),
}
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_bypass_route_specs_for_hosts(
mut hosts: Vec<Ipv4Addr>,
tunnel_iface: &str,
original_default_route: Option<&str>,
) -> Result<Vec<LinuxEndpointBypassRoute>> {
hosts.sort_unstable();
hosts.dedup();
let mut routes = Vec::with_capacity(hosts.len());
for host in hosts {
let output = command_stdout_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("get")
.arg(host.to_string()),
)?;
let spec = linux_route_get_spec_from_output(&output)
.and_then(|spec| {
if spec.dev == tunnel_iface {
None
} else {
Some(spec)
}
})
.or_else(|| {
original_default_route
.and_then(linux_route_get_spec_from_output)
.filter(|spec| spec.dev != tunnel_iface)
})
.ok_or_else(|| anyhow!("failed to resolve bypass route for {host}"))?;
routes.push(LinuxEndpointBypassRoute {
target: format!("{host}/32"),
gateway: spec.gateway,
dev: spec.dev,
src: spec.src,
});
}
Ok(routes)
}
#[cfg(target_os = "linux")]
pub(crate) fn apply_linux_endpoint_bypass_route(route: &LinuxEndpointBypassRoute) -> Result<()> {
let mut command = ProcessCommand::new("ip");
command
.arg("-4")
.arg("route")
.arg("replace")
.arg(&route.target);
if let Some(gateway) = route.gateway.as_deref() {
command.arg("via").arg(gateway);
}
command.arg("dev").arg(&route.dev);
if let Some(src) = route.src.as_deref() {
command.arg("src").arg(src);
}
run_checked(&mut command)
}
#[cfg(target_os = "linux")]
pub(crate) fn delete_linux_endpoint_bypass_route(target: &str) -> Result<()> {
run_checked(
ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("del")
.arg(target),
)
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_default_routes() -> Result<Vec<MacosRouteSpec>> {
crate::macos_network::macos_default_routes()
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn macos_underlay_default_route_from_routes(
routes: &[MacosRouteSpec],
) -> Option<MacosRouteSpec> {
crate::macos_network::macos_underlay_default_route_from_routes(routes)
}
#[cfg(target_os = "macos")]
pub(crate) fn delete_macos_managed_route(
target: &str,
gateway: Option<&str>,
interface: Option<&str>,
) -> Result<()> {
crate::macos_network::delete_macos_managed_route(target, gateway, interface)
}
#[cfg(target_os = "macos")]
pub(crate) fn restore_macos_default_route(route: &MacosRouteSpec) -> Result<()> {
crate::macos_network::restore_macos_default_route(route)
}
#[cfg(target_os = "macos")]
pub(crate) fn apply_macos_default_route(
gateway: Option<&str>,
ifscope: Option<&str>,
) -> Result<()> {
crate::macos_network::apply_macos_default_route(gateway, ifscope)
}
#[cfg(target_os = "macos")]
pub(crate) fn delete_macos_default_route_for_interface(iface: &str) -> Result<()> {
crate::macos_network::delete_macos_default_route_for_interface(iface)
}
#[cfg(target_os = "macos")]
pub(crate) fn macos_iface_has_ipv4_address(iface: &str, needle: Ipv4Addr) -> Result<bool> {
crate::macos_network::macos_iface_has_ipv4_address(iface, needle)
}
#[cfg(target_os = "macos")]
pub(crate) fn apply_macos_route_spec(
target: &str,
gateway: Option<&str>,
ifscope: Option<&str>,
) -> Result<()> {
crate::macos_network::apply_macos_route_spec(target, gateway, ifscope)
}
#[cfg(target_os = "macos")]
pub(crate) fn read_macos_ip_forward() -> Result<bool> {
crate::macos_network::read_macos_ip_forward()
}
#[cfg(target_os = "macos")]
pub(crate) fn write_macos_ip_forward(enabled: bool) -> Result<()> {
crate::macos_network::write_macos_ip_forward(enabled)
}
#[cfg(target_os = "macos")]
pub(crate) fn cleanup_macos_pf_nat() -> Result<()> {
crate::macos_network::cleanup_macos_pf_nat()
}
#[cfg(target_os = "linux")]
pub(crate) fn read_linux_ip_forward(family: LinuxExitNodeIpFamily) -> Result<bool> {
let path = linux_ip_forward_path(family);
Ok(fs::read_to_string(path)
.with_context(|| format!("failed to read {path}"))?
.trim()
== "1")
}
#[cfg(target_os = "linux")]
pub(crate) fn write_linux_ip_forward(family: LinuxExitNodeIpFamily, enabled: bool) -> Result<()> {
let path = linux_ip_forward_path(family);
fs::write(path, if enabled { "1" } else { "0" })
.with_context(|| format!("failed to write {path}"))
}
#[cfg(target_os = "linux")]
fn linux_ip_forward_path(family: LinuxExitNodeIpFamily) -> &'static str {
match family {
LinuxExitNodeIpFamily::V4 => "/proc/sys/net/ipv4/ip_forward",
LinuxExitNodeIpFamily::V6 => "/proc/sys/net/ipv6/conf/all/forwarding",
}
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_exit_node_source_cidr(tunnel_ip: &str) -> Option<String> {
let octets = strip_cidr(tunnel_ip).parse::<Ipv4Addr>().ok()?.octets();
if octets[0] == 10 && octets[1] == 44 {
return Some("10.44.0.0/16".to_string());
}
Some(format!("{}.{}.{}.0/24", octets[0], octets[1], octets[2]))
}
#[cfg(any(target_os = "linux", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinuxExitNodeIpFamily {
V4,
V6,
}
#[cfg(any(target_os = "linux", target_os = "macos", test))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
pub(crate) struct LinuxExitNodeDefaultRouteFamilies {
pub(crate) ipv4: bool,
pub(crate) ipv6: bool,
}
#[cfg(any(target_os = "linux", target_os = "macos", test))]
#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
pub(crate) fn linux_exit_node_default_route_families(
routes: &[String],
) -> LinuxExitNodeDefaultRouteFamilies {
LinuxExitNodeDefaultRouteFamilies {
ipv4: routes.iter().any(|route| route == "0.0.0.0/0"),
ipv6: routes.iter().any(|route| route == "::/0"),
}
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_exit_node_firewall_binary(family: LinuxExitNodeIpFamily) -> &'static str {
match family {
LinuxExitNodeIpFamily::V4 => "iptables",
LinuxExitNodeIpFamily::V6 => "ip6tables",
}
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_exit_node_forward_in_rule(
tunnel_iface: &str,
outbound_iface: &str,
tunnel_source_cidr: &str,
family: LinuxExitNodeIpFamily,
) -> Vec<String> {
vec![
"FORWARD".to_string(),
"-i".to_string(),
tunnel_iface.to_string(),
"-o".to_string(),
outbound_iface.to_string(),
"-s".to_string(),
tunnel_source_cidr.to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
match family {
LinuxExitNodeIpFamily::V4 => "nvpn-exit-forward-in",
LinuxExitNodeIpFamily::V6 => "nvpn-exit6-forward-in",
}
.to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_exit_node_forward_out_rule(
tunnel_iface: &str,
outbound_iface: &str,
family: LinuxExitNodeIpFamily,
) -> Vec<String> {
vec![
"FORWARD".to_string(),
"-i".to_string(),
outbound_iface.to_string(),
"-o".to_string(),
tunnel_iface.to_string(),
"-m".to_string(),
"conntrack".to_string(),
"--ctstate".to_string(),
"RELATED,ESTABLISHED".to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
match family {
LinuxExitNodeIpFamily::V4 => "nvpn-exit-forward-out",
LinuxExitNodeIpFamily::V6 => "nvpn-exit6-forward-out",
}
.to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_exit_node_legacy_forward_in_rule(
iface: &str,
family: LinuxExitNodeIpFamily,
) -> Vec<String> {
vec![
"FORWARD".to_string(),
"-i".to_string(),
iface.to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
match family {
LinuxExitNodeIpFamily::V4 => "nvpn-exit-forward-in",
LinuxExitNodeIpFamily::V6 => "nvpn-exit6-forward-in",
}
.to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_exit_node_legacy_forward_out_rule(
iface: &str,
family: LinuxExitNodeIpFamily,
) -> Vec<String> {
vec![
"FORWARD".to_string(),
"-o".to_string(),
iface.to_string(),
"-m".to_string(),
"conntrack".to_string(),
"--ctstate".to_string(),
"RELATED,ESTABLISHED".to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
match family {
LinuxExitNodeIpFamily::V4 => "nvpn-exit-forward-out",
LinuxExitNodeIpFamily::V6 => "nvpn-exit6-forward-out",
}
.to_string(),
"-j".to_string(),
"ACCEPT".to_string(),
]
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_exit_node_ipv4_masquerade_rule(
outbound_iface: &str,
tunnel_source_cidr: &str,
) -> Vec<String> {
vec![
"POSTROUTING".to_string(),
"-o".to_string(),
outbound_iface.to_string(),
"-s".to_string(),
tunnel_source_cidr.to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
"nvpn-exit-masq".to_string(),
"-j".to_string(),
"MASQUERADE".to_string(),
]
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_wireguard_exit_inbound_drop_rule(
wireguard_iface: &str,
tunnel_iface: &str,
tunnel_source_cidr: &str,
) -> Vec<String> {
vec![
"FORWARD".to_string(),
"-i".to_string(),
wireguard_iface.to_string(),
"-o".to_string(),
tunnel_iface.to_string(),
"-d".to_string(),
tunnel_source_cidr.to_string(),
"-m".to_string(),
"conntrack".to_string(),
"--ctstate".to_string(),
"NEW,INVALID".to_string(),
"-m".to_string(),
"comment".to_string(),
"--comment".to_string(),
"nvpn-wg-upstream-inbound-drop".to_string(),
"-j".to_string(),
"DROP".to_string(),
]
}
#[cfg(target_os = "linux")]
fn linux_iptables_rule_exists(
family: LinuxExitNodeIpFamily,
table: Option<&str>,
rule: &[String],
) -> Result<bool> {
let mut command = ProcessCommand::new(linux_exit_node_firewall_binary(family));
if let Some(table) = table {
command.arg("-t").arg(table);
}
command.arg("-C");
for arg in rule {
command.arg(arg);
}
let display = format!("{command:?}");
let output = command
.output()
.with_context(|| format!("failed to execute {display}"))?;
if output.status.success() {
return Ok(true);
}
if output.status.code() == Some(1) {
return Ok(false);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(anyhow!(
"command failed: {display}\nstdout: {}\nstderr: {}",
stdout.trim(),
stderr.trim()
))
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_iptables_ensure_rule(
family: LinuxExitNodeIpFamily,
table: Option<&str>,
rule: &[String],
) -> Result<()> {
if linux_iptables_rule_exists(family, table, rule)? {
return Ok(());
}
let mut command = ProcessCommand::new(linux_exit_node_firewall_binary(family));
if let Some(table) = table {
command.arg("-t").arg(table);
}
command.arg("-A");
for arg in rule {
command.arg(arg);
}
run_checked(&mut command)
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_iptables_ensure_rule_at_front(
family: LinuxExitNodeIpFamily,
table: Option<&str>,
rule: &[String],
) -> Result<()> {
if linux_iptables_rule_exists(family, table, rule)? {
return Ok(());
}
let Some((chain, args)) = rule.split_first() else {
return Err(anyhow!("iptables rule is missing a chain"));
};
let mut command = ProcessCommand::new(linux_exit_node_firewall_binary(family));
if let Some(table) = table {
command.arg("-t").arg(table);
}
command.arg("-I").arg(chain).arg("1");
for arg in args {
command.arg(arg);
}
run_checked(&mut command)
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_iptables_delete_rule(
family: LinuxExitNodeIpFamily,
table: Option<&str>,
rule: &[String],
) -> Result<()> {
if !linux_iptables_rule_exists(family, table, rule)? {
return Ok(());
}
let mut command = ProcessCommand::new(linux_exit_node_firewall_binary(family));
if let Some(table) = table {
command.arg("-t").arg(table);
}
command.arg("-D");
for arg in rule {
command.arg(arg);
}
run_checked(&mut command)
}
#[cfg(any(test, not(target_os = "windows")))]
#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
pub(crate) fn apply_local_interface_network_with_mtu(
iface: &str,
address: &str,
route_targets: &[String],
mtu: u16,
) -> Result<()> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
let mtu = mtu.to_string();
#[cfg(any(target_os = "linux", target_os = "macos"))]
let mtu = mtu.as_str();
#[cfg(target_os = "linux")]
{
let ipv4_route_source = linux_ipv4_route_source(address);
let local_has_ipv4 = linux_tunnel_address_is_ipv4(address);
let local_has_ipv6 = linux_tunnel_address_is_ipv6(address);
run_checked(
ProcessCommand::new("ip")
.arg("address")
.arg("replace")
.arg(address)
.arg("dev")
.arg(iface),
)?;
run_checked(
ProcessCommand::new("ip")
.arg("link")
.arg("set")
.arg("mtu")
.arg(mtu)
.arg("up")
.arg("dev")
.arg(iface),
)?;
for target in route_targets {
if linux_route_target_is_ipv4(target) && !local_has_ipv4 {
continue;
}
if linux_route_target_is_ipv6(target) && !local_has_ipv6 {
continue;
}
if target == "0.0.0.0/0" {
let _ = ProcessCommand::new("ip")
.arg("-4")
.arg("route")
.arg("del")
.arg("default")
.status();
} else if target == "::/0" {
let _ = ProcessCommand::new("ip")
.arg("-6")
.arg("route")
.arg("del")
.arg("default")
.status();
}
let mut command = ProcessCommand::new("ip");
command
.arg("route")
.arg("replace")
.arg(target)
.arg("dev")
.arg(iface);
if linux_route_target_is_ipv4(target)
&& let Some(source) = ipv4_route_source.as_deref()
{
command.arg("src").arg(source);
}
run_checked(&mut command)?;
}
return Ok(());
}
#[cfg(target_os = "macos")]
{
let ip = strip_cidr(address).to_string();
run_checked(
ProcessCommand::new("ifconfig")
.arg(iface)
.arg("inet")
.arg(&ip)
.arg(&ip)
.arg("netmask")
.arg("255.255.255.0")
.arg("mtu")
.arg(mtu)
.arg("up"),
)?;
eprintln!(
"tunnel: applying macOS interface {} with routes [{}]",
iface,
route_targets.join(", ")
);
for target in route_targets {
apply_macos_route(iface, target)?;
}
return Ok(());
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let _ = (iface, address, route_targets, mtu);
#[allow(unreachable_code)]
Err(anyhow!(
"interface setup is not implemented for this platform"
))
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_ipv4_route_source(address: &str) -> Option<String> {
strip_cidr(address)
.parse::<Ipv4Addr>()
.ok()
.map(|ip| ip.to_string())
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_tunnel_address_is_ipv4(address: &str) -> bool {
strip_cidr(address).parse::<Ipv4Addr>().is_ok()
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_tunnel_address_is_ipv6(address: &str) -> bool {
strip_cidr(address).parse::<Ipv6Addr>().is_ok()
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_route_target_is_ipv4(target: &str) -> bool {
strip_cidr(target).parse::<Ipv4Addr>().is_ok()
}
#[cfg(any(target_os = "linux", test))]
pub(crate) fn linux_route_target_is_ipv6(target: &str) -> bool {
strip_cidr(target).parse::<Ipv6Addr>().is_ok()
}
#[cfg(target_os = "macos")]
fn apply_macos_route(iface: &str, target: &str) -> Result<()> {
if target == "0.0.0.0/0" {
eprintln!("tunnel: applying macOS default route via interface {iface}");
return apply_macos_default_route(None, Some(iface));
}
apply_macos_route_spec(target, None, Some(iface))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linux_route_family_helpers_detect_ipv4_and_ipv6_cidrs() {
assert!(linux_tunnel_address_is_ipv4("10.44.0.1/32"));
assert!(!linux_tunnel_address_is_ipv6("10.44.0.1/32"));
assert!(linux_tunnel_address_is_ipv6("fd00::1/128"));
assert!(!linux_tunnel_address_is_ipv4("fd00::1/128"));
assert!(linux_route_target_is_ipv4("0.0.0.0/0"));
assert!(!linux_route_target_is_ipv4("::/0"));
assert!(linux_route_target_is_ipv6("::/0"));
assert!(!linux_route_target_is_ipv6("10.44.0.0/16"));
}
#[test]
fn wireguard_upstream_inbound_drop_rule_blocks_new_mesh_forwards() {
assert_eq!(
linux_wireguard_exit_inbound_drop_rule("nvpn-wg-exit", "nvpn0", "10.44.0.0/16"),
vec![
"FORWARD",
"-i",
"nvpn-wg-exit",
"-o",
"nvpn0",
"-d",
"10.44.0.0/16",
"-m",
"conntrack",
"--ctstate",
"NEW,INVALID",
"-m",
"comment",
"--comment",
"nvpn-wg-upstream-inbound-drop",
"-j",
"DROP",
]
);
}
#[test]
fn exit_node_forward_rules_are_scoped_to_mesh_source_and_outbound_iface() {
assert_eq!(
linux_exit_node_forward_in_rule(
"utun100",
"enp41s0",
"10.44.0.0/16",
LinuxExitNodeIpFamily::V4
),
vec![
"FORWARD",
"-i",
"utun100",
"-o",
"enp41s0",
"-s",
"10.44.0.0/16",
"-m",
"comment",
"--comment",
"nvpn-exit-forward-in",
"-j",
"ACCEPT",
]
);
assert_eq!(
linux_exit_node_forward_out_rule("utun100", "enp41s0", LinuxExitNodeIpFamily::V4),
vec![
"FORWARD",
"-i",
"enp41s0",
"-o",
"utun100",
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-m",
"comment",
"--comment",
"nvpn-exit-forward-out",
"-j",
"ACCEPT",
]
);
}
#[test]
fn legacy_exit_node_forward_rules_match_old_unscoped_rules_for_cleanup() {
assert_eq!(
linux_exit_node_legacy_forward_in_rule("utun100", LinuxExitNodeIpFamily::V6),
vec![
"FORWARD",
"-i",
"utun100",
"-m",
"comment",
"--comment",
"nvpn-exit6-forward-in",
"-j",
"ACCEPT",
]
);
assert_eq!(
linux_exit_node_legacy_forward_out_rule("utun100", LinuxExitNodeIpFamily::V6),
vec![
"FORWARD",
"-o",
"utun100",
"-m",
"conntrack",
"--ctstate",
"RELATED,ESTABLISHED",
"-m",
"comment",
"--comment",
"nvpn-exit6-forward-out",
"-j",
"ACCEPT",
]
);
}
}