summaryrefslogtreecommitdiffhomepage
path: root/test
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-09-11 16:10:31 +0200
committerDavid Lönnhager <david.l@mullvad.net>2025-09-12 11:29:56 +0200
commiteeb8da48efab6b635d11fb92ab57a3db72d486bd (patch)
tree6ab7adabe89746ad1e800e0720565974a9204896 /test
parent295eff0a7d5b0b2ab388bab47035a68d7169f7d1 (diff)
downloadmullvadvpn-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.rs954
-rw-r--r--test/test-runner/src/sys/linux.rs281
-rw-r--r--test/test-runner/src/sys/macos.rs148
-rw-r--r--test/test-runner/src/sys/mod.rs62
-rw-r--r--test/test-runner/src/sys/windows.rs443
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,
+ }))
+}