diff --git a/CHANGELOG.md b/CHANGELOG.md index 3460d73f0e3b..74a1ac81256d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ Line wrap the file at 100 chars. Th ### Added - Add custom bridge settings in GUI. +#### macOS +- Add support for split tunneling (beta). + ### Fixed #### Linux - Fix GUI not working on Ubuntu 24.04 by adding an AppArmor profile. diff --git a/Cargo.lock b/Cargo.lock index d39c7fd3639b..ad18fc949820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1267,7 +1267,7 @@ dependencies = [ "indexmap 2.2.6", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.10", "tracing", ] @@ -1286,7 +1286,7 @@ dependencies = [ "indexmap 2.2.6", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.10", "tracing", ] @@ -1399,7 +1399,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tokio-util", + "tokio-util 0.7.10", "tracing", ] @@ -1926,6 +1926,16 @@ dependencies = [ "rle-decode-fast", ] +[[package]] +name = "libloading" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" +dependencies = [ + "cfg-if", + "windows-targets 0.52.5", +] + [[package]] name = "libm" version = "0.2.8" @@ -2704,6 +2714,23 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pcap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45f1686828a29fd8002fbf9c01506b4b2dd575c2305e1b884da3731abae8b9e0" +dependencies = [ + "bitflags 1.3.2", + "errno 0.2.8", + "futures", + "libc", + "libloading", + "pkg-config", + "regex", + "tokio", + "windows-sys 0.36.1", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3877,15 +3904,21 @@ dependencies = [ "mnl", "nftnl", "nix 0.23.2", + "nix 0.28.0", "once_cell", "parking_lot", + "pcap", "pfctl", + "pnet_packet", "rand 0.8.5", "resolv-conf", + "serde", + "serde_json", "subslice", "system-configuration", "talpid-dbus", "talpid-openvpn", + "talpid-platform-metadata", "talpid-routing", "talpid-tunnel", "talpid-tunnel-config-client", @@ -3896,6 +3929,7 @@ dependencies = [ "tokio", "tonic-build", "triggered", + "tun", "which", "widestring", "windows-service", @@ -4283,6 +4317,20 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tokio-util" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -4360,7 +4408,7 @@ dependencies = [ "rand 0.8.5", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.10", "tower-layer", "tower-service", "tracing", @@ -4440,9 +4488,14 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc25e23adc6cac7dd895ce2780f255902290fc39b00e1ae3c33e89f3d20fa66" dependencies = [ + "byteorder", + "bytes", + "futures-core", "ioctl-sys", "libc", "thiserror", + "tokio", + "tokio-util 0.6.10", ] [[package]] @@ -4753,6 +4806,19 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -4844,6 +4910,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4862,6 +4934,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4886,6 +4964,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4904,6 +4988,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4940,6 +5030,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" diff --git a/docs/split-tunneling.md b/docs/split-tunneling.md index bda2ac5de6e9..722484ad7011 100644 --- a/docs/split-tunneling.md +++ b/docs/split-tunneling.md @@ -43,17 +43,17 @@ Some definitions of terms used later to describe behavior: *: On platforms where we have custom firewall integration. This is currently on desktop operating systems, and not mobile. -### Windows and Linux +### Desktop platforms (Windows, Linux, and macOS) -| In-app DNS setting | Normal & Excluded app | -|-|-| -| **Default DNS** | In tunnel (to relay) | -| **Private custom DNS** (e.g. 10.0.1.1) | LAN (to 10.0.1.1) | -| **Public custom DNS** (e.g. 8.8.8.8) | In tunnel (to 8.8.8.8) | +| In-app DNS setting | Normal & Excluded app | +|-|------------------------------------------------| +| **Default DNS** | In tunnel (to relay) | +| **Private custom DNS** (e.g. 10.0.1.1) | LAN (to 10.0.1.1)
**macOS**: Not supported | +| **Public custom DNS** (e.g. 8.8.8.8) | In tunnel (to 8.8.8.8) | -In other words: Normal and excluded processes always behave the same. This is because DNS is -typically handled by a service, e.g. DNS cache on Windows or systemd-resolved's resolver on Linux, -which is not an excluded process. +In other words: Normal and excluded processes behave the same. This is because DNS is typically +handled by a service, e.g. DNS cache on Windows or systemd-resolved's resolver on Linux, which is +not an excluded process. For the sake of simplicity and consistency, requests to public custom DNS resolvers are also sent inside the tunnel when using a plain old static `resolv.conf`, even though it is technically diff --git a/mullvad-cli/src/cmds/split_tunnel/macos.rs b/mullvad-cli/src/cmds/split_tunnel/macos.rs new file mode 100644 index 000000000000..534b4fd86a96 --- /dev/null +++ b/mullvad-cli/src/cmds/split_tunnel/macos.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use std::path::PathBuf; + +use clap::Subcommand; +use mullvad_management_interface::MullvadProxyClient; + +use super::super::BooleanOption; + +/// Set options for applications to exclude from the tunnel. +#[derive(Subcommand, Debug)] +pub enum SplitTunnel { + /// Display the split tunnel status and apps + Get, + + /// Enable or disable split tunnel + Set { policy: BooleanOption }, + + /// Manage applications to exclude from the tunnel + #[clap(subcommand)] + App(App), +} + +#[derive(Subcommand, Debug)] +pub enum App { + Add { path: PathBuf }, + Remove { path: PathBuf }, + Clear, +} + +impl SplitTunnel { + pub async fn handle(self) -> Result<()> { + match self { + SplitTunnel::Get => { + let mut rpc = MullvadProxyClient::new().await?; + let settings = rpc.get_settings().await?.split_tunnel; + + let enable_exclusions = BooleanOption::from(settings.enable_exclusions); + + println!("Split tunneling state: {enable_exclusions}"); + + println!("Excluded applications:"); + for path in &settings.apps { + println!("{}", path.display()); + } + + Ok(()) + } + SplitTunnel::Set { policy } => { + let mut rpc = MullvadProxyClient::new().await?; + rpc.set_split_tunnel_state(*policy).await?; + println!("Split tunnel policy: {policy}"); + Ok(()) + } + SplitTunnel::App(subcmd) => Self::app(subcmd).await, + } + } + + async fn app(subcmd: App) -> Result<()> { + match subcmd { + App::Add { path } => { + MullvadProxyClient::new() + .await? + .add_split_tunnel_app(path) + .await?; + println!("Added path to excluded apps list"); + Ok(()) + } + App::Remove { path } => { + MullvadProxyClient::new() + .await? + .remove_split_tunnel_app(path) + .await?; + println!("Stopped excluding app from tunnel"); + Ok(()) + } + App::Clear => { + MullvadProxyClient::new() + .await? + .clear_split_tunnel_apps() + .await?; + println!("Stopped excluding all apps"); + Ok(()) + } + } + } +} diff --git a/mullvad-cli/src/cmds/split_tunnel/mod.rs b/mullvad-cli/src/cmds/split_tunnel/mod.rs index c9e87f5d7c70..37ed33b64b85 100644 --- a/mullvad-cli/src/cmds/split_tunnel/mod.rs +++ b/mullvad-cli/src/cmds/split_tunnel/mod.rs @@ -6,5 +6,8 @@ mod imp; #[path = "windows.rs"] mod imp; -#[cfg(any(target_os = "linux", windows))] +#[cfg(target_os = "macos")] +#[path = "macos.rs"] +mod imp; + pub use imp::*; diff --git a/mullvad-cli/src/main.rs b/mullvad-cli/src/main.rs index 0dd5d26dbef4..67990bd88837 100644 --- a/mullvad-cli/src/main.rs +++ b/mullvad-cli/src/main.rs @@ -96,7 +96,6 @@ enum Cli { #[clap(subcommand)] Obfuscation(obfuscation::Obfuscation), - #[cfg(any(target_os = "windows", target_os = "linux"))] #[clap(subcommand)] SplitTunnel(split_tunnel::SplitTunnel), @@ -171,7 +170,6 @@ async fn main() -> Result<()> { Cli::FactoryReset => reset::handle().await, Cli::Relay(cmd) => cmd.handle().await, Cli::Tunnel(cmd) => cmd.handle().await, - #[cfg(any(target_os = "windows", target_os = "linux"))] Cli::SplitTunnel(cmd) => cmd.handle().await, Cli::Status { cmd, args } => status::handle(cmd, args).await, Cli::CustomList(cmd) => cmd.handle().await, diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index 69eebdbdd69a..c586c4c316ef 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -65,7 +65,7 @@ use relay_list::{RelayListUpdater, RelayListUpdaterHandle, RELAYS_FILENAME}; use settings::SettingsPersister; #[cfg(target_os = "android")] use std::os::unix::io::RawFd; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use std::{collections::HashSet, ffi::OsString}; use std::{ marker::PhantomData, @@ -75,7 +75,7 @@ use std::{ sync::{Arc, Weak}, time::Duration, }; -#[cfg(any(target_os = "linux", windows))] +#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] use talpid_core::split_tunnel; use talpid_core::{ mpsc::Sender, @@ -147,7 +147,7 @@ pub enum Error { #[error("Unable to initialize split tunneling")] InitSplitTunneling(#[source] split_tunnel::Error), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] #[error("Split tunneling error")] SplitTunnelError(#[source] split_tunnel::Error), @@ -331,16 +331,16 @@ pub enum DaemonCommand { #[cfg(target_os = "linux")] ClearSplitTunnelProcesses(ResponseTx<(), split_tunnel::Error>), /// Exclude traffic of an application from the tunnel - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] AddSplitTunnelApp(ResponseTx<(), Error>, PathBuf), /// Remove application from list of apps to exclude from the tunnel - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] RemoveSplitTunnelApp(ResponseTx<(), Error>, PathBuf), /// Clear list of apps to exclude from the tunnel - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] ClearSplitTunnelApps(ResponseTx<(), Error>), /// Enable or disable split tunneling - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] SetSplitTunnelState(ResponseTx<(), Error>, bool), /// Returns all processes currently being excluded from the tunnel #[cfg(windows)] @@ -392,11 +392,11 @@ pub(crate) enum InternalDaemonEvent { /// A geographical location has has been received from am.i.mullvad.net LocationEvent(LocationEventData), /// The split tunnel paths or state were updated. - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] ExcludedPathsEvent(ExcludedPathsUpdate, oneshot::Sender>), } -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] pub(crate) enum ExcludedPathsUpdate { SetState(bool), SetPaths(HashSet), @@ -767,7 +767,7 @@ where PersistentTargetState::new(&cache_dir).await }; - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] let exclude_paths = if settings.split_tunnel.enable_exclusions { settings .split_tunnel @@ -810,7 +810,7 @@ where .map_err(Error::ApiConnectionModeError)? .endpoint, reset_firewall: *target_state != TargetState::Secured, - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] exclude_paths, }, parameters_generator.clone(), @@ -994,7 +994,7 @@ where } => self.handle_access_method_event(event, endpoint_active_tx), DeviceMigrationEvent(event) => self.handle_device_migration_event(event), LocationEvent(location_data) => self.handle_location_event(location_data), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] ExcludedPathsEvent(update, tx) => self.handle_new_excluded_paths(update, tx).await, } } @@ -1273,13 +1273,13 @@ where RemoveSplitTunnelProcess(tx, pid) => self.on_remove_split_tunnel_process(tx, pid), #[cfg(target_os = "linux")] ClearSplitTunnelProcesses(tx) => self.on_clear_split_tunnel_processes(tx), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] AddSplitTunnelApp(tx, path) => self.on_add_split_tunnel_app(tx, path), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] RemoveSplitTunnelApp(tx, path) => self.on_remove_split_tunnel_app(tx, path), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] ClearSplitTunnelApps(tx) => self.on_clear_split_tunnel_apps(tx), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] SetSplitTunnelState(tx, enabled) => self.on_set_split_tunnel_state(tx, enabled), #[cfg(windows)] GetSplitTunnelProcesses(tx) => self.on_get_split_tunnel_processes(tx), @@ -1435,7 +1435,7 @@ where }); } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] async fn handle_new_excluded_paths( &mut self, update: ExcludedPathsUpdate, @@ -1808,7 +1808,7 @@ where } /// Update the split app paths in both the settings and tunnel - #[cfg(windows)] + #[cfg(target_os = "windows")] fn set_split_tunnel_paths( &mut self, tx: ResponseTx<(), Error>, @@ -1874,7 +1874,52 @@ where } } - #[cfg(windows)] + /// Update the split app paths in both the settings and tunnel + #[cfg(target_os = "macos")] + fn set_split_tunnel_paths( + &mut self, + tx: ResponseTx<(), Error>, + _response_msg: &'static str, + settings: Settings, + update: ExcludedPathsUpdate, + ) { + let tunnel_list = match update { + ExcludedPathsUpdate::SetPaths(ref paths) if settings.split_tunnel.enable_exclusions => { + paths.iter().map(OsString::from).collect() + } + ExcludedPathsUpdate::SetState(true) => settings + .split_tunnel + .apps + .iter() + .map(OsString::from) + .collect(), + _ => vec![], + }; + + let (result_tx, result_rx) = oneshot::channel(); + self.send_tunnel_command(TunnelCommand::SetExcludedApps(result_tx, tunnel_list)); + let daemon_tx = self.tx.clone(); + + tokio::spawn(async move { + match result_rx.await { + Ok(Ok(_)) => (), + Ok(Err(error)) => { + log::error!( + "{}", + error.display_chain_with_msg("Failed to set excluded apps list") + ); + // NOTE: On macOS, we don't care if this fails. The tunnel will prevent us from + // connecting if we're in a bad state, and we can reset it by clearing the paths + } + Err(_) => { + log::error!("The tunnel failed to return a result"); + } + } + let _ = daemon_tx.send(InternalDaemonEvent::ExcludedPathsEvent(update, tx)); + }); + } + + #[cfg(any(target_os = "windows", target_os = "macos"))] fn on_add_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) { let settings = self.settings.to_settings(); @@ -1889,7 +1934,7 @@ where ); } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] fn on_remove_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) { let settings = self.settings.to_settings(); @@ -1904,7 +1949,7 @@ where ); } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] fn on_clear_split_tunnel_apps(&mut self, tx: ResponseTx<(), Error>) { let settings = self.settings.to_settings(); let new_list = HashSet::new(); @@ -1916,7 +1961,7 @@ where ); } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] fn on_set_split_tunnel_state(&mut self, tx: ResponseTx<(), Error>, state: bool) { let settings = self.settings.to_settings(); self.set_split_tunnel_paths( diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs index 2e7f04b1d98d..c94b5f35063d 100644 --- a/mullvad-daemon/src/management_interface.rs +++ b/mullvad-daemon/src/management_interface.rs @@ -21,7 +21,7 @@ use mullvad_types::{ version, wireguard::{RotationInterval, RotationIntervalError}, }; -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] use std::path::PathBuf; use std::{ str::FromStr, @@ -831,7 +831,7 @@ impl ManagementService for ManagementServiceImpl { } } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] async fn add_split_tunnel_app(&self, request: Request) -> ServiceResult<()> { log::debug!("add_split_tunnel_app"); let path = PathBuf::from(request.into_inner()); @@ -842,12 +842,12 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] async fn add_split_tunnel_app(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] async fn remove_split_tunnel_app(&self, request: Request) -> ServiceResult<()> { log::debug!("remove_split_tunnel_app"); let path = PathBuf::from(request.into_inner()); @@ -858,12 +858,12 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] async fn remove_split_tunnel_app(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> { log::debug!("clear_split_tunnel_apps"); let (tx, rx) = oneshot::channel(); @@ -873,12 +873,12 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> { Ok(Response::new(())) } - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] async fn set_split_tunnel_state(&self, request: Request) -> ServiceResult<()> { log::debug!("set_split_tunnel_state"); let enabled = request.into_inner(); @@ -889,7 +889,7 @@ impl ManagementService for ManagementServiceImpl { .map_err(map_daemon_error) .map(Response::new) } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] async fn set_split_tunnel_state(&self, _: Request) -> ServiceResult<()> { Ok(Response::new(())) } @@ -1109,7 +1109,7 @@ fn map_daemon_error(error: crate::Error) -> Status { DaemonError::RemoveDeviceError(error) => map_device_error(&error), DaemonError::UpdateDeviceError(error) => map_device_error(&error), DaemonError::VoucherSubmission(error) => map_device_error(&error), - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] DaemonError::SplitTunnelError(error) => map_split_tunnel_error(error), DaemonError::AccountHistory(error) => map_account_history_error(error), DaemonError::NoAccountToken | DaemonError::NoAccountTokenHistory => { @@ -1136,6 +1136,12 @@ fn map_split_tunnel_error(error: talpid_core::split_tunnel::Error) -> Status { } } +#[cfg(target_os = "macos")] +/// Converts [`talpid_core::split_tunnel::Error`] into a tonic status. +fn map_split_tunnel_error(error: talpid_core::split_tunnel::Error) -> Status { + Status::unknown(error.to_string()) +} + /// Converts a REST API error into a tonic status. fn map_rest_error(error: &RestError) -> Status { match error { diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto index 31d39db3069b..efe9f9eb87a2 100644 --- a/mullvad-management-interface/proto/management_interface.proto +++ b/mullvad-management-interface/proto/management_interface.proto @@ -92,7 +92,7 @@ service ManagementService { rpc RemoveSplitTunnelProcess(google.protobuf.Int32Value) returns (google.protobuf.Empty) {} rpc ClearSplitTunnelProcesses(google.protobuf.Empty) returns (google.protobuf.Empty) {} - // Split tunneling (Windows) + // Split tunneling (Windows, macOS) rpc AddSplitTunnelApp(google.protobuf.StringValue) returns (google.protobuf.Empty) {} rpc RemoveSplitTunnelApp(google.protobuf.StringValue) returns (google.protobuf.Empty) {} rpc ClearSplitTunnelApps(google.protobuf.Empty) returns (google.protobuf.Empty) {} diff --git a/mullvad-management-interface/src/types/conversions/settings.rs b/mullvad-management-interface/src/types/conversions/settings.rs index 857f32d99102..0cad85def3fb 100644 --- a/mullvad-management-interface/src/types/conversions/settings.rs +++ b/mullvad-management-interface/src/types/conversions/settings.rs @@ -4,7 +4,7 @@ use talpid_types::ErrorExt; impl From<&mullvad_types::settings::Settings> for proto::Settings { fn from(settings: &mullvad_types::settings::Settings) -> Self { - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] let split_tunnel = { let mut converted_list = vec![]; for path in settings.split_tunnel.apps.clone().iter() { @@ -21,7 +21,7 @@ impl From<&mullvad_types::settings::Settings> for proto::Settings { apps: converted_list, }) }; - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] let split_tunnel = None; Self { @@ -159,7 +159,7 @@ impl TryFrom for mullvad_types::settings::Settings { .ok_or(FromProtobufTypeError::InvalidArgument( "missing api access methods settings", ))?; - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] let split_tunnel = settings .split_tunnel .ok_or(FromProtobufTypeError::InvalidArgument( @@ -184,7 +184,7 @@ impl TryFrom for mullvad_types::settings::Settings { .map(mullvad_types::relay_constraints::RelayOverride::try_from) .collect::, _>>()?, show_beta_releases: settings.show_beta_releases, - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] split_tunnel: mullvad_types::settings::SplitTunnelSettings::from(split_tunnel), obfuscation_settings: mullvad_types::relay_constraints::ObfuscationSettings::try_from( obfuscation_settings, @@ -219,7 +219,7 @@ pub fn try_bridge_state_from_i32( } } -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl From for mullvad_types::settings::SplitTunnelSettings { fn from(value: proto::SplitTunnelSettings) -> Self { mullvad_types::settings::SplitTunnelSettings { diff --git a/mullvad-management-interface/src/types/conversions/states.rs b/mullvad-management-interface/src/types/conversions/states.rs index f6e41f4d87be..881cbc6c9a0a 100644 --- a/mullvad-management-interface/src/types/conversions/states.rs +++ b/mullvad-management-interface/src/types/conversions/states.rs @@ -103,7 +103,7 @@ impl From for proto::TunnelState { talpid_tunnel::ErrorStateCause::VpnPermissionDenied => { i32::from(Cause::VpnPermissionDenied) } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] talpid_tunnel::ErrorStateCause::SplitTunnelError => { i32::from(Cause::SplitTunnelError) } diff --git a/mullvad-types/src/settings/mod.rs b/mullvad-types/src/settings/mod.rs index 2a4adbb06951..464a4c89c18b 100644 --- a/mullvad-types/src/settings/mod.rs +++ b/mullvad-types/src/settings/mod.rs @@ -12,7 +12,7 @@ use crate::{ #[cfg(target_os = "android")] use jnix::IntoJava; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use std::{collections::HashSet, path::PathBuf}; use talpid_types::net::{openvpn, GenericTunnelOptions}; @@ -100,14 +100,14 @@ pub struct Settings { /// Whether to notify users of beta updates. pub show_beta_releases: bool, /// Split tunneling settings - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] pub split_tunnel: SplitTunnelSettings, /// Specifies settings schema version #[cfg_attr(target_os = "android", jnix(skip))] pub settings_version: SettingsVersion, } -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] pub struct SplitTunnelSettings { /// Toggles split tunneling on or off @@ -145,7 +145,7 @@ impl Default for Settings { tunnel_options: TunnelOptions::default(), relay_overrides: vec![], show_beta_releases: false, - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] split_tunnel: SplitTunnelSettings::default(), settings_version: CURRENT_SETTINGS_VERSION, } diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml index b079257cb3b1..66b22563df62 100644 --- a/talpid-core/Cargo.toml +++ b/talpid-core/Cargo.toml @@ -53,6 +53,13 @@ subslice = "0.2" system-configuration = "0.5.1" hickory-proto = "0.24.1" hickory-server = { version = "0.24.1", features = ["resolver"] } +talpid-platform-metadata = { path = "../talpid-platform-metadata" } +pcap = { version = "2.0", features = ["capture-stream"] } +pnet_packet = "0.34" +tun = { version = "0.5.5", features = ["async"] } +nix = { version = "0.28", features = ["socket"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [target.'cfg(windows)'.dependencies] bitflags = "1.2" diff --git a/talpid-core/src/firewall/macos.rs b/talpid-core/src/firewall/macos.rs index b3502a8441d0..44975ea9f8e8 100644 --- a/talpid-core/src/firewall/macos.rs +++ b/talpid-core/src/firewall/macos.rs @@ -1,9 +1,11 @@ use super::{FirewallArguments, FirewallPolicy}; use ipnetwork::IpNetwork; +use libc::{c_int, sysctlbyname}; use pfctl::{DropAction, FilterRuleAction, Uid}; use std::{ - env, + env, io, net::{IpAddr, Ipv4Addr}, + ptr, }; use subslice::SubsliceExt; use talpid_types::net::{self, AllowedEndpoint, AllowedTunnelTraffic}; @@ -128,6 +130,7 @@ impl Firewall { allow_lan, allowed_endpoint, allowed_tunnel_traffic, + redirect_interface, } => { let mut rules = vec![self.get_allow_relay_rule(peer_endpoint)?]; rules.push(self.get_allowed_endpoint_rule(allowed_endpoint)?); @@ -137,14 +140,33 @@ impl Firewall { rules.append(&mut self.get_block_dns_rules()?); if let Some(tunnel) = tunnel { - rules.extend( - self.get_allow_tunnel_rules(&tunnel.interface, allowed_tunnel_traffic)?, - ); + match redirect_interface { + Some(redirect_interface) => { + enable_forwarding(); + + if !allowed_tunnel_traffic.all() { + log::warn!("Split tunneling does not respect the 'allowed tunnel traffic' setting"); + } + rules.append( + &mut self.get_split_tunnel_rules( + &tunnel.interface, + redirect_interface, + )?, + ); + } + None => { + rules.extend(self.get_allow_tunnel_rules( + &tunnel.interface, + allowed_tunnel_traffic, + )?); + } + } } if *allow_lan { rules.append(&mut self.get_allow_lan_rules()?); } + Ok(rules) } FirewallPolicy::Connected { @@ -152,6 +174,7 @@ impl Firewall { tunnel, allow_lan, dns_servers, + redirect_interface, } => { let mut rules = vec![]; @@ -165,15 +188,23 @@ impl Firewall { // can't leak to the wrong IPs in the tunnel or on the LAN. rules.append(&mut self.get_block_dns_rules()?); - rules.extend(self.get_allow_tunnel_rules( - tunnel.interface.as_str(), - &AllowedTunnelTraffic::All, - )?); - if *allow_lan { rules.append(&mut self.get_allow_lan_rules()?); } + if let Some(redirect_interface) = redirect_interface { + enable_forwarding(); + + rules.append( + &mut self.get_split_tunnel_rules(&tunnel.interface, redirect_interface)?, + ); + } else { + rules.extend(self.get_allow_tunnel_rules( + tunnel.interface.as_str(), + &AllowedTunnelTraffic::All, + )?); + } + Ok(rules) } FirewallPolicy::Blocked { @@ -343,28 +374,29 @@ impl Firewall { Ok(vec![block_tcp_dns_rule, block_udp_dns_rule]) } - fn base_rule( + fn get_allow_tunnel_rules( &self, - action: FilterRuleAction, tunnel_interface: &str, - ) -> pfctl::FilterRuleBuilder { - let mut rule_builder = self.create_rule_builder(action); - rule_builder - .quick(true) - .interface(tunnel_interface) - .keep_state(pfctl::StatePolicy::Keep) - .tcp_flags(Self::get_tcp_flags()); - rule_builder + allowed_traffic: &AllowedTunnelTraffic, + ) -> Result> { + self.get_allow_tunnel_rules_inner(tunnel_interface, allowed_traffic, Self::get_tcp_flags()) } - fn get_allow_tunnel_rules( + fn get_allow_tunnel_rules_inner( &self, tunnel_interface: &str, allowed_traffic: &AllowedTunnelTraffic, + tcp_flags: pfctl::TcpFlags, ) -> Result> { + let mut base_rule = &mut self.create_rule_builder(FilterRuleAction::Pass); + base_rule + .quick(true) + .interface(tunnel_interface) + .keep_state(pfctl::StatePolicy::Keep) + .tcp_flags(tcp_flags); + Ok(match allowed_traffic { AllowedTunnelTraffic::One(endpoint) => { - let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface); let pfctl_proto = as_pfctl_proto(endpoint.protocol); base_rule = base_rule.to(endpoint.address).proto(pfctl_proto); vec![base_rule.build()?] @@ -372,12 +404,10 @@ impl Firewall { AllowedTunnelTraffic::Two(endpoint1, endpoint2) => { let mut rules = Vec::with_capacity(2); - let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface); let pfctl_proto = as_pfctl_proto(endpoint1.protocol); base_rule = base_rule.to(endpoint1.address).proto(pfctl_proto); rules.push(base_rule.build()?); - let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface); let pfctl_proto = as_pfctl_proto(endpoint2.protocol); base_rule = base_rule.to(endpoint2.address).proto(pfctl_proto); rules.push(base_rule.build()?); @@ -385,7 +415,6 @@ impl Firewall { rules } AllowedTunnelTraffic::All => { - let base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface); vec![base_rule.build()?] } AllowedTunnelTraffic::None => { @@ -458,6 +487,39 @@ impl Firewall { Ok(rules) } + fn get_split_tunnel_rules( + &self, + from_interface: &str, + to_interface: &str, + ) -> Result> { + let tunnel_rule = self + .create_rule_builder(FilterRuleAction::Pass) + .quick(true) + .direction(pfctl::Direction::In) + .keep_state(pfctl::StatePolicy::None) + .interface(from_interface) + .build()?; + let allow_rule = self + .create_rule_builder(FilterRuleAction::Pass) + .quick(true) + .direction(pfctl::Direction::Out) + .keep_state(pfctl::StatePolicy::Keep) + .interface(to_interface) + .build()?; + let redir_rule = self + .create_rule_builder(FilterRuleAction::Pass) + .quick(true) + .direction(pfctl::Direction::Out) + .route(pfctl::Route::RouteTo(pfctl::PoolAddr::from( + pfctl::Interface::from(to_interface), + ))) + .keep_state(pfctl::StatePolicy::Keep) + .tcp_flags(Self::get_tcp_flags()) + .interface(from_interface) + .build()?; + Ok(vec![tunnel_rule, allow_rule, redir_rule]) + } + fn get_allow_dhcp_client_rules(&self) -> Result> { let mut dhcp_rule_builder = self.create_rule_builder(FilterRuleAction::Pass); dhcp_rule_builder.quick(true).proto(pfctl::Proto::Udp); @@ -676,3 +738,43 @@ enum RuleLogging { Drop, All, } + +fn enable_forwarding() { + if let Err(error) = enable_forwarding_for_family(true) { + log::error!("Failed to enable forwarding (IPv4): {error}"); + } + if let Err(error) = enable_forwarding_for_family(false) { + log::error!("Failed to enable forwarding (IPv6): {error}"); + } +} + +fn enable_forwarding_for_family(ipv4: bool) -> io::Result<()> { + if ipv4 { + log::trace!("Enabling forwarding (IPv4)"); + } else { + log::trace!("Enabling forwarding (IPv6)"); + } + + let mut val: c_int = 1; + + let option = if ipv4 { + c"net.inet.ip.forwarding" + } else { + c"net.inet6.ip6.forwarding" + }; + + // SAFETY: The strings are null-terminated. + let result = unsafe { + sysctlbyname( + option.as_ptr(), + ptr::null_mut(), + ptr::null_mut(), + &mut val as *mut _ as _, + std::mem::size_of_val(&val), + ) + }; + if result != 0 { + return Err(io::Error::from_raw_os_error(result)); + } + Ok(()) +} diff --git a/talpid-core/src/firewall/mod.rs b/talpid-core/src/firewall/mod.rs index a0afb39f5d9b..87533b852702 100644 --- a/talpid-core/src/firewall/mod.rs +++ b/talpid-core/src/firewall/mod.rs @@ -123,6 +123,9 @@ pub enum FirewallPolicy { allowed_endpoint: AllowedEndpoint, /// Networks for which to permit in-tunnel traffic. allowed_tunnel_traffic: AllowedTunnelTraffic, + /// Interface to redirect (VPN tunnel) traffic to + #[cfg(target_os = "macos")] + redirect_interface: Option, }, /// Allow traffic only to server and over tunnel interface @@ -136,6 +139,9 @@ pub enum FirewallPolicy { /// Servers that are allowed to respond to DNS requests. #[cfg(not(target_os = "android"))] dns_servers: Vec, + /// Interface to redirect (VPN tunnel) traffic to + #[cfg(target_os = "macos")] + redirect_interface: Option, }, /// Block all network traffic in and out from the computer. diff --git a/talpid-core/src/split_tunnel/macos/bindings.rs b/talpid-core/src/split_tunnel/macos/bindings.rs new file mode 100644 index 000000000000..1ff1727892e0 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/bindings.rs @@ -0,0 +1,350 @@ +/* automatically generated by rust-bindgen 0.69.2 */ + +pub const PTH_FLAG_DIR_OUT: u32 = 2; +pub const PCAP_ERRBUF_SIZE: u32 = 256; +pub type __int32_t = ::std::os::raw::c_int; +pub type __darwin_pid_t = __int32_t; +pub type __darwin_uuid_t = [::std::os::raw::c_uchar; 16usize]; +pub type u_int = ::std::os::raw::c_uint; +pub type pid_t = __darwin_pid_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct timeval32 { + pub tv_sec: __int32_t, + pub tv_usec: __int32_t, +} +#[test] +fn bindgen_test_layout_timeval32() { + const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit(); + let ptr = UNINIT.as_ptr(); + assert_eq!( + ::std::mem::size_of::(), + 8usize, + concat!("Size of: ", stringify!(timeval32)) + ); + assert_eq!( + ::std::mem::align_of::(), + 4usize, + concat!("Alignment of ", stringify!(timeval32)) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).tv_sec) as usize - ptr as usize }, + 0usize, + concat!( + "Offset of field: ", + stringify!(timeval32), + "::", + stringify!(tv_sec) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).tv_usec) as usize - ptr as usize }, + 4usize, + concat!( + "Offset of field: ", + stringify!(timeval32), + "::", + stringify!(tv_usec) + ) + ); +} +pub type uuid_t = __darwin_uuid_t; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pktap_header { + pub pth_length: u32, + pub pth_type_next: u32, + pub pth_dlt: u32, + pub pth_ifname: [::std::os::raw::c_char; 24usize], + pub pth_flags: u32, + pub pth_protocol_family: u32, + pub pth_frame_pre_length: u32, + pub pth_frame_post_length: u32, + pub pth_pid: pid_t, + pub pth_comm: [::std::os::raw::c_char; 17usize], + pub pth_svc: u32, + pub pth_iftype: u16, + pub pth_ifunit: u16, + pub pth_epid: pid_t, + pub pth_ecomm: [::std::os::raw::c_char; 17usize], + pub pth_flowid: u32, + pub pth_ipproto: u32, + pub pth_tstamp: timeval32, + pub pth_uuid: uuid_t, + pub pth_euuid: uuid_t, +} +#[test] +fn bindgen_test_layout_pktap_header() { + const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit(); + let ptr = UNINIT.as_ptr(); + assert_eq!( + ::std::mem::size_of::(), + 156usize, + concat!("Size of: ", stringify!(pktap_header)) + ); + assert_eq!( + ::std::mem::align_of::(), + 4usize, + concat!("Alignment of ", stringify!(pktap_header)) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_length) as usize - ptr as usize }, + 0usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_length) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_type_next) as usize - ptr as usize }, + 4usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_type_next) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_dlt) as usize - ptr as usize }, + 8usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_dlt) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_ifname) as usize - ptr as usize }, + 12usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_ifname) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_flags) as usize - ptr as usize }, + 36usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_flags) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_protocol_family) as usize - ptr as usize }, + 40usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_protocol_family) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_frame_pre_length) as usize - ptr as usize }, + 44usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_frame_pre_length) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_frame_post_length) as usize - ptr as usize }, + 48usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_frame_post_length) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_pid) as usize - ptr as usize }, + 52usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_pid) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_comm) as usize - ptr as usize }, + 56usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_comm) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_svc) as usize - ptr as usize }, + 76usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_svc) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_iftype) as usize - ptr as usize }, + 80usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_iftype) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_ifunit) as usize - ptr as usize }, + 82usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_ifunit) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_epid) as usize - ptr as usize }, + 84usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_epid) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_ecomm) as usize - ptr as usize }, + 88usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_ecomm) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_flowid) as usize - ptr as usize }, + 108usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_flowid) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_ipproto) as usize - ptr as usize }, + 112usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_ipproto) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_tstamp) as usize - ptr as usize }, + 116usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_tstamp) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_uuid) as usize - ptr as usize }, + 124usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_uuid) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).pth_euuid) as usize - ptr as usize }, + 140usize, + concat!( + "Offset of field: ", + stringify!(pktap_header), + "::", + stringify!(pth_euuid) + ) + ); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct bpf_stat { + pub bs_recv: u_int, + pub bs_drop: u_int, +} +#[test] +fn bindgen_test_layout_bpf_stat() { + const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit(); + let ptr = UNINIT.as_ptr(); + assert_eq!( + ::std::mem::size_of::(), + 8usize, + concat!("Size of: ", stringify!(bpf_stat)) + ); + assert_eq!( + ::std::mem::align_of::(), + 4usize, + concat!("Alignment of ", stringify!(bpf_stat)) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).bs_recv) as usize - ptr as usize }, + 0usize, + concat!( + "Offset of field: ", + stringify!(bpf_stat), + "::", + stringify!(bs_recv) + ) + ); + assert_eq!( + unsafe { ::std::ptr::addr_of!((*ptr).bs_drop) as usize - ptr as usize }, + 4usize, + concat!( + "Offset of field: ", + stringify!(bpf_stat), + "::", + stringify!(bs_drop) + ) + ); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct pcap { + _unused: [u8; 0], +} +pub type pcap_t = pcap; +extern "C" { + pub fn pcap_create( + arg1: *const ::std::os::raw::c_char, + arg2: *mut ::std::os::raw::c_char, + ) -> *mut pcap_t; +} +extern "C" { + pub fn pcap_set_want_pktap( + arg1: *mut pcap_t, + arg2: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; +} +pub const BIOCSWANTPKTAP: u64 = 3221504639; diff --git a/talpid-core/src/split_tunnel/macos/bpf.rs b/talpid-core/src/split_tunnel/macos/bpf.rs new file mode 100644 index 000000000000..d2cd42bdcc39 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/bpf.rs @@ -0,0 +1,364 @@ +//! This module provides a thin wrapper for BPF devices on macOS. BPF is used for packet +//! filtering/capture and is exposed as several devices `/dev/bpfN` (where `N` is some integer). +//! +//! BPF devices can be attached to network interface and used for reading and writing packets +//! directly on them, usually whole frames. +//! +//! Certain features may be macOS-specific, but much of the documentation for FreeBSD still holds +//! true. Read more here: https://man.freebsd.org/cgi/man.cgi?bpf +use futures::ready; +use libc::{ + bpf_hdr, ifreq, BIOCGBLEN, BIOCGDLT, BIOCIMMEDIATE, BIOCSBLEN, BIOCSETIF, BIOCSHDRCMPLT, + BIOCSSEESENT, BPF_ALIGNMENT, EBUSY, F_GETFL, F_SETFL, O_NONBLOCK, +}; +use std::{ + ffi::{c_int, c_uint}, + fs::File, + io::{self, Read, Write}, + mem, + os::fd::AsRawFd, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{unix::AsyncFd, AsyncRead, Interest, ReadBuf}; + +use super::bindings::BIOCSWANTPKTAP; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Failed to open BPF device + #[error("Failed to open BPF device")] + OpenBpfDevice(#[source] io::Error), + /// Failed to duplicate BPF fd + #[error("Failed to duplicate BPF device")] + Duplicate(#[source] io::Error), + /// No free BPF device found + #[error("No free BPF device found")] + NoFreeBpfDeviceFound, + /// Interface name too long + #[error("Interface name too long")] + InterfaceNameTooLong, + /// IOCTL failed + #[error("IOCTL failed")] + IoctlFailed(#[source] io::Error), + /// Failed to get flags for BPF device + #[error("Failed to get flags for BPF device")] + GetFileFlags(#[source] io::Error), + /// Failed to set flags for BPF device + #[error("Failed to set flags for BPF device")] + SetFileFlags(#[source] io::Error), + /// Failed to create AsyncFd + #[error("Failed to create AsyncFd")] + AsyncFd(#[source] io::Error), +} + +macro_rules! ioctl { + ($fd:expr, $request:expr, $($arg:expr),+) => { + if libc::ioctl($fd, $request, $($arg),+) >= 0 { + Ok(()) + } else { + Err(Error::IoctlFailed(io::Error::last_os_error())) + } + }; +} + +pub struct Bpf { + file: File, +} + +pub struct ReadHalf(File); + +pub struct WriteHalf(File); + +impl Bpf { + pub fn open() -> Result { + Ok(Self { + file: Self::open_device()?, + }) + } + + pub fn split(self) -> Result<(ReadHalf, WriteHalf), Error> { + let dup = self.file.try_clone().map_err(Error::Duplicate)?; + Ok((ReadHalf(dup), WriteHalf(self.file))) + } + + fn open_device() -> Result { + const MAX_BPF_COUNT: usize = 1000; + + // Find a free bpf device + for dev_num in 0..MAX_BPF_COUNT { + // Open as O_RDWR + match File::options() + .read(true) + .write(true) + .open(format!("/dev/bpf{dev_num}")) + { + Ok(file) => { + log::trace!("Opened BPF device: /dev/bpf{dev_num}"); + return Ok(file); + } + Err(_e) if _e.raw_os_error() == Some(EBUSY) => continue, + Err(error) => return Err(Error::OpenBpfDevice(error)), + } + } + Err(Error::NoFreeBpfDeviceFound) + } + + pub fn set_nonblocking(&self, enabled: bool) -> Result<(), Error> { + // SAFETY: The fd is valid for the lifetime of `self` + let mut flags = unsafe { libc::fcntl(self.as_raw_fd(), F_GETFL) }; + if flags == -1 { + return Err(Error::GetFileFlags(io::Error::last_os_error())); + } + if enabled { + flags |= O_NONBLOCK; + } else { + flags &= !O_NONBLOCK; + } + + // SAFETY: The fd is valid for the lifetime of `self` + let result = unsafe { libc::fcntl(self.as_raw_fd(), F_SETFL, flags) }; + if result == -1 { + return Err(Error::SetFileFlags(io::Error::last_os_error())); + } + Ok(()) + } + + /// Set BIOCSETIF + pub fn set_interface(&self, name: &str) -> Result<(), Error> { + // SAFETY: It is valid for this C struct to be zeroed. We fill in the details later + let mut ifr: ifreq = unsafe { std::mem::zeroed() }; + + let name_bytes = name.as_bytes(); + if name_bytes.len() >= std::mem::size_of_val(&ifr.ifr_name) { + return Err(Error::InterfaceNameTooLong); + } + + unsafe { + // SAFETY: `name_bytes` cannot exceed the size of `ifr_name` + std::ptr::copy_nonoverlapping( + name_bytes.as_ptr(), + &mut ifr.ifr_name as *mut _ as *mut _, + name_bytes.len(), + ); + // SAFETY: The fd is valid for the lifetime of `self`, and `ifr` has a valid interface + ioctl!(self.file.as_raw_fd(), BIOCSETIF, &ifr) + } + } + + /// Enable or disable immediate mode (BIOCIMMEDIATE) + pub fn set_immediate(&self, enable: bool) -> Result<(), Error> { + let enable: c_int = if enable { 1 } else { 0 }; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { ioctl!(self.file.as_raw_fd(), BIOCIMMEDIATE, &enable) } + } + + // See locally sent packets (BIOCSSEESENT) + pub fn set_see_sent(&self, enable: bool) -> Result<(), Error> { + let enable: c_int = if enable { 1 } else { 0 }; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { ioctl!(self.file.as_raw_fd(), BIOCSSEESENT, &enable) } + } + + /// Enable or disable locally sent messages (BIOCSHDRCMPLT) + pub fn set_header_complete(&self, enable: bool) -> Result<(), Error> { + let enable: c_int = if enable { 1 } else { 0 }; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { ioctl!(self.file.as_raw_fd(), BIOCSHDRCMPLT, &enable) } + } + + pub fn set_want_pktap(&self, enable: bool) -> Result<(), Error> { + let enable: c_int = if enable { 1 } else { 0 }; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { ioctl!(self.file.as_raw_fd(), BIOCSWANTPKTAP, &enable) } + } + + pub fn set_buffer_size(&self, mut buffer_size: c_uint) -> Result { + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { + ioctl!(self.file.as_raw_fd(), BIOCSBLEN, &mut buffer_size)?; + } + Ok(buffer_size as usize) + } + + pub fn required_buffer_size(&self) -> Result { + let mut buf_size = 0i32; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { + ioctl!(self.file.as_raw_fd(), BIOCGBLEN, &mut buf_size)?; + } + Ok(buf_size as usize) + } + + pub fn dlt(&self) -> Result { + let mut dlt = 0; + // SAFETY: The fd is valid for the lifetime of `self` + unsafe { + ioctl!(self.file.as_raw_fd(), BIOCGDLT, &mut dlt)?; + } + Ok(dlt) + } +} + +impl Read for Bpf { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.file.read(buf) + } +} + +impl Read for &Bpf { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + (&self.file).read(buf) + } +} + +impl Write for Bpf { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.file.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + // no-op + Ok(()) + } +} + +impl Write for &Bpf { + fn write(&mut self, buf: &[u8]) -> io::Result { + (&self.file).write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + // no-op + Ok(()) + } +} + +impl Read for ReadHalf { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.read(buf) + } +} + +impl Write for WriteHalf { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + // no-op + Ok(()) + } +} + +impl AsRawFd for Bpf { + fn as_raw_fd(&self) -> std::os::fd::RawFd { + self.file.as_raw_fd() + } +} + +pub struct BpfStream { + inner: AsyncFd, +} + +impl BpfStream { + pub fn from_read_half(reader: ReadHalf) -> Result { + Self::from_file(reader.0) + } + + fn from_file(file: File) -> Result { + Ok(BpfStream { + inner: AsyncFd::with_interest(file, Interest::READABLE).map_err(Error::AsyncFd)?, + }) + } +} + +impl AsyncRead for BpfStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + let mut guard = ready!(self.inner.poll_read_ready(cx))?; + + let unfilled = buf.initialize_unfilled(); + match guard.try_io(|inner| inner.get_ref().read(unfilled)) { + Ok(Ok(len)) => { + buf.advance(len); + return Poll::Ready(Ok(())); + } + Ok(Err(err)) => return Poll::Ready(Err(err)), + Err(_would_block) => continue, + } + } + } +} + +/// Parse one or more BPF headers and payloads from an arbitrarily sized buffer +pub struct BpfIterMut<'a> { + data: &'a mut [u8], + current_packet_offset: usize, +} + +impl<'a> BpfIterMut<'a> { + /// Return a new iterator over BPF packets + pub fn new(data: &'a mut [u8]) -> Self { + Self { + data, + current_packet_offset: 0, + } + } + + /// Return the next BPF payload, or None + pub fn next(&mut self) -> Option<&mut [u8]> { + let offset = self.current_packet_offset; + if self.data.len() <= offset || self.data.len() - offset < mem::size_of::() { + return None; + } + + // SAFETY: The buffer is large enough to contain a BPF header + let hdr = unsafe { &*(&self.data[offset] as *const u8 as *const bpf_hdr) }; + + if offset + hdr.bh_hdrlen as usize + hdr.bh_caplen as usize > self.data.len() { + return None; + } + + // SAFETY: This is within the bounds of 'data' + let payload = &mut self.data[offset + hdr.bh_hdrlen as usize + ..offset + (hdr.bh_hdrlen as usize + hdr.bh_caplen as usize)]; + + // Each packet starts on a word boundary after the previous header and capture + self.current_packet_offset = + offset + usize::try_from(bpf_wordalign(hdr.bh_hdrlen as u32 + hdr.bh_caplen)).unwrap(); + + Some(payload) + } +} + +/// Compute the next word boundary given `n`. `n` will be rounded up to a multiple of +/// "word" (defined by `BPF_ALIGNMENT`). Assuming `BPF_ALIGNMENT == 4`: +/// +/// ```text +/// n=0: bpf_wordalign(0) == 0 +/// n=1: bpf_wordalign(1) == 4 +/// n=2: bpf_wordalign(2) == 4 +/// n=3: bpf_wordalign(3) == 4 +/// n=4: bpf_wordalign(4) == 4 +/// n=5: bpf_wordalign(5) == 8 +/// n=6: bpf_wordalign(6) == 8 +/// ... +/// n=9: bpf_wordalign(9) == 12 +/// ``` +const fn bpf_wordalign(n: u32) -> u32 { + const ALIGNMENT: u32 = BPF_ALIGNMENT as u32; + (n + (ALIGNMENT - 1)) & (!(ALIGNMENT - 1)) +} + +#[test] +fn test_alignment() { + assert_eq!(bpf_wordalign(0), 0); + assert_eq!(bpf_wordalign(1), 4); + assert_eq!(bpf_wordalign(4), 4); + assert_eq!(bpf_wordalign(5), 8); +} diff --git a/talpid-core/src/split_tunnel/macos/default.rs b/talpid-core/src/split_tunnel/macos/default.rs new file mode 100644 index 000000000000..ce5415e76966 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/default.rs @@ -0,0 +1,96 @@ +//! Functions for handling default interfaces/routes + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use talpid_routing::{MacAddress, RouteManagerHandle}; + +/// Interface errors +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Failed to get default routes + #[error("Failed to get default routes")] + GetDefaultRoutes(#[source] talpid_routing::Error), + /// Failed to get default gateways + #[error("Failed to get default gateways")] + GetDefaultGateways(#[source] talpid_routing::Error), + /// Found no suitable default interface + #[error("Found no suitable default interface")] + NoDefaultInterface, + /// Using different interfaces for IPv4 and IPv6 is not supported + #[error("Using different interfaces for IPv4 and IPv6 is not supported")] + DefaultInterfaceMismatch, +} + +/// Interface name, addresses, and gateway +#[derive(Debug, Clone)] +pub struct DefaultInterface { + /// Interface name + pub name: String, + /// MAC/Hardware address of the gateway + pub v4_addrs: Option>, + /// MAC/Hardware address of the gateway + pub v6_addrs: Option>, +} + +/// Interface name, addresses, and gateway +#[derive(Debug, Clone)] +pub struct DefaultInterfaceAddrs { + /// Source IP address for excluded apps + pub source_ip: IpType, + /// MAC/Hardware address of the gateway + pub gateway_address: MacAddress, +} + +pub async fn get_default_interface( + route_manager: &RouteManagerHandle, +) -> Result { + let (v4_default, v6_default) = route_manager + .get_default_routes() + .await + .map_err(Error::GetDefaultRoutes)?; + let (v4_gateway, v6_gateway) = route_manager + .get_default_gateway() + .await + .map_err(Error::GetDefaultGateways)?; + + let default_interface = match (&v4_default, &v6_default) { + (Some(v4_default), Some(v6_default)) => { + if v4_default.interface != v6_default.interface { + return Err(Error::DefaultInterfaceMismatch); + } + v4_default.interface.to_owned() + } + (Some(default), None) | (None, Some(default)) => default.interface.to_owned(), + (None, None) => return Err(Error::NoDefaultInterface), + }; + + let default_v4 = if let Some(v4_gateway) = v4_gateway { + v4_default.map(|v4_default| DefaultInterfaceAddrs { + source_ip: match v4_default.ip { + IpAddr::V4(addr) => addr, + _ => unreachable!("unexpected IP address type"), + }, + gateway_address: v4_gateway.mac_address, + }) + } else { + log::debug!("Missing V4 gateway"); + None + }; + let default_v6 = if let Some(v6_gateway) = v6_gateway { + v6_default.map(|v6_default| DefaultInterfaceAddrs { + source_ip: match v6_default.ip { + IpAddr::V6(addr) => addr, + _ => unreachable!("unexpected IP address type"), + }, + gateway_address: v6_gateway.mac_address, + }) + } else { + log::debug!("Missing V6 gateway"); + None + }; + + Ok(DefaultInterface { + name: default_interface, + v4_addrs: default_v4, + v6_addrs: default_v6, + }) +} diff --git a/talpid-core/src/split_tunnel/macos/generate-bindings.sh b/talpid-core/src/split_tunnel/macos/generate-bindings.sh new file mode 100755 index 000000000000..a56fe69d2d0a --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/generate-bindings.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# This script generates bindings for certain pcap and pktap symbols. +# bindgen is required: cargo install bindgen-cli + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +curl https://opensource.apple.com/source/xnu/xnu-3789.41.3/bsd/net/pktap.h -o include/pktap.h +curl https://opensource.apple.com/source/libpcap/libpcap-67/libpcap/pcap/pcap.h -o include/pcap.h +curl https://opensource.apple.com/source/xnu/xnu-3789.41.3/bsd/net/bpf.h -o include/bpf.h + +bindgen "include/bindings.h" -o ./bindings.rs \ + --allowlist-item "^pcap_create" \ + --allowlist-item "^pcap_set_want_pktap" \ + --allowlist-item "^pktap_header" \ + --allowlist-item "PCAP_ERRBUF_SIZE" \ + --allowlist-item "^BIOCSWANTPKTAP" \ + --allowlist-item "^PTH_FLAG_DIR_OUT" \ + --allowlist-item "^bpf_stat" diff --git a/talpid-core/src/split_tunnel/macos/include/.gitignore b/talpid-core/src/split_tunnel/macos/include/.gitignore new file mode 100644 index 000000000000..a33b992fc860 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/include/.gitignore @@ -0,0 +1,3 @@ +/pktap.h +/pcap.h +/bpf.h diff --git a/talpid-core/src/split_tunnel/macos/include/bindings.h b/talpid-core/src/split_tunnel/macos/include/bindings.h new file mode 100644 index 000000000000..59fd6ca2be8f --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/include/bindings.h @@ -0,0 +1,12 @@ +#include +#include + +#define PRIVATE 1 +#include "pktap.h" +#include "bpf.h" +#include "pcap.h" + +/* workaround for lack of macro expansions in bindgen */ +const uint64_t _BIOCSWANTPKTAP = BIOCSWANTPKTAP; +#undef BIOCSWANTPKTAP +const uint64_t BIOCSWANTPKTAP = _BIOCSWANTPKTAP; diff --git a/talpid-core/src/split_tunnel/macos/mod.rs b/talpid-core/src/split_tunnel/macos/mod.rs new file mode 100644 index 000000000000..980097d94ba4 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/mod.rs @@ -0,0 +1,482 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Weak; +use talpid_routing::RouteManagerHandle; +use talpid_types::tunnel::ErrorStateCause; +use talpid_types::ErrorExt; +use tokio::sync::{mpsc, oneshot}; + +use self::process::ExclusionStatus; + +#[allow(non_camel_case_types)] +mod bindings; +mod bpf; +mod default; +mod process; +mod tun; + +use crate::tunnel_state_machine::TunnelCommand; +pub use tun::VpnInterface; + +/// Errors caused by split tunneling +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Process monitor error + #[error("Process monitor error")] + Process(#[from] process::Error), + /// Failed to initialize split tunnel + #[error("Failed to initialize split tunnel")] + InitializeTunnel(#[from] tun::Error), + /// Default interface unavailable + #[error("Default interface unavailable")] + Default(#[from] default::Error), + /// Split tunnel is unavailable + #[error("Split tunnel is unavailable")] + Unavailable, +} + +impl Error { + /// Return whether the error is due to a missing default route + pub fn is_offline(&self) -> bool { + matches!(self, Error::Default(_)) + } +} + +/// Split tunneling actor +pub struct SplitTunnel { + state: State, + tunnel_tx: Weak>, + rx: mpsc::UnboundedReceiver, + shutdown_tx: Option>, +} + +enum Message { + /// Return the name of the split tunnel interface + GetInterface { + result_tx: oneshot::Sender>, + }, + /// Shut down split tunnel service + Shutdown { result_tx: oneshot::Sender<()> }, + /// Set paths to exclude from the VPN tunnel + SetExcludePaths { + result_tx: oneshot::Sender>, + paths: HashSet, + }, + /// Update VPN tunnel interface + SetTunnel { + result_tx: oneshot::Sender>, + new_vpn_interface: Option, + }, +} + +/// Handle for interacting with the split tunnel module +#[derive(Clone)] +pub struct Handle { + tx: mpsc::UnboundedSender, +} + +impl Handle { + /// Shut down split tunnel + pub async fn shutdown(&self) { + let (result_tx, result_rx) = oneshot::channel(); + let _ = self.tx.send(Message::Shutdown { result_tx }); + if let Err(error) = result_rx.await { + log::error!( + "{}", + error.display_chain_with_msg("Split tunnel is already down") + ); + } + } + + /// Return name of split tunnel interface + pub async fn interface(&self) -> Option { + let (result_tx, result_rx) = oneshot::channel(); + let _ = self.tx.send(Message::GetInterface { result_tx }); + result_rx.await.ok()? + } + + /// Set paths to exclude + pub async fn set_exclude_paths(&self, paths: HashSet) -> Result<(), Error> { + let (result_tx, result_rx) = oneshot::channel(); + let _ = self.tx.send(Message::SetExcludePaths { result_tx, paths }); + result_rx.await.map_err(|_| Error::Unavailable)? + } + + /// Set VPN tunnel interface + pub async fn set_tunnel(&self, new_vpn_interface: Option) -> Result<(), Error> { + let (result_tx, result_rx) = oneshot::channel(); + let _ = self.tx.send(Message::SetTunnel { + result_tx, + new_vpn_interface, + }); + result_rx.await.map_err(|_| Error::Unavailable)? + } +} + +impl SplitTunnel { + /// Initialize split tunneling + pub fn spawn( + tunnel_tx: Weak>, + route_manager: RouteManagerHandle, + ) -> Handle { + let (tx, rx) = mpsc::unbounded_channel(); + let split_tunnel = Self { + state: State::NoExclusions { + route_manager, + vpn_interface: None, + }, + tunnel_tx, + rx, + shutdown_tx: None, + }; + + tokio::spawn(Self::run(split_tunnel)); + + Handle { tx } + } + + async fn run(mut self) { + loop { + let process_monitor_stopped = async { + match self.state.process_monitor() { + Some(process) => process.wait().await, + None => futures::future::pending().await, + } + }; + + tokio::select! { + // Handle process monitor being stopped + result = process_monitor_stopped => { + self.handle_process_monitor_shutdown(result); + } + + // Handle messages + message = self.rx.recv() => { + let Some(message) = message else { + break + }; + if !self.handle_message(message).await { + break; + } + } + } + } + + self.shutdown().await; + + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } + + /// Handle process monitor unexpectedly stopping + fn handle_process_monitor_shutdown(&mut self, result: Result<(), process::Error>) { + match result { + Ok(()) => log::error!("Process monitor stopped unexpectedly with no error"), + Err(error) => { + log::error!( + "{}", + error.display_chain_with_msg("Process monitor stopped unexpectedly") + ); + } + } + + // Enter the error state if split tunneling is active. Otherwise, we might make incorrect + // decisions for new processes + if self.state.active() { + if let Some(tunnel_tx) = self.tunnel_tx.upgrade() { + let _ = tunnel_tx + .unbounded_send(TunnelCommand::Block(ErrorStateCause::SplitTunnelError)); + } + } + + self.state.fail(); + } + + /// Handle an incoming message + /// Return whether the actor should continue running + async fn handle_message(&mut self, message: Message) -> bool { + match message { + Message::GetInterface { result_tx } => { + let _ = result_tx.send(self.interface().map(str::to_owned)); + } + Message::Shutdown { result_tx } => { + self.shutdown_tx = Some(result_tx); + return false; + } + Message::SetExcludePaths { result_tx, paths } => { + let _ = result_tx.send(self.state.set_exclude_paths(paths).await); + } + Message::SetTunnel { + result_tx, + new_vpn_interface, + } => { + let _ = result_tx.send(self.state.set_tunnel(new_vpn_interface).await); + } + } + true + } + + /// Shut down split tunnel + async fn shutdown(&mut self) { + match self.state.fail() { + State::ProcessMonitorOnly { mut process, .. } => { + process.shutdown().await; + } + State::Initialized { + mut process, + tun_handle, + .. + } => { + if let Err(error) = tun_handle.shutdown().await { + log::error!("Failed to stop split tunnel: {error}"); + } + process.shutdown().await; + } + State::Failed { .. } | State::NoExclusions { .. } => (), + } + } + + /// Return name of split tunnel interface + fn interface(&self) -> Option<&str> { + match &self.state { + State::Initialized { tun_handle, .. } => Some(tun_handle.name()), + _ => None, + } + } +} + +enum State { + /// The initial state: no paths have been provided + NoExclusions { + route_manager: RouteManagerHandle, + vpn_interface: Option, + }, + /// There is a process monitor (and paths) but no split tunnel utun yet + ProcessMonitorOnly { + route_manager: RouteManagerHandle, + process: process::ProcessMonitorHandle, + }, + /// There is a split tunnel utun as well as paths to exclude + Initialized { + route_manager: RouteManagerHandle, + process: process::ProcessMonitorHandle, + tun_handle: tun::SplitTunnelHandle, + vpn_interface: Option, + }, + /// State entered when anything at all fails. Users can force a transition out of this state + /// by disabling/clearing the paths to use. + Failed { + route_manager: RouteManagerHandle, + vpn_interface: Option, + }, +} + +impl State { + fn process_monitor(&mut self) -> Option<&mut process::ProcessMonitorHandle> { + match self { + State::ProcessMonitorOnly { process, .. } | State::Initialized { process, .. } => { + Some(process) + } + _ => None, + } + } + + fn route_manager(&self) -> &RouteManagerHandle { + match self { + State::NoExclusions { route_manager, .. } + | State::ProcessMonitorOnly { route_manager, .. } + | State::Initialized { route_manager, .. } + | State::Failed { route_manager, .. } => route_manager, + } + } + + fn vpn_interface(&self) -> Option<&VpnInterface> { + match self { + State::NoExclusions { vpn_interface, .. } + | State::Initialized { vpn_interface, .. } + | State::Failed { vpn_interface, .. } => vpn_interface.as_ref(), + State::ProcessMonitorOnly { .. } => None, + } + } + + /// Take `self`, leaving a failed state in its place. The original value is returned + fn fail(&mut self) -> Self { + std::mem::replace( + self, + State::Failed { + route_manager: self.route_manager().clone(), + vpn_interface: self.vpn_interface().cloned(), + }, + ) + } + + /// Return whether split tunneling is currently engaged. That is, there's both a process monitor + /// and a VPN tunnel present + fn active(&self) -> bool { + matches!(self, State::Initialized { vpn_interface, .. } if vpn_interface.is_some()) + } + + /// Set paths to exclude. For a non-empty path, this will initialize split tunneling if a tunnel + /// device is also set. + async fn set_exclude_paths(&mut self, paths: HashSet) -> Result<(), Error> { + let state = self.fail(); + *self = state.set_exclude_paths_inner(paths).await?; + Ok(()) + } + + async fn set_exclude_paths_inner(mut self, paths: HashSet) -> Result { + match self { + // If there are currently no paths and no process monitor, initialize it + State::NoExclusions { + route_manager, + vpn_interface, + } if !paths.is_empty() => { + log::debug!("Initializing process monitor"); + + let process = process::ProcessMonitor::spawn().await?; + process.states().exclude_paths(paths); + + State::ProcessMonitorOnly { + route_manager, + process, + } + .set_tunnel_inner(vpn_interface) + .await + } + // If 'paths' is empty, do nothing + State::NoExclusions { .. } => Ok(self), + // If split tunneling is already initialized, or only the process monitor is, update the paths only + State::Initialized { + ref mut process, .. + } + | State::ProcessMonitorOnly { + ref mut process, .. + } => { + process.states().exclude_paths(paths); + Ok(self) + } + // If 'paths' is empty, transition out of the failed state + State::Failed { + route_manager, + vpn_interface, + } if paths.is_empty() => { + log::debug!("Transitioning out of split tunnel error state"); + + Ok(State::NoExclusions { + route_manager: route_manager.clone(), + vpn_interface: vpn_interface.clone(), + }) + } + // Otherwise, remain in the failed state + State::Failed { .. } => Err(Error::Unavailable), + } + } + + /// Update VPN tunnel interface that non-excluded packets are sent on + async fn set_tunnel(&mut self, new_vpn_interface: Option) -> Result<(), Error> { + let state = self.fail(); + *self = state.set_tunnel_inner(new_vpn_interface).await?; + Ok(()) + } + + async fn set_tunnel_inner( + mut self, + new_vpn_interface: Option, + ) -> Result { + match self { + // If split tunneling is already initialized, just update the interfaces + State::Initialized { + route_manager, + mut process, + tun_handle, + vpn_interface: _, + } => { + // Try to update the default interface first + // If this fails, remain in the current state and just fail + let default_interface = default::get_default_interface(&route_manager).await?; + + log::debug!("Updating split tunnel device"); + + match tun_handle + .set_interfaces(default_interface, new_vpn_interface.clone()) + .await + { + Ok(tun_handle) => Ok(State::Initialized { + route_manager, + process, + tun_handle, + vpn_interface: new_vpn_interface, + }), + Err(error) => { + process.shutdown().await; + Err(error.into()) + } + } + } + // If there is a process monitor, initialize split tunneling + State::ProcessMonitorOnly { + route_manager, + mut process, + } if new_vpn_interface.is_some() => { + // Try to update the default interface first + // If this fails, remain in the current state and just fail + let default_interface = default::get_default_interface(&route_manager).await?; + + log::debug!("Initializing split tunnel device"); + + let states = process.states().clone(); + let result = tun::create_split_tunnel( + default_interface, + new_vpn_interface.clone(), + Box::new(move |packet| { + match states.get_process_status(packet.header.pth_pid as u32) { + ExclusionStatus::Excluded => tun::RoutingDecision::DefaultInterface, + ExclusionStatus::Included => tun::RoutingDecision::VpnTunnel, + ExclusionStatus::Unknown => { + // TODO: Delay decision until next exec + tun::RoutingDecision::Drop + } + } + }), + ) + .await; + + match result { + Ok(tun_handle) => Ok(State::Initialized { + route_manager, + process, + tun_handle, + vpn_interface: new_vpn_interface, + }), + Err(error) => { + process.shutdown().await; + Err(error.into()) + } + } + } + // No-op there's a process monitor but we didn't get a VPN interface + State::ProcessMonitorOnly { .. } => Ok(self), + // If there are no paths to exclude, remain in the current state + State::NoExclusions { + ref mut vpn_interface, + .. + } => { + *vpn_interface = new_vpn_interface; + Ok(self) + } + // Remain in the failed state and return error if VPN is up + State::Failed { + ref mut vpn_interface, + .. + } => { + *vpn_interface = new_vpn_interface; + if vpn_interface.is_some() { + Err(Error::Unavailable) + } else { + Ok(self) + } + } + } + } +} diff --git a/talpid-core/src/split_tunnel/macos/process.rs b/talpid-core/src/split_tunnel/macos/process.rs new file mode 100644 index 000000000000..f913b9bb496d --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/process.rs @@ -0,0 +1,514 @@ +//! This module keeps tracks of maintains a list of processes, and keeps it up to date by observing +//! the syscalls `fork`, `exec`, and `exit`. +//! Each process has an exclusion state, based on which paths the process monitor is instructed to +//! exclude. +//! The module currently relies on the `eslogger` tool to do so, which in turn relies on the +//! Endpoint Security framework. + +use futures::channel::oneshot; +use libc::{proc_listallpids, proc_pidpath}; +use once_cell::sync::Lazy; +use serde::Deserialize; +use std::collections::HashSet; +use std::{ + collections::HashMap, + ffi::c_void, + io, + path::PathBuf, + process::Stdio, + ptr, + sync::{Arc, Mutex}, + time::Duration, +}; +use talpid_platform_metadata::MacosVersion; +use tokio::io::{AsyncBufReadExt, BufReader}; + +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(500); + +static MIN_OS_VERSION: Lazy = + Lazy::new(|| MacosVersion::from_raw_version("13.0.0").unwrap()); + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Only macOS 13 and later is supported + #[error("Unsupported macOS version: {actual}, expected at least {}", *MIN_OS_VERSION)] + UnsupportedMacosVersion { + actual: talpid_platform_metadata::MacosVersion, + }, + /// Failed to start eslogger listener + #[error("Failed to start eslogger")] + StartMonitor(#[source] io::Error), + /// The app requires TCC approval from the user. + #[error("The app needs TCC approval from the user for Full Disk Access")] + NeedFullDiskPermissions, + /// eslogger failed due to an unknown error + #[error("eslogger returned an error")] + MonitorFailed(#[source] io::Error), + /// Monitor task panicked + #[error("Monitor task panicked")] + MonitorTaskPanicked(#[source] tokio::task::JoinError), + /// Failed to list processes + #[error("Failed to list processes")] + InitializePids(#[source] io::Error), + /// Failed to find path for a process + #[error("Failed to find path for a process: {}", _0)] + FindProcessPath(#[source] io::Error, u32), +} + +pub struct ProcessMonitor(()); + +#[derive(Debug)] +pub struct ProcessMonitorHandle { + stop_proc_tx: Option>>, + proc_task: tokio::task::JoinHandle>, + states: ProcessStates, +} + +impl ProcessMonitor { + pub async fn spawn() -> Result { + check_os_version_support()?; + let states = ProcessStates::new()?; + + let proc = spawn_eslogger()?; + let (stop_proc_tx, stop_rx): (_, oneshot::Receiver>) = + oneshot::channel(); + let mut proc_task = tokio::spawn(handle_eslogger_output(proc, states.clone(), stop_rx)); + + match tokio::time::timeout(EARLY_FAIL_TIMEOUT, &mut proc_task).await { + // On timeout, all is well + Err(_) => (), + // The process returned an error + Ok(Ok(Err(error))) => return Err(error), + Ok(Ok(Ok(()))) => unreachable!("process monitor stopped prematurely"), + Ok(Err(_)) => unreachable!("process monitor panicked"), + } + + Ok(ProcessMonitorHandle { + stop_proc_tx: Some(stop_proc_tx), + proc_task, + states, + }) + } +} + +/// Run until the process exits or `stop_rx` is signaled +async fn handle_eslogger_output( + mut proc: tokio::process::Child, + states: ProcessStates, + stop_rx: oneshot::Receiver>, +) -> Result<(), Error> { + let stdout = proc.stdout.take().unwrap(); + let stderr = proc.stderr.take().unwrap(); + + // Parse each line from stdout as an ESMessage + tokio::spawn(async move { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + + while let Ok(Some(line)) = lines.next_line().await { + // Each line from eslogger is a JSON object, one of several types of messages; + // see `ESMessage` + let val: ESMessage = match serde_json::from_str(&line) { + Ok(val) => val, + Err(error) => { + log::error!("Failed to parse eslogger message: {error}"); + continue; + } + }; + + let mut inner = states.inner.lock().unwrap(); + inner.handle_message(val); + } + }); + + // Store the most recent stderr line in case we need to return an error + let last_stderr = tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + let mut last_error = None; + + while let Ok(Some(line)) = lines.next_line().await { + last_error = Some(line); + } + last_error + }); + + // Wait for a stop signal or process exit + let result = tokio::select! { + result = proc.wait() => { + match result { + Ok(status) => { + if let Ok(Some(last_error)) = last_stderr.await { + log::error!("eslogger error: {last_error}"); + if let Some(error) = parse_eslogger_error(&last_error) { + return Err(error); + } + } + Err(Error::MonitorFailed(io::Error::other(format!("eslogger stopped unexpectedly: {status}")))) + } + Err(error) => Err(Error::MonitorFailed(error)), + } + } + Ok(response_tx) = stop_rx => { + if let Err(error) = proc.kill().await { + log::error!("Failed to kill eslogger: {error}"); + } + if tokio::time::timeout(SHUTDOWN_TIMEOUT, proc.wait()) + .await + .is_err() + { + log::error!("Failed to wait for ST process handler"); + } + let _ = response_tx.send(()); + + Ok(()) + } + }; + + log::debug!("Process monitor stopped"); + + result +} + +/// Launch a new instance of `eslogger`, listening for exec, fork, and exit syscalls +fn spawn_eslogger() -> Result { + let mut cmd = tokio::process::Command::new("/usr/bin/eslogger"); + cmd.args(["exec", "fork", "exit"]) + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.spawn().map_err(Error::StartMonitor) +} + +impl ProcessMonitorHandle { + pub async fn shutdown(&mut self) { + let Some(stop_tx) = self.stop_proc_tx.take() else { + return; + }; + + let (tx, rx) = oneshot::channel(); + let _ = stop_tx.send(tx); + let _ = rx.await; + } + + pub async fn wait(&mut self) -> Result<(), Error> { + (&mut self.proc_task) + .await + .map_err(Error::MonitorTaskPanicked)? + } + + pub fn states(&self) -> &ProcessStates { + &self.states + } +} + +/// Controls the known exclusion states of all processes +#[derive(Debug, Clone)] +pub struct ProcessStates { + inner: Arc>, +} + +/// Possible states of each process +#[derive(Debug, Clone)] +pub enum ExclusionStatus { + /// The process should be excluded from the VPN + Excluded, + /// The process should not be excluded from the VPN + Included, + /// The process is unknown + Unknown, +} + +#[derive(Debug)] +struct InnerProcessStates { + processes: HashMap, + exclude_paths: HashSet, +} + +impl ProcessStates { + /// Initialize process states + fn new() -> Result { + let mut states = InnerProcessStates { + processes: HashMap::new(), + exclude_paths: HashSet::new(), + }; + + let processes = list_pids().map_err(Error::InitializePids)?; + + for pid in processes { + let path = process_path(pid).map_err(|error| Error::FindProcessPath(error, pid))?; + states.processes.insert(pid, ProcessInfo::included(path)); + } + + Ok(ProcessStates { + inner: Arc::new(Mutex::new(states)), + }) + } + + pub fn exclude_paths(&self, paths: HashSet) { + let mut inner = self.inner.lock().unwrap(); + + for info in inner.processes.values_mut() { + // Remove no-longer excluded paths from exclusion list + let mut new_exclude_paths: HashSet<_> = info + .excluded_by_paths + .intersection(&paths) + .cloned() + .collect(); + + // Check if own path is excluded + if paths.contains(&info.exec_path) && !new_exclude_paths.contains(&info.exec_path) { + new_exclude_paths.insert(info.exec_path.to_owned()); + } + + info.excluded_by_paths = new_exclude_paths; + } + + inner.exclude_paths = paths; + } + + pub fn get_process_status(&self, pid: u32) -> ExclusionStatus { + let inner = self.inner.lock().unwrap(); + match inner.processes.get(&pid) { + Some(val) if val.is_excluded() => ExclusionStatus::Excluded, + Some(_) => ExclusionStatus::Included, + None => ExclusionStatus::Unknown, + } + } +} + +impl InnerProcessStates { + fn handle_message(&mut self, msg: ESMessage) { + let pid = msg.process.audit_token.pid; + + match msg.event { + ESEvent::Fork(evt) => self.handle_fork(pid, msg.process.executable.path, evt), + ESEvent::Exec(evt) => self.handle_exec(pid, evt), + ESEvent::Exit {} => self.handle_exit(pid), + } + } + + // For new processes, inherit all exclusion state from the parent, if there is one. + // Otherwise, look up excluded paths + fn handle_fork(&mut self, parent_pid: u32, exec_path: PathBuf, msg: ESForkEvent) { + let pid = msg.child.audit_token.pid; + + if self.processes.contains_key(&pid) { + log::error!("Conflicting pid! State already contains {pid}"); + } + + // Inherit exclusion status from parent + let base_info = match self.processes.get(&parent_pid) { + Some(parent_info) => parent_info.to_owned(), + None => { + log::error!("{pid}: Unknown parent pid {parent_pid}!"); + ProcessInfo::included(exec_path) + } + }; + + // no exec yet; only pid and parent pid change + if base_info.is_excluded() { + println!( + "{pid} excluded (inherited from {parent_pid}) (exclude paths: {:?}", + base_info.excluded_by_paths + ); + } + + self.processes.insert(pid, base_info); + } + + fn handle_exec(&mut self, pid: u32, msg: ESExecEvent) { + let Some(info) = self.processes.get_mut(&pid) else { + log::error!("exec received for unknown pid {pid}"); + return; + }; + + info.exec_path = PathBuf::from(msg.dyld_exec_path); + + // If the path is already excluded, no need to add it again + if info.excluded_by_paths.contains(&info.exec_path) { + return; + } + + // Exclude if path is excluded + if self.exclude_paths.contains(&info.exec_path) { + info.excluded_by_paths.insert(info.exec_path.to_owned()); + log::trace!("Excluding {pid} by path: {}", info.exec_path.display()); + } + } + + fn handle_exit(&mut self, pid: u32) { + if self.processes.remove(&pid).is_none() { + log::error!("exit syscall for unknown pid {pid}"); + } + } +} + +/// Obtain a list of all pids +fn list_pids() -> io::Result> { + // SAFETY: Passing in null and 0 returns the number of processes + let num_pids = unsafe { proc_listallpids(ptr::null_mut(), 0) }; + if num_pids <= 0 { + return Err(io::Error::last_os_error()); + } + let num_pids = usize::try_from(num_pids).unwrap(); + let mut pids = vec![0u32; num_pids]; + + let buf_sz = (num_pids * std::mem::size_of::()) as i32; + // SAFETY: 'pids' is large enough to contain 'num_pids' processes + let num_pids = unsafe { proc_listallpids(pids.as_mut_ptr() as *mut c_void, buf_sz) }; + if num_pids == -1 { + return Err(io::Error::last_os_error()); + } + + pids.resize(usize::try_from(num_pids).unwrap(), 0); + + Ok(pids) +} + +fn process_path(pid: u32) -> io::Result { + let mut buffer = [0u8; libc::MAXPATHLEN as usize]; + // SAFETY: `proc_pidpath` returns at most `buffer.len()` bytes + let buf_len = unsafe { + proc_pidpath( + pid as i32, + buffer.as_mut_ptr() as *mut c_void, + buffer.len() as u32, + ) + }; + if buf_len == -1 { + return Err(io::Error::last_os_error()); + } + Ok(PathBuf::from( + std::str::from_utf8(&buffer[0..buf_len as usize]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid process path"))?, + )) +} + +#[derive(Debug, Clone)] +struct ProcessInfo { + exec_path: PathBuf, + excluded_by_paths: HashSet, +} + +impl ProcessInfo { + fn included(exec_path: PathBuf) -> Self { + ProcessInfo { + exec_path, + excluded_by_paths: HashSet::new(), + } + } + + fn is_excluded(&self) -> bool { + !self.excluded_by_paths.is_empty() + } +} + +/// `fork` event details +#[derive(Debug, Deserialize)] +struct ESForkChild { + audit_token: ESAuditToken, +} + +/// `fork` event returned by `eslogger` +#[derive(Debug, Deserialize)] +struct ESForkEvent { + child: ESForkChild, +} + +/// `exec` event returned by `eslogger` +#[derive(Debug, Deserialize)] +struct ESExecEvent { + dyld_exec_path: String, +} + +/// Event that triggered the message returned by `eslogger`. +/// See the `es_events_t` struct for more information: +/// https://developer.apple.com/documentation/endpointsecurity/es_message_t/3228969-event?language=objc +/// A list of all event types can be found here: +/// https://developer.apple.com/documentation/endpointsecurity/es_event_type_t/es_event_type_notify_fork?language=objc +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ESEvent { + Fork(ESForkEvent), + Exec(ESExecEvent), + Exit {}, +} + +/// Message containing the path to the image of the process. +/// This message is analogous to the `executable` field of `es_process_t`: +/// https://developer.apple.com/documentation/endpointsecurity/es_process_t/3228975-audit_token?language=objc +#[derive(Debug, Deserialize)] +struct ESExecutable { + path: PathBuf, +} + +/// Message containing the process identifier of the process. +/// This message is analogous to the `audit_token` field of `es_process_t`: +/// https://developer.apple.com/documentation/endpointsecurity/es_process_t/3228975-audit_token?language=objc +#[derive(Debug, Deserialize)] +struct ESAuditToken { + pid: u32, +} + +/// Process information for the message returned by `eslogger`. +/// This message is analogous to the `es_process_t` struct: +/// https://developer.apple.com/documentation/endpointsecurity/es_process_t?language=objc +#[derive(Debug, Deserialize)] +struct ESProcess { + audit_token: ESAuditToken, + executable: ESExecutable, +} + +/// This struct represents each message returned by eslogger +/// This message is analogous to the `es_message_t` struct: +/// https://developer.apple.com/documentation/endpointsecurity/es_message_t?language=objc +#[derive(Debug, Deserialize)] +struct ESMessage { + event: ESEvent, + process: ESProcess, +} + +fn parse_eslogger_error(stderr_str: &str) -> Option { + if stderr_str.contains("ES_NEW_CLIENT_RESULT_ERR_NOT_PERMITTED") { + Some(Error::NeedFullDiskPermissions) + } else { + None + } +} + +/// Check whether the current macOS version is supported, and return an error otherwise +fn check_os_version_support() -> Result<(), Error> { + match MacosVersion::new() { + Ok(version) => check_os_version_support_inner(version), + Err(error) => { + log::error!("Failed to detect macOS version: {error}"); + Ok(()) + } + } +} + +fn check_os_version_support_inner(version: MacosVersion) -> Result<(), Error> { + if version >= *MIN_OS_VERSION { + Ok(()) + } else { + Err(Error::UnsupportedMacosVersion { actual: version }) + } +} + +#[test] +fn test_min_os_version() { + assert!(check_os_version_support_inner(MIN_OS_VERSION.clone()).is_ok()); + + // test unsupported version + assert!( + check_os_version_support_inner(MacosVersion::from_raw_version("12.1").unwrap()).is_err() + ); + + // test supported version + assert!( + check_os_version_support_inner(MacosVersion::from_raw_version("13.0").unwrap()).is_ok() + ); +} diff --git a/talpid-core/src/split_tunnel/macos/tun.rs b/talpid-core/src/split_tunnel/macos/tun.rs new file mode 100644 index 000000000000..3f70e1c65543 --- /dev/null +++ b/talpid-core/src/split_tunnel/macos/tun.rs @@ -0,0 +1,802 @@ +//! This module implements a tunnel capable of redirecting traffic through one of two interfaces, +//! either the default interface or a VPN tunnel interface. + +use super::{ + bindings::{ + pcap_create, pcap_set_want_pktap, pktap_header, PCAP_ERRBUF_SIZE, PTH_FLAG_DIR_OUT, + }, + bpf, + default::DefaultInterface, +}; +use futures::{Stream, StreamExt}; +use libc::{AF_INET, AF_INET6}; +use pcap::PacketCodec; +use pnet_packet::{ + ethernet::{EtherTypes, MutableEthernetPacket}, + ip::IpNextHeaderProtocols, + ipv4::MutableIpv4Packet, + ipv6::MutableIpv6Packet, + tcp::MutableTcpPacket, + udp::MutableUdpPacket, + MutablePacket, Packet, +}; +use std::ffi::c_uint; +use std::{ + ffi::CStr, + io::{self, IoSlice, Write}, + net::{Ipv4Addr, Ipv6Addr}, + ptr::NonNull, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::broadcast, +}; +use tun::Device; + +/// IP address used by the ST utun +const ST_IFACE_IPV4: Ipv4Addr = Ipv4Addr::new(10, 123, 123, 123); +const ST_IFACE_IPV6: Ipv6Addr = Ipv6Addr::new(0xfd, 0x12, 0x12, 0x12, 0xfe, 0xfe, 0xfe, 0xfe); + +const DEFAULT_BUFFER_SIZE: c_uint = 16 * 1024 * 1024; + +/// Errors related to split tunneling. +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Failed to create split tunnel utun + #[error("Failed to create split tunnel interface")] + CreateSplitTunnelInterface(#[source] tun::Error), + /// Failed to set IPv6 address on tunnel interface + #[error("Failed to set IPv6 address on tunnel interface")] + AddIpv6Address(#[source] io::Error), + /// Failed to begin capture on split tunnel utun + #[error("Failed to begin capture on split tunnel utun")] + CaptureSplitTunnelDevice(#[source] pcap::Error), + /// Failed to set direction on capture + #[error("Failed to set direction on pcap")] + SetDirection(#[source] pcap::Error), + /// Failed to enable nonblocking I/O + #[error("Failed to enable nonblocking I/O")] + EnableNonblock(#[source] pcap::Error), + /// pcap_create failed + #[error("pcap_create failed: {}", _0)] + CreatePcap(String), + /// Failed to create packet stream + #[error("Failed to create packet stream")] + CreateStream(#[source] pcap::Error), + /// Failed to get next packet + #[error("Failed to get next packet")] + GetNextPacket(#[source] pcap::Error), + /// Failed to create BPF device for default interface + #[error("Failed to create BPF device for default interface")] + CreateDefaultBpf(#[source] bpf::Error), + /// Failed to configure BPF device for default interface + #[error("Failed to configure BPF device for default interface")] + ConfigDefaultBpf(#[source] bpf::Error), + /// Failed to retrieve BPF buffer size + #[error("Failed to retrieve BPF buffer size")] + GetBpfBufferSize(#[source] bpf::Error), + /// Failed to create BPF device for VPN tunnel + #[error("Failed to create BPF device for VPN tunnel")] + CreateVpnBpf(#[source] bpf::Error), + /// Failed to configure BPF device for VPN + #[error("Failed to configure BPF device for VPN tunnel")] + ConfigVpnBpf(#[source] bpf::Error), + /// Failed to stop tunnel redirection + #[error("Failed to stop tunnel redirection")] + StopRedirect, + /// Failed to receive next pktap packet + #[error("Failed to receive next pktap packet")] + PktapStreamStopped, +} + +/// Routing decision made for an outbound packet +#[derive(Debug, Clone, Copy)] +pub enum RoutingDecision { + /// Send outgoing packets through the default interface + DefaultInterface, + /// Send outgoing packets through the VPN tunnel + VpnTunnel, + /// Drop the packet + Drop, +} + +/// VPN tunnel interface details +#[derive(Debug, Clone)] +pub struct VpnInterface { + /// VPN tunnel interface name + pub name: String, + /// VPN tunnel IPv4 address + pub v4_address: Option, + /// VPN tunnel IPv6 address + pub v6_address: Option, +} + +pub struct SplitTunnelHandle { + /// Name of the split tunneling utun interface (which receives traffic to redirect) + tun_name: String, + /// A sender that gracefully stops the other tasks (`ingress_task`, and `egress_task`) + abort_tx: broadcast::Sender<()>, + /// Task that handles incoming packets. On completion, it returns a handle for the ST utun + ingress_task: tokio::task::JoinHandle, + /// Task that handles outgoing packets. On completion, it returns a handle for the pktap, as + /// well as the function used to classify packets + egress_task: tokio::task::JoinHandle>, +} + +impl SplitTunnelHandle { + pub async fn shutdown(self) -> Result<(), Error> { + log::debug!("Shutting down split tunnel"); + let _ = self.abort_tx.send(()); + let _ = self.ingress_task.await.map_err(|_| Error::StopRedirect)?; + let _ = self.egress_task.await.map_err(|_| Error::StopRedirect)??; + Ok(()) + } + + /// Return split tunnel interface name + pub fn name(&self) -> &str { + &self.tun_name + } + + pub async fn set_interfaces( + self, + default_interface: DefaultInterface, + vpn_interface: Option, + ) -> Result { + let _ = self.abort_tx.send(()); + + let st_utun = self.ingress_task.await.map_err(|_| Error::StopRedirect)?; + + let egress_completion = self.egress_task.await.map_err(|_| Error::StopRedirect)??; + + redirect_packets_for_pktap_stream( + st_utun, + egress_completion.pktap_stream, + default_interface, + vpn_interface, + egress_completion.classify, + ) + } +} + +/// Create split tunnel device and handle all packets using `classify`. Handle any changes to the +/// default interface or gateway. +/// +/// # Note +/// +/// `classify` receives an Ethernet frame. The Ethernet header is not valid at this point, however. +/// Only the IP header and payload are. +pub async fn create_split_tunnel( + default_interface: DefaultInterface, + vpn_interface: Option, + classify: ClassifyFn, +) -> Result { + let tun_device = create_utun().await?; + redirect_packets(tun_device, default_interface, vpn_interface, classify) +} + +/// Create a utun device for split tunneling, and configure its IP addresses. +async fn create_utun() -> Result { + let mut tun_config = tun::configure(); + tun_config.address(ST_IFACE_IPV4).up(); + let tun_device = + tun::create_as_async(&tun_config).map_err(Error::CreateSplitTunnelInterface)?; + let tun_name = tun_device.get_ref().name().to_owned(); + add_ipv6_address(&tun_name, ST_IFACE_IPV6).await?; + Ok(tun_device) +} + +/// Set the given IPv6 address `addr` as an IP address for the interface `iface`. +async fn add_ipv6_address(iface: &str, addr: Ipv6Addr) -> Result<(), Error> { + let output = tokio::process::Command::new("ifconfig") + .args([iface, "inet6", &addr.to_string(), "alias"]) + .output() + .await + .map_err(Error::AddIpv6Address)?; + if !output.status.success() { + return Err(Error::AddIpv6Address(io::Error::other("ifconfig failed"))); + } + Ok(()) +} + +type PktapStream = std::pin::Pin> + Send>>; +/// A function that is used to classify whether packets should be VPN-tunneled or excluded +type ClassifyFn = Box RoutingDecision + Send>; + +/// Monitor outgoing traffic on `st_tun_device` using a pktap. A routing decision is +/// made for each packet using `classify`. Based on this, a packet is forced out on either +/// `default_interface` or `vpn_interface`, or dropped. +/// +/// # Note +/// +/// `classify` receives an Ethernet frame. The Ethernet header is not valid at this point, however. +/// Only the IP header and payload are. +fn redirect_packets( + st_tun_device: tun::AsyncDevice, + default_interface: DefaultInterface, + vpn_interface: Option, + classify: ClassifyFn, +) -> Result { + let pktap_stream = capture_outbound_packets(st_tun_device.get_ref().name())?; + redirect_packets_for_pktap_stream( + st_tun_device, + Box::pin(pktap_stream), + default_interface, + vpn_interface, + Box::new(classify), + ) +} + +/// Monitor outgoing traffic on `st_tun_device` using `pktap_stream`. A routing decision is made for +/// each packet using `classify`. Based on this, a packet is forced out on either +/// `default_interface` or `vpn_interface`, or dropped. +/// +/// # Note +/// +/// `classify` receives an Ethernet frame. The Ethernet header is not valid at this point, however. +/// Only the IP header and payload are. +fn redirect_packets_for_pktap_stream( + st_tun_device: tun::AsyncDevice, + pktap_stream: PktapStream, + default_interface: DefaultInterface, + vpn_interface: Option, + classify: ClassifyFn, +) -> Result { + let (default_stream, default_write, read_buffer_size) = open_default_bpf(&default_interface)?; + + let st_utun_name = st_tun_device.get_ref().name().to_owned(); + + let (abort_tx, abort_rx) = broadcast::channel(1); + + let ingress_task: tokio::task::JoinHandle = tokio::spawn(run_ingress_task( + st_tun_device, + default_stream, + read_buffer_size, + vpn_interface.clone(), + abort_rx, + )); + + let egress_abort_rx = abort_tx.subscribe(); + let egress_task = tokio::spawn(run_egress_task( + pktap_stream, + classify, + default_interface, + default_write, + vpn_interface, + egress_abort_rx, + )); + + Ok(SplitTunnelHandle { + tun_name: st_utun_name, + abort_tx, + ingress_task, + egress_task, + }) +} + +/// Open a BPF device for the specified default interface. Return a read and write half, and the buffer size. +fn open_default_bpf( + default_interface: &DefaultInterface, +) -> Result<(bpf::BpfStream, bpf::WriteHalf, usize), Error> { + let default_dev = bpf::Bpf::open().map_err(Error::CreateDefaultBpf)?; + let read_buffer_size = default_dev + .set_buffer_size(DEFAULT_BUFFER_SIZE) + .map_err(Error::ConfigDefaultBpf)?; + default_dev + .set_interface(&default_interface.name) + .map_err(Error::ConfigDefaultBpf)?; + default_dev + .set_immediate(true) + .map_err(Error::ConfigDefaultBpf)?; + default_dev + .set_see_sent(false) + .map_err(Error::ConfigDefaultBpf)?; + + // Split the default device BPF handle into a read and write half + let (default_read, default_write) = default_dev.split().map_err(Error::ConfigDefaultBpf)?; + let default_stream = + bpf::BpfStream::from_read_half(default_read).map_err(Error::CreateDefaultBpf)?; + + Ok((default_stream, default_write, read_buffer_size)) +} + +/// Read incoming packets on the default interface and send them back to the ST utun. +async fn run_ingress_task( + st_tun_device: tun::AsyncDevice, + mut default_read: bpf::BpfStream, + read_buffer_size: usize, + vpn_interface: Option, + mut abort_rx: broadcast::Receiver<()>, +) -> tun::AsyncDevice { + let mut read_buffer = vec![0u8; read_buffer_size]; + log::trace!("Default BPF reader buffer size: {:?}", read_buffer.len()); + + let vpn_v4 = vpn_interface.as_ref().and_then(|iface| iface.v4_address); + let vpn_v6 = vpn_interface.and_then(|iface| iface.v6_address); + + let (mut tun_reader, mut tun_writer) = tokio::io::split(st_tun_device); + + let mut abort_read_rx = abort_rx.resubscribe(); + + // Swallow all data written to the tun by reading from it + // Do this to prevent the read buffer from filling up and preventing writes + let mut garbage: Vec = vec![0u8; 8 * 1024 * 1024]; + let dummy_read = tokio::spawn(async move { + loop { + tokio::select! { + result = tun_reader.read(&mut garbage) => { + if result.is_err() { + break; + } + } + Ok(()) | Err(_) = abort_read_rx.recv() => { + break; + } + } + } + tun_reader + }); + + // Write data incoming on the default interface to the ST utun + let tun_writer = loop { + tokio::select! { + result = default_read.read(&mut read_buffer) => { + let Ok(read_n) = result else { + break tun_writer; + }; + let read_data = &mut read_buffer[0..read_n]; + + let mut iter = bpf::BpfIterMut::new(read_data); + while let Some(payload) = iter.next() { + handle_incoming_data(&mut tun_writer, payload, vpn_v4, vpn_v6).await; + } + } + Ok(()) | Err(_) = abort_rx.recv() => { + break tun_writer; + } + } + }; + + let tun_reader = dummy_read.await.unwrap(); + + log::debug!("Stopping ST utun ingress"); + + tun_reader.unsplit(tun_writer) +} + +/// Arguments to `run_egress_task` that are returned when the function succeeds +struct EgressResult { + pktap_stream: PktapStream, + classify: ClassifyFn, +} + +/// Read outgoing packets and send them out on either the default interface or VPN interface, +/// based on the result of `classify`. +async fn run_egress_task( + mut pktap_stream: PktapStream, + classify: ClassifyFn, + default_interface: DefaultInterface, + mut default_write: bpf::WriteHalf, + vpn_interface: Option, + mut abort_rx: broadcast::Receiver<()>, +) -> Result { + let mut vpn_dev = if let Some(ref vpn_interface) = vpn_interface { + Some(open_vpn_bpf(vpn_interface)?) + } else { + None + }; + + loop { + tokio::select! { + packet = pktap_stream.next() => { + let mut packet = packet.ok_or_else(|| { + log::debug!("packet stream closed"); + Error::PktapStreamStopped + })??; + + let vpn_device = match (vpn_interface.as_ref(), vpn_dev.as_mut()) { + (Some(interface), Some(device)) => Some((interface, device)), + (None, None) => None, + _ => unreachable!("missing tun interface or addresses"), + }; + + classify_and_send(&classify, &mut packet, &default_interface, &mut default_write, vpn_device) + } + Ok(()) | Err(_) = abort_rx.recv() => { + log::debug!("stopping packet processing"); + break Ok(EgressResult { pktap_stream, classify }); + } + } + } +} + +/// Open a BPF device for the specified VPN interface +fn open_vpn_bpf(vpn_interface: &VpnInterface) -> Result { + let vpn_dev = bpf::Bpf::open().map_err(Error::CreateVpnBpf)?; + vpn_dev + .set_interface(&vpn_interface.name) + .map_err(Error::ConfigVpnBpf)?; + vpn_dev.set_immediate(true).map_err(Error::ConfigVpnBpf)?; + vpn_dev.set_see_sent(false).map_err(Error::ConfigVpnBpf)?; + Ok(vpn_dev) +} + +fn classify_and_send( + classify: &ClassifyFn, + packet: &mut PktapPacket, + default_interface: &DefaultInterface, + default_write: &mut bpf::WriteHalf, + vpn_interface: Option<(&VpnInterface, &mut bpf::Bpf)>, +) { + match classify(packet) { + RoutingDecision::DefaultInterface => match packet.frame.get_ethertype() { + EtherTypes::Ipv4 => { + let Some(ref addrs) = default_interface.v4_addrs else { + log::trace!("dropping IPv4 packet since there's no default route"); + return; + }; + packet + .frame + .set_destination(addrs.gateway_address.into_bytes().into()); + let Some(mut ip) = MutableIpv4Packet::new(packet.frame.payload_mut()) else { + log::error!("dropping invalid IPv4 packet"); + return; + }; + fix_ipv4_checksums(&mut ip, Some(addrs.source_ip), None); + if let Err(error) = default_write.write(packet.frame.packet()) { + log::error!("Failed to forward to default device: {error}"); + } + } + EtherTypes::Ipv6 => { + let Some(ref addrs) = default_interface.v6_addrs else { + log::trace!("dropping IPv6 packet since there's no default route"); + return; + }; + packet + .frame + .set_destination(addrs.gateway_address.into_bytes().into()); + let Some(mut ip) = MutableIpv6Packet::new(packet.frame.payload_mut()) else { + log::error!("dropping invalid IPv6 packet"); + return; + }; + fix_ipv6_checksums(&mut ip, Some(addrs.source_ip), None); + if let Err(error) = default_write.write(packet.frame.packet()) { + log::error!("Failed to forward to default device: {error}"); + } + } + other => log::error!("unknown ethertype: {other}"), + }, + RoutingDecision::VpnTunnel => { + let Some((vpn_interface, vpn_write)) = vpn_interface else { + log::trace!("dropping IP packet since there's no tun route"); + return; + }; + + match packet.frame.get_ethertype() { + EtherTypes::Ipv4 => { + let Some(addr) = vpn_interface.v4_address else { + log::trace!("dropping IPv4 packet since there's no tun route"); + return; + }; + let Some(mut ip) = MutableIpv4Packet::new(packet.frame.payload_mut()) else { + log::error!("dropping invalid IPv4 packet"); + return; + }; + fix_ipv4_checksums(&mut ip, Some(addr), None); + if let Err(error) = vpn_write.write(packet.frame.payload()) { + log::error!("Failed to forward to tun device: {error}"); + } + } + EtherTypes::Ipv6 => { + let Some(addr) = vpn_interface.v6_address else { + log::trace!("dropping IPv6 packet since there's no tun route"); + return; + }; + let Some(mut ip) = MutableIpv6Packet::new(packet.frame.payload_mut()) else { + log::error!("dropping invalid IPv6 packet"); + return; + }; + fix_ipv6_checksums(&mut ip, Some(addr), None); + if let Err(error) = vpn_write.write(packet.frame.payload()) { + log::error!("Failed to forward to tun device: {error}"); + } + } + other => log::error!("unknown ethertype: {other}"), + } + } + RoutingDecision::Drop => { + log::trace!("Dropped packet from pid {}", packet.header.pth_pid); + } + } +} + +async fn handle_incoming_data( + tun_writer: &mut tokio::io::WriteHalf, + payload: &mut [u8], + vpn_v4: Option, + vpn_v6: Option, +) { + let Some(mut frame) = MutableEthernetPacket::new(payload) else { + log::trace!("discarding non-Ethernet frame"); + return; + }; + + match frame.get_ethertype() { + EtherTypes::Ipv4 => { + let Some(vpn_addr) = vpn_v4 else { + log::trace!("discarding incoming IPv4 packet: no tun V4 addr"); + return; + }; + let Some(ip) = MutableIpv4Packet::new(frame.payload_mut()) else { + log::trace!("discarding non-IPv4 packet"); + return; + }; + handle_incoming_data_v4(tun_writer, ip, vpn_addr).await; + } + EtherTypes::Ipv6 => { + let Some(vpn_addr) = vpn_v6 else { + log::trace!("discarding incoming IPv6 packet: no tun V6 addr"); + return; + }; + let Some(ip) = MutableIpv6Packet::new(frame.payload_mut()) else { + log::trace!("discarding non-IPv6 packet"); + return; + }; + handle_incoming_data_v6(tun_writer, ip, vpn_addr).await; + } + ethertype => { + log::trace!("discarding non-IP frame: {ethertype}"); + } + } +} + +async fn handle_incoming_data_v4( + tun_writer: &mut tokio::io::WriteHalf, + mut ip: MutableIpv4Packet<'_>, + vpn_addr: Ipv4Addr, +) { + if ip.get_destination() == vpn_addr { + // Drop attempt to send packets to tun IP on the real interface + log::trace!("Dropping packet to VPN IP on default interface"); + return; + } + + fix_ipv4_checksums(&mut ip, None, Some(vpn_addr)); + + const BSD_LB_HEADER: &[u8] = &(AF_INET as u32).to_be_bytes(); + if let Err(error) = tun_writer + .write_vectored(&[IoSlice::new(BSD_LB_HEADER), IoSlice::new(ip.packet())]) + .await + { + log::error!("Failed to redirect incoming IPv4 packet: {error}"); + } +} + +async fn handle_incoming_data_v6( + tun_writer: &mut tokio::io::WriteHalf, + mut ip: MutableIpv6Packet<'_>, + vpn_addr: Ipv6Addr, +) { + if ip.get_destination() == vpn_addr { + // Drop attempt to send packets to tun IP on the real interface + log::trace!("Dropping packet to VPN IP on default interface"); + return; + } + + fix_ipv6_checksums(&mut ip, None, Some(vpn_addr)); + + const BSD_LB_HEADER: &[u8] = &(AF_INET6 as u32).to_be_bytes(); + if let Err(error) = tun_writer + .write_vectored(&[IoSlice::new(BSD_LB_HEADER), IoSlice::new(ip.packet())]) + .await + { + log::error!("Failed to redirect incoming IPv6 packet: {error}"); + } +} + +// Recalculate L3 and L4 checksums. Silently fail on error +fn fix_ipv4_checksums( + ip: &mut MutableIpv4Packet<'_>, + new_source: Option, + new_destination: Option, +) { + // Update source and update checksums + if let Some(source_ip) = new_source { + ip.set_source(source_ip); + } + if let Some(dest_ip) = new_destination { + ip.set_destination(dest_ip); + } + + let source_ip = ip.get_source(); + let destination_ip = ip.get_destination(); + + match ip.get_next_level_protocol() { + IpNextHeaderProtocols::Tcp => { + if let Some(mut tcp) = MutableTcpPacket::new(ip.payload_mut()) { + use pnet_packet::tcp::ipv4_checksum; + tcp.set_checksum(ipv4_checksum( + &tcp.to_immutable(), + &source_ip, + &destination_ip, + )); + } + } + IpNextHeaderProtocols::Udp => { + if let Some(mut udp) = MutableUdpPacket::new(ip.payload_mut()) { + use pnet_packet::udp::ipv4_checksum; + udp.set_checksum(ipv4_checksum( + &udp.to_immutable(), + &source_ip, + &destination_ip, + )); + } + } + _ => (), + } + + ip.set_checksum(pnet_packet::ipv4::checksum(&ip.to_immutable())); +} + +// Recalculate L3 and L4 checksums. Silently fail on error +fn fix_ipv6_checksums( + ip: &mut MutableIpv6Packet<'_>, + new_source: Option, + new_destination: Option, +) { + // Update source and update checksums + if let Some(source_ip) = new_source { + ip.set_source(source_ip); + } + if let Some(dest_ip) = new_destination { + ip.set_destination(dest_ip); + } + + let source_ip = ip.get_source(); + let destination_ip = ip.get_destination(); + + match ip.get_next_header() { + IpNextHeaderProtocols::Tcp => { + if let Some(mut tcp) = MutableTcpPacket::new(ip.payload_mut()) { + use pnet_packet::tcp::ipv6_checksum; + tcp.set_checksum(ipv6_checksum( + &tcp.to_immutable(), + &source_ip, + &destination_ip, + )); + } + } + IpNextHeaderProtocols::Udp => { + if let Some(mut udp) = MutableUdpPacket::new(ip.payload_mut()) { + use pnet_packet::udp::ipv6_checksum; + udp.set_checksum(ipv6_checksum( + &udp.to_immutable(), + &source_ip, + &destination_ip, + )); + } + } + _ => (), + } +} + +/// This returns a stream of outbound packets on a utun tunnel. +/// +/// * `utun_iface`- name of a utun interface to capture packets on. Note that if this does not +/// exist, the function will not fail, but the stream will never return anything. +fn capture_outbound_packets( + utun_iface: &str, +) -> Result> + Send, Error> { + let cap = pktap_capture()? + .immediate_mode(true) + .open() + .map_err(Error::CaptureSplitTunnelDevice)?; + + // TODO: This is unsupported on macOS 13 and lower, so we determine the direction using the + // pktap header flags. Once macOS 13 is no longer supported, this can be uncommented. + //cap.direction(pcap::Direction::Out) + // .map_err(Error::SetDirection)?; + + let cap = cap.setnonblock().map_err(Error::EnableNonblock)?; + let stream = cap + .stream(PktapCodec::new(utun_iface.to_owned())) + .map_err(Error::CreateStream)? + .filter_map(|pkt| async { pkt.map_err(Error::GetNextPacket).transpose() }); + + Ok(stream) +} + +struct PktapCodec { + interface: String, +} + +impl PktapCodec { + fn new(interface: String) -> PktapCodec { + Self { interface } + } +} + +#[derive(Debug)] +pub struct PktapPacket { + pub header: pktap_header, + pub frame: MutableEthernetPacket<'static>, +} + +impl PacketCodec for PktapCodec { + type Item = Option; + + fn decode(&mut self, packet: pcap::Packet<'_>) -> Self::Item { + assert!(packet.data.len() >= std::mem::size_of::()); + + // SAFETY: packet is large enough to contain the header + let header: &pktap_header = unsafe { &*(packet.data.as_ptr() as *const pktap_header) }; + + let data = match usize::try_from(header.pth_length).unwrap() { + // Non-empty payload + len if len < packet.data.len() => &packet.data[len..], + // Empty payload + len if len == packet.data.len() => &[], + // Malformed header/payload + _ => return None, + }; + + // TODO: `Capture::direction` is unsupported on macOS 13 and lower, so we determine the + // direction using the pktap header. Once macOS 13 is no longer supported, this can + // be removed. + if header.pth_flags ^ PTH_FLAG_DIR_OUT == 0 { + // Ignore incoming packets + return None; + } + + let iface = unsafe { CStr::from_ptr(header.pth_ifname.as_ptr() as *const _) }; + if iface.to_bytes() != self.interface.as_bytes() { + return None; + } + + // TODO: Wasteful. Could share single buffer if handling one frame at a time (assuming no + // concurrency is needed). Allocating the frame here is purely done for efficiency reasons. + let mut frame = MutableEthernetPacket::owned(vec![0u8; 14 + data.len() - 4]).unwrap(); + + let (raw_family, payload) = data.split_first_chunk()?; + let ethertype = match i32::from_ne_bytes(*raw_family) { + AF_INET => EtherTypes::Ipv4, + AF_INET6 => EtherTypes::Ipv6, + _ => return None, + }; + + frame.set_ethertype(ethertype); + frame.set_payload(payload); + + Some(PktapPacket { + header: header.to_owned(), + frame, + }) + } +} + +/// Create a pktap interface using `libpcap` +fn pktap_capture() -> Result, Error> { + // We want to create a pktap "pseudo-device" and capture data on it using a bpf device. + // This provides packet data plus a pktap header including process information. + // libpcap will do the heavy lifting for us if we simply request a "pktap" device. + + let mut errbuf = [0u8; PCAP_ERRBUF_SIZE as usize]; + + let pcap = unsafe { pcap_create(c"pktap".as_ptr(), errbuf.as_mut_ptr() as _) }; + if pcap.is_null() { + let errstr = CStr::from_bytes_until_nul(&errbuf) + .unwrap() + .to_string_lossy() + .into_owned(); + return Err(Error::CreatePcap(errstr)); + } + unsafe { pcap_set_want_pktap(pcap, 1) }; + + // TODO: Upstream setting "want pktap" directly on Capture + // If we had that, we could have simply used pcap::Capture::from_device("pktap") + // TODO: Also upstream exposure of a raw handle to pcap_t on Capture + + // just casting a pointer to a private type using _. that's fine, apparently + Ok(pcap::Capture::from(unsafe { + NonNull::new_unchecked(pcap as *mut _) + })) +} diff --git a/talpid-core/src/split_tunnel/mod.rs b/talpid-core/src/split_tunnel/mod.rs index 3c3f6af2949e..95bd486e8ce8 100644 --- a/talpid-core/src/split_tunnel/mod.rs +++ b/talpid-core/src/split_tunnel/mod.rs @@ -2,12 +2,13 @@ #[path = "linux.rs"] mod imp; -#[cfg(target_os = "linux")] -pub use imp::*; - -#[cfg(windows)] +#[cfg(target_os = "windows")] #[path = "windows/mod.rs"] mod imp; -#[cfg(windows)] +#[cfg(target_os = "macos")] +#[path = "macos/mod.rs"] +mod imp; + +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] pub use imp::*; diff --git a/talpid-core/src/tunnel_state_machine/connected_state.rs b/talpid-core/src/tunnel_state_machine/connected_state.rs index c73232a89546..80ca26ec56ce 100644 --- a/talpid-core/src/tunnel_state_machine/connected_state.rs +++ b/talpid-core/src/tunnel_state_machine/connected_state.rs @@ -152,12 +152,19 @@ impl ConnectedState { let peer_endpoint = AllowedEndpoint { endpoint, clients }; + #[cfg(target_os = "macos")] + let redirect_interface = shared_values + .runtime + .block_on(shared_values.split_tunnel.interface()); + FirewallPolicy::Connected { peer_endpoint, tunnel: self.metadata.clone(), allow_lan: shared_values.allow_lan, #[cfg(not(target_os = "android"))] dns_servers: self.get_dns_servers(shared_values), + #[cfg(target_os = "macos")] + redirect_interface, } } @@ -323,11 +330,38 @@ impl ConnectedState { shared_values.bypass_socket(fd, done_tx); SameState(self) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); SameState(self) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + match shared_values.set_exclude_paths(paths) { + Ok(added_device) => { + let _ = result_tx.send(Ok(())); + + if added_device { + if let Err(error) = self.set_firewall_policy(shared_values) { + return self.disconnect( + shared_values, + AfterDisconnect::Block( + ErrorStateCause::SetFirewallPolicyError(error), + ), + ); + } + } + } + Err(error) => { + let _ = result_tx.send(Err(error)); + return self.disconnect( + shared_values, + AfterDisconnect::Block(ErrorStateCause::SplitTunnelError), + ); + } + } + SameState(self) + } } } diff --git a/talpid-core/src/tunnel_state_machine/connecting_state.rs b/talpid-core/src/tunnel_state_machine/connecting_state.rs index 96800f8a0d7f..5df58a6adf18 100644 --- a/talpid-core/src/tunnel_state_machine/connecting_state.rs +++ b/talpid-core/src/tunnel_state_machine/connecting_state.rs @@ -158,12 +158,19 @@ impl ConnectingState { let peer_endpoint = AllowedEndpoint { endpoint, clients }; + #[cfg(target_os = "macos")] + let redirect_interface = shared_values + .runtime + .block_on(shared_values.split_tunnel.interface()); + let policy = FirewallPolicy::Connecting { peer_endpoint, tunnel: tunnel_metadata.clone(), allow_lan: shared_values.allow_lan, allowed_endpoint: shared_values.allowed_endpoint.clone(), allowed_tunnel_traffic, + #[cfg(target_os = "macos")] + redirect_interface, }; shared_values .firewall @@ -463,11 +470,43 @@ impl ConnectingState { shared_values.bypass_socket(fd, done_tx); SameState(self) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); SameState(self) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + match shared_values.set_exclude_paths(paths) { + Ok(added_device) => { + let _ = result_tx.send(Ok(())); + + if added_device { + if let Err(error) = Self::set_firewall_policy( + shared_values, + &self.tunnel_parameters, + &self.tunnel_metadata, + self.allowed_tunnel_traffic.clone(), + ) { + return self.disconnect( + shared_values, + AfterDisconnect::Block( + ErrorStateCause::SetFirewallPolicyError(error), + ), + ); + } + } + } + Err(error) => { + let _ = result_tx.send(Err(error)); + return self.disconnect( + shared_values, + AfterDisconnect::Block(ErrorStateCause::SplitTunnelError), + ); + } + } + SameState(self) + } } } @@ -501,6 +540,11 @@ impl ConnectingState { ); } + #[cfg(target_os = "macos")] + if let Err(error) = shared_values.enable_split_tunnel(&metadata) { + return self.disconnect(shared_values, AfterDisconnect::Block(error)); + } + self.allowed_tunnel_traffic = allowed_tunnel_traffic; self.tunnel_metadata = Some(metadata); diff --git a/talpid-core/src/tunnel_state_machine/disconnected_state.rs b/talpid-core/src/tunnel_state_machine/disconnected_state.rs index 41d0b04ddf86..25ed491a8e79 100644 --- a/talpid-core/src/tunnel_state_machine/disconnected_state.rs +++ b/talpid-core/src/tunnel_state_machine/disconnected_state.rs @@ -211,11 +211,16 @@ impl TunnelState for DisconnectedState { shared_values.bypass_socket(fd, done_tx); SameState(self) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); SameState(self) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + let _ = result_tx.send(shared_values.set_exclude_paths(paths).map(|_| ())); + SameState(self) + } None => { Self::reset_dns(shared_values); Finished diff --git a/talpid-core/src/tunnel_state_machine/disconnecting_state.rs b/talpid-core/src/tunnel_state_machine/disconnecting_state.rs index deecb3455bbb..2a5160af5700 100644 --- a/talpid-core/src/tunnel_state_machine/disconnecting_state.rs +++ b/talpid-core/src/tunnel_state_machine/disconnecting_state.rs @@ -76,11 +76,16 @@ impl DisconnectingState { shared_values.bypass_socket(fd, done_tx); AfterDisconnect::Nothing } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); AfterDisconnect::Nothing } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + let _ = result_tx.send(shared_values.set_exclude_paths(paths).map(|_| ())); + AfterDisconnect::Nothing + } }, AfterDisconnect::Block(reason) => match command { Some(TunnelCommand::AllowLan(allow_lan, complete_tx)) => { @@ -122,11 +127,16 @@ impl DisconnectingState { shared_values.bypass_socket(fd, done_tx); AfterDisconnect::Block(reason) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); AfterDisconnect::Block(reason) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + let _ = result_tx.send(shared_values.set_exclude_paths(paths).map(|_| ())); + AfterDisconnect::Block(reason) + } None => AfterDisconnect::Block(reason), }, AfterDisconnect::Reconnect(retry_attempt) => match command { @@ -169,11 +179,16 @@ impl DisconnectingState { shared_values.bypass_socket(fd, done_tx); AfterDisconnect::Reconnect(retry_attempt) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); AfterDisconnect::Reconnect(retry_attempt) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + let _ = result_tx.send(shared_values.set_exclude_paths(paths).map(|_| ())); + AfterDisconnect::Reconnect(retry_attempt) + } }, }; diff --git a/talpid-core/src/tunnel_state_machine/error_state.rs b/talpid-core/src/tunnel_state_machine/error_state.rs index 538ee5de1aca..d9180e634293 100644 --- a/talpid-core/src/tunnel_state_machine/error_state.rs +++ b/talpid-core/src/tunnel_state_machine/error_state.rs @@ -211,11 +211,16 @@ impl TunnelState for ErrorState { shared_values.bypass_socket(fd, done_tx); SameState(self) } - #[cfg(windows)] + #[cfg(target_os = "windows")] Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { shared_values.split_tunnel.set_paths(&paths, result_tx); SameState(self) } + #[cfg(target_os = "macos")] + Some(TunnelCommand::SetExcludedApps(result_tx, paths)) => { + let _ = result_tx.send(shared_values.set_exclude_paths(paths).map(|_| ())); + SameState(self) + } } } } diff --git a/talpid-core/src/tunnel_state_machine/mod.rs b/talpid-core/src/tunnel_state_machine/mod.rs index 49ec6674eb0f..ad3e9459aa82 100644 --- a/talpid-core/src/tunnel_state_machine/mod.rs +++ b/talpid-core/src/tunnel_state_machine/mod.rs @@ -11,7 +11,7 @@ use self::{ disconnecting_state::{AfterDisconnect, DisconnectingState}, error_state::ErrorState, }; -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] use crate::split_tunnel; use crate::{ dns::DnsMonitor, @@ -19,10 +19,14 @@ use crate::{ mpsc::Sender, offline, }; -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] use std::ffi::OsString; use talpid_routing::RouteManagerHandle; +#[cfg(target_os = "macos")] +use talpid_tunnel::TunnelMetadata; use talpid_tunnel::{tun_provider::TunProvider, TunnelEvent}; +#[cfg(target_os = "macos")] +use talpid_types::ErrorExt; use futures::{ channel::{mpsc, oneshot}, @@ -56,7 +60,7 @@ pub enum Error { OfflineMonitorError(#[from] crate::offline::Error), /// Unable to set up split tunneling - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] #[error("Failed to initialize split tunneling")] InitSplitTunneling(#[from] split_tunnel::Error), @@ -100,7 +104,7 @@ pub struct InitialTunnelState { /// Whether to reset any existing firewall rules when initializing the disconnected state. pub reset_firewall: bool, /// Programs to exclude from the tunnel using the split tunnel driver. - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] pub exclude_paths: Vec, } @@ -210,7 +214,7 @@ pub enum TunnelCommand { #[cfg(target_os = "android")] BypassSocket(RawFd, oneshot::Sender<()>), /// Set applications that are allowed to send and receive traffic outside of the tunnel. - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] SetExcludedApps( oneshot::Sender>, Vec, @@ -288,6 +292,10 @@ impl TunnelStateMachine { ) .map_err(Error::InitSplitTunneling)?; + #[cfg(target_os = "macos")] + let split_tunnel = + split_tunnel::SplitTunnel::spawn(args.command_tx.clone(), route_manager.clone()); + let fw_args = FirewallArguments { initial_state: if args.settings.block_when_disconnected || !args.settings.reset_firewall { @@ -341,8 +349,25 @@ impl TunnelStateMachine { .set_paths_sync(&args.settings.exclude_paths) .map_err(Error::InitSplitTunneling)?; + #[cfg(target_os = "macos")] + if let Err(error) = split_tunnel + .set_exclude_paths( + args.settings + .exclude_paths + .iter() + .map(PathBuf::from) + .collect(), + ) + .await + { + log::error!( + "{}", + error.display_chain_with_msg("Failed to set initial split tunnel paths") + ); + } + let mut shared_values = SharedTunnelStateValues { - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] split_tunnel, runtime, firewall, @@ -405,6 +430,8 @@ impl TunnelStateMachine { log::debug!("Tunnel state machine exited"); + #[cfg(target_os = "macos")] + runtime.block_on(self.shared_values.split_tunnel.shutdown()); runtime.block_on(self.shared_values.route_manager.stop()); } } @@ -428,6 +455,8 @@ struct SharedTunnelStateValues { /// instance), since the driver may add filters to the same sublayer. #[cfg(windows)] split_tunnel: split_tunnel::SplitTunnel, + #[cfg(target_os = "macos")] + split_tunnel: split_tunnel::Handle, runtime: tokio::runtime::Handle, firewall: Firewall, dns_monitor: DnsMonitor, @@ -462,6 +491,69 @@ struct SharedTunnelStateValues { } impl SharedTunnelStateValues { + /// Return whether an split tunnel interface was created + #[cfg(target_os = "macos")] + pub fn set_exclude_paths(&mut self, paths: Vec) -> Result { + self.runtime.block_on(async { + let had_interface = self.split_tunnel.interface().await.is_some(); + self.split_tunnel + .set_exclude_paths(paths.into_iter().map(PathBuf::from).collect()) + .await + .map_err(|error| { + log::error!( + "{}", + error.display_chain_with_msg("Failed to set split tunnel paths") + ); + error + })?; + let has_interface = self.split_tunnel.interface().await.is_some(); + Ok(!had_interface && has_interface) + }) + } + + #[cfg(target_os = "macos")] + pub fn enable_split_tunnel( + &mut self, + metadata: &TunnelMetadata, + ) -> Result<(), ErrorStateCause> { + let v4_address = metadata + .ips + .iter() + .find(|ip| ip.is_ipv4()) + .map(|addr| match addr { + IpAddr::V4(addr) => *addr, + _ => unreachable!("unexpected address family"), + }); + let v6_address = metadata + .ips + .iter() + .find(|ip| ip.is_ipv6()) + .map(|addr| match addr { + IpAddr::V6(addr) => *addr, + _ => unreachable!("unexpected address family"), + }); + let vpn_interface = crate::split_tunnel::VpnInterface { + name: metadata.interface.clone(), + v4_address, + v6_address, + }; + self.runtime + .block_on(self.split_tunnel.set_tunnel(Some(vpn_interface))) + .inspect_err(|error| { + log::error!( + "{}", + error.display_chain_with_msg("Failed to set VPN interface for split tunnel") + ) + }) + .map_err(|error| { + if error.is_offline() { + ErrorStateCause::IsOffline + } else { + ErrorStateCause::SplitTunnelError + } + }) + } + pub fn set_allow_lan(&mut self, allow_lan: bool) -> Result<(), ErrorStateCause> { if self.allow_lan != allow_lan { self.allow_lan = allow_lan; diff --git a/talpid-platform-metadata/src/macos.rs b/talpid-platform-metadata/src/macos.rs index 707130721b90..f68cabb82b23 100644 --- a/talpid-platform-metadata/src/macos.rs +++ b/talpid-platform-metadata/src/macos.rs @@ -1,7 +1,9 @@ mod command; use command::command_stdout_lossy; -use std::io; +use std::cmp::Ordering; +use std::fmt::Formatter; +use std::{fmt, io}; pub fn version() -> String { let version = MacosVersion::new() @@ -21,12 +23,35 @@ pub fn extra_metadata() -> impl Iterator { std::iter::empty() } -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone)] pub struct MacosVersion { raw_version: String, major: u32, minor: u32, - patch: u32, + patch: Option, +} + +impl PartialEq for MacosVersion { + fn eq(&self, other: &Self) -> bool { + self.major_version() == other.major_version() + && self.minor_version() == other.minor_version() + && self.patch_version() == other.patch_version() + } +} + +impl PartialOrd for MacosVersion { + fn partial_cmp(&self, other: &Self) -> Option { + let major = self.major_version().partial_cmp(&other.major_version())?; + let minor = self.minor_version().partial_cmp(&other.minor_version())?; + let patch = self.patch_version().partial_cmp(&other.patch_version())?; + Some(major.then(minor).then(patch)) + } +} + +impl fmt::Display for MacosVersion { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&self.version()) + } } impl MacosVersion { @@ -34,7 +59,7 @@ impl MacosVersion { Self::from_raw_version(&run_sw_vers()?) } - fn from_raw_version(version_string: &str) -> Result { + pub fn from_raw_version(version_string: &str) -> Result { let (major, minor, patch) = parse_version_output(version_string).ok_or(io::Error::new( io::ErrorKind::InvalidInput, "Failed to parse raw version string", @@ -66,7 +91,7 @@ impl MacosVersion { } pub fn patch_version(&self) -> u32 { - self.patch + self.patch.unwrap_or(0) } } @@ -75,14 +100,11 @@ fn run_sw_vers() -> io::Result { command_stdout_lossy("sw_vers", &["-productVersion"]) } -fn parse_version_output(output: &str) -> Option<(u32, u32, u32)> { +fn parse_version_output(output: &str) -> Option<(u32, u32, Option)> { let mut parts = output.split('.'); let major = parts.next()?.parse().ok()?; let minor = parts.next()?.parse().ok()?; - let patch = parts - .next() - .and_then(|patch| patch.parse().ok()) - .unwrap_or(0); + let patch = parts.next().and_then(|patch| patch.parse().ok()); Some((major, minor, patch)) } @@ -95,3 +117,52 @@ fn test_version_parsing() { assert_eq!(version.minor_version(), 2); assert_eq!(version.patch_version(), 1); } + +#[test] +fn test_version_order() { + assert_eq!( + MacosVersion::from_raw_version("13.0").unwrap(), + MacosVersion::from_raw_version("13.0.0").unwrap() + ); + + assert_eq!( + MacosVersion::from_raw_version("13.0") + .unwrap() + .partial_cmp(&MacosVersion::from_raw_version("13.0.0").unwrap()), + Some(Ordering::Equal), + ); + + // test major version + assert!( + MacosVersion::from_raw_version("13.0").unwrap() + < MacosVersion::from_raw_version("14.2.1").unwrap() + ); + assert!( + MacosVersion::from_raw_version("13.0").unwrap() + > MacosVersion::from_raw_version("12.1").unwrap() + ); + + // test minor version + assert!( + MacosVersion::from_raw_version("14.3").unwrap() + > MacosVersion::from_raw_version("14.2").unwrap() + ); + assert!( + MacosVersion::from_raw_version("14.2").unwrap() + < MacosVersion::from_raw_version("14.3").unwrap() + ); + + // test patch version + assert!( + MacosVersion::from_raw_version("14.2.1").unwrap() + > MacosVersion::from_raw_version("14.2").unwrap() + ); + assert!( + MacosVersion::from_raw_version("14.2.2").unwrap() + > MacosVersion::from_raw_version("14.2.1").unwrap() + ); + assert!( + MacosVersion::from_raw_version("14.2.2").unwrap() + < MacosVersion::from_raw_version("14.2.3").unwrap() + ); +} diff --git a/talpid-routing/src/lib.rs b/talpid-routing/src/lib.rs index 0ba3eb1275ec..b80f96ccdc56 100644 --- a/talpid-routing/src/lib.rs +++ b/talpid-routing/src/lib.rs @@ -28,6 +28,47 @@ pub use imp::{imp::RouteError, DefaultRouteEvent, PlatformError}; pub use imp::{Error, RouteManagerHandle}; +/// Link-layer/MAC adress +#[cfg(target_os = "macos")] +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub struct MacAddress(pub [u8; 6]); + +#[cfg(target_os = "macos")] +impl MacAddress { + /// Consume bytes that make up the link address + pub fn into_bytes(self) -> [u8; 6] { + self.0 + } +} + +#[cfg(target_os = "macos")] +impl From<[u8; 6]> for MacAddress { + fn from(addr: [u8; 6]) -> Self { + Self(addr) + } +} + +#[cfg(target_os = "macos")] +impl fmt::Display for MacAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:<02X}{:<02X}{:<02X}{:<02X}{:<02X}{:<02X}", + self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5] + ) + } +} + +/// Gateway, including IP address and MAC address +#[cfg(target_os = "macos")] +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct Gateway { + /// Network layer address for the gateway + pub ip_address: IpAddr, + /// Link layer address for the gateway + pub mac_address: MacAddress, +} + /// A network route with a specific network node, destination and an optional metric. #[derive(Debug, Hash, Eq, PartialEq, Clone)] pub struct Route { diff --git a/talpid-routing/src/unix/macos/interface.rs b/talpid-routing/src/unix/macos/interface.rs index ebfe11fa16e6..25e3d2e3cfdd 100644 --- a/talpid-routing/src/unix/macos/interface.rs +++ b/talpid-routing/src/unix/macos/interface.rs @@ -11,6 +11,7 @@ use std::{ }; use super::data::{Destination, RouteMessage}; +use system_configuration::core_foundation::string::CFStringRef; use system_configuration::{ core_foundation::{ array::CFArray, @@ -60,6 +61,7 @@ impl Family { struct NetworkServiceDetails { name: String, router_ip: IpAddr, + first_ip: IpAddr, } pub struct PrimaryInterfaceMonitor { @@ -74,6 +76,34 @@ pub enum InterfaceEvent { Update, } +/// Default interface/route +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultRoute { + /// Default interface name + pub interface: String, + /// Default interface index + pub interface_index: u16, + /// Router IP + pub router_ip: IpAddr, + /// Default interface IP address + pub ip: IpAddr, +} + +impl From for RouteMessage { + fn from(route: DefaultRoute) -> Self { + let network = if route.router_ip.is_ipv4() { + Family::V4.default_network() + } else { + Family::V6.default_network() + }; + // The route message requires a socket address. The port is ignored in this case. + let router_addr = SocketAddr::from((route.router_ip, 0)); + RouteMessage::new_route(Destination::Network(network)) + .set_gateway_addr(router_addr) + .set_interface_index(route.interface_index) + } +} + impl PrimaryInterfaceMonitor { pub fn new() -> (Self, UnboundedReceiver) { let store = SCDynamicStoreBuilder::new("talpid-routing").build(); @@ -126,7 +156,7 @@ impl PrimaryInterfaceMonitor { /// Retrieve the best current default route. This is based on the primary interface, or else /// the first active interface in the network service order. - pub fn get_route(&self, family: Family) -> Option { + pub fn get_route(&self, family: Family) -> Option { let ifaces = self .get_primary_interface(family) .map(|iface| { @@ -138,134 +168,110 @@ impl PrimaryInterfaceMonitor { let (iface, index) = ifaces .into_iter() .filter_map(|iface| { - let index = if_nametoindex(iface.name.as_str()).map_err(|error| { - log::error!("Failed to retrieve interface index for \"{}\": {error}", iface.name); - error - }).ok()?; - - let active = is_active_interface(&iface.name, family).unwrap_or_else(|error| { - log::error!("is_active_interface() returned an error for interface \"{}\", assuming active. Error: {error}", iface.name); - true - }); - if !active { - log::debug!("Skipping inactive interface {}, router IP {}", iface.name, iface.router_ip); - return None; - } + let index = if_nametoindex(iface.name.as_str()) + .inspect_err(|error| { + log::error!( + "Failed to retrieve interface index for \"{}\": {error}", + iface.name + ); + }) + .ok()?; Some((iface, index)) }) .next()?; - let router_addr = (iface.router_ip, 0); - let mut router_addr = SocketAddr::from(router_addr); + let index = u16::try_from(index).unwrap(); - // If the gateway is a link-local address, scope ID must be specified - if let SocketAddr::V6(ref mut v6_addr) = router_addr { - let v6ip = v6_addr.ip(); - - if is_link_local_v6(v6ip) { + let mut router_ip = iface.router_ip; + if let IpAddr::V6(ref mut addr) = router_ip { + if is_link_local_v6(addr) { // The second pair of octets should be set to the scope id // See getaddr() in route.c: // https://opensource.apple.com/source/network_cmds/network_cmds-396.6/route.tproj/route.c.auto.html - let second_octet = u16::try_from(index).unwrap().to_be_bytes(); + let second_octet = index.to_be_bytes(); - let mut octets = v6ip.octets(); + let mut octets = addr.octets(); octets[2] = second_octet[0]; octets[3] = second_octet[1]; - let new_ip = Ipv6Addr::from(octets); - - v6_addr.set_ip(new_ip); + *addr = Ipv6Addr::from(octets); } } - let msg = RouteMessage::new_route(Destination::Network(family.default_network())) - .set_gateway_addr(router_addr) - .set_interface_index(u16::try_from(index).unwrap()); - Some(msg) + Some(DefaultRoute { + interface: iface.name, + interface_index: index, + router_ip, + ip: iface.first_ip, + }) } fn get_primary_interface(&self, family: Family) -> Option { - let global_name = if family == Family::V4 { + let key = if family == Family::V4 { STATE_IPV4_KEY } else { STATE_IPV6_KEY }; - let global_dict = self + let ip_dict = self .store - .get(CFString::new(global_name)) + .get(key) .and_then(|v| v.downcast_into::())?; - let name = global_dict - .find(unsafe { kSCDynamicStorePropNetPrimaryInterface }.to_void()) - .map(|s| unsafe { CFType::wrap_under_get_rule(*s) }) - .and_then(|s| s.downcast::()) - .map(|s| s.to_string()) - .or_else(|| { - log::debug!("Missing name for primary interface ({family})"); - None - })?; - - let router_key = if family == Family::V4 { - unsafe { kSCPropNetIPv4Router.to_void() } - } else { - unsafe { kSCPropNetIPv6Router.to_void() } - }; - - let router_ip = global_dict - .find(router_key) - .map(|s| unsafe { CFType::wrap_under_get_rule(*s) }) - .and_then(|s| s.downcast::()) - .and_then(|ip| ip.to_string().parse().ok()) - .or_else(|| { - log::debug!("Missing router IP for primary interface \"{name}\""); - None - })?; - - Some(NetworkServiceDetails { name, router_ip }) + let name = + get_dict_elem_as_string(&ip_dict, unsafe { kSCDynamicStorePropNetPrimaryInterface }) + .or_else(|| { + log::debug!("Missing name for primary interface ({family})"); + None + })?; + let router_ip = get_service_router_ip(&ip_dict, family).or_else(|| { + log::debug!("Missing router IP for primary interface ({name}, {family})"); + None + })?; + let first_ip = find_first_ip(&name, family).or_else(|| { + log::debug!("Missing IP for primary interface ({name}, {family})"); + None + })?; + + Some(NetworkServiceDetails { + name, + router_ip, + first_ip, + }) } fn network_services(&self, family: Family) -> Vec { - let router_key = if family == Family::V4 { - unsafe { kSCPropNetIPv4Router.to_void() } - } else { - unsafe { kSCPropNetIPv6Router.to_void() } - }; - SCNetworkSet::new(&self.prefs) .service_order() .iter() .filter_map(|service_id| { let service_id_s = service_id.to_string(); - let key = if family == Family::V4 { + let service_key = if family == Family::V4 { format!("State:/Network/Service/{service_id_s}/IPv4") } else { format!("State:/Network/Service/{service_id_s}/IPv6") }; - let ip_dict = self .store - .get(CFString::new(&key)) + .get(CFString::new(&service_key)) .and_then(|v| v.downcast_into::())?; - let name = ip_dict - .find(unsafe { kSCPropInterfaceName }.to_void()) - .map(|s| unsafe { CFType::wrap_under_get_rule(*s) }) - .and_then(|s| s.downcast::()) - .map(|s| s.to_string()) - .or_else(|| { - log::debug!("Missing name for service {service_id_s} ({family})"); - None - })?; - let router_ip = ip_dict - .find(router_key) - .map(|s| unsafe { CFType::wrap_under_get_rule(*s) }) - .and_then(|s| s.downcast::()) - .and_then(|ip| ip.to_string().parse().ok()) + let name = get_dict_elem_as_string(&ip_dict, unsafe { kSCPropInterfaceName }) .or_else(|| { - log::debug!("Missing router IP for {service_id_s} ({name}, {family})"); + log::debug!("Missing name for service {service_key} ({family})"); None })?; - - Some(NetworkServiceDetails { name, router_ip }) + let router_ip = get_service_router_ip(&ip_dict, family).or_else(|| { + log::debug!("Missing router IP for {service_key} ({name}, {family})"); + None + })?; + let first_ip = find_first_ip(&name, family).or_else(|| { + log::debug!("Missing IP for \"{service_key}\" ({name}, {family})"); + None + })?; + Some(NetworkServiceDetails { + name, + router_ip, + first_ip, + }) }) .collect::>() } @@ -297,26 +303,30 @@ pub fn get_interface_link_addresses() -> io::Result io::Result { +/// Return the first assigned (unicast) IP address for the given interface +fn find_first_ip(interface_name: &str, family: Family) -> Option { let required_link_flags: InterfaceFlags = InterfaceFlags::IFF_UP | InterfaceFlags::IFF_RUNNING; - let has_ip_addr = nix::ifaddrs::getifaddrs()? + nix::ifaddrs::getifaddrs() + .ok()? .filter(|addr| (addr.flags & required_link_flags) == required_link_flags) .filter(|addr| addr.interface_name == interface_name) - .any(|addr| { - if let Some(addr) = addr.address { - // Check if family matches; ignore if link-local address - match family { - Family::V4 => matches!(addr.as_sockaddr_in(), Some(addr_in) if is_routable_v4(&addr_in.ip())), - Family::V6 => { - matches!(addr.as_sockaddr_in6(), Some(addr_in) if is_routable_v6(&addr_in.ip())) - } - } - } else { - false - } - }); - Ok(has_ip_addr) + .filter_map(|addr| addr.address) + .find_map(|addr| match family { + Family::V4 => addr + .as_sockaddr_in() + .map(|addr_in| IpAddr::from(addr_in.ip())), + Family::V6 => addr + .as_sockaddr_in6() + .map(|addr_in| IpAddr::from(addr_in.ip())), + }) + .filter(is_routable) +} + +fn is_routable(addr: &IpAddr) -> bool { + match addr { + IpAddr::V4(ip) => is_routable_v4(ip), + IpAddr::V6(ip) => is_routable_v6(ip), + } } fn is_routable_v4(addr: &Ipv4Addr) -> bool { @@ -330,3 +340,19 @@ fn is_routable_v6(addr: &Ipv6Addr) -> bool { fn is_link_local_v6(addr: &Ipv6Addr) -> bool { (addr.segments()[0] & 0xffc0) == 0xfe80 } + +fn get_service_router_ip(ip_dict: &CFDictionary, family: Family) -> Option { + let router_key = if family == Family::V4 { + unsafe { kSCPropNetIPv4Router } + } else { + unsafe { kSCPropNetIPv6Router } + }; + get_dict_elem_as_string(ip_dict, router_key).and_then(|ip| ip.parse().ok()) +} + +fn get_dict_elem_as_string(dict: &CFDictionary, key: CFStringRef) -> Option { + dict.find(key.to_void()) + .map(|s| unsafe { CFType::wrap_under_get_rule(*s) }) + .and_then(|s| s.downcast::()) + .map(|s| s.to_string()) +} diff --git a/talpid-routing/src/unix/macos/mod.rs b/talpid-routing/src/unix/macos/mod.rs index 448a87427c9f..141b8c06d38d 100644 --- a/talpid-routing/src/unix/macos/mod.rs +++ b/talpid-routing/src/unix/macos/mod.rs @@ -1,4 +1,4 @@ -use crate::{debounce::BurstGuard, NetNode, Node, RequiredRoute, Route}; +use crate::{debounce::BurstGuard, Gateway, MacAddress, NetNode, RequiredRoute, Route}; use futures::{ channel::mpsc::{self, UnboundedReceiver}, @@ -8,6 +8,7 @@ use futures::{ use ipnetwork::IpNetwork; use std::{ collections::{BTreeMap, HashSet}, + net::{IpAddr, SocketAddr}, pin::Pin, sync::Weak, time::Duration, @@ -18,6 +19,8 @@ use watch::RoutingTable; use super::{DefaultRouteEvent, RouteManagerCommand}; use data::{Destination, RouteDestination, RouteMessage, RouteSocketMessage}; +pub use interface::DefaultRoute; + mod data; mod interface; mod routing_socket; @@ -83,8 +86,8 @@ pub struct RouteManagerImpl { v4_tunnel_default_route: Option, v6_tunnel_default_route: Option, applied_routes: BTreeMap, - v4_default_route: Option, - v6_default_route: Option, + v4_default_route: Option, + v6_default_route: Option, update_trigger: BurstGuard, default_route_listeners: Vec>, check_default_routes_restored: Pin + Send>>, @@ -194,33 +197,22 @@ impl RouteManagerImpl { let _ = tx.send(events_rx); } Some(RouteManagerCommand::GetDefaultRoutes(tx)) => { - // NOTE: The device name isn't really relevant here, - // as we only care about routes with a gateway IP. - let v4_route = self.v4_default_route.as_ref().map(|route| { - Route { - node: Node { - device: None, - ip: route.gateway_ip(), - }, - prefix: interface::Family::V4.default_network(), - metric: None, - mtu: None, - } - }); - let v6_route = self.v6_default_route.as_ref().map(|route| { - Route { - node: Node { - device: None, - ip: route.gateway_ip(), - }, - prefix: interface::Family::V6.default_network(), - metric: None, - mtu: None, - } - }); - + let v4_route = self.v4_default_route.clone(); + let v6_route = self.v6_default_route.clone(); let _ = tx.send((v4_route, v6_route)); } + Some(RouteManagerCommand::GetDefaultGateway(tx)) => { + let mut v4_gateway = None; + let mut v6_gateway = None; + + if let Some(v4_route) = &self.v4_default_route { + v4_gateway = self.get_gateway_link_address(v4_route.router_ip).await; + } + if let Some(v6_route) = &self.v6_default_route { + v6_gateway = self.get_gateway_link_address(v6_route.router_ip).await; + } + let _ = tx.send((v4_gateway, v6_gateway)); + } Some(RouteManagerCommand::AddRoutes(routes, tx)) => { if !self.check_default_routes_restored.is_terminated() { @@ -259,6 +251,25 @@ impl RouteManagerImpl { } } + async fn get_gateway_link_address(&mut self, gateway_ip: IpAddr) -> Option { + let gateway_msg = RouteMessage::new_route(Destination::Host(gateway_ip)); + + if let Ok(Some(msg)) = self.routing_table.get_route(&gateway_msg).await { + if let Some(gateway) = msg + .gateway() + .and_then(|gateway| gateway.as_link_addr()) + .and_then(|addr| addr.addr()) + { + let mac_address = MacAddress::from(gateway); + return Some(Gateway { + ip_address: gateway_ip, + mac_address, + }); + } + } + None + } + async fn add_required_routes(&mut self, required_routes: HashSet) -> Result<()> { let mut routes_to_apply = vec![]; @@ -430,6 +441,7 @@ impl RouteManagerImpl { /// On success, the function returns whether the previously known best default changed. fn update_best_default_route(&mut self, family: interface::Family) -> Result { let best_route = self.primary_interface_monitor.get_route(family); + let current_route = get_current_best_default_route!(self, family); log::trace!("Best route ({family:?}): {best_route:?}"); @@ -441,10 +453,10 @@ impl RouteManagerImpl { let old_pair = current_route .as_ref() - .map(|r| (r.interface_index(), r.gateway_ip())); + .map(|r| (r.interface_index, r.router_ip)); let new_pair = best_route .as_ref() - .map(|r| (r.interface_index(), r.gateway_ip())); + .map(|r| (r.interface_index, r.router_ip)); log::debug!("Best default route ({family}) changed from {old_pair:?} to {new_pair:?}"); let _ = std::mem::replace(current_route, best_route); @@ -535,13 +547,11 @@ impl RouteManagerImpl { let v4_gateway = self .v4_default_route .as_ref() - .and_then(|route| route.gateway()) - .cloned(); + .map(|route| SocketAddr::new(route.router_ip, 0)); let v6_gateway = self .v6_default_route .as_ref() - .and_then(|route| route.gateway()) - .cloned(); + .map(|route| SocketAddr::new(route.router_ip, 0)); // Reapply routes that use the default (non-tunnel) node for dest in self.non_tunnel_routes.clone() { @@ -555,7 +565,7 @@ impl RouteManagerImpl { None => continue, }; let route = - RouteMessage::new_route(Destination::Network(dest)).set_gateway_sockaddr(gateway); + RouteMessage::new_route(Destination::Network(dest)).set_gateway_addr(gateway); if let Some(dest) = self .applied_routes @@ -579,8 +589,9 @@ impl RouteManagerImpl { return Ok(()); }; - let interface_index = default_route.interface_index(); - let new_route = default_route.clone().set_ifscope(interface_index); + let interface_index = default_route.interface_index; + let default_route = RouteMessage::from(default_route.clone()); + let new_route = default_route.set_ifscope(interface_index); log::trace!("Setting ifscope: {new_route:?}"); @@ -590,12 +601,15 @@ impl RouteManagerImpl { async fn add_route_with_record(&mut self, route: RouteMessage) -> Result<()> { let destination = RouteDestination::try_from(&route).map_err(Error::InvalidData)?; - self.routing_table + let add_result = self + .routing_table .add_route(&route) .await .map_err(Error::AddRoute)?; - self.applied_routes.insert(destination, route); + if add_result == watch::AddResult::Ok { + self.applied_routes.insert(destination, route); + } Ok(()) } @@ -672,6 +686,7 @@ impl RouteManagerImpl { let Some(desired_default_route) = self.primary_interface_monitor.get_route(family) else { return true; }; + let desired_default_route = RouteMessage::from(desired_default_route); let current_default_route = RouteMessage::new_route(family.default_network().into()); if let Ok(Some(current_default)) = diff --git a/talpid-routing/src/unix/macos/watch.rs b/talpid-routing/src/unix/macos/watch.rs index 70271a85d431..61c620b2f3a6 100644 --- a/talpid-routing/src/unix/macos/watch.rs +++ b/talpid-routing/src/unix/macos/watch.rs @@ -37,6 +37,15 @@ pub struct RoutingTable { socket: routing_socket::RoutingSocket, } +/// Result of successfully adding a route +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum AddResult { + /// A new route was created + Ok, + /// The route already exists + AlreadyExists, +} + impl RoutingTable { pub fn new() -> Result { let socket = routing_socket::RoutingSocket::new().map_err(Error::RoutingSocket)?; @@ -63,14 +72,14 @@ impl RoutingTable { data::RouteSocketMessage::parse_message(msg_buf).map_err(Error::InvalidMessage) } - pub async fn add_route(&mut self, message: &RouteMessage) -> Result<()> { + pub async fn add_route(&mut self, message: &RouteMessage) -> Result { if let Ok(destination) = message.destination_ip() { if Some(destination.ip()) == message.gateway_ip() { // Workaround that allows us to reach a wg peer on our router. // If we don't do this, adding the route fails due to errno 49 // ("Can't assign requested address"). log::warn!("Ignoring route because the destination equals its gateway"); - return Ok(()); + return Ok(AddResult::AlreadyExists); } } @@ -79,11 +88,11 @@ impl RoutingTable { .await; match msg { - Ok(RouteSocketMessage::AddRoute(_route)) => Ok(()), + Ok(RouteSocketMessage::AddRoute(_route)) => Ok(AddResult::Ok), Err(Error::Send(routing_socket::Error::Write(err))) if err.kind() == io::ErrorKind::AlreadyExists => { - Ok(()) + Ok(AddResult::AlreadyExists) } Ok(anything_else) => { log::error!("Unexpected route message: {anything_else:?}"); diff --git a/talpid-routing/src/unix/mod.rs b/talpid-routing/src/unix/mod.rs index 7fe7e9bf3137..5f14d88d67ac 100644 --- a/talpid-routing/src/unix/mod.rs +++ b/talpid-routing/src/unix/mod.rs @@ -1,5 +1,7 @@ -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(target_os = "linux")] use crate::Route; +#[cfg(target_os = "macos")] +pub use crate::{imp::imp::DefaultRoute, Gateway}; use super::RequiredRoute; @@ -70,6 +72,7 @@ impl Error { type Fwmark = u32; /// Commands for the underlying route manager object. +#[cfg(target_os = "linux")] #[derive(Debug)] pub(crate) enum RouteManagerCommand { AddRoutes( @@ -78,22 +81,11 @@ pub(crate) enum RouteManagerCommand { ), ClearRoutes, Shutdown(oneshot::Sender<()>), - #[cfg(target_os = "macos")] - RefreshRoutes, - #[cfg(target_os = "macos")] - NewDefaultRouteListener(oneshot::Sender>), - #[cfg(target_os = "macos")] - GetDefaultRoutes(oneshot::Sender<(Option, Option)>), - #[cfg(target_os = "linux")] CreateRoutingRules(bool, oneshot::Sender>), - #[cfg(target_os = "linux")] ClearRoutingRules(oneshot::Sender>), - #[cfg(target_os = "linux")] NewChangeListener(oneshot::Sender>), - #[cfg(target_os = "linux")] GetMtuForRoute(IpAddr, oneshot::Sender>), /// Attempt to fetch a route for the given destination with an optional firewall mark. - #[cfg(target_os = "linux")] GetDestinationRoute( IpAddr, Option, @@ -101,6 +93,35 @@ pub(crate) enum RouteManagerCommand { ), } +/// Commands for the underlying route manager object. +#[cfg(target_os = "android")] +#[derive(Debug)] +pub(crate) enum RouteManagerCommand { + AddRoutes( + HashSet, + oneshot::Sender>, + ), + ClearRoutes, + Shutdown(oneshot::Sender<()>), +} + +/// Commands for the underlying route manager object. +#[cfg(target_os = "macos")] +#[derive(Debug)] +pub(crate) enum RouteManagerCommand { + AddRoutes( + HashSet, + oneshot::Sender>, + ), + ClearRoutes, + Shutdown(oneshot::Sender<()>), + RefreshRoutes, + NewDefaultRouteListener(oneshot::Sender>), + GetDefaultRoutes(oneshot::Sender<(Option, Option)>), + /// Return gateway for V4 and V6 + GetDefaultGateway(oneshot::Sender<(Option, Option)>), +} + /// Event that is sent when a preferred non-tunnel default route is /// added or removed. #[cfg(target_os = "macos")] @@ -196,7 +217,9 @@ impl RouteManagerHandle { /// Get current non-tunnel default routes. #[cfg(target_os = "macos")] - pub async fn get_default_routes(&self) -> Result<(Option, Option), Error> { + pub async fn get_default_routes( + &self, + ) -> Result<(Option, Option), Error> { let (response_tx, response_rx) = oneshot::channel(); self.tx .unbounded_send(RouteManagerCommand::GetDefaultRoutes(response_tx)) @@ -204,6 +227,16 @@ impl RouteManagerHandle { response_rx.await.map_err(|_| Error::ManagerChannelDown) } + /// Get default gateway + #[cfg(target_os = "macos")] + pub async fn get_default_gateway(&self) -> Result<(Option, Option), Error> { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .unbounded_send(RouteManagerCommand::GetDefaultGateway(response_tx)) + .map_err(|_| Error::RouteManagerDown)?; + response_rx.await.map_err(|_| Error::ManagerChannelDown) + } + /// Get current non-tunnel default routes. #[cfg(target_os = "macos")] pub fn refresh_routes(&self) -> Result<(), Error> { diff --git a/talpid-types/src/net/mod.rs b/talpid-types/src/net/mod.rs index a17d8ceb5f25..dcf7d9a22c53 100644 --- a/talpid-types/src/net/mod.rs +++ b/talpid-types/src/net/mod.rs @@ -409,6 +409,12 @@ pub enum AllowedTunnelTraffic { Two(Endpoint, Endpoint), } +impl AllowedTunnelTraffic { + pub fn all(&self) -> bool { + matches!(self, AllowedTunnelTraffic::All) + } +} + impl fmt::Display for AllowedTunnelTraffic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { match self { diff --git a/talpid-types/src/tunnel.rs b/talpid-types/src/tunnel.rs index 6c85dcfb34d6..e67db2f0c484 100644 --- a/talpid-types/src/tunnel.rs +++ b/talpid-types/src/tunnel.rs @@ -108,7 +108,7 @@ pub enum ErrorStateCause { #[cfg(target_os = "android")] VpnPermissionDenied, /// Error reported by split tunnel module. - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] SplitTunnelError, } @@ -215,7 +215,7 @@ impl fmt::Display for ErrorStateCause { IsOffline => "This device is offline, no tunnels can be established", #[cfg(target_os = "android")] VpnPermissionDenied => "The Android VPN permission was denied when creating the tunnel", - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] SplitTunnelError => "The split tunneling module reported an error", }; diff --git a/test/test-manager/src/tests/helpers.rs b/test/test-manager/src/tests/helpers.rs index b733939da073..fbf447df5d73 100644 --- a/test/test-manager/src/tests/helpers.rs +++ b/test/test-manager/src/tests/helpers.rs @@ -828,6 +828,10 @@ impl ConnChecker { .await?; } + // TODO: The ST process monitor is a bit racy on macOS, such that processes aren't + // immediately recognized. This is a workaround until fixed. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + Ok(ConnCheckerHandle { pid, checker: self }) } @@ -838,13 +842,12 @@ impl ConnChecker { match TEST_CONFIG.os { Os::Linux => { /* linux programs can't be split until they are spawned */ } - Os::Windows => { + Os::Macos | Os::Windows => { self.mullvad_client .add_split_tunnel_app(&self.executable_path) .await?; self.mullvad_client.set_split_tunnel_state(true).await?; } - Os::Macos => unimplemented!("MacOS"), } Ok(()) @@ -857,13 +860,12 @@ impl ConnChecker { match TEST_CONFIG.os { Os::Linux => {} - Os::Windows => { + Os::Macos | Os::Windows => { self.mullvad_client.set_split_tunnel_state(false).await?; self.mullvad_client .remove_split_tunnel_app(&self.executable_path) .await?; } - Os::Macos => unimplemented!("MacOS"), } Ok(()) diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs index ce38308b0d7a..8db87e4eaefd 100644 --- a/test/test-manager/src/tests/mod.rs +++ b/test/test-manager/src/tests/mod.rs @@ -96,6 +96,8 @@ pub async fn cleanup_after_test( tunnel_options, relay_overrides, show_beta_releases, + #[cfg(target_os = "macos")] + split_tunnel: _, settings_version: _, // N/A } = Default::default(); diff --git a/test/test-manager/src/tests/split_tunnel.rs b/test/test-manager/src/tests/split_tunnel.rs index 609acf7ac8c8..3823e285f772 100644 --- a/test/test-manager/src/tests/split_tunnel.rs +++ b/test/test-manager/src/tests/split_tunnel.rs @@ -2,6 +2,7 @@ use anyhow::Context; use mullvad_management_interface::MullvadProxyClient; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use test_macro::test_function; +use test_rpc::meta::OsVersion; use test_rpc::ServiceClient; use super::{ @@ -15,12 +16,22 @@ const LEAK_DESTINATION: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, /// - Splitting a process shouldn't do anything if tunnel is not connected. /// - A split process should never push traffic through the tunnel. /// - Splitting/unsplitting should work regardless if process is running. -#[test_function(target_os = "linux", target_os = "windows")] +#[test_function] pub async fn test_split_tunnel( _ctx: TestContext, rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, ) -> anyhow::Result<()> { + // Skip test on macOS 12, since the feature is unsupported + match rpc.get_os_version().await.context("Detect OS version")? { + OsVersion::Macos(version) if version.major <= 12 => { + // TODO: Skip test cleanly, e.g. by returning a result `Pass | Skip` + log::info!("Skipping test on macOS 12"); + return Ok(()); + } + _ => (), + } + let mut checker = ConnChecker::new(rpc.clone(), mullvad_client.clone(), LEAK_DESTINATION); // Test that program is behaving when we are disconnected @@ -53,6 +64,7 @@ pub async fn test_split_tunnel( // Test running a split program checker.split().await?; + checker .spawn() .await?