diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3460d73f0e3b..74a1ac81256d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,9 @@ Line wrap the file at 100 chars. Th
### Added
- Add custom bridge settings in GUI.
+#### macOS
+- Add support for split tunneling (beta).
+
### Fixed
#### Linux
- Fix GUI not working on Ubuntu 24.04 by adding an AppArmor profile.
diff --git a/Cargo.lock b/Cargo.lock
index d39c7fd3639b..ad18fc949820 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1267,7 +1267,7 @@ dependencies = [
"indexmap 2.2.6",
"slab",
"tokio",
- "tokio-util",
+ "tokio-util 0.7.10",
"tracing",
]
@@ -1286,7 +1286,7 @@ dependencies = [
"indexmap 2.2.6",
"slab",
"tokio",
- "tokio-util",
+ "tokio-util 0.7.10",
"tracing",
]
@@ -1399,7 +1399,7 @@ dependencies = [
"thiserror",
"time",
"tokio",
- "tokio-util",
+ "tokio-util 0.7.10",
"tracing",
]
@@ -1926,6 +1926,16 @@ dependencies = [
"rle-decode-fast",
]
+[[package]]
+name = "libloading"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19"
+dependencies = [
+ "cfg-if",
+ "windows-targets 0.52.5",
+]
+
[[package]]
name = "libm"
version = "0.2.8"
@@ -2704,6 +2714,23 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
+[[package]]
+name = "pcap"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45f1686828a29fd8002fbf9c01506b4b2dd575c2305e1b884da3731abae8b9e0"
+dependencies = [
+ "bitflags 1.3.2",
+ "errno 0.2.8",
+ "futures",
+ "libc",
+ "libloading",
+ "pkg-config",
+ "regex",
+ "tokio",
+ "windows-sys 0.36.1",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.1"
@@ -3877,15 +3904,21 @@ dependencies = [
"mnl",
"nftnl",
"nix 0.23.2",
+ "nix 0.28.0",
"once_cell",
"parking_lot",
+ "pcap",
"pfctl",
+ "pnet_packet",
"rand 0.8.5",
"resolv-conf",
+ "serde",
+ "serde_json",
"subslice",
"system-configuration",
"talpid-dbus",
"talpid-openvpn",
+ "talpid-platform-metadata",
"talpid-routing",
"talpid-tunnel",
"talpid-tunnel-config-client",
@@ -3896,6 +3929,7 @@ dependencies = [
"tokio",
"tonic-build",
"triggered",
+ "tun",
"which",
"widestring",
"windows-service",
@@ -4283,6 +4317,20 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "tokio-util"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "log",
+ "pin-project-lite",
+ "tokio",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.10"
@@ -4360,7 +4408,7 @@ dependencies = [
"rand 0.8.5",
"slab",
"tokio",
- "tokio-util",
+ "tokio-util 0.7.10",
"tower-layer",
"tower-service",
"tracing",
@@ -4440,9 +4488,14 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc25e23adc6cac7dd895ce2780f255902290fc39b00e1ae3c33e89f3d20fa66"
dependencies = [
+ "byteorder",
+ "bytes",
+ "futures-core",
"ioctl-sys",
"libc",
"thiserror",
+ "tokio",
+ "tokio-util 0.6.10",
]
[[package]]
@@ -4753,6 +4806,19 @@ dependencies = [
"windows-sys 0.45.0",
]
+[[package]]
+name = "windows-sys"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2"
+dependencies = [
+ "windows_aarch64_msvc 0.36.1",
+ "windows_i686_gnu 0.36.1",
+ "windows_i686_msvc 0.36.1",
+ "windows_x86_64_gnu 0.36.1",
+ "windows_x86_64_msvc 0.36.1",
+]
+
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -4844,6 +4910,12 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4862,6 +4934,12 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"
+
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4886,6 +4964,12 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"
+
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4904,6 +4988,12 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4940,6 +5030,12 @@ version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
diff --git a/docs/split-tunneling.md b/docs/split-tunneling.md
index bda2ac5de6e9..722484ad7011 100644
--- a/docs/split-tunneling.md
+++ b/docs/split-tunneling.md
@@ -43,17 +43,17 @@ Some definitions of terms used later to describe behavior:
*: On platforms where we have custom firewall integration. This is currently on desktop operating
systems, and not mobile.
-### Windows and Linux
+### Desktop platforms (Windows, Linux, and macOS)
-| In-app DNS setting | Normal & Excluded app |
-|-|-|
-| **Default DNS** | In tunnel (to relay) |
-| **Private custom DNS** (e.g. 10.0.1.1) | LAN (to 10.0.1.1) |
-| **Public custom DNS** (e.g. 8.8.8.8) | In tunnel (to 8.8.8.8) |
+| In-app DNS setting | Normal & Excluded app |
+|-|------------------------------------------------|
+| **Default DNS** | In tunnel (to relay) |
+| **Private custom DNS** (e.g. 10.0.1.1) | LAN (to 10.0.1.1)
**macOS**: Not supported |
+| **Public custom DNS** (e.g. 8.8.8.8) | In tunnel (to 8.8.8.8) |
-In other words: Normal and excluded processes always behave the same. This is because DNS is
-typically handled by a service, e.g. DNS cache on Windows or systemd-resolved's resolver on Linux,
-which is not an excluded process.
+In other words: Normal and excluded processes behave the same. This is because DNS is typically
+handled by a service, e.g. DNS cache on Windows or systemd-resolved's resolver on Linux, which is
+not an excluded process.
For the sake of simplicity and consistency, requests to public custom DNS resolvers are also sent
inside the tunnel when using a plain old static `resolv.conf`, even though it is technically
diff --git a/mullvad-cli/src/cmds/split_tunnel/macos.rs b/mullvad-cli/src/cmds/split_tunnel/macos.rs
new file mode 100644
index 000000000000..534b4fd86a96
--- /dev/null
+++ b/mullvad-cli/src/cmds/split_tunnel/macos.rs
@@ -0,0 +1,86 @@
+use anyhow::Result;
+use std::path::PathBuf;
+
+use clap::Subcommand;
+use mullvad_management_interface::MullvadProxyClient;
+
+use super::super::BooleanOption;
+
+/// Set options for applications to exclude from the tunnel.
+#[derive(Subcommand, Debug)]
+pub enum SplitTunnel {
+ /// Display the split tunnel status and apps
+ Get,
+
+ /// Enable or disable split tunnel
+ Set { policy: BooleanOption },
+
+ /// Manage applications to exclude from the tunnel
+ #[clap(subcommand)]
+ App(App),
+}
+
+#[derive(Subcommand, Debug)]
+pub enum App {
+ Add { path: PathBuf },
+ Remove { path: PathBuf },
+ Clear,
+}
+
+impl SplitTunnel {
+ pub async fn handle(self) -> Result<()> {
+ match self {
+ SplitTunnel::Get => {
+ let mut rpc = MullvadProxyClient::new().await?;
+ let settings = rpc.get_settings().await?.split_tunnel;
+
+ let enable_exclusions = BooleanOption::from(settings.enable_exclusions);
+
+ println!("Split tunneling state: {enable_exclusions}");
+
+ println!("Excluded applications:");
+ for path in &settings.apps {
+ println!("{}", path.display());
+ }
+
+ Ok(())
+ }
+ SplitTunnel::Set { policy } => {
+ let mut rpc = MullvadProxyClient::new().await?;
+ rpc.set_split_tunnel_state(*policy).await?;
+ println!("Split tunnel policy: {policy}");
+ Ok(())
+ }
+ SplitTunnel::App(subcmd) => Self::app(subcmd).await,
+ }
+ }
+
+ async fn app(subcmd: App) -> Result<()> {
+ match subcmd {
+ App::Add { path } => {
+ MullvadProxyClient::new()
+ .await?
+ .add_split_tunnel_app(path)
+ .await?;
+ println!("Added path to excluded apps list");
+ Ok(())
+ }
+ App::Remove { path } => {
+ MullvadProxyClient::new()
+ .await?
+ .remove_split_tunnel_app(path)
+ .await?;
+ println!("Stopped excluding app from tunnel");
+ Ok(())
+ }
+ App::Clear => {
+ MullvadProxyClient::new()
+ .await?
+ .clear_split_tunnel_apps()
+ .await?;
+ println!("Stopped excluding all apps");
+ Ok(())
+ }
+ }
+ }
+}
diff --git a/mullvad-cli/src/cmds/split_tunnel/mod.rs b/mullvad-cli/src/cmds/split_tunnel/mod.rs
index c9e87f5d7c70..37ed33b64b85 100644
--- a/mullvad-cli/src/cmds/split_tunnel/mod.rs
+++ b/mullvad-cli/src/cmds/split_tunnel/mod.rs
@@ -6,5 +6,8 @@ mod imp;
#[path = "windows.rs"]
mod imp;
-#[cfg(any(target_os = "linux", windows))]
+#[cfg(target_os = "macos")]
+#[path = "macos.rs"]
+mod imp;
+
pub use imp::*;
diff --git a/mullvad-cli/src/main.rs b/mullvad-cli/src/main.rs
index 0dd5d26dbef4..67990bd88837 100644
--- a/mullvad-cli/src/main.rs
+++ b/mullvad-cli/src/main.rs
@@ -96,7 +96,6 @@ enum Cli {
#[clap(subcommand)]
Obfuscation(obfuscation::Obfuscation),
- #[cfg(any(target_os = "windows", target_os = "linux"))]
#[clap(subcommand)]
SplitTunnel(split_tunnel::SplitTunnel),
@@ -171,7 +170,6 @@ async fn main() -> Result<()> {
Cli::FactoryReset => reset::handle().await,
Cli::Relay(cmd) => cmd.handle().await,
Cli::Tunnel(cmd) => cmd.handle().await,
- #[cfg(any(target_os = "windows", target_os = "linux"))]
Cli::SplitTunnel(cmd) => cmd.handle().await,
Cli::Status { cmd, args } => status::handle(cmd, args).await,
Cli::CustomList(cmd) => cmd.handle().await,
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index 69eebdbdd69a..c586c4c316ef 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -65,7 +65,7 @@ use relay_list::{RelayListUpdater, RelayListUpdaterHandle, RELAYS_FILENAME};
use settings::SettingsPersister;
#[cfg(target_os = "android")]
use std::os::unix::io::RawFd;
-#[cfg(target_os = "windows")]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
use std::{collections::HashSet, ffi::OsString};
use std::{
marker::PhantomData,
@@ -75,7 +75,7 @@ use std::{
sync::{Arc, Weak},
time::Duration,
};
-#[cfg(any(target_os = "linux", windows))]
+#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
use talpid_core::split_tunnel;
use talpid_core::{
mpsc::Sender,
@@ -147,7 +147,7 @@ pub enum Error {
#[error("Unable to initialize split tunneling")]
InitSplitTunneling(#[source] split_tunnel::Error),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
#[error("Split tunneling error")]
SplitTunnelError(#[source] split_tunnel::Error),
@@ -331,16 +331,16 @@ pub enum DaemonCommand {
#[cfg(target_os = "linux")]
ClearSplitTunnelProcesses(ResponseTx<(), split_tunnel::Error>),
/// Exclude traffic of an application from the tunnel
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
AddSplitTunnelApp(ResponseTx<(), Error>, PathBuf),
/// Remove application from list of apps to exclude from the tunnel
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
RemoveSplitTunnelApp(ResponseTx<(), Error>, PathBuf),
/// Clear list of apps to exclude from the tunnel
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
ClearSplitTunnelApps(ResponseTx<(), Error>),
/// Enable or disable split tunneling
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
SetSplitTunnelState(ResponseTx<(), Error>, bool),
/// Returns all processes currently being excluded from the tunnel
#[cfg(windows)]
@@ -392,11 +392,11 @@ pub(crate) enum InternalDaemonEvent {
/// A geographical location has has been received from am.i.mullvad.net
LocationEvent(LocationEventData),
/// The split tunnel paths or state were updated.
- #[cfg(target_os = "windows")]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
ExcludedPathsEvent(ExcludedPathsUpdate, oneshot::Sender>),
}
-#[cfg(target_os = "windows")]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
pub(crate) enum ExcludedPathsUpdate {
SetState(bool),
SetPaths(HashSet),
@@ -767,7 +767,7 @@ where
PersistentTargetState::new(&cache_dir).await
};
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
let exclude_paths = if settings.split_tunnel.enable_exclusions {
settings
.split_tunnel
@@ -810,7 +810,7 @@ where
.map_err(Error::ApiConnectionModeError)?
.endpoint,
reset_firewall: *target_state != TargetState::Secured,
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
exclude_paths,
},
parameters_generator.clone(),
@@ -994,7 +994,7 @@ where
} => self.handle_access_method_event(event, endpoint_active_tx),
DeviceMigrationEvent(event) => self.handle_device_migration_event(event),
LocationEvent(location_data) => self.handle_location_event(location_data),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
ExcludedPathsEvent(update, tx) => self.handle_new_excluded_paths(update, tx).await,
}
}
@@ -1273,13 +1273,13 @@ where
RemoveSplitTunnelProcess(tx, pid) => self.on_remove_split_tunnel_process(tx, pid),
#[cfg(target_os = "linux")]
ClearSplitTunnelProcesses(tx) => self.on_clear_split_tunnel_processes(tx),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
AddSplitTunnelApp(tx, path) => self.on_add_split_tunnel_app(tx, path),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
RemoveSplitTunnelApp(tx, path) => self.on_remove_split_tunnel_app(tx, path),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
ClearSplitTunnelApps(tx) => self.on_clear_split_tunnel_apps(tx),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
SetSplitTunnelState(tx, enabled) => self.on_set_split_tunnel_state(tx, enabled),
#[cfg(windows)]
GetSplitTunnelProcesses(tx) => self.on_get_split_tunnel_processes(tx),
@@ -1435,7 +1435,7 @@ where
});
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn handle_new_excluded_paths(
&mut self,
update: ExcludedPathsUpdate,
@@ -1808,7 +1808,7 @@ where
}
/// Update the split app paths in both the settings and tunnel
- #[cfg(windows)]
+ #[cfg(target_os = "windows")]
fn set_split_tunnel_paths(
&mut self,
tx: ResponseTx<(), Error>,
@@ -1874,7 +1874,52 @@ where
}
}
- #[cfg(windows)]
+ /// Update the split app paths in both the settings and tunnel
+ #[cfg(target_os = "macos")]
+ fn set_split_tunnel_paths(
+ &mut self,
+ tx: ResponseTx<(), Error>,
+ _response_msg: &'static str,
+ settings: Settings,
+ update: ExcludedPathsUpdate,
+ ) {
+ let tunnel_list = match update {
+ ExcludedPathsUpdate::SetPaths(ref paths) if settings.split_tunnel.enable_exclusions => {
+ paths.iter().map(OsString::from).collect()
+ }
+ ExcludedPathsUpdate::SetState(true) => settings
+ .split_tunnel
+ .apps
+ .iter()
+ .map(OsString::from)
+ .collect(),
+ _ => vec![],
+ };
+
+ let (result_tx, result_rx) = oneshot::channel();
+ self.send_tunnel_command(TunnelCommand::SetExcludedApps(result_tx, tunnel_list));
+ let daemon_tx = self.tx.clone();
+
+ tokio::spawn(async move {
+ match result_rx.await {
+ Ok(Ok(_)) => (),
+ Ok(Err(error)) => {
+ log::error!(
+ "{}",
+ error.display_chain_with_msg("Failed to set excluded apps list")
+ );
+ // NOTE: On macOS, we don't care if this fails. The tunnel will prevent us from
+ // connecting if we're in a bad state, and we can reset it by clearing the paths
+ }
+ Err(_) => {
+ log::error!("The tunnel failed to return a result");
+ }
+ }
+ let _ = daemon_tx.send(InternalDaemonEvent::ExcludedPathsEvent(update, tx));
+ });
+ }
+
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
fn on_add_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) {
let settings = self.settings.to_settings();
@@ -1889,7 +1934,7 @@ where
);
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
fn on_remove_split_tunnel_app(&mut self, tx: ResponseTx<(), Error>, path: PathBuf) {
let settings = self.settings.to_settings();
@@ -1904,7 +1949,7 @@ where
);
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
fn on_clear_split_tunnel_apps(&mut self, tx: ResponseTx<(), Error>) {
let settings = self.settings.to_settings();
let new_list = HashSet::new();
@@ -1916,7 +1961,7 @@ where
);
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
fn on_set_split_tunnel_state(&mut self, tx: ResponseTx<(), Error>, state: bool) {
let settings = self.settings.to_settings();
self.set_split_tunnel_paths(
diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs
index 2e7f04b1d98d..c94b5f35063d 100644
--- a/mullvad-daemon/src/management_interface.rs
+++ b/mullvad-daemon/src/management_interface.rs
@@ -21,7 +21,7 @@ use mullvad_types::{
version,
wireguard::{RotationInterval, RotationIntervalError},
};
-#[cfg(windows)]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
use std::path::PathBuf;
use std::{
str::FromStr,
@@ -831,7 +831,7 @@ impl ManagementService for ManagementServiceImpl {
}
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn add_split_tunnel_app(&self, request: Request) -> ServiceResult<()> {
log::debug!("add_split_tunnel_app");
let path = PathBuf::from(request.into_inner());
@@ -842,12 +842,12 @@ impl ManagementService for ManagementServiceImpl {
.map_err(map_daemon_error)
.map(Response::new)
}
- #[cfg(not(windows))]
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
async fn add_split_tunnel_app(&self, _: Request) -> ServiceResult<()> {
Ok(Response::new(()))
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn remove_split_tunnel_app(&self, request: Request) -> ServiceResult<()> {
log::debug!("remove_split_tunnel_app");
let path = PathBuf::from(request.into_inner());
@@ -858,12 +858,12 @@ impl ManagementService for ManagementServiceImpl {
.map_err(map_daemon_error)
.map(Response::new)
}
- #[cfg(not(windows))]
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
async fn remove_split_tunnel_app(&self, _: Request) -> ServiceResult<()> {
Ok(Response::new(()))
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> {
log::debug!("clear_split_tunnel_apps");
let (tx, rx) = oneshot::channel();
@@ -873,12 +873,12 @@ impl ManagementService for ManagementServiceImpl {
.map_err(map_daemon_error)
.map(Response::new)
}
- #[cfg(not(windows))]
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
async fn clear_split_tunnel_apps(&self, _: Request<()>) -> ServiceResult<()> {
Ok(Response::new(()))
}
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
async fn set_split_tunnel_state(&self, request: Request) -> ServiceResult<()> {
log::debug!("set_split_tunnel_state");
let enabled = request.into_inner();
@@ -889,7 +889,7 @@ impl ManagementService for ManagementServiceImpl {
.map_err(map_daemon_error)
.map(Response::new)
}
- #[cfg(not(windows))]
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
async fn set_split_tunnel_state(&self, _: Request) -> ServiceResult<()> {
Ok(Response::new(()))
}
@@ -1109,7 +1109,7 @@ fn map_daemon_error(error: crate::Error) -> Status {
DaemonError::RemoveDeviceError(error) => map_device_error(&error),
DaemonError::UpdateDeviceError(error) => map_device_error(&error),
DaemonError::VoucherSubmission(error) => map_device_error(&error),
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
DaemonError::SplitTunnelError(error) => map_split_tunnel_error(error),
DaemonError::AccountHistory(error) => map_account_history_error(error),
DaemonError::NoAccountToken | DaemonError::NoAccountTokenHistory => {
@@ -1136,6 +1136,12 @@ fn map_split_tunnel_error(error: talpid_core::split_tunnel::Error) -> Status {
}
}
+#[cfg(target_os = "macos")]
+/// Converts [`talpid_core::split_tunnel::Error`] into a tonic status.
+fn map_split_tunnel_error(error: talpid_core::split_tunnel::Error) -> Status {
+ Status::unknown(error.to_string())
+}
+
/// Converts a REST API error into a tonic status.
fn map_rest_error(error: &RestError) -> Status {
match error {
diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto
index 31d39db3069b..efe9f9eb87a2 100644
--- a/mullvad-management-interface/proto/management_interface.proto
+++ b/mullvad-management-interface/proto/management_interface.proto
@@ -92,7 +92,7 @@ service ManagementService {
rpc RemoveSplitTunnelProcess(google.protobuf.Int32Value) returns (google.protobuf.Empty) {}
rpc ClearSplitTunnelProcesses(google.protobuf.Empty) returns (google.protobuf.Empty) {}
- // Split tunneling (Windows)
+ // Split tunneling (Windows, macOS)
rpc AddSplitTunnelApp(google.protobuf.StringValue) returns (google.protobuf.Empty) {}
rpc RemoveSplitTunnelApp(google.protobuf.StringValue) returns (google.protobuf.Empty) {}
rpc ClearSplitTunnelApps(google.protobuf.Empty) returns (google.protobuf.Empty) {}
diff --git a/mullvad-management-interface/src/types/conversions/settings.rs b/mullvad-management-interface/src/types/conversions/settings.rs
index 857f32d99102..0cad85def3fb 100644
--- a/mullvad-management-interface/src/types/conversions/settings.rs
+++ b/mullvad-management-interface/src/types/conversions/settings.rs
@@ -4,7 +4,7 @@ use talpid_types::ErrorExt;
impl From<&mullvad_types::settings::Settings> for proto::Settings {
fn from(settings: &mullvad_types::settings::Settings) -> Self {
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
let split_tunnel = {
let mut converted_list = vec![];
for path in settings.split_tunnel.apps.clone().iter() {
@@ -21,7 +21,7 @@ impl From<&mullvad_types::settings::Settings> for proto::Settings {
apps: converted_list,
})
};
- #[cfg(not(windows))]
+ #[cfg(not(any(target_os = "windows", target_os = "macos")))]
let split_tunnel = None;
Self {
@@ -159,7 +159,7 @@ impl TryFrom for mullvad_types::settings::Settings {
.ok_or(FromProtobufTypeError::InvalidArgument(
"missing api access methods settings",
))?;
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
let split_tunnel = settings
.split_tunnel
.ok_or(FromProtobufTypeError::InvalidArgument(
@@ -184,7 +184,7 @@ impl TryFrom for mullvad_types::settings::Settings {
.map(mullvad_types::relay_constraints::RelayOverride::try_from)
.collect::, _>>()?,
show_beta_releases: settings.show_beta_releases,
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
split_tunnel: mullvad_types::settings::SplitTunnelSettings::from(split_tunnel),
obfuscation_settings: mullvad_types::relay_constraints::ObfuscationSettings::try_from(
obfuscation_settings,
@@ -219,7 +219,7 @@ pub fn try_bridge_state_from_i32(
}
}
-#[cfg(windows)]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
impl From for mullvad_types::settings::SplitTunnelSettings {
fn from(value: proto::SplitTunnelSettings) -> Self {
mullvad_types::settings::SplitTunnelSettings {
diff --git a/mullvad-management-interface/src/types/conversions/states.rs b/mullvad-management-interface/src/types/conversions/states.rs
index f6e41f4d87be..881cbc6c9a0a 100644
--- a/mullvad-management-interface/src/types/conversions/states.rs
+++ b/mullvad-management-interface/src/types/conversions/states.rs
@@ -103,7 +103,7 @@ impl From for proto::TunnelState {
talpid_tunnel::ErrorStateCause::VpnPermissionDenied => {
i32::from(Cause::VpnPermissionDenied)
}
- #[cfg(target_os = "windows")]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
talpid_tunnel::ErrorStateCause::SplitTunnelError => {
i32::from(Cause::SplitTunnelError)
}
diff --git a/mullvad-types/src/settings/mod.rs b/mullvad-types/src/settings/mod.rs
index 2a4adbb06951..464a4c89c18b 100644
--- a/mullvad-types/src/settings/mod.rs
+++ b/mullvad-types/src/settings/mod.rs
@@ -12,7 +12,7 @@ use crate::{
#[cfg(target_os = "android")]
use jnix::IntoJava;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
-#[cfg(target_os = "windows")]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
use std::{collections::HashSet, path::PathBuf};
use talpid_types::net::{openvpn, GenericTunnelOptions};
@@ -100,14 +100,14 @@ pub struct Settings {
/// Whether to notify users of beta updates.
pub show_beta_releases: bool,
/// Split tunneling settings
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
pub split_tunnel: SplitTunnelSettings,
/// Specifies settings schema version
#[cfg_attr(target_os = "android", jnix(skip))]
pub settings_version: SettingsVersion,
}
-#[cfg(windows)]
+#[cfg(any(target_os = "windows", target_os = "macos"))]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
pub struct SplitTunnelSettings {
/// Toggles split tunneling on or off
@@ -145,7 +145,7 @@ impl Default for Settings {
tunnel_options: TunnelOptions::default(),
relay_overrides: vec![],
show_beta_releases: false,
- #[cfg(windows)]
+ #[cfg(any(target_os = "windows", target_os = "macos"))]
split_tunnel: SplitTunnelSettings::default(),
settings_version: CURRENT_SETTINGS_VERSION,
}
diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml
index b079257cb3b1..66b22563df62 100644
--- a/talpid-core/Cargo.toml
+++ b/talpid-core/Cargo.toml
@@ -53,6 +53,13 @@ subslice = "0.2"
system-configuration = "0.5.1"
hickory-proto = "0.24.1"
hickory-server = { version = "0.24.1", features = ["resolver"] }
+talpid-platform-metadata = { path = "../talpid-platform-metadata" }
+pcap = { version = "2.0", features = ["capture-stream"] }
+pnet_packet = "0.34"
+tun = { version = "0.5.5", features = ["async"] }
+nix = { version = "0.28", features = ["socket"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
[target.'cfg(windows)'.dependencies]
bitflags = "1.2"
diff --git a/talpid-core/src/firewall/macos.rs b/talpid-core/src/firewall/macos.rs
index b3502a8441d0..44975ea9f8e8 100644
--- a/talpid-core/src/firewall/macos.rs
+++ b/talpid-core/src/firewall/macos.rs
@@ -1,9 +1,11 @@
use super::{FirewallArguments, FirewallPolicy};
use ipnetwork::IpNetwork;
+use libc::{c_int, sysctlbyname};
use pfctl::{DropAction, FilterRuleAction, Uid};
use std::{
- env,
+ env, io,
net::{IpAddr, Ipv4Addr},
+ ptr,
};
use subslice::SubsliceExt;
use talpid_types::net::{self, AllowedEndpoint, AllowedTunnelTraffic};
@@ -128,6 +130,7 @@ impl Firewall {
allow_lan,
allowed_endpoint,
allowed_tunnel_traffic,
+ redirect_interface,
} => {
let mut rules = vec![self.get_allow_relay_rule(peer_endpoint)?];
rules.push(self.get_allowed_endpoint_rule(allowed_endpoint)?);
@@ -137,14 +140,33 @@ impl Firewall {
rules.append(&mut self.get_block_dns_rules()?);
if let Some(tunnel) = tunnel {
- rules.extend(
- self.get_allow_tunnel_rules(&tunnel.interface, allowed_tunnel_traffic)?,
- );
+ match redirect_interface {
+ Some(redirect_interface) => {
+ enable_forwarding();
+
+ if !allowed_tunnel_traffic.all() {
+ log::warn!("Split tunneling does not respect the 'allowed tunnel traffic' setting");
+ }
+ rules.append(
+ &mut self.get_split_tunnel_rules(
+ &tunnel.interface,
+ redirect_interface,
+ )?,
+ );
+ }
+ None => {
+ rules.extend(self.get_allow_tunnel_rules(
+ &tunnel.interface,
+ allowed_tunnel_traffic,
+ )?);
+ }
+ }
}
if *allow_lan {
rules.append(&mut self.get_allow_lan_rules()?);
}
+
Ok(rules)
}
FirewallPolicy::Connected {
@@ -152,6 +174,7 @@ impl Firewall {
tunnel,
allow_lan,
dns_servers,
+ redirect_interface,
} => {
let mut rules = vec![];
@@ -165,15 +188,23 @@ impl Firewall {
// can't leak to the wrong IPs in the tunnel or on the LAN.
rules.append(&mut self.get_block_dns_rules()?);
- rules.extend(self.get_allow_tunnel_rules(
- tunnel.interface.as_str(),
- &AllowedTunnelTraffic::All,
- )?);
-
if *allow_lan {
rules.append(&mut self.get_allow_lan_rules()?);
}
+ if let Some(redirect_interface) = redirect_interface {
+ enable_forwarding();
+
+ rules.append(
+ &mut self.get_split_tunnel_rules(&tunnel.interface, redirect_interface)?,
+ );
+ } else {
+ rules.extend(self.get_allow_tunnel_rules(
+ tunnel.interface.as_str(),
+ &AllowedTunnelTraffic::All,
+ )?);
+ }
+
Ok(rules)
}
FirewallPolicy::Blocked {
@@ -343,28 +374,29 @@ impl Firewall {
Ok(vec![block_tcp_dns_rule, block_udp_dns_rule])
}
- fn base_rule(
+ fn get_allow_tunnel_rules(
&self,
- action: FilterRuleAction,
tunnel_interface: &str,
- ) -> pfctl::FilterRuleBuilder {
- let mut rule_builder = self.create_rule_builder(action);
- rule_builder
- .quick(true)
- .interface(tunnel_interface)
- .keep_state(pfctl::StatePolicy::Keep)
- .tcp_flags(Self::get_tcp_flags());
- rule_builder
+ allowed_traffic: &AllowedTunnelTraffic,
+ ) -> Result> {
+ self.get_allow_tunnel_rules_inner(tunnel_interface, allowed_traffic, Self::get_tcp_flags())
}
- fn get_allow_tunnel_rules(
+ fn get_allow_tunnel_rules_inner(
&self,
tunnel_interface: &str,
allowed_traffic: &AllowedTunnelTraffic,
+ tcp_flags: pfctl::TcpFlags,
) -> Result> {
+ let mut base_rule = &mut self.create_rule_builder(FilterRuleAction::Pass);
+ base_rule
+ .quick(true)
+ .interface(tunnel_interface)
+ .keep_state(pfctl::StatePolicy::Keep)
+ .tcp_flags(tcp_flags);
+
Ok(match allowed_traffic {
AllowedTunnelTraffic::One(endpoint) => {
- let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface);
let pfctl_proto = as_pfctl_proto(endpoint.protocol);
base_rule = base_rule.to(endpoint.address).proto(pfctl_proto);
vec![base_rule.build()?]
@@ -372,12 +404,10 @@ impl Firewall {
AllowedTunnelTraffic::Two(endpoint1, endpoint2) => {
let mut rules = Vec::with_capacity(2);
- let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface);
let pfctl_proto = as_pfctl_proto(endpoint1.protocol);
base_rule = base_rule.to(endpoint1.address).proto(pfctl_proto);
rules.push(base_rule.build()?);
- let mut base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface);
let pfctl_proto = as_pfctl_proto(endpoint2.protocol);
base_rule = base_rule.to(endpoint2.address).proto(pfctl_proto);
rules.push(base_rule.build()?);
@@ -385,7 +415,6 @@ impl Firewall {
rules
}
AllowedTunnelTraffic::All => {
- let base_rule = &mut self.base_rule(FilterRuleAction::Pass, tunnel_interface);
vec![base_rule.build()?]
}
AllowedTunnelTraffic::None => {
@@ -458,6 +487,39 @@ impl Firewall {
Ok(rules)
}
+ fn get_split_tunnel_rules(
+ &self,
+ from_interface: &str,
+ to_interface: &str,
+ ) -> Result> {
+ let tunnel_rule = self
+ .create_rule_builder(FilterRuleAction::Pass)
+ .quick(true)
+ .direction(pfctl::Direction::In)
+ .keep_state(pfctl::StatePolicy::None)
+ .interface(from_interface)
+ .build()?;
+ let allow_rule = self
+ .create_rule_builder(FilterRuleAction::Pass)
+ .quick(true)
+ .direction(pfctl::Direction::Out)
+ .keep_state(pfctl::StatePolicy::Keep)
+ .interface(to_interface)
+ .build()?;
+ let redir_rule = self
+ .create_rule_builder(FilterRuleAction::Pass)
+ .quick(true)
+ .direction(pfctl::Direction::Out)
+ .route(pfctl::Route::RouteTo(pfctl::PoolAddr::from(
+ pfctl::Interface::from(to_interface),
+ )))
+ .keep_state(pfctl::StatePolicy::Keep)
+ .tcp_flags(Self::get_tcp_flags())
+ .interface(from_interface)
+ .build()?;
+ Ok(vec![tunnel_rule, allow_rule, redir_rule])
+ }
+
fn get_allow_dhcp_client_rules(&self) -> Result> {
let mut dhcp_rule_builder = self.create_rule_builder(FilterRuleAction::Pass);
dhcp_rule_builder.quick(true).proto(pfctl::Proto::Udp);
@@ -676,3 +738,43 @@ enum RuleLogging {
Drop,
All,
}
+
+fn enable_forwarding() {
+ if let Err(error) = enable_forwarding_for_family(true) {
+ log::error!("Failed to enable forwarding (IPv4): {error}");
+ }
+ if let Err(error) = enable_forwarding_for_family(false) {
+ log::error!("Failed to enable forwarding (IPv6): {error}");
+ }
+}
+
+fn enable_forwarding_for_family(ipv4: bool) -> io::Result<()> {
+ if ipv4 {
+ log::trace!("Enabling forwarding (IPv4)");
+ } else {
+ log::trace!("Enabling forwarding (IPv6)");
+ }
+
+ let mut val: c_int = 1;
+
+ let option = if ipv4 {
+ c"net.inet.ip.forwarding"
+ } else {
+ c"net.inet6.ip6.forwarding"
+ };
+
+ // SAFETY: The strings are null-terminated.
+ let result = unsafe {
+ sysctlbyname(
+ option.as_ptr(),
+ ptr::null_mut(),
+ ptr::null_mut(),
+ &mut val as *mut _ as _,
+ std::mem::size_of_val(&val),
+ )
+ };
+ if result != 0 {
+ return Err(io::Error::from_raw_os_error(result));
+ }
+ Ok(())
+}
diff --git a/talpid-core/src/firewall/mod.rs b/talpid-core/src/firewall/mod.rs
index a0afb39f5d9b..87533b852702 100644
--- a/talpid-core/src/firewall/mod.rs
+++ b/talpid-core/src/firewall/mod.rs
@@ -123,6 +123,9 @@ pub enum FirewallPolicy {
allowed_endpoint: AllowedEndpoint,
/// Networks for which to permit in-tunnel traffic.
allowed_tunnel_traffic: AllowedTunnelTraffic,
+ /// Interface to redirect (VPN tunnel) traffic to
+ #[cfg(target_os = "macos")]
+ redirect_interface: Option,
},
/// Allow traffic only to server and over tunnel interface
@@ -136,6 +139,9 @@ pub enum FirewallPolicy {
/// Servers that are allowed to respond to DNS requests.
#[cfg(not(target_os = "android"))]
dns_servers: Vec,
+ /// Interface to redirect (VPN tunnel) traffic to
+ #[cfg(target_os = "macos")]
+ redirect_interface: Option,
},
/// Block all network traffic in and out from the computer.
diff --git a/talpid-core/src/split_tunnel/macos/bindings.rs b/talpid-core/src/split_tunnel/macos/bindings.rs
new file mode 100644
index 000000000000..1ff1727892e0
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/bindings.rs
@@ -0,0 +1,350 @@
+/* automatically generated by rust-bindgen 0.69.2 */
+
+pub const PTH_FLAG_DIR_OUT: u32 = 2;
+pub const PCAP_ERRBUF_SIZE: u32 = 256;
+pub type __int32_t = ::std::os::raw::c_int;
+pub type __darwin_pid_t = __int32_t;
+pub type __darwin_uuid_t = [::std::os::raw::c_uchar; 16usize];
+pub type u_int = ::std::os::raw::c_uint;
+pub type pid_t = __darwin_pid_t;
+#[repr(C)]
+#[derive(Debug, Copy, Clone)]
+pub struct timeval32 {
+ pub tv_sec: __int32_t,
+ pub tv_usec: __int32_t,
+}
+#[test]
+fn bindgen_test_layout_timeval32() {
+ const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::(),
+ 8usize,
+ concat!("Size of: ", stringify!(timeval32))
+ );
+ assert_eq!(
+ ::std::mem::align_of::(),
+ 4usize,
+ concat!("Alignment of ", stringify!(timeval32))
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).tv_sec) as usize - ptr as usize },
+ 0usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(timeval32),
+ "::",
+ stringify!(tv_sec)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).tv_usec) as usize - ptr as usize },
+ 4usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(timeval32),
+ "::",
+ stringify!(tv_usec)
+ )
+ );
+}
+pub type uuid_t = __darwin_uuid_t;
+#[repr(C)]
+#[derive(Debug, Copy, Clone)]
+pub struct pktap_header {
+ pub pth_length: u32,
+ pub pth_type_next: u32,
+ pub pth_dlt: u32,
+ pub pth_ifname: [::std::os::raw::c_char; 24usize],
+ pub pth_flags: u32,
+ pub pth_protocol_family: u32,
+ pub pth_frame_pre_length: u32,
+ pub pth_frame_post_length: u32,
+ pub pth_pid: pid_t,
+ pub pth_comm: [::std::os::raw::c_char; 17usize],
+ pub pth_svc: u32,
+ pub pth_iftype: u16,
+ pub pth_ifunit: u16,
+ pub pth_epid: pid_t,
+ pub pth_ecomm: [::std::os::raw::c_char; 17usize],
+ pub pth_flowid: u32,
+ pub pth_ipproto: u32,
+ pub pth_tstamp: timeval32,
+ pub pth_uuid: uuid_t,
+ pub pth_euuid: uuid_t,
+}
+#[test]
+fn bindgen_test_layout_pktap_header() {
+ const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::(),
+ 156usize,
+ concat!("Size of: ", stringify!(pktap_header))
+ );
+ assert_eq!(
+ ::std::mem::align_of::(),
+ 4usize,
+ concat!("Alignment of ", stringify!(pktap_header))
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_length) as usize - ptr as usize },
+ 0usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_length)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_type_next) as usize - ptr as usize },
+ 4usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_type_next)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_dlt) as usize - ptr as usize },
+ 8usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_dlt)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_ifname) as usize - ptr as usize },
+ 12usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_ifname)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_flags) as usize - ptr as usize },
+ 36usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_flags)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_protocol_family) as usize - ptr as usize },
+ 40usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_protocol_family)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_frame_pre_length) as usize - ptr as usize },
+ 44usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_frame_pre_length)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_frame_post_length) as usize - ptr as usize },
+ 48usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_frame_post_length)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_pid) as usize - ptr as usize },
+ 52usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_pid)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_comm) as usize - ptr as usize },
+ 56usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_comm)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_svc) as usize - ptr as usize },
+ 76usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_svc)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_iftype) as usize - ptr as usize },
+ 80usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_iftype)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_ifunit) as usize - ptr as usize },
+ 82usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_ifunit)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_epid) as usize - ptr as usize },
+ 84usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_epid)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_ecomm) as usize - ptr as usize },
+ 88usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_ecomm)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_flowid) as usize - ptr as usize },
+ 108usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_flowid)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_ipproto) as usize - ptr as usize },
+ 112usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_ipproto)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_tstamp) as usize - ptr as usize },
+ 116usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_tstamp)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_uuid) as usize - ptr as usize },
+ 124usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_uuid)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).pth_euuid) as usize - ptr as usize },
+ 140usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(pktap_header),
+ "::",
+ stringify!(pth_euuid)
+ )
+ );
+}
+#[repr(C)]
+#[derive(Debug, Copy, Clone)]
+pub struct bpf_stat {
+ pub bs_recv: u_int,
+ pub bs_drop: u_int,
+}
+#[test]
+fn bindgen_test_layout_bpf_stat() {
+ const UNINIT: ::std::mem::MaybeUninit = ::std::mem::MaybeUninit::uninit();
+ let ptr = UNINIT.as_ptr();
+ assert_eq!(
+ ::std::mem::size_of::(),
+ 8usize,
+ concat!("Size of: ", stringify!(bpf_stat))
+ );
+ assert_eq!(
+ ::std::mem::align_of::(),
+ 4usize,
+ concat!("Alignment of ", stringify!(bpf_stat))
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).bs_recv) as usize - ptr as usize },
+ 0usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(bpf_stat),
+ "::",
+ stringify!(bs_recv)
+ )
+ );
+ assert_eq!(
+ unsafe { ::std::ptr::addr_of!((*ptr).bs_drop) as usize - ptr as usize },
+ 4usize,
+ concat!(
+ "Offset of field: ",
+ stringify!(bpf_stat),
+ "::",
+ stringify!(bs_drop)
+ )
+ );
+}
+#[repr(C)]
+#[derive(Debug, Copy, Clone)]
+pub struct pcap {
+ _unused: [u8; 0],
+}
+pub type pcap_t = pcap;
+extern "C" {
+ pub fn pcap_create(
+ arg1: *const ::std::os::raw::c_char,
+ arg2: *mut ::std::os::raw::c_char,
+ ) -> *mut pcap_t;
+}
+extern "C" {
+ pub fn pcap_set_want_pktap(
+ arg1: *mut pcap_t,
+ arg2: ::std::os::raw::c_int,
+ ) -> ::std::os::raw::c_int;
+}
+pub const BIOCSWANTPKTAP: u64 = 3221504639;
diff --git a/talpid-core/src/split_tunnel/macos/bpf.rs b/talpid-core/src/split_tunnel/macos/bpf.rs
new file mode 100644
index 000000000000..d2cd42bdcc39
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/bpf.rs
@@ -0,0 +1,364 @@
+//! This module provides a thin wrapper for BPF devices on macOS. BPF is used for packet
+//! filtering/capture and is exposed as several devices `/dev/bpfN` (where `N` is some integer).
+//!
+//! BPF devices can be attached to network interface and used for reading and writing packets
+//! directly on them, usually whole frames.
+//!
+//! Certain features may be macOS-specific, but much of the documentation for FreeBSD still holds
+//! true. Read more here: https://man.freebsd.org/cgi/man.cgi?bpf
+use futures::ready;
+use libc::{
+ bpf_hdr, ifreq, BIOCGBLEN, BIOCGDLT, BIOCIMMEDIATE, BIOCSBLEN, BIOCSETIF, BIOCSHDRCMPLT,
+ BIOCSSEESENT, BPF_ALIGNMENT, EBUSY, F_GETFL, F_SETFL, O_NONBLOCK,
+};
+use std::{
+ ffi::{c_int, c_uint},
+ fs::File,
+ io::{self, Read, Write},
+ mem,
+ os::fd::AsRawFd,
+ pin::Pin,
+ task::{Context, Poll},
+};
+use tokio::io::{unix::AsyncFd, AsyncRead, Interest, ReadBuf};
+
+use super::bindings::BIOCSWANTPKTAP;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Failed to open BPF device
+ #[error("Failed to open BPF device")]
+ OpenBpfDevice(#[source] io::Error),
+ /// Failed to duplicate BPF fd
+ #[error("Failed to duplicate BPF device")]
+ Duplicate(#[source] io::Error),
+ /// No free BPF device found
+ #[error("No free BPF device found")]
+ NoFreeBpfDeviceFound,
+ /// Interface name too long
+ #[error("Interface name too long")]
+ InterfaceNameTooLong,
+ /// IOCTL failed
+ #[error("IOCTL failed")]
+ IoctlFailed(#[source] io::Error),
+ /// Failed to get flags for BPF device
+ #[error("Failed to get flags for BPF device")]
+ GetFileFlags(#[source] io::Error),
+ /// Failed to set flags for BPF device
+ #[error("Failed to set flags for BPF device")]
+ SetFileFlags(#[source] io::Error),
+ /// Failed to create AsyncFd
+ #[error("Failed to create AsyncFd")]
+ AsyncFd(#[source] io::Error),
+}
+
+macro_rules! ioctl {
+ ($fd:expr, $request:expr, $($arg:expr),+) => {
+ if libc::ioctl($fd, $request, $($arg),+) >= 0 {
+ Ok(())
+ } else {
+ Err(Error::IoctlFailed(io::Error::last_os_error()))
+ }
+ };
+}
+
+pub struct Bpf {
+ file: File,
+}
+
+pub struct ReadHalf(File);
+
+pub struct WriteHalf(File);
+
+impl Bpf {
+ pub fn open() -> Result {
+ Ok(Self {
+ file: Self::open_device()?,
+ })
+ }
+
+ pub fn split(self) -> Result<(ReadHalf, WriteHalf), Error> {
+ let dup = self.file.try_clone().map_err(Error::Duplicate)?;
+ Ok((ReadHalf(dup), WriteHalf(self.file)))
+ }
+
+ fn open_device() -> Result {
+ const MAX_BPF_COUNT: usize = 1000;
+
+ // Find a free bpf device
+ for dev_num in 0..MAX_BPF_COUNT {
+ // Open as O_RDWR
+ match File::options()
+ .read(true)
+ .write(true)
+ .open(format!("/dev/bpf{dev_num}"))
+ {
+ Ok(file) => {
+ log::trace!("Opened BPF device: /dev/bpf{dev_num}");
+ return Ok(file);
+ }
+ Err(_e) if _e.raw_os_error() == Some(EBUSY) => continue,
+ Err(error) => return Err(Error::OpenBpfDevice(error)),
+ }
+ }
+ Err(Error::NoFreeBpfDeviceFound)
+ }
+
+ pub fn set_nonblocking(&self, enabled: bool) -> Result<(), Error> {
+ // SAFETY: The fd is valid for the lifetime of `self`
+ let mut flags = unsafe { libc::fcntl(self.as_raw_fd(), F_GETFL) };
+ if flags == -1 {
+ return Err(Error::GetFileFlags(io::Error::last_os_error()));
+ }
+ if enabled {
+ flags |= O_NONBLOCK;
+ } else {
+ flags &= !O_NONBLOCK;
+ }
+
+ // SAFETY: The fd is valid for the lifetime of `self`
+ let result = unsafe { libc::fcntl(self.as_raw_fd(), F_SETFL, flags) };
+ if result == -1 {
+ return Err(Error::SetFileFlags(io::Error::last_os_error()));
+ }
+ Ok(())
+ }
+
+ /// Set BIOCSETIF
+ pub fn set_interface(&self, name: &str) -> Result<(), Error> {
+ // SAFETY: It is valid for this C struct to be zeroed. We fill in the details later
+ let mut ifr: ifreq = unsafe { std::mem::zeroed() };
+
+ let name_bytes = name.as_bytes();
+ if name_bytes.len() >= std::mem::size_of_val(&ifr.ifr_name) {
+ return Err(Error::InterfaceNameTooLong);
+ }
+
+ unsafe {
+ // SAFETY: `name_bytes` cannot exceed the size of `ifr_name`
+ std::ptr::copy_nonoverlapping(
+ name_bytes.as_ptr(),
+ &mut ifr.ifr_name as *mut _ as *mut _,
+ name_bytes.len(),
+ );
+ // SAFETY: The fd is valid for the lifetime of `self`, and `ifr` has a valid interface
+ ioctl!(self.file.as_raw_fd(), BIOCSETIF, &ifr)
+ }
+ }
+
+ /// Enable or disable immediate mode (BIOCIMMEDIATE)
+ pub fn set_immediate(&self, enable: bool) -> Result<(), Error> {
+ let enable: c_int = if enable { 1 } else { 0 };
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe { ioctl!(self.file.as_raw_fd(), BIOCIMMEDIATE, &enable) }
+ }
+
+ // See locally sent packets (BIOCSSEESENT)
+ pub fn set_see_sent(&self, enable: bool) -> Result<(), Error> {
+ let enable: c_int = if enable { 1 } else { 0 };
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe { ioctl!(self.file.as_raw_fd(), BIOCSSEESENT, &enable) }
+ }
+
+ /// Enable or disable locally sent messages (BIOCSHDRCMPLT)
+ pub fn set_header_complete(&self, enable: bool) -> Result<(), Error> {
+ let enable: c_int = if enable { 1 } else { 0 };
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe { ioctl!(self.file.as_raw_fd(), BIOCSHDRCMPLT, &enable) }
+ }
+
+ pub fn set_want_pktap(&self, enable: bool) -> Result<(), Error> {
+ let enable: c_int = if enable { 1 } else { 0 };
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe { ioctl!(self.file.as_raw_fd(), BIOCSWANTPKTAP, &enable) }
+ }
+
+ pub fn set_buffer_size(&self, mut buffer_size: c_uint) -> Result {
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe {
+ ioctl!(self.file.as_raw_fd(), BIOCSBLEN, &mut buffer_size)?;
+ }
+ Ok(buffer_size as usize)
+ }
+
+ pub fn required_buffer_size(&self) -> Result {
+ let mut buf_size = 0i32;
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe {
+ ioctl!(self.file.as_raw_fd(), BIOCGBLEN, &mut buf_size)?;
+ }
+ Ok(buf_size as usize)
+ }
+
+ pub fn dlt(&self) -> Result {
+ let mut dlt = 0;
+ // SAFETY: The fd is valid for the lifetime of `self`
+ unsafe {
+ ioctl!(self.file.as_raw_fd(), BIOCGDLT, &mut dlt)?;
+ }
+ Ok(dlt)
+ }
+}
+
+impl Read for Bpf {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ self.file.read(buf)
+ }
+}
+
+impl Read for &Bpf {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ (&self.file).read(buf)
+ }
+}
+
+impl Write for Bpf {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ self.file.write(buf)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ // no-op
+ Ok(())
+ }
+}
+
+impl Write for &Bpf {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ (&self.file).write(buf)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ // no-op
+ Ok(())
+ }
+}
+
+impl Read for ReadHalf {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result {
+ self.0.read(buf)
+ }
+}
+
+impl Write for WriteHalf {
+ fn write(&mut self, buf: &[u8]) -> io::Result {
+ self.0.write(buf)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ // no-op
+ Ok(())
+ }
+}
+
+impl AsRawFd for Bpf {
+ fn as_raw_fd(&self) -> std::os::fd::RawFd {
+ self.file.as_raw_fd()
+ }
+}
+
+pub struct BpfStream {
+ inner: AsyncFd,
+}
+
+impl BpfStream {
+ pub fn from_read_half(reader: ReadHalf) -> Result {
+ Self::from_file(reader.0)
+ }
+
+ fn from_file(file: File) -> Result {
+ Ok(BpfStream {
+ inner: AsyncFd::with_interest(file, Interest::READABLE).map_err(Error::AsyncFd)?,
+ })
+ }
+}
+
+impl AsyncRead for BpfStream {
+ fn poll_read(
+ self: Pin<&mut Self>,
+ cx: &mut Context<'_>,
+ buf: &mut ReadBuf<'_>,
+ ) -> Poll> {
+ loop {
+ let mut guard = ready!(self.inner.poll_read_ready(cx))?;
+
+ let unfilled = buf.initialize_unfilled();
+ match guard.try_io(|inner| inner.get_ref().read(unfilled)) {
+ Ok(Ok(len)) => {
+ buf.advance(len);
+ return Poll::Ready(Ok(()));
+ }
+ Ok(Err(err)) => return Poll::Ready(Err(err)),
+ Err(_would_block) => continue,
+ }
+ }
+ }
+}
+
+/// Parse one or more BPF headers and payloads from an arbitrarily sized buffer
+pub struct BpfIterMut<'a> {
+ data: &'a mut [u8],
+ current_packet_offset: usize,
+}
+
+impl<'a> BpfIterMut<'a> {
+ /// Return a new iterator over BPF packets
+ pub fn new(data: &'a mut [u8]) -> Self {
+ Self {
+ data,
+ current_packet_offset: 0,
+ }
+ }
+
+ /// Return the next BPF payload, or None
+ pub fn next(&mut self) -> Option<&mut [u8]> {
+ let offset = self.current_packet_offset;
+ if self.data.len() <= offset || self.data.len() - offset < mem::size_of::() {
+ return None;
+ }
+
+ // SAFETY: The buffer is large enough to contain a BPF header
+ let hdr = unsafe { &*(&self.data[offset] as *const u8 as *const bpf_hdr) };
+
+ if offset + hdr.bh_hdrlen as usize + hdr.bh_caplen as usize > self.data.len() {
+ return None;
+ }
+
+ // SAFETY: This is within the bounds of 'data'
+ let payload = &mut self.data[offset + hdr.bh_hdrlen as usize
+ ..offset + (hdr.bh_hdrlen as usize + hdr.bh_caplen as usize)];
+
+ // Each packet starts on a word boundary after the previous header and capture
+ self.current_packet_offset =
+ offset + usize::try_from(bpf_wordalign(hdr.bh_hdrlen as u32 + hdr.bh_caplen)).unwrap();
+
+ Some(payload)
+ }
+}
+
+/// Compute the next word boundary given `n`. `n` will be rounded up to a multiple of
+/// "word" (defined by `BPF_ALIGNMENT`). Assuming `BPF_ALIGNMENT == 4`:
+///
+/// ```text
+/// n=0: bpf_wordalign(0) == 0
+/// n=1: bpf_wordalign(1) == 4
+/// n=2: bpf_wordalign(2) == 4
+/// n=3: bpf_wordalign(3) == 4
+/// n=4: bpf_wordalign(4) == 4
+/// n=5: bpf_wordalign(5) == 8
+/// n=6: bpf_wordalign(6) == 8
+/// ...
+/// n=9: bpf_wordalign(9) == 12
+/// ```
+const fn bpf_wordalign(n: u32) -> u32 {
+ const ALIGNMENT: u32 = BPF_ALIGNMENT as u32;
+ (n + (ALIGNMENT - 1)) & (!(ALIGNMENT - 1))
+}
+
+#[test]
+fn test_alignment() {
+ assert_eq!(bpf_wordalign(0), 0);
+ assert_eq!(bpf_wordalign(1), 4);
+ assert_eq!(bpf_wordalign(4), 4);
+ assert_eq!(bpf_wordalign(5), 8);
+}
diff --git a/talpid-core/src/split_tunnel/macos/default.rs b/talpid-core/src/split_tunnel/macos/default.rs
new file mode 100644
index 000000000000..ce5415e76966
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/default.rs
@@ -0,0 +1,96 @@
+//! Functions for handling default interfaces/routes
+
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+use talpid_routing::{MacAddress, RouteManagerHandle};
+
+/// Interface errors
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Failed to get default routes
+ #[error("Failed to get default routes")]
+ GetDefaultRoutes(#[source] talpid_routing::Error),
+ /// Failed to get default gateways
+ #[error("Failed to get default gateways")]
+ GetDefaultGateways(#[source] talpid_routing::Error),
+ /// Found no suitable default interface
+ #[error("Found no suitable default interface")]
+ NoDefaultInterface,
+ /// Using different interfaces for IPv4 and IPv6 is not supported
+ #[error("Using different interfaces for IPv4 and IPv6 is not supported")]
+ DefaultInterfaceMismatch,
+}
+
+/// Interface name, addresses, and gateway
+#[derive(Debug, Clone)]
+pub struct DefaultInterface {
+ /// Interface name
+ pub name: String,
+ /// MAC/Hardware address of the gateway
+ pub v4_addrs: Option>,
+ /// MAC/Hardware address of the gateway
+ pub v6_addrs: Option>,
+}
+
+/// Interface name, addresses, and gateway
+#[derive(Debug, Clone)]
+pub struct DefaultInterfaceAddrs {
+ /// Source IP address for excluded apps
+ pub source_ip: IpType,
+ /// MAC/Hardware address of the gateway
+ pub gateway_address: MacAddress,
+}
+
+pub async fn get_default_interface(
+ route_manager: &RouteManagerHandle,
+) -> Result {
+ let (v4_default, v6_default) = route_manager
+ .get_default_routes()
+ .await
+ .map_err(Error::GetDefaultRoutes)?;
+ let (v4_gateway, v6_gateway) = route_manager
+ .get_default_gateway()
+ .await
+ .map_err(Error::GetDefaultGateways)?;
+
+ let default_interface = match (&v4_default, &v6_default) {
+ (Some(v4_default), Some(v6_default)) => {
+ if v4_default.interface != v6_default.interface {
+ return Err(Error::DefaultInterfaceMismatch);
+ }
+ v4_default.interface.to_owned()
+ }
+ (Some(default), None) | (None, Some(default)) => default.interface.to_owned(),
+ (None, None) => return Err(Error::NoDefaultInterface),
+ };
+
+ let default_v4 = if let Some(v4_gateway) = v4_gateway {
+ v4_default.map(|v4_default| DefaultInterfaceAddrs {
+ source_ip: match v4_default.ip {
+ IpAddr::V4(addr) => addr,
+ _ => unreachable!("unexpected IP address type"),
+ },
+ gateway_address: v4_gateway.mac_address,
+ })
+ } else {
+ log::debug!("Missing V4 gateway");
+ None
+ };
+ let default_v6 = if let Some(v6_gateway) = v6_gateway {
+ v6_default.map(|v6_default| DefaultInterfaceAddrs {
+ source_ip: match v6_default.ip {
+ IpAddr::V6(addr) => addr,
+ _ => unreachable!("unexpected IP address type"),
+ },
+ gateway_address: v6_gateway.mac_address,
+ })
+ } else {
+ log::debug!("Missing V6 gateway");
+ None
+ };
+
+ Ok(DefaultInterface {
+ name: default_interface,
+ v4_addrs: default_v4,
+ v6_addrs: default_v6,
+ })
+}
diff --git a/talpid-core/src/split_tunnel/macos/generate-bindings.sh b/talpid-core/src/split_tunnel/macos/generate-bindings.sh
new file mode 100755
index 000000000000..a56fe69d2d0a
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/generate-bindings.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+# This script generates bindings for certain pcap and pktap symbols.
+# bindgen is required: cargo install bindgen-cli
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+curl https://opensource.apple.com/source/xnu/xnu-3789.41.3/bsd/net/pktap.h -o include/pktap.h
+curl https://opensource.apple.com/source/libpcap/libpcap-67/libpcap/pcap/pcap.h -o include/pcap.h
+curl https://opensource.apple.com/source/xnu/xnu-3789.41.3/bsd/net/bpf.h -o include/bpf.h
+
+bindgen "include/bindings.h" -o ./bindings.rs \
+ --allowlist-item "^pcap_create" \
+ --allowlist-item "^pcap_set_want_pktap" \
+ --allowlist-item "^pktap_header" \
+ --allowlist-item "PCAP_ERRBUF_SIZE" \
+ --allowlist-item "^BIOCSWANTPKTAP" \
+ --allowlist-item "^PTH_FLAG_DIR_OUT" \
+ --allowlist-item "^bpf_stat"
diff --git a/talpid-core/src/split_tunnel/macos/include/.gitignore b/talpid-core/src/split_tunnel/macos/include/.gitignore
new file mode 100644
index 000000000000..a33b992fc860
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/include/.gitignore
@@ -0,0 +1,3 @@
+/pktap.h
+/pcap.h
+/bpf.h
diff --git a/talpid-core/src/split_tunnel/macos/include/bindings.h b/talpid-core/src/split_tunnel/macos/include/bindings.h
new file mode 100644
index 000000000000..59fd6ca2be8f
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/include/bindings.h
@@ -0,0 +1,12 @@
+#include
+#include
+
+#define PRIVATE 1
+#include "pktap.h"
+#include "bpf.h"
+#include "pcap.h"
+
+/* workaround for lack of macro expansions in bindgen */
+const uint64_t _BIOCSWANTPKTAP = BIOCSWANTPKTAP;
+#undef BIOCSWANTPKTAP
+const uint64_t BIOCSWANTPKTAP = _BIOCSWANTPKTAP;
diff --git a/talpid-core/src/split_tunnel/macos/mod.rs b/talpid-core/src/split_tunnel/macos/mod.rs
new file mode 100644
index 000000000000..980097d94ba4
--- /dev/null
+++ b/talpid-core/src/split_tunnel/macos/mod.rs
@@ -0,0 +1,482 @@
+use std::collections::HashSet;
+use std::path::PathBuf;
+use std::sync::Weak;
+use talpid_routing::RouteManagerHandle;
+use talpid_types::tunnel::ErrorStateCause;
+use talpid_types::ErrorExt;
+use tokio::sync::{mpsc, oneshot};
+
+use self::process::ExclusionStatus;
+
+#[allow(non_camel_case_types)]
+mod bindings;
+mod bpf;
+mod default;
+mod process;
+mod tun;
+
+use crate::tunnel_state_machine::TunnelCommand;
+pub use tun::VpnInterface;
+
+/// Errors caused by split tunneling
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ /// Process monitor error
+ #[error("Process monitor error")]
+ Process(#[from] process::Error),
+ /// Failed to initialize split tunnel
+ #[error("Failed to initialize split tunnel")]
+ InitializeTunnel(#[from] tun::Error),
+ /// Default interface unavailable
+ #[error("Default interface unavailable")]
+ Default(#[from] default::Error),
+ /// Split tunnel is unavailable
+ #[error("Split tunnel is unavailable")]
+ Unavailable,
+}
+
+impl Error {
+ /// Return whether the error is due to a missing default route
+ pub fn is_offline(&self) -> bool {
+ matches!(self, Error::Default(_))
+ }
+}
+
+/// Split tunneling actor
+pub struct SplitTunnel {
+ state: State,
+ tunnel_tx: Weak>,
+ rx: mpsc::UnboundedReceiver,
+ shutdown_tx: Option>,
+}
+
+enum Message {
+ /// Return the name of the split tunnel interface
+ GetInterface {
+ result_tx: oneshot::Sender