diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-09-11 16:10:31 +0200 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-09-12 11:29:56 +0200 |
| commit | eeb8da48efab6b635d11fb92ab57a3db72d486bd (patch) | |
| tree | 6ab7adabe89746ad1e800e0720565974a9204896 /test | |
| parent | 295eff0a7d5b0b2ab388bab47035a68d7169f7d1 (diff) | |
| download | mullvadvpn-eeb8da48efab6b635d11fb92ab57a3db72d486bd.tar.xz mullvadvpn-eeb8da48efab6b635d11fb92ab57a3db72d486bd.zip | |
Split sys module in test-runner into one per platform
Diffstat (limited to 'test')
| -rw-r--r-- | test/test-runner/src/sys.rs | 954 | ||||
| -rw-r--r-- | test/test-runner/src/sys/linux.rs | 281 | ||||
| -rw-r--r-- | test/test-runner/src/sys/macos.rs | 148 | ||||
| -rw-r--r-- | test/test-runner/src/sys/mod.rs | 62 | ||||
| -rw-r--r-- | test/test-runner/src/sys/windows.rs | 443 |
5 files changed, 934 insertions, 954 deletions
diff --git a/test/test-runner/src/sys.rs b/test/test-runner/src/sys.rs deleted file mode 100644 index b6e109afc8..0000000000 --- a/test/test-runner/src/sys.rs +++ /dev/null @@ -1,954 +0,0 @@ -use std::collections::HashMap; -#[cfg(target_os = "windows")] -use std::io; -use std::path::Path; -use test_rpc::mullvad_daemon::ServiceStatus; -use test_rpc::{meta::OsVersion, mullvad_daemon::Verbosity}; - -#[cfg(target_os = "windows")] -use std::ffi::OsString; -use test_rpc::mullvad_daemon::SOCKET_PATH; -#[cfg(target_os = "windows")] -use windows_service::{ - service::{Service, ServiceAccess, ServiceInfo, ServiceState}, - service_manager::{ServiceManager, ServiceManagerAccess}, -}; - -#[cfg(target_os = "linux")] -const SYSTEMD_OVERRIDE_FILE: &str = "/etc/systemd/system/mullvad-daemon.service.d/override.conf"; - -#[cfg(target_os = "macos")] -const PLIST_OVERRIDE_FILE: &str = "/Library/LaunchDaemons/net.mullvad.daemon.plist"; - -#[cfg(target_os = "windows")] -const MULLVAD_WIN_REGISTRY: &str = r"SYSTEM\CurrentControlSet\Services\Mullvad VPN"; - -#[cfg(target_os = "windows")] -pub fn reboot() -> Result<(), test_rpc::Error> { - use windows_sys::Win32::{ - System::Shutdown::{ - EWX_REBOOT, ExitWindowsEx, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_APPLICATION, - SHTDN_REASON_MINOR_OTHER, - }, - UI::WindowsAndMessaging::EWX_FORCEIFHUNG, - }; - - grant_shutdown_privilege()?; - - std::thread::spawn(|| { - std::thread::sleep(std::time::Duration::from_secs(5)); - - let shutdown_result = unsafe { - ExitWindowsEx( - EWX_FORCEIFHUNG | EWX_REBOOT, - SHTDN_REASON_MAJOR_APPLICATION - | SHTDN_REASON_MINOR_OTHER - | SHTDN_REASON_FLAG_PLANNED, - ) - }; - - if shutdown_result == 0 { - log::error!( - "Failed to restart test machine: {}", - io::Error::last_os_error() - ); - std::process::exit(1); - } - - std::process::exit(0); - }); - - // NOTE: We do not bother to revoke the privilege. - - Ok(()) -} - -#[cfg(target_os = "windows")] -fn grant_shutdown_privilege() -> Result<(), test_rpc::Error> { - use windows_sys::Win32::{ - Foundation::{CloseHandle, HANDLE, LUID}, - Security::{ - AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, - SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, - }, - System::{ - SystemServices::SE_SHUTDOWN_NAME, - Threading::{GetCurrentProcess, OpenProcessToken}, - }, - }; - - let mut privileges = TOKEN_PRIVILEGES { - PrivilegeCount: 1, - Privileges: [LUID_AND_ATTRIBUTES { - Luid: LUID { - HighPart: 0, - LowPart: 0, - }, - Attributes: SE_PRIVILEGE_ENABLED, - }], - }; - - if unsafe { - LookupPrivilegeValueW( - std::ptr::null(), - SE_SHUTDOWN_NAME, - &mut privileges.Privileges[0].Luid, - ) - } == 0 - { - log::error!( - "Failed to lookup shutdown privilege LUID: {}", - io::Error::last_os_error() - ); - return Err(test_rpc::Error::Syscall); - } - - let mut token_handle: HANDLE = 0; - - if unsafe { - OpenProcessToken( - GetCurrentProcess(), - TOKEN_ADJUST_PRIVILEGES, - &mut token_handle, - ) - } == 0 - { - log::error!("OpenProcessToken() failed: {}", io::Error::last_os_error()); - return Err(test_rpc::Error::Syscall); - } - - let result = unsafe { - AdjustTokenPrivileges( - token_handle, - 0, - &privileges, - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - }; - - unsafe { CloseHandle(token_handle) }; - - if result == 0 { - log::error!( - "Failed to enable SE_SHUTDOWN_NAME: {}", - io::Error::last_os_error() - ); - return Err(test_rpc::Error::Syscall); - } - - Ok(()) -} - -#[cfg(unix)] -pub fn reboot() -> Result<(), test_rpc::Error> { - log::debug!("Rebooting system"); - - std::thread::spawn(|| { - #[cfg(target_os = "linux")] - let mut cmd = std::process::Command::new("/usr/sbin/shutdown"); - #[cfg(target_os = "macos")] - let mut cmd = std::process::Command::new("/sbin/shutdown"); - cmd.args(["-r", "now"]); - - std::thread::sleep(std::time::Duration::from_secs(5)); - - let _ = cmd.spawn().map_err(|error| { - log::error!("Failed to spawn shutdown command: {error}"); - error - }); - }); - - Ok(()) -} - -#[cfg(target_os = "linux")] -pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { - use tokio::io::AsyncWriteExt; - log::debug!("Setting log level"); - - let verbosity = match verbosity_level { - Verbosity::Info => "", - Verbosity::Debug => "-v", - Verbosity::Trace => "-vv", - }; - let systemd_service_file_content = format!( - r#"[Service] -ExecStart= -ExecStart=/usr/bin/mullvad-daemon --disable-stdout-timestamps {verbosity}"# - ); - - let override_path = std::path::Path::new(SYSTEMD_OVERRIDE_FILE); - if let Some(parent) = override_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - } - - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(override_path) - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - - file.write_all(systemd_service_file_content.as_bytes()) - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - - tokio::process::Command::new("systemctl") - .args(["daemon-reload"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; - - restart_app().await?; - Ok(()) -} - -/// Restart the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "linux")] -pub async fn restart_app() -> Result<(), test_rpc::Error> { - tokio::process::Command::new("systemctl") - .args(["restart", "mullvad-daemon"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; - wait_for_service_state(ServiceState::Running).await?; - Ok(()) -} - -/// Stop the Mullvad VPN application. -/// -/// This function waits for the app to successfully shut down. -#[cfg(target_os = "linux")] -pub async fn stop_app() -> Result<(), test_rpc::Error> { - tokio::process::Command::new("systemctl") - .args(["stop", "mullvad-daemon"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; - wait_for_service_state(ServiceState::Inactive).await?; - - Ok(()) -} - -/// Start the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "linux")] -pub async fn start_app() -> Result<(), test_rpc::Error> { - tokio::process::Command::new("systemctl") - .args(["start", "mullvad-daemon"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; - wait_for_service_state(ServiceState::Running).await?; - Ok(()) -} - -/// Restart the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "windows")] -pub async fn restart_app() -> Result<(), test_rpc::Error> { - stop_app().await?; - start_app().await?; - Ok(()) -} - -/// Stop the Mullvad VPN application. -/// -/// This function waits for the app to successfully shut down. -#[cfg(target_os = "windows")] -pub async fn stop_app() -> Result<(), test_rpc::Error> { - let _ = tokio::process::Command::new("net") - .args(["stop", "mullvadvpn"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; - Ok(()) -} - -/// Start the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "windows")] -pub async fn start_app() -> Result<(), test_rpc::Error> { - let _ = tokio::process::Command::new("net") - .args(["start", "mullvadvpn"]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; - Ok(()) -} - -/// Disable the Mullvad VPN system service startup. This will not trigger the service to stop -/// immediately, but it will prevent it from starting on the next system boot. -#[cfg(target_os = "windows")] -pub async fn disable_system_service_startup() -> Result<(), test_rpc::Error> { - let status = tokio::process::Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - "Set-Service -Name MullvadVPN -StartupType Disabled", - ]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - - if !status.success() { - return Err(test_rpc::Error::ServiceChange( - "Failed to disable MullvadVPN service".to_string(), - )); - } - - Ok(()) -} - -/// Enable the Mullvad VPN system service startup. This will configure the service to start automatically on system boot. -#[cfg(target_os = "windows")] -pub async fn enable_system_service_startup() -> Result<(), test_rpc::Error> { - let status = tokio::process::Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - "Set-Service -Name MullvadVPN -StartupType Automatic", - ]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - - if !status.success() { - return Err(test_rpc::Error::ServiceChange( - "Failed to enable MullvadVPN service".to_string(), - )); - } - - Ok(()) -} - -/// Restart the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "macos")] -pub async fn restart_app() -> Result<(), test_rpc::Error> { - stop_app().await?; - start_app().await?; - Ok(()) -} - -/// Stop the Mullvad VPN application. -/// -/// This function waits for the app to successfully shut down. -#[cfg(target_os = "macos")] -pub async fn stop_app() -> Result<(), test_rpc::Error> { - set_launch_daemon_state(false).await?; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - Ok(()) -} - -/// Start the Mullvad VPN application. -/// -/// This function waits for the app to successfully start again. -#[cfg(target_os = "macos")] -pub async fn start_app() -> Result<(), test_rpc::Error> { - set_launch_daemon_state(true).await?; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - Ok(()) -} - -#[cfg(target_os = "windows")] -pub fn get_daemon_system_service_status() -> Result<ServiceStatus, test_rpc::Error> { - let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) - .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; - let service = manager - .open_service("mullvadvpn", ServiceAccess::QUERY_STATUS) - .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; - - get_daemon_system_service_status_inner(&service) -} - -#[cfg(target_os = "windows")] -fn get_daemon_system_service_status_inner( - service: &Service, -) -> Result<ServiceStatus, test_rpc::Error> { - let status = service - .query_status() - .map_err(|e| test_rpc::Error::Other(e.to_string()))?; - - let status = match status.current_state { - ServiceState::Running => ServiceStatus::Running, - // NOTE: not counting pending start as running, since we cannot set log level then - _ => ServiceStatus::NotRunning, - }; - - Ok(status) -} - -#[cfg(target_os = "windows")] -async fn wait_for_service_status( - service: &Service, - accept_fn: impl Fn(&windows_service::service::ServiceStatus) -> bool, -) -> Result<(), test_rpc::Error> { - const MAX_ATTEMPTS: usize = 10; - const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); - - for _ in 0..MAX_ATTEMPTS { - let status = service - .query_status() - .map_err(|e| test_rpc::Error::Other(e.to_string()))?; - if accept_fn(&status) { - return Ok(()); - } - tokio::time::sleep(POLL_INTERVAL).await; - } - Err(test_rpc::Error::ServiceStart( - "Awaiting new service state timed out".to_string(), - )) -} - -#[cfg(target_os = "windows")] -pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { - use std::error::Error; - - fn error_with_source(e: &impl Error) -> String { - if let Some(source) = e.source() { - format!("{e}: {source}") - } else { - e.to_string() - } - } - - log::debug!("Setting log level"); - - let verbosity = match verbosity_level { - Verbosity::Info => "", - Verbosity::Debug => "-v", - Verbosity::Trace => "-vv", - }; - - let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) - .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; - let service = manager - .open_service( - "mullvadvpn", - ServiceAccess::QUERY_CONFIG - | ServiceAccess::QUERY_STATUS - | ServiceAccess::CHANGE_CONFIG - | ServiceAccess::START - | ServiceAccess::STOP, - ) - .map_err(|e| { - test_rpc::Error::ServiceNotFound(format!( - "Failed to open service: {}", - error_with_source(&e) - )) - })?; - - log::info!("Stopping service"); - - // Stop the service - service - .stop() - .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; - - // Wait until the service is fully stopped - wait_for_service_status(&service, |status| { - status.current_state == ServiceState::Stopped - }) - .await?; - - // Get the current service configuration - let config = service.query_config().map_err(|e| { - test_rpc::Error::ServiceNotFound(format!( - "Failed to query service config: {}", - error_with_source(&e) - )) - })?; - - let executable_path = "C:\\Program Files\\Mullvad VPN\\resources\\mullvad-daemon.exe"; - let launch_arguments = vec![ - OsString::from("--run-as-service"), - OsString::from(verbosity), - ]; - - // Update the service binary arguments - let updated_config = ServiceInfo { - name: config.display_name.clone(), - display_name: config.display_name.clone(), - service_type: config.service_type, - start_type: config.start_type, - error_control: config.error_control, - executable_path: std::path::PathBuf::from(executable_path), - launch_arguments, - dependencies: config.dependencies.clone(), - account_name: config.account_name.clone(), - account_password: None, - }; - - // Apply the updated configuration - service.change_config(&updated_config).map_err(|e| { - test_rpc::Error::ServiceChange(format!("Update service config: {}", error_with_source(&e))) - })?; - - // Start the service - service.start::<String>(&[]).map_err(|e| { - test_rpc::Error::ServiceNotFound(format!( - "Failed to start service: {}", - error_with_source(&e) - )) - })?; - - // Wait until the service is fully started - wait_for_service_status(&service, |status| { - status.current_state == ServiceState::Running - }) - .await?; - - Ok(()) -} - -#[cfg(target_os = "macos")] -#[allow(clippy::unused_async)] -pub async fn set_daemon_log_level(_verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { - // TODO: Not implemented - log::warn!("Setting log level is not implemented on macOS"); - Ok(()) -} - -#[cfg(target_os = "linux")] -#[derive(Debug)] -struct EnvVar { - var: String, - value: String, -} - -#[cfg(target_os = "linux")] -impl EnvVar { - fn from_systemd_string(s: &str) -> Result<Self, &'static str> { - // Here, we are only concerned with parsing a line that starts with "Environment". - let error = "Failed to parse systemd env-config"; - let mut input = s.trim().split('='); - let pre = input.next().ok_or(error)?; - match pre { - "Environment" => { - // Process the input just a bit more - remove the leading and trailing quote ("). - let var = input - .next() - .ok_or(error)? - .trim_start_matches('"') - .to_string(); - let value = input.next().ok_or(error)?.trim_end_matches('"').to_string(); - Ok(EnvVar { var, value }) - } - _ => Err(error), - } - } - - fn to_systemd_string(&self) -> String { - format!( - "Environment=\"{key}={value}\"", - key = self.var, - value = self.value - ) - } -} - -#[cfg(target_os = "linux")] -pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { - use std::{fmt::Write, ops::Not}; - - let mut override_content = String::new(); - override_content.push_str("[Service]\n"); - - for env_var in env - .into_iter() - .map(|(var, value)| EnvVar { var, value }) - .map(|env_var| env_var.to_systemd_string()) - { - writeln!(&mut override_content, "{env_var}") - .map_err(|err| test_rpc::Error::ServiceChange(err.to_string()))?; - } - - let override_path = std::path::Path::new(SYSTEMD_OVERRIDE_FILE); - if let Some(parent) = override_path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - } - - tokio::fs::write(override_path, override_content) - .await - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - - if tokio::process::Command::new("systemctl") - .args(["daemon-reload"]) - .status() - .await - .map_err(|e| test_rpc::Error::Io(e.to_string()))? - .success() - .not() - { - return Err(test_rpc::Error::ServiceChange( - "Daemon service could not be reloaded".to_owned(), - )); - }; - - if tokio::process::Command::new("systemctl") - .args(["restart", "mullvad-daemon"]) - .status() - .await - .map_err(|e| test_rpc::Error::Io(e.to_string()))? - .success() - .not() - { - return Err(test_rpc::Error::ServiceStart( - "Daemon service could not be restarted".to_owned(), - )); - }; - - wait_for_service_state(ServiceState::Running).await?; - Ok(()) -} - -#[cfg(target_os = "windows")] -pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { - // Set environment globally (not for service) to prevent it from being lost on upgrade - for (k, v) in env.clone() { - tokio::process::Command::new("setx") - .arg("/m") - .args([k, v]) - .status() - .await - .map_err(|e| test_rpc::Error::Registry(e.to_string()))?; - } - // Persist the changed environment variables, such that we can retrieve them at will. - use winreg::{RegKey, enums::*}; - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let path = Path::new(MULLVAD_WIN_REGISTRY).join("Environment"); - let (registry, _) = hklm.create_subkey(&path).map_err(|error| { - test_rpc::Error::Registry(format!("Failed to open Mullvad VPN subkey: {error}")) - })?; - for (k, v) in env { - registry.set_value(k, &v).map_err(|error| { - test_rpc::Error::Registry(format!("Failed to set Environment var: {error}")) - })?; - } - - // Restart service - stop_app().await?; - start_app().await?; - - Ok(()) -} - -#[cfg(target_os = "windows")] -pub fn get_system_path_var() -> Result<String, test_rpc::Error> { - use winreg::{enums::*, *}; - - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let key = hklm - .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment") - .map_err(|error| { - test_rpc::Error::Registry(format!("Failed to open Environment subkey: {error}")) - })?; - - let path: String = key - .get_value("Path") - .map_err(|error| test_rpc::Error::Registry(format!("Failed to get PATH: {error}")))?; - - Ok(path) -} - -#[cfg(target_os = "macos")] -pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { - tokio::task::spawn_blocking(|| { - let mut parsed_plist = plist::Value::from_file(PLIST_OVERRIDE_FILE).map_err(|error| { - test_rpc::Error::ServiceNotFound(format!("failed to parse plist: {error}")) - })?; - - let mut vars = plist::Dictionary::new(); - for (k, v) in env { - // Set environment globally (not for service) to prevent it from being lost on upgrade - std::process::Command::new("launchctl") - .arg("setenv") - .args([&k, &v]) - .status() - .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; - vars.insert(k, plist::Value::String(v)); - } - - // Add permanent env var - parsed_plist - .as_dictionary_mut() - .ok_or_else(|| test_rpc::Error::ServiceChange("plist missing dict".to_owned()))? - .insert( - "EnvironmentVariables".to_owned(), - plist::Value::Dictionary(vars), - ); - - let daemon_plist = std::fs::File::create(PLIST_OVERRIDE_FILE) - .map_err(|e| test_rpc::Error::ServiceChange(format!("failed to open plist: {e}")))?; - - parsed_plist - .to_writer_xml(daemon_plist) - .map_err(|e| test_rpc::Error::ServiceChange(format!("failed to replace plist: {e}")))?; - - Ok::<(), test_rpc::Error>(()) - }) - .await - .unwrap()?; - - // Restart service - set_launch_daemon_state(false).await?; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - set_launch_daemon_state(true).await?; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - Ok(()) -} - -#[cfg(target_os = "macos")] -async fn set_launch_daemon_state(on: bool) -> Result<(), test_rpc::Error> { - let mut launchctl = tokio::process::Command::new("launchctl"); - if on { - launchctl - .args(["load", "-w", PLIST_OVERRIDE_FILE]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; - } else { - launchctl - .args(["unload", "-w", PLIST_OVERRIDE_FILE]) - .status() - .await - .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; - } - Ok(()) -} - -#[cfg(target_os = "linux")] -pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { - let text = tokio::fs::read_to_string(SYSTEMD_OVERRIDE_FILE) - .await - .map_err(|err| test_rpc::Error::FileSystem(err.to_string()))?; - - let env: HashMap<String, String> = parse_systemd_env_file(&text) - .map(|EnvVar { var, value }| (var, value)) - .collect(); - Ok(env) -} - -/// Parse a systemd env-file. `input` is assumed to be the entire text content of a systemd-env -/// file. -/// -/// Example systemd-env file: -/// ``` -/// [Service] -/// Environment="VAR1=pGNqduRFkB4K9C2vijOmUDa2kPtUhArN" -/// Environment="VAR2=JP8YLOc2bsNlrGuD6LVTq7L36obpjzxd" -/// ``` -#[cfg(target_os = "linux")] -fn parse_systemd_env_file(input: &str) -> impl Iterator<Item = EnvVar> + '_ { - input - .lines() - .map(EnvVar::from_systemd_string) - .filter_map(|env_var| env_var.ok()) - .inspect(|env_var| log::trace!("Parsed {env_var:?}")) -} - -#[cfg(target_os = "windows")] -pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { - use winreg::{RegKey, enums::*}; - - let env = - tokio::task::spawn_blocking(|| -> Result<HashMap<String, String>, test_rpc::Error> { - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let key = hklm.open_subkey(MULLVAD_WIN_REGISTRY).map_err(|error| { - test_rpc::Error::Registry(format!("Failed to open Mullvad VPN subkey: {error}")) - })?; - - // The Strings will be quoted (surrounded by ") when read from the registry - we should - // trim that! - let trim = |string: String| string.trim_matches('"').to_owned(); - let env = key - .open_subkey("Environment") - .map_err(|error| { - test_rpc::Error::Registry( - format!("Failed to open Environment subkey: {error}",), - ) - })? - .enum_values() - .filter_map(|x| x.inspect_err(|err| log::trace!("{err}")).ok()) - .map(|(k, v)| (trim(k), trim(v.to_string()))) - .collect(); - Ok(env) - }) - .await - .map_err(test_rpc::Error::from_tokio_join_error)??; - - Ok(env) -} - -#[cfg(target_os = "macos")] -pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { - let plist = tokio::task::spawn_blocking(|| { - let parsed_plist = plist::Value::from_file(PLIST_OVERRIDE_FILE).map_err(|error| { - test_rpc::Error::ServiceNotFound(format!("failed to parse plist: {error}")) - })?; - - Ok::<plist::Value, test_rpc::Error>(parsed_plist) - }) - .await - .map_err(test_rpc::Error::from_tokio_join_error)??; - - let plist_tree = plist - .as_dictionary() - .ok_or_else(|| test_rpc::Error::ServiceNotFound("plist missing dict".to_owned()))?; - let Some(env_vars) = plist_tree.get("EnvironmentVariables") else { - // `EnvironmentVariables` does not exist in plist file, so there are no env variables to - // parse. - return Ok(HashMap::new()); - }; - let env_vars = env_vars.as_dictionary().ok_or_else(|| { - test_rpc::Error::ServiceNotFound("`EnvironmentVariables` is not a dict".to_owned()) - })?; - - let env = env_vars - .clone() - .into_iter() - .filter_map(|(key, value)| Some((key, value.into_string()?))) - .collect(); - - Ok(env) -} - -#[cfg(target_os = "linux")] -enum ServiceState { - Running, - Inactive, -} - -#[cfg(target_os = "linux")] -async fn wait_for_service_state(awaited_state: ServiceState) -> Result<(), test_rpc::Error> { - const RETRY_ATTEMPTS: usize = 10; - let mut attempt = 0; - loop { - attempt += 1; - if attempt > RETRY_ATTEMPTS { - return Err(test_rpc::Error::ServiceStart(String::from( - "Awaiting new service state timed out", - ))); - } - - let output = tokio::process::Command::new("systemctl") - .args(["status", "mullvad-daemon"]) - .output() - .await - .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))? - .stdout; - let output = String::from_utf8_lossy(&output); - - match awaited_state { - ServiceState::Running => { - if output.contains("active (running)") { - break; - } - } - ServiceState::Inactive => { - if output.contains("inactive (dead)") { - break; - } - } - } - - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - } - Ok(()) -} - -#[cfg(target_os = "macos")] -pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { - use test_rpc::meta::MacosVersion; - - let version = talpid_platform_metadata::MacosVersion::new() - .inspect_err(|error| { - log::error!("Failed to obtain OS version: {error}"); - }) - .map_err(|_| test_rpc::Error::Syscall)?; - - Ok(OsVersion::Macos(MacosVersion { - major: version.major_version(), - })) -} - -#[cfg(target_os = "windows")] -pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { - use test_rpc::meta::WindowsVersion; - - let version = talpid_platform_metadata::WindowsVersion::new() - .inspect_err(|error| { - log::error!("Failed to obtain OS version: {error}"); - }) - .map_err(|_| test_rpc::Error::Syscall)?; - - Ok(OsVersion::Windows(WindowsVersion { - major: version.release_version().0, - })) -} - -#[cfg(target_os = "linux")] -pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { - Ok(OsVersion::Linux) -} - -pub fn get_daemon_status() -> ServiceStatus { - let rpc_socket_exists = Path::new(SOCKET_PATH).exists(); - - // On Windows, we must also make sure service isn't in a pending state, since interacting with - // the service may fail even if there is a working named pipe. - #[cfg(target_os = "windows")] - let service_is_started = - get_daemon_system_service_status().unwrap_or(ServiceStatus::NotRunning); - - // NOTE: May not be necessary on non-Windows - #[cfg(not(target_os = "windows"))] - let service_is_started = ServiceStatus::Running; - - match (rpc_socket_exists, service_is_started) { - (true, ServiceStatus::Running) => ServiceStatus::Running, - _ => ServiceStatus::NotRunning, - } -} - -#[cfg(test)] -mod test { - - #[cfg(target_os = "linux")] - #[test] - fn parse_systemd_environment_variables() { - use super::parse_systemd_env_file; - // Define an example systemd environment file - let systemd_file = " - [Service] - Environment=\"var1=value1\" - Environment=\"var2=value2\" - "; - - // Parse the "file" - let env_vars: Vec<_> = parse_systemd_env_file(systemd_file).collect(); - - // Assert that the environment variables it defines are parsed as expected. - assert_eq!(env_vars.len(), 2); - let first = env_vars.first().unwrap(); - assert_eq!(first.var, "var1"); - assert_eq!(first.value, "value1"); - let second = env_vars.get(1).unwrap(); - assert_eq!(second.var, "var2"); - assert_eq!(second.value, "value2"); - } -} diff --git a/test/test-runner/src/sys/linux.rs b/test/test-runner/src/sys/linux.rs new file mode 100644 index 0000000000..b26cd2b618 --- /dev/null +++ b/test/test-runner/src/sys/linux.rs @@ -0,0 +1,281 @@ +use std::collections::HashMap; +use test_rpc::{meta::OsVersion, mullvad_daemon::Verbosity}; + +const SYSTEMD_OVERRIDE_FILE: &str = "/etc/systemd/system/mullvad-daemon.service.d/override.conf"; + +pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { + use tokio::io::AsyncWriteExt; + log::debug!("Setting log level"); + + let verbosity = match verbosity_level { + Verbosity::Info => "", + Verbosity::Debug => "-v", + Verbosity::Trace => "-vv", + }; + let systemd_service_file_content = format!( + r#"[Service] +ExecStart= +ExecStart=/usr/bin/mullvad-daemon --disable-stdout-timestamps {verbosity}"# + ); + + let override_path = std::path::Path::new(SYSTEMD_OVERRIDE_FILE); + if let Some(parent) = override_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + } + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(override_path) + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + + file.write_all(systemd_service_file_content.as_bytes()) + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + + tokio::process::Command::new("systemctl") + .args(["daemon-reload"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; + + restart_app().await?; + Ok(()) +} + +/// Restart the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn restart_app() -> Result<(), test_rpc::Error> { + tokio::process::Command::new("systemctl") + .args(["restart", "mullvad-daemon"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; + wait_for_service_state(ServiceState::Running).await?; + Ok(()) +} + +/// Stop the Mullvad VPN application. +/// +/// This function waits for the app to successfully shut down. +pub async fn stop_app() -> Result<(), test_rpc::Error> { + tokio::process::Command::new("systemctl") + .args(["stop", "mullvad-daemon"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; + wait_for_service_state(ServiceState::Inactive).await?; + + Ok(()) +} + +/// Start the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn start_app() -> Result<(), test_rpc::Error> { + tokio::process::Command::new("systemctl") + .args(["start", "mullvad-daemon"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; + wait_for_service_state(ServiceState::Running).await?; + Ok(()) +} + +#[derive(Debug)] +struct EnvVar { + var: String, + value: String, +} + +impl EnvVar { + fn from_systemd_string(s: &str) -> Result<Self, &'static str> { + // Here, we are only concerned with parsing a line that starts with "Environment". + let error = "Failed to parse systemd env-config"; + let mut input = s.trim().split('='); + let pre = input.next().ok_or(error)?; + match pre { + "Environment" => { + // Process the input just a bit more - remove the leading and trailing quote ("). + let var = input + .next() + .ok_or(error)? + .trim_start_matches('"') + .to_string(); + let value = input.next().ok_or(error)?.trim_end_matches('"').to_string(); + Ok(EnvVar { var, value }) + } + _ => Err(error), + } + } + + fn to_systemd_string(&self) -> String { + format!( + "Environment=\"{key}={value}\"", + key = self.var, + value = self.value + ) + } +} + +pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { + use std::{fmt::Write, ops::Not}; + + let mut override_content = String::new(); + override_content.push_str("[Service]\n"); + + for env_var in env + .into_iter() + .map(|(var, value)| EnvVar { var, value }) + .map(|env_var| env_var.to_systemd_string()) + { + writeln!(&mut override_content, "{env_var}") + .map_err(|err| test_rpc::Error::ServiceChange(err.to_string()))?; + } + + let override_path = std::path::Path::new(SYSTEMD_OVERRIDE_FILE); + if let Some(parent) = override_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + } + + tokio::fs::write(override_path, override_content) + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + + if tokio::process::Command::new("systemctl") + .args(["daemon-reload"]) + .status() + .await + .map_err(|e| test_rpc::Error::Io(e.to_string()))? + .success() + .not() + { + return Err(test_rpc::Error::ServiceChange( + "Daemon service could not be reloaded".to_owned(), + )); + }; + + if tokio::process::Command::new("systemctl") + .args(["restart", "mullvad-daemon"]) + .status() + .await + .map_err(|e| test_rpc::Error::Io(e.to_string()))? + .success() + .not() + { + return Err(test_rpc::Error::ServiceStart( + "Daemon service could not be restarted".to_owned(), + )); + }; + + wait_for_service_state(ServiceState::Running).await?; + Ok(()) +} + +pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { + let text = tokio::fs::read_to_string(SYSTEMD_OVERRIDE_FILE) + .await + .map_err(|err| test_rpc::Error::FileSystem(err.to_string()))?; + + let env: HashMap<String, String> = parse_systemd_env_file(&text) + .map(|EnvVar { var, value }| (var, value)) + .collect(); + Ok(env) +} + +/// Parse a systemd env-file. `input` is assumed to be the entire text content of a systemd-env +/// file. +/// +/// Example systemd-env file: +/// ``` +/// [Service] +/// Environment="VAR1=pGNqduRFkB4K9C2vijOmUDa2kPtUhArN" +/// Environment="VAR2=JP8YLOc2bsNlrGuD6LVTq7L36obpjzxd" +/// ``` +fn parse_systemd_env_file(input: &str) -> impl Iterator<Item = EnvVar> + '_ { + input + .lines() + .map(EnvVar::from_systemd_string) + .filter_map(|env_var| env_var.ok()) + .inspect(|env_var| log::trace!("Parsed {env_var:?}")) +} + +enum ServiceState { + Running, + Inactive, +} + +async fn wait_for_service_state(awaited_state: ServiceState) -> Result<(), test_rpc::Error> { + const RETRY_ATTEMPTS: usize = 10; + let mut attempt = 0; + loop { + attempt += 1; + if attempt > RETRY_ATTEMPTS { + return Err(test_rpc::Error::ServiceStart(String::from( + "Awaiting new service state timed out", + ))); + } + + let output = tokio::process::Command::new("systemctl") + .args(["status", "mullvad-daemon"]) + .output() + .await + .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))? + .stdout; + let output = String::from_utf8_lossy(&output); + + match awaited_state { + ServiceState::Running => { + if output.contains("active (running)") { + break; + } + } + ServiceState::Inactive => { + if output.contains("inactive (dead)") { + break; + } + } + } + + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + } + Ok(()) +} + +pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { + Ok(OsVersion::Linux) +} + +#[cfg(test)] +mod test { + + #[test] + fn parse_systemd_environment_variables() { + use super::parse_systemd_env_file; + // Define an example systemd environment file + let systemd_file = " + [Service] + Environment=\"var1=value1\" + Environment=\"var2=value2\" + "; + + // Parse the "file" + let env_vars: Vec<_> = parse_systemd_env_file(systemd_file).collect(); + + // Assert that the environment variables it defines are parsed as expected. + assert_eq!(env_vars.len(), 2); + let first = env_vars.first().unwrap(); + assert_eq!(first.var, "var1"); + assert_eq!(first.value, "value1"); + let second = env_vars.get(1).unwrap(); + assert_eq!(second.var, "var2"); + assert_eq!(second.value, "value2"); + } +} diff --git a/test/test-runner/src/sys/macos.rs b/test/test-runner/src/sys/macos.rs new file mode 100644 index 0000000000..7febdb24d3 --- /dev/null +++ b/test/test-runner/src/sys/macos.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use test_rpc::{meta::OsVersion, mullvad_daemon::Verbosity}; + +const PLIST_OVERRIDE_FILE: &str = "/Library/LaunchDaemons/net.mullvad.daemon.plist"; + +/// Restart the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn restart_app() -> Result<(), test_rpc::Error> { + stop_app().await?; + start_app().await?; + Ok(()) +} + +/// Stop the Mullvad VPN application. +/// +/// This function waits for the app to successfully shut down. +pub async fn stop_app() -> Result<(), test_rpc::Error> { + set_launch_daemon_state(false).await?; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(()) +} + +/// Start the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn start_app() -> Result<(), test_rpc::Error> { + set_launch_daemon_state(true).await?; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(()) +} + +#[allow(clippy::unused_async)] +pub async fn set_daemon_log_level(_verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { + // TODO: Not implemented + log::warn!("Setting log level is not implemented on macOS"); + Ok(()) +} + +pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { + tokio::task::spawn_blocking(|| { + let mut parsed_plist = plist::Value::from_file(PLIST_OVERRIDE_FILE).map_err(|error| { + test_rpc::Error::ServiceNotFound(format!("failed to parse plist: {error}")) + })?; + + let mut vars = plist::Dictionary::new(); + for (k, v) in env { + // Set environment globally (not for service) to prevent it from being lost on upgrade + std::process::Command::new("launchctl") + .arg("setenv") + .args([&k, &v]) + .status() + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + vars.insert(k, plist::Value::String(v)); + } + + // Add permanent env var + parsed_plist + .as_dictionary_mut() + .ok_or_else(|| test_rpc::Error::ServiceChange("plist missing dict".to_owned()))? + .insert( + "EnvironmentVariables".to_owned(), + plist::Value::Dictionary(vars), + ); + + let daemon_plist = std::fs::File::create(PLIST_OVERRIDE_FILE) + .map_err(|e| test_rpc::Error::ServiceChange(format!("failed to open plist: {e}")))?; + + parsed_plist + .to_writer_xml(daemon_plist) + .map_err(|e| test_rpc::Error::ServiceChange(format!("failed to replace plist: {e}")))?; + + Ok::<(), test_rpc::Error>(()) + }) + .await + .unwrap()?; + + // Restart service + set_launch_daemon_state(false).await?; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + set_launch_daemon_state(true).await?; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(()) +} + +async fn set_launch_daemon_state(on: bool) -> Result<(), test_rpc::Error> { + let mut launchctl = tokio::process::Command::new("launchctl"); + if on { + launchctl + .args(["load", "-w", PLIST_OVERRIDE_FILE]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; + } else { + launchctl + .args(["unload", "-w", PLIST_OVERRIDE_FILE]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; + } + Ok(()) +} + +pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { + let plist = tokio::task::spawn_blocking(|| { + let parsed_plist = plist::Value::from_file(PLIST_OVERRIDE_FILE).map_err(|error| { + test_rpc::Error::ServiceNotFound(format!("failed to parse plist: {error}")) + })?; + + Ok::<plist::Value, test_rpc::Error>(parsed_plist) + }) + .await + .map_err(test_rpc::Error::from_tokio_join_error)??; + + let plist_tree = plist + .as_dictionary() + .ok_or_else(|| test_rpc::Error::ServiceNotFound("plist missing dict".to_owned()))?; + let Some(env_vars) = plist_tree.get("EnvironmentVariables") else { + // `EnvironmentVariables` does not exist in plist file, so there are no env variables to + // parse. + return Ok(HashMap::new()); + }; + let env_vars = env_vars.as_dictionary().ok_or_else(|| { + test_rpc::Error::ServiceNotFound("`EnvironmentVariables` is not a dict".to_owned()) + })?; + + let env = env_vars + .clone() + .into_iter() + .filter_map(|(key, value)| Some((key, value.into_string()?))) + .collect(); + + Ok(env) +} + +pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { + use test_rpc::meta::MacosVersion; + + let version = talpid_platform_metadata::MacosVersion::new() + .inspect_err(|error| { + log::error!("Failed to obtain OS version: {error}"); + }) + .map_err(|_| test_rpc::Error::Syscall)?; + + Ok(OsVersion::Macos(MacosVersion { + major: version.major_version(), + })) +} diff --git a/test/test-runner/src/sys/mod.rs b/test/test-runner/src/sys/mod.rs new file mode 100644 index 0000000000..48b539739e --- /dev/null +++ b/test/test-runner/src/sys/mod.rs @@ -0,0 +1,62 @@ +use std::path::Path; +use test_rpc::mullvad_daemon::SOCKET_PATH; +use test_rpc::mullvad_daemon::ServiceStatus; + +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "windows")] +pub use windows::*; + +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(target_os = "macos")] +pub use macos::*; + +#[cfg(unix)] +pub fn reboot() -> Result<(), test_rpc::Error> { + log::debug!("Rebooting system"); + + std::thread::spawn(|| { + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("/usr/sbin/shutdown"); + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("/sbin/shutdown"); + cmd.args(["-r", "now"]); + + std::thread::sleep(std::time::Duration::from_secs(5)); + + let _ = cmd.spawn().map_err(|error| { + log::error!("Failed to spawn shutdown command: {error}"); + error + }); + }); + + Ok(()) +} + +pub fn get_daemon_status() -> ServiceStatus { + let rpc_socket_exists = Path::new(SOCKET_PATH).exists(); + + // On Windows, we must also make sure service isn't in a pending state, since interacting with + // the service may fail even if there is a working named pipe. + #[cfg(target_os = "windows")] + let service_is_started = + get_daemon_system_service_status().unwrap_or(ServiceStatus::NotRunning); + + // NOTE: May not be necessary on non-Windows + #[cfg(not(target_os = "windows"))] + let service_is_started = ServiceStatus::Running; + + match (rpc_socket_exists, service_is_started) { + (true, ServiceStatus::Running) => ServiceStatus::Running, + _ => ServiceStatus::NotRunning, + } +} diff --git a/test/test-runner/src/sys/windows.rs b/test/test-runner/src/sys/windows.rs new file mode 100644 index 0000000000..a8ef5e4f0e --- /dev/null +++ b/test/test-runner/src/sys/windows.rs @@ -0,0 +1,443 @@ +use std::collections::HashMap; +use std::ffi::OsString; +use std::io; +use std::path::Path; +use test_rpc::mullvad_daemon::ServiceStatus; +use test_rpc::{meta::OsVersion, mullvad_daemon::Verbosity}; +use windows_service::{ + service::{Service, ServiceAccess, ServiceInfo, ServiceState}, + service_manager::{ServiceManager, ServiceManagerAccess}, +}; +use windows_sys::Win32::{ + System::Shutdown::{ + EWX_REBOOT, ExitWindowsEx, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_APPLICATION, + SHTDN_REASON_MINOR_OTHER, + }, + UI::WindowsAndMessaging::EWX_FORCEIFHUNG, +}; + +const MULLVAD_WIN_REGISTRY: &str = r"SYSTEM\CurrentControlSet\Services\Mullvad VPN"; + +pub fn reboot() -> Result<(), test_rpc::Error> { + grant_shutdown_privilege()?; + + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(5)); + + let shutdown_result = unsafe { + ExitWindowsEx( + EWX_FORCEIFHUNG | EWX_REBOOT, + SHTDN_REASON_MAJOR_APPLICATION + | SHTDN_REASON_MINOR_OTHER + | SHTDN_REASON_FLAG_PLANNED, + ) + }; + + if shutdown_result == 0 { + log::error!( + "Failed to restart test machine: {}", + io::Error::last_os_error() + ); + std::process::exit(1); + } + + std::process::exit(0); + }); + + // NOTE: We do not bother to revoke the privilege. + + Ok(()) +} + +fn grant_shutdown_privilege() -> Result<(), test_rpc::Error> { + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, LUID}, + Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, + }, + System::{ + SystemServices::SE_SHUTDOWN_NAME, + Threading::{GetCurrentProcess, OpenProcessToken}, + }, + }; + + let mut privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: LUID { + HighPart: 0, + LowPart: 0, + }, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + + if unsafe { + LookupPrivilegeValueW( + std::ptr::null(), + SE_SHUTDOWN_NAME, + &mut privileges.Privileges[0].Luid, + ) + } == 0 + { + log::error!( + "Failed to lookup shutdown privilege LUID: {}", + io::Error::last_os_error() + ); + return Err(test_rpc::Error::Syscall); + } + + let mut token_handle: HANDLE = 0; + + if unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES, + &mut token_handle, + ) + } == 0 + { + log::error!("OpenProcessToken() failed: {}", io::Error::last_os_error()); + return Err(test_rpc::Error::Syscall); + } + + let result = unsafe { + AdjustTokenPrivileges( + token_handle, + 0, + &privileges, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + + unsafe { CloseHandle(token_handle) }; + + if result == 0 { + log::error!( + "Failed to enable SE_SHUTDOWN_NAME: {}", + io::Error::last_os_error() + ); + return Err(test_rpc::Error::Syscall); + } + + Ok(()) +} + +/// Restart the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn restart_app() -> Result<(), test_rpc::Error> { + stop_app().await?; + start_app().await?; + Ok(()) +} + +/// Stop the Mullvad VPN application. +/// +/// This function waits for the app to successfully shut down. +pub async fn stop_app() -> Result<(), test_rpc::Error> { + let _ = tokio::process::Command::new("net") + .args(["stop", "mullvadvpn"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; + Ok(()) +} + +/// Start the Mullvad VPN application. +/// +/// This function waits for the app to successfully start again. +pub async fn start_app() -> Result<(), test_rpc::Error> { + let _ = tokio::process::Command::new("net") + .args(["start", "mullvadvpn"]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceStart(e.to_string()))?; + Ok(()) +} + +/// Disable the Mullvad VPN system service startup. This will not trigger the service to stop +/// immediately, but it will prevent it from starting on the next system boot. +pub async fn disable_system_service_startup() -> Result<(), test_rpc::Error> { + let status = tokio::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Set-Service -Name MullvadVPN -StartupType Disabled", + ]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + + if !status.success() { + return Err(test_rpc::Error::ServiceChange( + "Failed to disable MullvadVPN service".to_string(), + )); + } + + Ok(()) +} + +/// Enable the Mullvad VPN system service startup. This will configure the service to start automatically on system boot. +pub async fn enable_system_service_startup() -> Result<(), test_rpc::Error> { + let status = tokio::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Set-Service -Name MullvadVPN -StartupType Automatic", + ]) + .status() + .await + .map_err(|e| test_rpc::Error::ServiceChange(e.to_string()))?; + + if !status.success() { + return Err(test_rpc::Error::ServiceChange( + "Failed to enable MullvadVPN service".to_string(), + )); + } + + Ok(()) +} + +pub fn get_daemon_system_service_status() -> Result<ServiceStatus, test_rpc::Error> { + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; + let service = manager + .open_service("mullvadvpn", ServiceAccess::QUERY_STATUS) + .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; + + get_daemon_system_service_status_inner(&service) +} + +fn get_daemon_system_service_status_inner( + service: &Service, +) -> Result<ServiceStatus, test_rpc::Error> { + let status = service + .query_status() + .map_err(|e| test_rpc::Error::Other(e.to_string()))?; + + let status = match status.current_state { + ServiceState::Running => ServiceStatus::Running, + // NOTE: not counting pending start as running, since we cannot set log level then + _ => ServiceStatus::NotRunning, + }; + + Ok(status) +} + +async fn wait_for_service_status( + service: &Service, + accept_fn: impl Fn(&windows_service::service::ServiceStatus) -> bool, +) -> Result<(), test_rpc::Error> { + const MAX_ATTEMPTS: usize = 10; + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); + + for _ in 0..MAX_ATTEMPTS { + let status = service + .query_status() + .map_err(|e| test_rpc::Error::Other(e.to_string()))?; + if accept_fn(&status) { + return Ok(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(test_rpc::Error::ServiceStart( + "Awaiting new service state timed out".to_string(), + )) +} + +pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { + use std::error::Error; + + fn error_with_source(e: &impl Error) -> String { + if let Some(source) = e.source() { + format!("{e}: {source}") + } else { + e.to_string() + } + } + + log::debug!("Setting log level"); + + let verbosity = match verbosity_level { + Verbosity::Info => "", + Verbosity::Debug => "-v", + Verbosity::Trace => "-vv", + }; + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .map_err(|e| test_rpc::Error::ServiceNotFound(e.to_string()))?; + let service = manager + .open_service( + "mullvadvpn", + ServiceAccess::QUERY_CONFIG + | ServiceAccess::QUERY_STATUS + | ServiceAccess::CHANGE_CONFIG + | ServiceAccess::START + | ServiceAccess::STOP, + ) + .map_err(|e| { + test_rpc::Error::ServiceNotFound(format!( + "Failed to open service: {}", + error_with_source(&e) + )) + })?; + + log::info!("Stopping service"); + + // Stop the service + service + .stop() + .map_err(|e| test_rpc::Error::ServiceStop(e.to_string()))?; + + // Wait until the service is fully stopped + wait_for_service_status(&service, |status| { + status.current_state == ServiceState::Stopped + }) + .await?; + + // Get the current service configuration + let config = service.query_config().map_err(|e| { + test_rpc::Error::ServiceNotFound(format!( + "Failed to query service config: {}", + error_with_source(&e) + )) + })?; + + let executable_path = "C:\\Program Files\\Mullvad VPN\\resources\\mullvad-daemon.exe"; + let launch_arguments = vec![ + OsString::from("--run-as-service"), + OsString::from(verbosity), + ]; + + // Update the service binary arguments + let updated_config = ServiceInfo { + name: config.display_name.clone(), + display_name: config.display_name.clone(), + service_type: config.service_type, + start_type: config.start_type, + error_control: config.error_control, + executable_path: std::path::PathBuf::from(executable_path), + launch_arguments, + dependencies: config.dependencies.clone(), + account_name: config.account_name.clone(), + account_password: None, + }; + + // Apply the updated configuration + service.change_config(&updated_config).map_err(|e| { + test_rpc::Error::ServiceChange(format!("Update service config: {}", error_with_source(&e))) + })?; + + // Start the service + service.start::<String>(&[]).map_err(|e| { + test_rpc::Error::ServiceNotFound(format!( + "Failed to start service: {}", + error_with_source(&e) + )) + })?; + + // Wait until the service is fully started + wait_for_service_status(&service, |status| { + status.current_state == ServiceState::Running + }) + .await?; + + Ok(()) +} + +pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { + // Set environment globally (not for service) to prevent it from being lost on upgrade + for (k, v) in env.clone() { + tokio::process::Command::new("setx") + .arg("/m") + .args([k, v]) + .status() + .await + .map_err(|e| test_rpc::Error::Registry(e.to_string()))?; + } + // Persist the changed environment variables, such that we can retrieve them at will. + use winreg::{RegKey, enums::*}; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let path = Path::new(MULLVAD_WIN_REGISTRY).join("Environment"); + let (registry, _) = hklm.create_subkey(&path).map_err(|error| { + test_rpc::Error::Registry(format!("Failed to open Mullvad VPN subkey: {error}")) + })?; + for (k, v) in env { + registry.set_value(k, &v).map_err(|error| { + test_rpc::Error::Registry(format!("Failed to set Environment var: {error}")) + })?; + } + + // Restart service + stop_app().await?; + start_app().await?; + + Ok(()) +} + +pub fn get_system_path_var() -> Result<String, test_rpc::Error> { + use winreg::{enums::*, *}; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm + .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment") + .map_err(|error| { + test_rpc::Error::Registry(format!("Failed to open Environment subkey: {error}")) + })?; + + let path: String = key + .get_value("Path") + .map_err(|error| test_rpc::Error::Registry(format!("Failed to get PATH: {error}")))?; + + Ok(path) +} + +pub async fn get_daemon_environment() -> Result<HashMap<String, String>, test_rpc::Error> { + use winreg::{RegKey, enums::*}; + + let env = + tokio::task::spawn_blocking(|| -> Result<HashMap<String, String>, test_rpc::Error> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = hklm.open_subkey(MULLVAD_WIN_REGISTRY).map_err(|error| { + test_rpc::Error::Registry(format!("Failed to open Mullvad VPN subkey: {error}")) + })?; + + // The Strings will be quoted (surrounded by ") when read from the registry - we should + // trim that! + let trim = |string: String| string.trim_matches('"').to_owned(); + let env = key + .open_subkey("Environment") + .map_err(|error| { + test_rpc::Error::Registry( + format!("Failed to open Environment subkey: {error}",), + ) + })? + .enum_values() + .filter_map(|x| x.inspect_err(|err| log::trace!("{err}")).ok()) + .map(|(k, v)| (trim(k), trim(v.to_string()))) + .collect(); + Ok(env) + }) + .await + .map_err(test_rpc::Error::from_tokio_join_error)??; + + Ok(env) +} + +pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { + use test_rpc::meta::WindowsVersion; + + let version = talpid_platform_metadata::WindowsVersion::new() + .inspect_err(|error| { + log::error!("Failed to obtain OS version: {error}"); + }) + .map_err(|_| test_rpc::Error::Syscall)?; + + Ok(OsVersion::Windows(WindowsVersion { + major: version.release_version().0, + })) +} |
