summaryrefslogtreecommitdiffhomepage
path: root/test/test-runner/src/sys
diff options
context:
space:
mode:
Diffstat (limited to 'test/test-runner/src/sys')
-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
4 files changed, 934 insertions, 0 deletions
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,
+ }))
+}