summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2024-01-23 14:15:29 +0100
committerDavid Lönnhager <david.l@mullvad.net>2024-04-30 16:22:46 +0200
commita1da9e64b657f95dc2690a1ac4c4e5293b83d1e5 (patch)
treeb3c7145bf11570832dcddaaf438cb20237f3eaae
parentb871bdf78c66848011925d8fd0d4291537e6e18e (diff)
downloadmullvadvpn-a1da9e64b657f95dc2690a1ac4c4e5293b83d1e5.tar.xz
mullvadvpn-a1da9e64b657f95dc2690a1ac4c4e5293b83d1e5.zip
Add initial split tunneling implementation for macOS
-rw-r--r--Cargo.lock104
-rw-r--r--mullvad-cli/src/cmds/split_tunnel/macos.rs86
-rw-r--r--mullvad-cli/src/cmds/split_tunnel/mod.rs5
-rw-r--r--mullvad-cli/src/main.rs2
-rw-r--r--mullvad-daemon/src/lib.rs89
-rw-r--r--mullvad-daemon/src/management_interface.rs26
-rw-r--r--mullvad-management-interface/proto/management_interface.proto2
-rw-r--r--mullvad-management-interface/src/types/conversions/settings.rs10
-rw-r--r--mullvad-management-interface/src/types/conversions/states.rs2
-rw-r--r--mullvad-types/src/settings/mod.rs8
-rw-r--r--talpid-core/Cargo.toml7
-rw-r--r--talpid-core/src/firewall/macos.rs166
-rw-r--r--talpid-core/src/firewall/mod.rs6
-rw-r--r--talpid-core/src/split_tunnel/macos/bindings.rs349
-rw-r--r--talpid-core/src/split_tunnel/macos/bpf.rs364
-rw-r--r--talpid-core/src/split_tunnel/macos/default.rs96
-rwxr-xr-xtalpid-core/src/split_tunnel/macos/generate-bindings.sh21
-rw-r--r--talpid-core/src/split_tunnel/macos/include/.gitignore3
-rw-r--r--talpid-core/src/split_tunnel/macos/include/bindings.h12
-rw-r--r--talpid-core/src/split_tunnel/macos/mod.rs468
-rw-r--r--talpid-core/src/split_tunnel/macos/process.rs458
-rw-r--r--talpid-core/src/split_tunnel/macos/tun.rs764
-rw-r--r--talpid-core/src/split_tunnel/mod.rs11
-rw-r--r--talpid-core/src/tunnel_state_machine/connected_state.rs36
-rw-r--r--talpid-core/src/tunnel_state_machine/connecting_state.rs46
-rw-r--r--talpid-core/src/tunnel_state_machine/disconnected_state.rs7
-rw-r--r--talpid-core/src/tunnel_state_machine/disconnecting_state.rs21
-rw-r--r--talpid-core/src/tunnel_state_machine/error_state.rs7
-rw-r--r--talpid-core/src/tunnel_state_machine/mod.rs104
-rw-r--r--talpid-routing/src/lib.rs41
-rw-r--r--talpid-routing/src/unix/macos/interface.rs195
-rw-r--r--talpid-routing/src/unix/macos/mod.rs86
-rw-r--r--talpid-routing/src/unix/mod.rs59
-rw-r--r--talpid-types/src/net/mod.rs6
-rw-r--r--talpid-types/src/tunnel.rs4
35 files changed, 3452 insertions, 219 deletions
diff --git a/Cargo.lock b/Cargo.lock
index d39c7fd363..ad18fc9498 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",
]
@@ -1927,6 +1927,16 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2705,6 +2715,23 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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",
@@ -4285,6 +4319,20 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
@@ -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]]
@@ -4755,6 +4808,19 @@ dependencies = [
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
@@ -4846,6 +4912,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
@@ -4864,6 +4936,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
@@ -4888,6 +4966,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
@@ -4906,6 +4990,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
@@ -4942,6 +5032,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
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 0000000000..534b4fd86a
--- /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 c9e87f5d7c..37ed33b64b 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 0dd5d26dbe..67990bd888 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 69eebdbdd6..c586c4c316 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<Result<(), Error>>),
}
-#[cfg(target_os = "windows")]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
pub(crate) enum ExcludedPathsUpdate {
SetState(bool),
SetPaths(HashSet<PathBuf>),
@@ -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 2e7f04b1d9..c94b5f3506 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<String>) -> 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<String>) -> ServiceResult<()> {
Ok(Response::new(()))
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn remove_split_tunnel_app(&self, request: Request<String>) -> 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<String>) -> 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<bool>) -> 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<bool>) -> 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 31d39db306..efe9f9eb87 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 857f32d991..0cad85def3 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<proto::Settings> 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<proto::Settings> for mullvad_types::settings::Settings {
.map(mullvad_types::relay_constraints::RelayOverride::try_from)
.collect::<Result<Vec<_>, _>>()?,
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<proto::SplitTunnelSettings> 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 f6e41f4d87..881cbc6c9a 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<mullvad_types::states::TunnelState> 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 2a4adbb069..464a4c89c1 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 b079257cb3..66b22563df 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 b3502a8441..156815302b 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,37 @@ 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.extend(self.get_allow_established_tunnel_rules(
+ &tunnel.interface,
+ allowed_tunnel_traffic,
+ )?);
+ 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 +178,7 @@ impl Firewall {
tunnel,
allow_lan,
dns_servers,
+ redirect_interface,
} => {
let mut rules = vec![];
@@ -165,15 +192,27 @@ 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.extend(self.get_allow_established_tunnel_rules(
+ &tunnel.interface,
+ &AllowedTunnelTraffic::All,
+ )?);
+ 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 +382,44 @@ 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<Vec<pfctl::FilterRule>> {
+ self.get_allow_tunnel_rules_inner(tunnel_interface, allowed_traffic, Self::get_tcp_flags())
}
- fn get_allow_tunnel_rules(
+ fn get_allow_established_tunnel_rules(
&self,
tunnel_interface: &str,
allowed_traffic: &AllowedTunnelTraffic,
) -> Result<Vec<pfctl::FilterRule>> {
+ self.get_allow_tunnel_rules_inner(
+ tunnel_interface,
+ allowed_traffic,
+ pfctl::TcpFlags::new(
+ &[pfctl::TcpFlag::Syn, pfctl::TcpFlag::Ack],
+ &[pfctl::TcpFlag::Syn, pfctl::TcpFlag::Ack],
+ ),
+ )
+ }
+
+ fn get_allow_tunnel_rules_inner(
+ &self,
+ tunnel_interface: &str,
+ allowed_traffic: &AllowedTunnelTraffic,
+ tcp_flags: pfctl::TcpFlags,
+ ) -> Result<Vec<pfctl::FilterRule>> {
+ 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 +427,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 +438,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 +510,32 @@ impl Firewall {
Ok(rules)
}
+ fn get_split_tunnel_rules(
+ &self,
+ from_interface: &str,
+ to_interface: &str,
+ ) -> Result<Vec<pfctl::FilterRule>> {
+ let allow_rule = self
+ .create_rule_builder(FilterRuleAction::Pass)
+ .quick(true)
+ .direction(pfctl::Direction::Any)
+ .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![allow_rule, redir_rule])
+ }
+
fn get_allow_dhcp_client_rules(&self) -> Result<Vec<pfctl::FilterRule>> {
let mut dhcp_rule_builder = self.create_rule_builder(FilterRuleAction::Pass);
dhcp_rule_builder.quick(true).proto(pfctl::Proto::Udp);
@@ -676,3 +754,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 a0afb39f5d..87533b8527 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<String>,
},
/// 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<IpAddr>,
+ /// Interface to redirect (VPN tunnel) traffic to
+ #[cfg(target_os = "macos")]
+ redirect_interface: Option<String>,
},
/// 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 0000000000..311158b610
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/bindings.rs
@@ -0,0 +1,349 @@
+/* automatically generated by rust-bindgen 0.69.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<timeval32> = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::<timeval32>(),
+ 8usize,
+ concat!("Size of: ", stringify!(timeval32))
+ );
+ assert_eq!(
+ ::std::mem::align_of::<timeval32>(),
+ 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<pktap_header> = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::<pktap_header>(),
+ 156usize,
+ concat!("Size of: ", stringify!(pktap_header))
+ );
+ assert_eq!(
+ ::std::mem::align_of::<pktap_header>(),
+ 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<bpf_stat> = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::<bpf_stat>(),
+ 8usize,
+ concat!("Size of: ", stringify!(bpf_stat))
+ );
+ assert_eq!(
+ ::std::mem::align_of::<bpf_stat>(),
+ 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 0000000000..d2cd42bdcc
--- /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<Self, Error> {
+ 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<File, Error> {
+ 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<usize, Error> {
+ // 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<usize, Error> {
+ 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<u32, Error> {
+ 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<usize> {
+ self.file.read(buf)
+ }
+}
+
+impl Read for &Bpf {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ (&self.file).read(buf)
+ }
+}
+
+impl Write for Bpf {
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+ 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<usize> {
+ (&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<usize> {
+ self.0.read(buf)
+ }
+}
+
+impl Write for WriteHalf {
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+ 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<File>,
+}
+
+impl BpfStream {
+ pub fn from_read_half(reader: ReadHalf) -> Result<Self, Error> {
+ Self::from_file(reader.0)
+ }
+
+ fn from_file(file: File) -> Result<Self, Error> {
+ 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<io::Result<()>> {
+ 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::<bpf_hdr>() {
+ 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 0000000000..ce5415e769
--- /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<DefaultInterfaceAddrs<Ipv4Addr>>,
+ /// MAC/Hardware address of the gateway
+ pub v6_addrs: Option<DefaultInterfaceAddrs<Ipv6Addr>>,
+}
+
+/// Interface name, addresses, and gateway
+#[derive(Debug, Clone)]
+pub struct DefaultInterfaceAddrs<IpType> {
+ /// 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<DefaultInterface, Error> {
+ 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 0000000000..e78e03fde2
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/generate-bindings.sh
@@ -0,0 +1,21 @@
+#!/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 "^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 0000000000..a33b992fc8
--- /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 0000000000..59fd6ca2be
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/include/bindings.h
@@ -0,0 +1,12 @@
+#include <sys/param.h>
+#include <sys/ioctl.h>
+
+#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 0000000000..f7039a0eaf
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/mod.rs
@@ -0,0 +1,468 @@
+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<futures::channel::mpsc::UnboundedSender<TunnelCommand>>,
+ rx: mpsc::UnboundedReceiver<Message>,
+}
+
+enum Message {
+ GetInterface {
+ result_tx: oneshot::Sender<Option<String>>,
+ },
+ Shutdown {
+ result_tx: oneshot::Sender<()>,
+ },
+ SetExcludePaths {
+ result_tx: oneshot::Sender<Result<(), Error>>,
+ paths: HashSet<PathBuf>,
+ },
+ SetTunnel {
+ result_tx: oneshot::Sender<Result<(), Error>>,
+ new_vpn_interface: Option<VpnInterface>,
+ },
+}
+
+/// Handle for interacting with the split tunnel module
+#[derive(Clone)]
+pub struct Handle {
+ tx: mpsc::UnboundedSender<Message>,
+}
+
+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<String> {
+ 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<PathBuf>) -> 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<VpnInterface>) -> 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<futures::channel::mpsc::UnboundedSender<TunnelCommand>>,
+ 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,
+ };
+
+ 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 => {
+ 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 messages
+ message = self.rx.recv() => {
+ let Some(message) = message else {
+ // Shut down split tunnel
+ break
+ };
+
+ match message {
+ Message::GetInterface {
+ result_tx,
+ } => {
+ let _ = result_tx.send(self.interface().map(str::to_owned));
+ }
+ Message::Shutdown {
+ result_tx,
+ } => {
+ // Shut down; early exit
+ self.shutdown().await;
+ let _ = result_tx.send(());
+ return;
+ }
+ 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);
+ }
+ }
+ }
+ }
+ }
+
+ self.shutdown().await;
+ }
+
+ /// Shut down split tunnel
+ async fn shutdown(self) {
+ match self.state {
+ 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,
+ }
+ }
+}
+
+/// State machine
+enum State {
+ /// The initial state: no paths have been provided
+ NoExclusions {
+ route_manager: RouteManagerHandle,
+ vpn_interface: Option<VpnInterface>,
+ },
+ /// 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<VpnInterface>,
+ },
+ /// 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<VpnInterface>,
+ },
+}
+
+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<PathBuf>) -> 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<PathBuf>) -> Result<Self, Error> {
+ 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<VpnInterface>) -> 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<VpnInterface>,
+ ) -> Result<Self, Error> {
+ 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(),
+ 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 0000000000..52696a7d68
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/process.rs
@@ -0,0 +1,458 @@
+//! 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 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 tokio::io::{AsyncBufReadExt, BufReader};
+
+const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
+const EARLY_FAIL_TIMEOUT: Duration = Duration::from_millis(500);
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// 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<oneshot::Sender<oneshot::Sender<()>>>,
+ proc_task: tokio::task::JoinHandle<Result<(), Error>>,
+ states: ProcessStates,
+}
+
+impl ProcessMonitor {
+ pub async fn spawn() -> Result<ProcessMonitorHandle, Error> {
+ let states = ProcessStates::new()?;
+
+ 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());
+
+ let mut proc = cmd.spawn().map_err(Error::StartMonitor)?;
+
+ let stdout = proc.stdout.take().unwrap();
+ let stderr = proc.stderr.take().unwrap();
+
+ let states_clone = states.clone();
+
+ let (stop_proc_tx, stop_rx): (_, oneshot::Receiver<oneshot::Sender<_>>) =
+ oneshot::channel();
+
+ let mut proc_task = tokio::spawn(async move {
+ 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_clone.inner.lock().unwrap();
+ inner.handle_message(val);
+ }
+ });
+ 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
+ });
+
+ 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
+ });
+
+ 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,
+ })
+ }
+}
+
+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<Mutex<InnerProcessStates>>,
+}
+
+/// 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<u32, ProcessInfo>,
+ exclude_paths: HashSet<PathBuf>,
+}
+
+impl ProcessStates {
+ /// Initialize process states
+ fn new() -> Result<Self, Error> {
+ 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<PathBuf>) {
+ 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<Vec<u32>> {
+ // 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::<u32>()) 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<PathBuf> {
+ 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<PathBuf>,
+}
+
+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<Error> {
+ if stderr_str.contains("ES_NEW_CLIENT_RESULT_ERR_NOT_PERMITTED") {
+ Some(Error::NeedFullDiskPermissions)
+ } else {
+ None
+ }
+}
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 0000000000..7263f6c7bd
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/tun.rs
@@ -0,0 +1,764 @@
+//! 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},
+ 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<Ipv4Addr>,
+ /// VPN tunnel IPv6 address
+ pub v6_address: Option<Ipv6Addr>,
+}
+
+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<tun::AsyncDevice>,
+ /// 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<Result<EgressResult, Error>>,
+}
+
+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<VpnInterface>,
+ ) -> Result<Self, Error> {
+ 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<VpnInterface>,
+ classify: impl Fn(&PktapPacket) -> RoutingDecision + Send + 'static,
+) -> Result<SplitTunnelHandle, Error> {
+ 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
+ let output = tokio::process::Command::new("ifconfig")
+ .args([&tun_name, "inet6", &ST_IFACE_IPV6.to_string(), "alias"])
+ .output()
+ .await
+ .map_err(Error::AddIpv6Address)?;
+ if !output.status.success() {
+ return Err(Error::AddIpv6Address(io::Error::other("ifconfig failed")));
+ }
+
+ redirect_packets(tun_device, default_interface, vpn_interface, classify)
+}
+
+type PktapStream = std::pin::Pin<Box<dyn Stream<Item = Result<PktapPacket, Error>> + 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<VpnInterface>,
+ classify: impl Fn(&PktapPacket) -> RoutingDecision + Send + 'static,
+) -> Result<SplitTunnelHandle, Error> {
+ 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<VpnInterface>,
+ classify: Box<dyn Fn(&PktapPacket) -> RoutingDecision + Send>,
+) -> Result<SplitTunnelHandle, 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)?;
+
+ let st_utun_name = st_tun_device.get_ref().name().to_owned();
+
+ 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)?;
+
+ let (abort_tx, abort_rx) = broadcast::channel(1);
+
+ let ingress_task: tokio::task::JoinHandle<tun::AsyncDevice> = 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,
+ })
+}
+
+/// 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<VpnInterface>,
+ 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<u8> = 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: Box<dyn Fn(&PktapPacket) -> RoutingDecision + Send>,
+}
+
+/// 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: Box<dyn Fn(&PktapPacket) -> RoutingDecision + Send>,
+ default_interface: DefaultInterface,
+ mut default_write: bpf::WriteHalf,
+ vpn_interface: Option<VpnInterface>,
+ mut abort_rx: broadcast::Receiver<()>,
+) -> Result<EgressResult, Error> {
+ let mut vpn_dev = if let Some(ref vpn_interface) = vpn_interface {
+ 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)?;
+ Some(vpn_dev)
+ } 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 });
+ }
+ }
+ }
+}
+
+fn classify_and_send(
+ classify: &(dyn Fn(&PktapPacket) -> RoutingDecision),
+ 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<tun::AsyncDevice>,
+ payload: &mut [u8],
+ vpn_v4: Option<Ipv4Addr>,
+ vpn_v6: Option<Ipv6Addr>,
+) {
+ 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<tun::AsyncDevice>,
+ 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<tun::AsyncDevice>,
+ 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<Ipv4Addr>,
+ new_destination: Option<Ipv4Addr>,
+) {
+ // 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<Ipv6Addr>,
+ new_destination: Option<Ipv6Addr>,
+) {
+ // 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<impl Stream<Item = Result<PktapPacket, Error>> + Send, Error> {
+ let cap = pktap_capture()?
+ .immediate_mode(true)
+ .open()
+ .map_err(Error::CaptureSplitTunnelDevice)?;
+
+ 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<PktapPacket>;
+
+ fn decode(&mut self, packet: pcap::Packet<'_>) -> Self::Item {
+ assert!(packet.data.len() >= std::mem::size_of::<pktap_header>());
+
+ // 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,
+ };
+
+ 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<pcap::Capture<pcap::Inactive>, 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<Inactive>
+
+ // 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 3c3f6af294..95bd486e8c 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 c73232a895..80ca26ec56 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 96800f8a0d..5df58a6adf 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 41d0b04ddf..25ed491a8e 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 deecb3455b..2a5160af57 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 538ee5de1a..d9180e6342 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 49ec6674eb..ad3e9459aa 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<OsString>,
}
@@ -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<Result<(), split_tunnel::Error>>,
Vec<OsString>,
@@ -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<OsString>) -> Result<bool, split_tunnel::Error> {
+ 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-routing/src/lib.rs b/talpid-routing/src/lib.rs
index 0ba3eb1275..b80f96ccdc 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 ebfe11fa16..410456cbb0 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,
@@ -23,8 +24,8 @@ use system_configuration::{
network_configuration::SCNetworkSet,
preferences::SCPreferences,
sys::schema_definitions::{
- kSCDynamicStorePropNetPrimaryInterface, kSCPropInterfaceName, kSCPropNetIPv4Router,
- kSCPropNetIPv6Router,
+ kSCDynamicStorePropNetPrimaryInterface, kSCPropInterfaceName, kSCPropNetIPv4Addresses,
+ kSCPropNetIPv4Router, kSCPropNetIPv6Addresses, kSCPropNetIPv6Router,
},
};
@@ -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<DefaultRoute> 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<InterfaceEvent>) {
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<RouteMessage> {
+ pub fn get_route(&self, family: Family) -> Option<DefaultRoute> {
let ifaces = self
.get_primary_interface(family)
.map(|iface| {
@@ -155,117 +185,97 @@ impl PrimaryInterfaceMonitor {
})
.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<NetworkServiceDetails> {
- 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::<CFDictionary>())?;
- let name = global_dict
- .find(unsafe { kSCDynamicStorePropNetPrimaryInterface }.to_void())
- .map(|s| unsafe { CFType::wrap_under_get_rule(*s) })
- .and_then(|s| s.downcast::<CFString>())
- .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::<CFString>())
- .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 = get_service_first_ip(&ip_dict, 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<NetworkServiceDetails> {
- 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::<CFDictionary>())?;
- let name = ip_dict
- .find(unsafe { kSCPropInterfaceName }.to_void())
- .map(|s| unsafe { CFType::wrap_under_get_rule(*s) })
- .and_then(|s| s.downcast::<CFString>())
- .map(|s| s.to_string())
+ let name = get_dict_elem_as_string(&ip_dict, unsafe { kSCPropInterfaceName })
.or_else(|| {
- log::debug!("Missing name for service {service_id_s} ({family})");
+ log::debug!("Missing name for service {service_key} ({family})");
None
})?;
- let router_ip = ip_dict
- .find(router_key)
- .map(|s| unsafe { CFType::wrap_under_get_rule(*s) })
- .and_then(|s| s.downcast::<CFString>())
- .and_then(|ip| ip.to_string().parse().ok())
- .or_else(|| {
- log::debug!("Missing router IP for {service_id_s} ({name}, {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 = get_service_first_ip(&ip_dict, family).or_else(|| {
+ log::debug!("Missing IP for \"{service_key}\" ({name}, {family})");
+ None
+ })?;
+ Some(NetworkServiceDetails {
+ name,
+ router_ip,
+ first_ip,
+ })
})
.collect::<Vec<_>>()
}
@@ -330,3 +340,38 @@ 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<IpAddr> {
+ 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_service_first_ip(ip_dict: &CFDictionary, family: Family) -> Option<IpAddr> {
+ let ip_key = if family == Family::V4 {
+ unsafe { kSCPropNetIPv4Addresses }
+ } else {
+ unsafe { kSCPropNetIPv6Addresses }
+ };
+ ip_dict
+ .find(ip_key.to_void())
+ .map(|s| unsafe { CFType::wrap_under_get_rule(*s) })
+ .and_then(|s| s.downcast::<CFArray>())
+ .and_then(|ips| {
+ ips.get(0)
+ .map(|ip| unsafe { CFType::wrap_under_get_rule(*ip) })
+ })
+ .and_then(|s| s.downcast::<CFString>())
+ .map(|s| s.to_string())
+ .and_then(|ip| ip.parse().ok())
+}
+
+fn get_dict_elem_as_string(dict: &CFDictionary, key: CFStringRef) -> Option<String> {
+ dict.find(key.to_void())
+ .map(|s| unsafe { CFType::wrap_under_get_rule(*s) })
+ .and_then(|s| s.downcast::<CFString>())
+ .map(|s| s.to_string())
+}
diff --git a/talpid-routing/src/unix/macos/mod.rs b/talpid-routing/src/unix/macos/mod.rs
index 448a87427c..9a4d8bb180 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<data::RouteMessage>,
v6_tunnel_default_route: Option<data::RouteMessage>,
applied_routes: BTreeMap<RouteDestination, RouteMessage>,
- v4_default_route: Option<data::RouteMessage>,
- v6_default_route: Option<data::RouteMessage>,
+ v4_default_route: Option<interface::DefaultRoute>,
+ v6_default_route: Option<interface::DefaultRoute>,
update_trigger: BurstGuard,
default_route_listeners: Vec<mpsc::UnboundedSender<DefaultRouteEvent>>,
check_default_routes_restored: Pin<Box<dyn FusedStream<Item = ()> + 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<Gateway> {
+ 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<RequiredRoute>) -> 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<bool> {
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:?}");
@@ -672,6 +683,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/mod.rs b/talpid-routing/src/unix/mod.rs
index 7fe7e9bf31..5f14d88d67 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<mpsc::UnboundedReceiver<DefaultRouteEvent>>),
- #[cfg(target_os = "macos")]
- GetDefaultRoutes(oneshot::Sender<(Option<Route>, Option<Route>)>),
- #[cfg(target_os = "linux")]
CreateRoutingRules(bool, oneshot::Sender<Result<(), PlatformError>>),
- #[cfg(target_os = "linux")]
ClearRoutingRules(oneshot::Sender<Result<(), PlatformError>>),
- #[cfg(target_os = "linux")]
NewChangeListener(oneshot::Sender<mpsc::UnboundedReceiver<CallbackMessage>>),
- #[cfg(target_os = "linux")]
GetMtuForRoute(IpAddr, oneshot::Sender<Result<u16, PlatformError>>),
/// Attempt to fetch a route for the given destination with an optional firewall mark.
- #[cfg(target_os = "linux")]
GetDestinationRoute(
IpAddr,
Option<Fwmark>,
@@ -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<RequiredRoute>,
+ oneshot::Sender<Result<(), PlatformError>>,
+ ),
+ ClearRoutes,
+ Shutdown(oneshot::Sender<()>),
+}
+
+/// Commands for the underlying route manager object.
+#[cfg(target_os = "macos")]
+#[derive(Debug)]
+pub(crate) enum RouteManagerCommand {
+ AddRoutes(
+ HashSet<RequiredRoute>,
+ oneshot::Sender<Result<(), PlatformError>>,
+ ),
+ ClearRoutes,
+ Shutdown(oneshot::Sender<()>),
+ RefreshRoutes,
+ NewDefaultRouteListener(oneshot::Sender<mpsc::UnboundedReceiver<DefaultRouteEvent>>),
+ GetDefaultRoutes(oneshot::Sender<(Option<DefaultRoute>, Option<DefaultRoute>)>),
+ /// Return gateway for V4 and V6
+ GetDefaultGateway(oneshot::Sender<(Option<Gateway>, Option<Gateway>)>),
+}
+
/// 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<Route>, Option<Route>), Error> {
+ pub async fn get_default_routes(
+ &self,
+ ) -> Result<(Option<DefaultRoute>, Option<DefaultRoute>), 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<Gateway>, Option<Gateway>), 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 a17d8ceb5f..dcf7d9a22c 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 6c85dcfb34..e67db2f0c4 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",
};