diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-04-15 12:57:26 +0200 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-04-23 13:03:49 +0200 |
| commit | 76b41fa149771f603faa73e0c4a4c3e9b82f3f46 (patch) | |
| tree | 6b83f9a3ac48567b0e616123503252271d1e2fe7 | |
| parent | 4c5dbdce39e95206b7cbb85f47318aafa3d2c97e (diff) | |
| download | mullvadvpn-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.lock | 74 | ||||
| -rwxr-xr-x | dist-assets/pkg-scripts/postinstall | 12 | ||||
| -rwxr-xr-x | dist-assets/pkg-scripts/preinstall | 11 | ||||
| -rwxr-xr-x | dist-assets/uninstall_macos.sh | 29 | ||||
| -rw-r--r-- | mullvad-daemon/Cargo.toml | 1 | ||||
| -rw-r--r-- | mullvad-daemon/src/lib.rs | 10 | ||||
| -rw-r--r-- | mullvad-daemon/src/macos.rs | 124 |
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") +} |
