summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-04-15 12:57:26 +0200
committerDavid Lönnhager <david.l@mullvad.net>2025-04-23 13:03:49 +0200
commit76b41fa149771f603faa73e0c4a4c3e9b82f3f46 (patch)
tree6b83f9a3ac48567b0e616123503252271d1e2fe7
parent4c5dbdce39e95206b7cbb85f47318aafa3d2c97e (diff)
downloadmullvadvpn-76b41fa149771f603faa73e0c4a4c3e9b82f3f46.tar.xz
mullvadvpn-76b41fa149771f603faa73e0c4a4c3e9b82f3f46.zip
Detect and run cleanup when Mullvad app is removed
Co-authored-by: Joakim Hulthe <joakim.hulthe@mullvad.net>
-rw-r--r--Cargo.lock74
-rwxr-xr-xdist-assets/pkg-scripts/postinstall12
-rwxr-xr-xdist-assets/pkg-scripts/preinstall11
-rwxr-xr-xdist-assets/uninstall_macos.sh29
-rw-r--r--mullvad-daemon/Cargo.toml1
-rw-r--r--mullvad-daemon/src/lib.rs10
-rw-r--r--mullvad-daemon/src/macos.rs124
7 files changed, 222 insertions, 39 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 791221baf7..ea1474a865 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -348,9 +348,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.6.0"
+version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
+checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
[[package]]
name = "blake3"
@@ -2043,6 +2043,17 @@ dependencies = [
]
[[package]]
+name = "inotify"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
+dependencies = [
+ "bitflags 2.9.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2388,7 +2399,7 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"libc",
]
@@ -2577,6 +2588,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
dependencies = [
"hermit-abi",
"libc",
+ "log",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@@ -2726,6 +2738,7 @@ dependencies = [
"mullvad-types",
"mullvad-version",
"nix 0.23.2",
+ "notify 8.0.0",
"objc2",
"regex",
"serde",
@@ -3160,7 +3173,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06a7491dd91b71643f65546389f25506da70723d1f1ec8c8d6d20444d1c23f27"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"log",
"nftnl-sys",
]
@@ -3206,7 +3219,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
@@ -3219,7 +3232,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
@@ -3238,7 +3251,7 @@ version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
@@ -3252,6 +3265,31 @@ dependencies = [
]
[[package]]
+name = "notify"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943"
+dependencies = [
+ "bitflags 2.9.0",
+ "filetime",
+ "fsevent-sys",
+ "inotify 0.11.0",
+ "kqueue",
+ "libc",
+ "log",
+ "mio 1.0.2",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d"
+
+[[package]]
name = "nseventforwarder"
version = "0.0.0"
dependencies = [
@@ -3310,7 +3348,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"block2",
"libc",
"objc2",
@@ -3326,7 +3364,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"block2",
"objc2",
"objc2-foundation",
@@ -3356,7 +3394,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"block2",
"libc",
"objc2",
@@ -3368,7 +3406,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"block2",
"objc2",
"objc2-foundation",
@@ -3380,7 +3418,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"block2",
"objc2",
"objc2-foundation",
@@ -3876,7 +3914,7 @@ checksum = "31b476131c3c86cb68032fdc5cb6d5a1045e3e42d96b69fa599fd77701e1f5bf"
dependencies = [
"bit-set",
"bit-vec",
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"lazy_static",
"num-traits",
"rand 0.8.5",
@@ -4367,7 +4405,7 @@ version = "0.38.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"errno 0.3.8",
"libc",
"linux-raw-sys",
@@ -4654,7 +4692,7 @@ dependencies = [
"hickory-resolver",
"libc",
"log",
- "notify",
+ "notify 6.1.1",
"once_cell",
"percent-encoding",
"pin-project",
@@ -4954,7 +4992,7 @@ name = "talpid-core"
version = "0.0.0"
dependencies = [
"async-trait",
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"chrono",
"duct",
"futures",
@@ -5110,7 +5148,7 @@ dependencies = [
name = "talpid-routing"
version = "0.0.0"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
"futures",
"ipnetwork",
"jnix",
@@ -6569,7 +6607,7 @@ version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
- "bitflags 2.6.0",
+ "bitflags 2.9.0",
]
[[package]]
diff --git a/dist-assets/pkg-scripts/postinstall b/dist-assets/pkg-scripts/postinstall
index 3f498baeb8..6f6f6f9904 100755
--- a/dist-assets/pkg-scripts/postinstall
+++ b/dist-assets/pkg-scripts/postinstall
@@ -19,17 +19,8 @@ exec > $LOG_DIR/postinstall.log 2>&1
echo "Running postinstall at $(date)"
-# Run CLI to force macOS to check the certificate, and shut down the already running daemon, if one
-# exists.
-# This is a temporary workaround. After 2023.3, we switched from signing with Amagicom AB to
-# Mullvad VPN AB certificates. This could potentially be removed when older versions are no longer
-# supported.
-"$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad" &>/dev/null || true
-"$TMPDIR/mullvad-setup" prepare-restart &>/dev/null || \
- echo "Failed to send 'prepare-restart' command to old mullvad-daemon"
-
# NOTE: This path must be kept in sync with the path defined
-# in mullvad-daemon/src/macos_launch_daemon.rs
+# in mullvad-daemon/src/macos_launch_daemon.rs and preinstall
DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist"
DAEMON_PLIST=$(cat <<-EOM
@@ -79,7 +70,6 @@ else
FISH_COMPLETIONS_DIR="/usr/local/share/fish/vendor_completions.d/"
fi
-launchctl unload -w $DAEMON_PLIST_PATH
cp "$LOG_DIR/daemon.log" "$LOG_DIR/old-install-daemon.log" \
|| echo "Failed to copy old daemon log"
diff --git a/dist-assets/pkg-scripts/preinstall b/dist-assets/pkg-scripts/preinstall
index ed65b866aa..95a42235d1 100755
--- a/dist-assets/pkg-scripts/preinstall
+++ b/dist-assets/pkg-scripts/preinstall
@@ -22,9 +22,14 @@ exec > $LOG_DIR/preinstall.log 2>&1
echo "Running preinstall at $(date)"
-# We need to run this is after extracting the new files and running "mullvad" in postinstall rather
-# than in preinstall.
-cp "$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad-setup" "$TMPDIR/" || echo "Failed to copy mullvad-setup"
+# Stop the existing daemon
+"$INSTALL_DIR/Mullvad VPN.app/Contents/Resources/mullvad-setup" prepare-restart &>/dev/null || \
+ echo "Failed to send 'prepare-restart' command to old mullvad-daemon"
+
+# NOTE: This path must be kept in sync with the path defined
+# in mullvad-daemon/src/macos_launch_daemon.rs and postinstall
+DAEMON_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.daemon.plist"
+launchctl unload -w $DAEMON_PLIST_PATH || echo "Failed to unload old mullvad-daemon"
# Migrate cache files from <=2020.8-beta2 paths
OLD_CACHE_DIR="/var/root/Library/Caches/mullvad-vpn"
diff --git a/dist-assets/uninstall_macos.sh b/dist-assets/uninstall_macos.sh
index c4ef471fee..0983ee43ae 100755
--- a/dist-assets/uninstall_macos.sh
+++ b/dist-assets/uninstall_macos.sh
@@ -2,8 +2,21 @@
set -ue
-read -r -p "Are you sure you want to stop and uninstall Mullvad VPN? (y/n) "
-if [[ "$REPLY" =~ [Yy]$ ]]; then
+ASSUMEYES="n"
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ --yes) ASSUMEYES="y";;
+ *)
+ echo "Unknown parameter: $1"
+ exit 1
+ ;;
+ esac
+ shift
+done
+
+[[ $ASSUMEYES == "y" ]] || read -r -p "Are you sure you want to stop and uninstall Mullvad VPN? (y/n) "
+if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then
echo "Uninstalling Mullvad VPN ..."
else
echo "Aborting uninstall"
@@ -40,8 +53,8 @@ sudo pkgutil --forget net.mullvad.vpn || true
echo "Removing login item ..."
osascript -e 'tell application "System Events" to delete login item "Mullvad VPN"' 2>/dev/null || true
-read -r -p "Do you want to delete the log and cache files the app has created? (y/n) "
-if [[ "$REPLY" =~ [Yy]$ ]]; then
+[[ $ASSUMEYES == "y" ]] || read -r -p "Do you want to delete the log and cache files the app has created? (y/n) "
+if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then
sudo rm -rf /var/log/mullvad-vpn /var/root/Library/Caches/mullvad-vpn /Library/Caches/mullvad-vpn
for user in /Users/*; do
user_log_dir="$user/Library/Logs/Mullvad VPN"
@@ -52,8 +65,8 @@ if [[ "$REPLY" =~ [Yy]$ ]]; then
done
fi
-read -r -p "Do you want to delete the Mullvad VPN settings? (y/n) "
-if [[ "$REPLY" =~ [Yy]$ ]]; then
+[[ $ASSUMEYES == "y" ]] || read -r -p "Do you want to delete the Mullvad VPN settings? (y/n) "
+if [[ $ASSUMEYES == "y" || "$REPLY" =~ [Yy]$ ]]; then
sudo rm -rf /etc/mullvad-vpn
for user in /Users/*; do
user_settings_dir="$user/Library/Application Support/Mullvad VPN"
@@ -63,3 +76,7 @@ if [[ "$REPLY" =~ [Yy]$ ]]; then
fi
done
fi
+
+# When run from a non-standard directory, like when detecting that the app bundle is gone,
+# we must also delete the uninstall script itself
+rm -f "$0" || true
diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml
index 66edca63a2..27ab61b581 100644
--- a/mullvad-daemon/Cargo.toml
+++ b/mullvad-daemon/Cargo.toml
@@ -67,6 +67,7 @@ talpid-dbus = { path = "../talpid-dbus" }
[target.'cfg(target_os="macos")'.dependencies]
objc2 = { version = "0.5.2", features = ["exception"] }
+notify = "8.0.0"
[target.'cfg(windows)'.dependencies]
ctrlc = "3.0"
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index f5ca13f976..f90cdfa00e 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -962,6 +962,16 @@ impl Daemon {
api_availability.unsuspend();
+ #[cfg(target_os = "macos")]
+ {
+ let account_manager = daemon.account_manager.clone();
+ tokio::task::spawn(async {
+ if let Err(error) = macos::handle_app_bundle_removal(account_manager).await {
+ log::error!("Failed to handle app removal: {error}");
+ }
+ });
+ }
+
Ok(daemon)
}
diff --git a/mullvad-daemon/src/macos.rs b/mullvad-daemon/src/macos.rs
index 1db0dcb83d..772ace096f 100644
--- a/mullvad-daemon/src/macos.rs
+++ b/mullvad-daemon/src/macos.rs
@@ -1,4 +1,11 @@
-use std::io;
+use std::{fmt, io, path::Path, process::Stdio};
+
+use anyhow::{anyhow, Context};
+use notify::{RecursiveMode, Watcher};
+use std::io::Write;
+use tokio::{fs::File, process::Command};
+
+use crate::device::AccountManagerHandle;
/// Bump filehandle limit
pub fn bump_filehandle_limit() {
@@ -35,3 +42,118 @@ pub fn bump_filehandle_limit() {
);
}
}
+
+/// Detect when the app bundle is deleted
+pub async fn handle_app_bundle_removal(
+ account_manager_handle: AccountManagerHandle,
+) -> anyhow::Result<()> {
+ /// Uninstall script to run if the .app disappears
+ const UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../dist-assets/uninstall_macos.sh");
+
+ /// Path to extract the uninstall script to.
+ /// This directory must be owned by root to prevent privilege escalation.
+ const UNINSTALL_SCRIPT_PATH: &str = "/var/root/uninstall_mullvad.sh";
+
+ /// Mullvad app install path
+ const APP_PATH: &str = "/Applications/Mullvad VPN.app";
+
+ let daemon_path = std::env::current_exe().context("Failed to get daemon path")?;
+
+ // Ignore app removal if the daemon isn't installed in the app directory
+ if !daemon_path.starts_with(APP_PATH) {
+ log::trace!("Stopping handle_app_bundle_removal as the daemon is not installed");
+ return Ok(());
+ }
+
+ let (fs_notify_tx, mut fs_notify_rx) = tokio::sync::mpsc::channel(1);
+ let mut fs_watcher =
+ notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
+ // Ignore access events
+ let is_access_event = event.map(|evt| evt.kind.is_access()).unwrap_or(false);
+
+ // Check if the daemon binary still exists
+ if !is_access_event && !daemon_path.exists() {
+ _ = fs_notify_tx.try_send(());
+ }
+ })
+ .context("Failed to start filesystem watcher")?;
+
+ fs_watcher
+ .watch(Path::new(APP_PATH), RecursiveMode::Recursive)
+ .context(anyhow!("Failed to watch {APP_PATH}"))?;
+
+ fs_notify_rx
+ .recv()
+ .await
+ .context("Filesystem watcher stopped unexpectedly")?;
+ drop(fs_watcher);
+
+ // Create file to log output from uninstallation process.
+ // This is useful since the daemon will be killed during uninstallation.
+ let mut log_file = async {
+ let log_path: std::path::PathBuf = mullvad_paths::log_dir()?.join("uninstall.log");
+
+ let file = File::create(log_path).await?;
+ anyhow::Ok(file.into_std().await)
+ }
+ .await
+ .inspect_err(|e| {
+ log::warn!("Failed to create uninstaller log-file: {e:#?}");
+ })
+ .ok();
+
+ // Log to both daemon log and uninstaller log.
+ let mut log = |msg: fmt::Arguments<'_>| {
+ log::info!("{msg}");
+ if let Some(log_file) = &mut log_file {
+ let _ = writeln!(log_file, "{msg}");
+ }
+ };
+
+ log(format_args!("{APP_PATH} was removed. Running uninstaller."));
+
+ tokio::fs::write(UNINSTALL_SCRIPT_PATH, UNINSTALL_SCRIPT)
+ .await
+ .context("Failed to write uninstall script")?;
+
+ // If reset_firewall errors, log the error and continue anyway.
+ log(format_args!("Resetting firewall"));
+ if let Err(error) = reset_firewall() {
+ log(format_args!("{error:#?}"));
+ }
+
+ // Remove the current device from the account
+ log(format_args!("Logging out"));
+ if let Err(error) = account_manager_handle.logout().await {
+ log(format_args!("Failed to remove device: {error:#?}"));
+ }
+
+ // This will kill the daemon.
+ log(format_args!("Running {UNINSTALL_SCRIPT_PATH:?}"));
+ let mut cmd = Command::new("/bin/bash");
+ cmd
+ .arg(UNINSTALL_SCRIPT_PATH)
+ // Don't prompt for confirmation.
+ .arg("--yes")
+ // Spawn as its own process group.
+ // This prevents the command from being killed when the daemon is killed.
+ .process_group(0)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ if let Some(log_file) = log_file {
+ cmd.stdout(log_file.try_clone().context("Failed to clone log fd")?);
+ cmd.stderr(log_file);
+ };
+ cmd.spawn().context("Failed to spawn uninstaller script")?;
+
+ Ok(())
+}
+
+fn reset_firewall() -> anyhow::Result<()> {
+ talpid_core::firewall::Firewall::new()
+ .context("Failed to create firewall instance")?
+ .reset_policy()
+ .context("Failed to reset firewall policy")
+}