summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-06-09 14:54:44 +0200
committerDavid Lönnhager <david.l@mullvad.net>2025-06-09 14:54:44 +0200
commit87e716c551f563b6bf181bcef87a58bee0fb2599 (patch)
treec8506ed39f7b90a732c2214269590d43aa25960f
parentbcca08c804560b4d2413fdfd3e3df8413330fae9 (diff)
parent0859b5d8c0b69f402087ce17b5fc9d178d802d32 (diff)
downloadmullvadvpn-87e716c551f563b6bf181bcef87a58bee0fb2599.tar.xz
mullvadvpn-87e716c551f563b6bf181bcef87a58bee0fb2599.zip
Merge branch 'macos-set-reuseport'
-rw-r--r--Cargo.lock4
-rw-r--r--talpid-core/Cargo.toml7
-rw-r--r--talpid-core/src/resolver.rs222
-rw-r--r--talpid-core/src/tunnel_state_machine/mod.rs4
-rw-r--r--talpid-macos/Cargo.toml4
-rw-r--r--talpid-macos/src/lib.rs3
-rw-r--r--talpid-macos/src/net.rs45
-rw-r--r--test/Cargo.lock15
-rw-r--r--test/test-manager/src/tests/macos.rs95
-rw-r--r--test/test-manager/src/tests/mod.rs1
-rw-r--r--test/test-manager/test_macro/src/lib.rs2
-rw-r--r--test/test-rpc/src/client.rs20
-rw-r--r--test/test-rpc/src/lib.rs7
-rw-r--r--test/test-runner/Cargo.toml4
-rw-r--r--test/test-runner/src/main.rs34
15 files changed, 427 insertions, 40 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 649fa608ca..4af995fc7b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5339,6 +5339,7 @@ dependencies = [
"resolv-conf",
"serde",
"serde_json",
+ "socket2 0.5.8",
"system-configuration",
"talpid-dbus",
"talpid-macos",
@@ -5357,6 +5358,7 @@ dependencies = [
"tonic-build",
"triggered",
"tun 0.5.5",
+ "typed-builder 0.20.1",
"which",
"widestring",
"windows 0.58.0",
@@ -5392,8 +5394,10 @@ dependencies = [
name = "talpid-macos"
version = "0.0.0"
dependencies = [
+ "anyhow",
"libc",
"log",
+ "tokio",
]
[[package]]
diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml
index 35518f7ac0..4a11e9125d 100644
--- a/talpid-core/Cargo.toml
+++ b/talpid-core/Cargo.toml
@@ -57,9 +57,10 @@ talpid-platform-metadata = { path = "../talpid-platform-metadata" }
pcap = { version = "2.1", features = ["capture-stream"] }
pnet_packet = { workspace = true }
tun = { workspace = true, features = ["async"] }
-nix = { version = "0.28", features = ["socket", "signal"] }
+nix = { version = "0.28", features = ["socket", "signal", "user"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+socket2 = { workspace = true }
talpid-macos = { path = "../talpid-macos" }
talpid-net = { path = "../talpid-net" }
@@ -102,10 +103,12 @@ features = [
"Win32_System_SystemInformation",
]
+[target.'cfg(target_os = "macos")'.dev-dependencies]
+typed-builder = "0.20.0"
+
[build-dependencies]
tonic-build = { workspace = true, default-features = false, features = ["transport", "prost"] }
-
[dev-dependencies]
test-log = "0.2.17"
tokio = { workspace = true, features = ["io-util", "test-util", "time"] }
diff --git a/talpid-core/src/resolver.rs b/talpid-core/src/resolver.rs
index 0f51e5023e..2a69998b2e 100644
--- a/talpid-core/src/resolver.rs
+++ b/talpid-core/src/resolver.rs
@@ -41,11 +41,11 @@ use hickory_server::{
ServerFuture,
};
use rand::random;
+use socket2::{Domain, Protocol, Socket, Type};
use std::sync::LazyLock;
use talpid_types::drop_guard::{on_drop, OnDrop};
use tokio::{
net::{self, UdpSocket},
- process::Command,
task::JoinHandle,
};
@@ -91,10 +91,24 @@ const TTL_SECONDS: u32 = 3;
/// belongs to the documentation range so should never be reachable.
const RESOLVED_ADDR: Ipv4Addr = Ipv4Addr::new(198, 51, 100, 1);
+#[derive(Clone, Debug, PartialEq)]
+pub struct LocalResolverConfig {
+ /// Try to bind to a random address in the `127/8` subnet.
+ pub use_random_loopback: bool,
+}
+
+impl Default for LocalResolverConfig {
+ fn default() -> Self {
+ Self {
+ use_random_loopback: true,
+ }
+ }
+}
+
/// Starts a resolver. Returns a cloneable handle, which can activate, deactivate and shut down the
/// resolver. When all instances of a handle are dropped, the server will stop.
-pub async fn start_resolver() -> Result<ResolverHandle, Error> {
- let (resolver, resolver_handle) = LocalResolver::new().await?;
+pub async fn start_resolver(config: LocalResolverConfig) -> Result<ResolverHandle, Error> {
+ let (resolver, resolver_handle) = LocalResolver::new(config).await?;
tokio::spawn(resolver.run());
Ok(resolver_handle)
}
@@ -140,6 +154,12 @@ enum ResolverMessage {
/// Channel for the query response
response_tx: oneshot::Sender<std::result::Result<Box<dyn LookupObject>, ResolveError>>,
},
+
+ /// Gracefully stop resolver
+ Stop {
+ /// Channel for the query response
+ response_tx: oneshot::Sender<()>,
+ },
}
/// Configuration for [Resolver]
@@ -237,7 +257,7 @@ impl Resolver {
/// A handle to control a DNS resolver.
///
/// When all resolver handles are dropped, the resolver will stop.
-#[derive(Clone)]
+#[derive(Clone, Debug)]
pub struct ResolverHandle {
tx: Arc<mpsc::UnboundedSender<ResolverMessage>>,
listening_addr: SocketAddr,
@@ -274,16 +294,25 @@ impl ResolverHandle {
let _ = response_rx.await;
}
+
+ /// Gracefully shut down resolver
+ pub async fn stop(self) {
+ let (response_tx, response_rx) = oneshot::channel();
+ let _ = self
+ .tx
+ .unbounded_send(ResolverMessage::Stop { response_tx });
+ let _ = response_rx.await;
+ }
}
impl LocalResolver {
/// Constructs a new filtering resolver and it's handle.
- async fn new() -> Result<(Self, ResolverHandle), Error> {
+ async fn new(config: LocalResolverConfig) -> Result<(Self, ResolverHandle), Error> {
let (command_tx, command_rx) = mpsc::unbounded();
let command_tx = Arc::new(command_tx);
let weak_tx = Arc::downgrade(&command_tx);
- let (socket, cleanup_ifconfig) = Self::new_random_socket().await?;
+ let (socket, cleanup_ifconfig) = Self::new_random_socket(&config).await?;
let resolver_addr = socket.local_addr().map_err(Error::GetSocketAddr)?;
let mut server = Self::new_server(socket, weak_tx.clone())?;
@@ -357,11 +386,14 @@ impl LocalResolver {
///
/// We do this to try and avoid collisions with other DNS servers running on the same system.
///
+ /// If [LocalResolverConfig::use_random_loopback] is `false`, we will only try to bind to
+ /// `127.0.0.1`.
+ ///
/// # Returns
/// - The first successfully bound [UdpSocket]
/// - An [OnDrop] guard that will delete the IP aliases added, if any.
/// If the guard is dropped while the socket is in use, calls to read/write will likely fail.
- async fn new_random_socket() -> Result<(UdpSocket, OnDrop), Error> {
+ async fn new_random_socket(config: &LocalResolverConfig) -> Result<(UdpSocket, OnDrop), Error> {
use std::net::Ipv4Addr;
let random_loopback = || async move {
@@ -370,33 +402,22 @@ impl LocalResolver {
// TODO: this command requires root privileges and will thus not work in `cargo test`.
// This means that the tests will fall back to 127.0.0.1, and will not assert that the
// ifconfig stuff actually works. We probably do want to test this, so what do?
- let output = Command::new("ifconfig")
- .args([LOOPBACK, "alias", &format!("{addr}"), "up"])
- .output()
+ talpid_macos::net::add_alias(LOOPBACK, IpAddr::from(addr))
.await
.inspect_err(|e| {
- log::warn!("Failed to spawn `ifconfig {LOOPBACK} alias {addr} up`: {e}")
+ log::warn!("Failed to add loopback {LOOPBACK} alias {addr}: {e}");
})
.ok()?;
- if !output.status.success() {
- log::warn!("Non-zero exit code from ifconfig: {}", output.status);
- return None;
- }
-
log::debug!("Created loopback address {addr}");
// Clean up ip address when stopping the resolver
let cleanup_ifconfig = on_drop(move || {
tokio::task::spawn(async move {
log::debug!("Cleaning up loopback address {addr}");
-
- let result = Command::new("ifconfig")
- .args([LOOPBACK, "delete", &format!("{addr}")])
- .output()
- .await;
-
- if let Err(e) = result {
+ if let Err(e) =
+ talpid_macos::net::remove_alias(LOOPBACK, IpAddr::from(addr)).await
+ {
log::warn!("Failed to clean up {LOOPBACK} alias {addr}: {e}");
}
});
@@ -408,16 +429,42 @@ impl LocalResolver {
for attempt in 0.. {
let (socket_addr, on_drop) = match attempt {
+ ..3 if !config.use_random_loopback => continue,
..3 => match random_loopback().await {
Some(random) => random,
None => continue,
},
+
3 => (Ipv4Addr::LOCALHOST, OnDrop::noop()),
4.. => break,
};
- match net::UdpSocket::bind((socket_addr, DNS_PORT)).await {
- Ok(socket) => return Ok((socket, on_drop)),
+ let sock = match Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) {
+ Ok(sock) => sock,
+ Err(error) => {
+ log::error!("Failed to open IPv4/UDP socket: {error}");
+ continue;
+ }
+ };
+
+ // SO_NONBLOCK is required for turning this into a tokio socket.
+ if let Err(error) = sock.set_nonblocking(true) {
+ log::warn!("Failed to set socket as nonblocking: {error}");
+ continue;
+ }
+
+ // SO_REUSEADDR allows us to bind to `127.x.y.z` even if another socket is bound to
+ // `0.0.0.0`. This can happen e.g. when macOS "Internet Sharing" is turned on.
+ if let Err(error) = sock.set_reuse_address(true) {
+ log::warn!("Failed to set SO_REUSEADDR on resolver socket: {error}");
+ }
+
+ match sock.bind(&SocketAddr::from((socket_addr, DNS_PORT)).into()) {
+ Ok(()) => {
+ let socket =
+ net::UdpSocket::from_std(sock.into()).expect("socket is non-blocking");
+ return Ok((socket, on_drop));
+ }
Err(err) => log::warn!("Failed to bind DNS server to {socket_addr}: {err}"),
}
}
@@ -432,6 +479,7 @@ impl LocalResolver {
async fn run(mut self) {
let abort_handle = self.dns_server_task.abort_handle();
let _abort_dns_server_task = on_drop(|| abort_handle.abort());
+ let mut stop_tx = None;
while let Some(request) = self.rx.next().await {
match request {
@@ -451,8 +499,19 @@ impl LocalResolver {
} => {
self.inner_resolver.resolve(dns_query, response_tx);
}
+ ResolverMessage::Stop { response_tx } => {
+ stop_tx = Some(response_tx);
+ break;
+ }
}
}
+
+ self.dns_server_task.abort();
+ let _ = self.dns_server_task.await;
+
+ if let Some(stop_tx) = stop_tx {
+ let _ = stop_tx.send(());
+ }
}
/// Update the current DNS config.
@@ -632,14 +691,21 @@ mod test {
config::{NameServerConfigGroup, ResolverConfig, ResolverOpts},
TokioAsyncResolver,
};
- use std::{mem, net::UdpSocket, sync::Mutex, thread, time::Duration};
+ use std::{net::UdpSocket, sync::Mutex};
+ use typed_builder::TypedBuilder;
/// Can't have multiple local resolvers running at the same time, as they will try to bind to
/// the same address and port. The tests below use this lock to run sequentially.
static LOCK: Mutex<()> = Mutex::new(());
async fn start_resolver() -> ResolverHandle {
- super::start_resolver().await.unwrap()
+ // NOTE: We're disabling lo0 aliases
+ super::start_resolver(LocalResolverConfig {
+ // Bind resolver to 127.0.0.1
+ use_random_loopback: false,
+ })
+ .await
+ .unwrap()
}
fn get_test_resolver(addr: SocketAddr) -> hickory_server::resolver::TokioAsyncResolver {
@@ -651,6 +717,61 @@ mod test {
TokioAsyncResolver::tokio(resolver_config, ResolverOpts::default())
}
+ /// Test whether we can successfully bind the socket even if the address is already used to
+ /// in different scenarios.
+ ///
+ /// # Note
+ ///
+ /// This test does not test aliases on lo0, as that requires root privileges.
+ #[test_log::test]
+ fn test_bind() {
+ let _mutex = LOCK.lock().unwrap();
+ let rt = tokio::runtime::Runtime::new().unwrap();
+
+ rt.block_on(async move {
+ // bind() succeeds if wildcard address is bound without REUSEADDR and REUSEPORT
+ let _sock = bind_sock(
+ BindParams::builder()
+ .bind_addr(format!("0.0.0.0:{DNS_PORT}").parse().unwrap())
+ .reuse_addr(false)
+ .reuse_port(false)
+ .build(),
+ )
+ .unwrap();
+
+ let handle = start_resolver().await;
+ let test_resolver = get_test_resolver(handle.listening_addr());
+ test_resolver
+ .lookup(&ALLOWED_DOMAINS[0], RecordType::A)
+ .await
+ .expect("lookup should succeed");
+ drop(_sock);
+ handle.stop().await;
+
+ // bind() succeeds if wildcard address is bound with REUSEADDR and REUSEPORT
+ let _sock = bind_sock(
+ BindParams::builder()
+ .bind_addr(format!("0.0.0.0:{DNS_PORT}").parse().unwrap())
+ .reuse_addr(true)
+ .reuse_port(true)
+ .build(),
+ )
+ .unwrap();
+
+ let handle = start_resolver().await;
+ let test_resolver = get_test_resolver(handle.listening_addr());
+ test_resolver
+ .lookup(&ALLOWED_DOMAINS[0], RecordType::A)
+ .await
+ .expect("lookup should succeed");
+ drop(_sock);
+ handle.stop().await;
+
+ // bind() should succeeds if 127.0.0.1 is already bound without REUSEADDR and REUSEPORT
+ // NOTE: We cannot test this as creating an alias requires root privileges.
+ });
+ }
+
#[test_log::test]
fn test_successful_lookup() {
let _mutex = LOCK.lock().unwrap();
@@ -688,15 +809,52 @@ mod test {
)
}
+ /// Test that we close the socket when shutting down the local resolver.
#[test_log::test]
- fn test_shutdown() {
+ fn test_unbind_socket_on_stop() {
let _mutex = LOCK.lock().unwrap();
- let rt = tokio::runtime::Runtime::new().unwrap();
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .unwrap();
- let handle = rt.block_on(start_resolver());
+ let config = LocalResolverConfig {
+ // Bind resolver to 127.0.0.1 so that we can easily bind to the same address here.
+ use_random_loopback: false,
+ };
+ let handle = rt.block_on(super::start_resolver(config)).unwrap();
let addr = handle.listening_addr();
- mem::drop(handle);
- thread::sleep(Duration::from_millis(300));
+ assert_eq!(addr, SocketAddr::from((Ipv4Addr::LOCALHOST, DNS_PORT)));
+ rt.block_on(handle.stop());
UdpSocket::bind(addr).expect("Failed to bind to a port that should have been removed");
}
+
+ #[derive(TypedBuilder)]
+ struct BindParams {
+ bind_addr: SocketAddr,
+ reuse_addr: bool,
+ reuse_port: bool,
+ #[builder(default)]
+ connect_addr: Option<SocketAddr>,
+ }
+
+ /// Helper function for creating and binding a UDP socket
+ fn bind_sock(params: BindParams) -> io::Result<UdpSocket> {
+ let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
+
+ let addr = params.bind_addr;
+ sock.set_reuse_address(params.reuse_addr)?;
+ sock.set_reuse_port(params.reuse_port)?;
+ sock.bind(&addr.into())?;
+
+ if let Some(addr) = params.connect_addr {
+ sock.connect(&addr.into())?;
+ }
+
+ println!(
+ "Bound to {} (reuseport: {}, reuseaddr: {})",
+ params.bind_addr, params.reuse_port, params.reuse_addr
+ );
+ Ok(sock.into())
+ }
}
diff --git a/talpid-core/src/tunnel_state_machine/mod.rs b/talpid-core/src/tunnel_state_machine/mod.rs
index fc393e466e..207291360c 100644
--- a/talpid-core/src/tunnel_state_machine/mod.rs
+++ b/talpid-core/src/tunnel_state_machine/mod.rs
@@ -277,7 +277,7 @@ impl TunnelStateMachine {
let runtime = tokio::runtime::Handle::current();
#[cfg(target_os = "macos")]
- let filtering_resolver = crate::resolver::start_resolver().await?;
+ let filtering_resolver = crate::resolver::start_resolver(Default::default()).await?;
#[cfg(windows)]
let split_tunnel = split_tunnel::SplitTunnel::new(
@@ -436,6 +436,8 @@ impl TunnelStateMachine {
#[cfg(target_os = "macos")]
runtime.block_on(self.shared_values.split_tunnel.shutdown());
+ #[cfg(target_os = "macos")]
+ runtime.block_on(self.shared_values.filtering_resolver.stop());
runtime.block_on(self.shared_values.route_manager.stop());
}
}
diff --git a/talpid-macos/Cargo.toml b/talpid-macos/Cargo.toml
index 7b910f5e6d..0b4caa4258 100644
--- a/talpid-macos/Cargo.toml
+++ b/talpid-macos/Cargo.toml
@@ -11,5 +11,7 @@ rust-version.workspace = true
workspace = true
[target.'cfg(target_os="macos")'.dependencies]
+anyhow.workspace = true
+log.workspace = true
libc = "0.2.172"
-log = { workspace = true }
+tokio = { workspace = true, features = ["process"] }
diff --git a/talpid-macos/src/lib.rs b/talpid-macos/src/lib.rs
index 5a282660d3..1dad718685 100644
--- a/talpid-macos/src/lib.rs
+++ b/talpid-macos/src/lib.rs
@@ -9,3 +9,6 @@ pub mod process;
/// OS bindings generated by 'generate_bindings.rs'
#[allow(non_camel_case_types)]
mod bindings;
+
+/// Networking utilities
+pub mod net;
diff --git a/talpid-macos/src/net.rs b/talpid-macos/src/net.rs
new file mode 100644
index 0000000000..5eff2f6878
--- /dev/null
+++ b/talpid-macos/src/net.rs
@@ -0,0 +1,45 @@
+use anyhow::{anyhow, bail, Context};
+use std::net::IpAddr;
+use tokio::process::Command;
+
+/// Adds an alias to a network interface.
+pub async fn add_alias(interface: &str, addr: IpAddr) -> anyhow::Result<()> {
+ let context = || anyhow!("Failed to add interface {interface} alias {addr}");
+ let output = Command::new("ifconfig")
+ .args([interface, "alias", &format!("{addr}"), "up"])
+ .output()
+ .await
+ .context("Failed to spawn ifconfig")
+ .with_context(context)?;
+
+ if !output.status.success() {
+ bail!(
+ "{}: Non-zero exit code from ifconfig: {}",
+ context(),
+ output.status
+ );
+ }
+
+ Ok(())
+}
+
+/// Removes an alias from a network interface.
+pub async fn remove_alias(interface: &str, addr: IpAddr) -> anyhow::Result<()> {
+ let context = || anyhow!("Failed to remove interface {interface} alias {addr}");
+ let output = Command::new("ifconfig")
+ .args([interface, "delete", &format!("{addr}")])
+ .output()
+ .await
+ .context("Failed to spawn ifconfig")
+ .with_context(context)?;
+
+ if !output.status.success() {
+ bail!(
+ "{}: Non-zero exit code from ifconfig: {}",
+ context(),
+ output.status
+ );
+ }
+
+ Ok(())
+}
diff --git a/test/Cargo.lock b/test/Cargo.lock
index 1ccf04e2e7..dee10ed1cb 100644
--- a/test/Cargo.lock
+++ b/test/Cargo.lock
@@ -1871,9 +1871,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
-version = "0.2.171"
+version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
+checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libloading"
@@ -3632,6 +3632,16 @@ dependencies = [
]
[[package]]
+name = "talpid-macos"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "libc",
+ "log",
+ "tokio",
+]
+
+[[package]]
name = "talpid-platform-metadata"
version = "0.0.0"
dependencies = [
@@ -3816,6 +3826,7 @@ dependencies = [
"serde_json",
"socket2 0.5.8",
"surge-ping",
+ "talpid-macos",
"talpid-platform-metadata",
"talpid-windows",
"tarpc",
diff --git a/test/test-manager/src/tests/macos.rs b/test/test-manager/src/tests/macos.rs
new file mode 100644
index 0000000000..362a9b3c61
--- /dev/null
+++ b/test/test-manager/src/tests/macos.rs
@@ -0,0 +1,95 @@
+//! macOS-specific tests.
+
+use anyhow::{bail, ensure, Context};
+use mullvad_management_interface::MullvadProxyClient;
+use std::net::{Ipv4Addr, SocketAddr};
+use test_macro::test_function;
+use test_rpc::ServiceClient;
+
+use super::TestContext;
+
+/// Test that we can add and remove IP "aliases" to network interfaces.
+///
+/// This is effectively testing that macOS behaves as expected, and that future versions of it
+/// don't break this functionality.
+#[test_function(target_os = "macos")]
+async fn test_ifconfig_add_alias(
+ _: TestContext,
+ rpc: ServiceClient,
+ _: MullvadProxyClient,
+) -> anyhow::Result<()> {
+ let alias = Ipv4Addr::new(127, 123, 123, 123);
+ let interface = "lo0";
+
+ log::info!("Will try to assign alias {alias} to interface {interface}");
+
+ // Sanity-check that alias does not exist before we add it.
+ ensure!(
+ !alias_exists(&rpc, interface, alias).await?,
+ "Alias shouldn't exist before it's created. Was it left over from a previous test?"
+ );
+
+ // Add alias and assert that it exists.
+ rpc.ifconfig_alias_add(interface, alias).await?;
+ ensure!(
+ alias_exists(&rpc, interface, alias).await?,
+ "Alias should have been created!"
+ );
+
+ // Ensure that we clean up the alias after the test, even if it fails
+ let rpc2 = rpc.clone();
+ let _cleanup_guard = scopeguard::guard((), |()| {
+ log::info!("Cleaning up after test_ifconfig_add_alias");
+
+ let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
+ log::error!("Missing tokio runtime");
+ return;
+ };
+
+ runtime_handle.spawn(async move {
+ // Ensure that the alias is removed even if the test fails.
+ if let Err(e) = rpc2.ifconfig_alias_remove(interface, alias).await {
+ log::error!("Failed to remove alias {alias} from interface {interface}: {e}");
+ }
+ });
+ });
+
+ // Assert that we can bind to the alias.
+ rpc.send_udp(
+ None,
+ SocketAddr::from((alias, 0)),
+ SocketAddr::from((Ipv4Addr::LOCALHOST, 1234)),
+ )
+ .await
+ .context("Failed to bind to alias")?;
+
+ // Remove alias and assert that it doesn't exist.
+ rpc.ifconfig_alias_remove(interface, alias).await?;
+ ensure!(
+ !alias_exists(&rpc, interface, alias).await?,
+ "Alias should have been removed!"
+ );
+
+ Ok(())
+}
+
+/// Check if an IP alias exists for `interface`.
+async fn alias_exists(
+ rpc: &ServiceClient,
+ interface: &str,
+ alias: Ipv4Addr,
+) -> anyhow::Result<bool> {
+ let alias = alias.to_string();
+ let result = rpc.exec("ifconfig", [interface]).await?;
+
+ let stdout = String::from_utf8(result.stdout)?;
+ let stderr = String::from_utf8(result.stderr)?;
+
+ if result.code != Some(0) {
+ log::error!("ifconfig stdout:\n{stdout}");
+ log::error!("ifconfig stderr:\n{stderr}");
+ bail!("`ifconfig` exited with code {:?}", result.code);
+ }
+
+ Ok(stdout.contains(&alias))
+}
diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs
index 6d39c94e51..0858ffd9fa 100644
--- a/test/test-manager/src/tests/mod.rs
+++ b/test/test-manager/src/tests/mod.rs
@@ -6,6 +6,7 @@ mod daita;
mod dns;
mod helpers;
mod install;
+mod macos;
mod relay_ip_overrides;
mod settings;
mod software;
diff --git a/test/test-manager/test_macro/src/lib.rs b/test/test-manager/test_macro/src/lib.rs
index 048bb1975e..5f5af2c4da 100644
--- a/test/test-manager/test_macro/src/lib.rs
+++ b/test/test-manager/test_macro/src/lib.rs
@@ -154,7 +154,7 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream {
let wrapper_closure = quote! {
|test_context: crate::tests::TestContext,
rpc: test_rpc::ServiceClient,
- mullvad_client: Option<MullvadProxyClient>|
+ mullvad_client: Option<::mullvad_management_interface::MullvadProxyClient>|
{
let mullvad_client = mullvad_client.expect("Test functions defined using the macro should be given a mullvad client");
Box::pin(async move {
diff --git a/test/test-rpc/src/client.rs b/test/test-rpc/src/client.rs
index e1a8bc5ef9..ad3d39dce9 100644
--- a/test/test-rpc/src/client.rs
+++ b/test/test-rpc/src/client.rs
@@ -420,4 +420,24 @@ impl ServiceClient {
.get_os_version(tarpc::context::current())
.await?
}
+
+ pub async fn ifconfig_alias_add(
+ &self,
+ interface: impl Into<String>,
+ alias: impl Into<IpAddr>,
+ ) -> Result<(), Error> {
+ self.client
+ .ifconfig_alias_add(tarpc::context::current(), interface.into(), alias.into())
+ .await?
+ }
+
+ pub async fn ifconfig_alias_remove(
+ &self,
+ interface: impl Into<String>,
+ alias: impl Into<IpAddr>,
+ ) -> Result<(), Error> {
+ self.client
+ .ifconfig_alias_remove(tarpc::context::current(), interface.into(), alias.into())
+ .await?
+ }
}
diff --git a/test/test-rpc/src/lib.rs b/test/test-rpc/src/lib.rs
index a23eb84266..d92e6ff3af 100644
--- a/test/test-rpc/src/lib.rs
+++ b/test/test-rpc/src/lib.rs
@@ -71,6 +71,8 @@ pub enum Error {
UnknownPid(u32),
#[error("Failed to join tokio task: {0}")]
TokioJoinError(String),
+ #[error("gRPC command is not implemented for this target")]
+ TargetNotImplemented,
#[error("{0}")]
Other(String),
}
@@ -275,6 +277,11 @@ mod service {
/// Returns operating system details
async fn get_os_version() -> Result<meta::OsVersion, Error>;
+
+ /// Create an IP alias for the provided interface. (macOS only)
+ async fn ifconfig_alias_add(interface: String, alias: IpAddr) -> Result<(), Error>;
+ /// Remove an IP alias for the provided interface. (macOS only)
+ async fn ifconfig_alias_remove(interface: String, alias: IpAddr) -> Result<(), Error>;
}
}
diff --git a/test/test-runner/Cargo.toml b/test/test-runner/Cargo.toml
index af84ef4dae..ad01aecbc8 100644
--- a/test/test-runner/Cargo.toml
+++ b/test/test-runner/Cargo.toml
@@ -35,7 +35,8 @@ talpid-platform-metadata = { path = "../../talpid-platform-metadata", default-fe
socket2 = { workspace = true, features = ["all"] }
-[target."cfg(target_os=\"windows\")".dependencies]
+
+[target.'cfg(windows)'.dependencies]
talpid-windows = { path = "../../talpid-windows" }
windows-service = "0.6"
@@ -64,4 +65,5 @@ nix = { workspace = true, features = ["user"] }
rs-release = "0.1.7"
[target.'cfg(target_os = "macos")'.dependencies]
+talpid-macos = { path = "../../talpid-macos" }
plist = "1"
diff --git a/test/test-runner/src/main.rs b/test/test-runner/src/main.rs
index a107f29f3c..a7e1a26515 100644
--- a/test/test-runner/src/main.rs
+++ b/test/test-runner/src/main.rs
@@ -576,6 +576,40 @@ impl Service for TestServer {
async fn get_os_version(self, _: context::Context) -> Result<OsVersion, test_rpc::Error> {
sys::get_os_version()
}
+
+ #[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
+ async fn ifconfig_alias_add(
+ self,
+ _: context::Context,
+ interface: String,
+ alias: IpAddr,
+ ) -> Result<(), test_rpc::Error> {
+ #[cfg(not(target_os = "macos"))]
+ return Err(test_rpc::Error::TargetNotImplemented);
+
+ #[cfg(target_os = "macos")]
+ talpid_macos::net::add_alias(&interface, alias)
+ .await
+ .map_err(|e| format!("{e:#}"))
+ .map_err(test_rpc::Error::Other)
+ }
+
+ #[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
+ async fn ifconfig_alias_remove(
+ self,
+ _: context::Context,
+ interface: String,
+ alias: IpAddr,
+ ) -> Result<(), test_rpc::Error> {
+ #[cfg(not(target_os = "macos"))]
+ return Err(test_rpc::Error::TargetNotImplemented);
+
+ #[cfg(target_os = "macos")]
+ talpid_macos::net::remove_alias(&interface, alias)
+ .await
+ .map_err(|e| format!("{e:#}"))
+ .map_err(test_rpc::Error::Other)
+ }
}
fn get_pipe_status() -> ServiceStatus {