diff options
| author | Markus Pettersson <markus.pettersson@mullvad.net> | 2024-04-12 15:39:30 +0200 |
|---|---|---|
| committer | Markus Pettersson <markus.pettersson@mullvad.net> | 2024-04-12 15:39:30 +0200 |
| commit | 6b7c1fcf2b28e624d90480bab480bfd7404fc499 (patch) | |
| tree | 3e088931812cf2c158ffbc9f4795924d54cc9f2a | |
| parent | f36bd2e90024501f46a9dabfb032bbc9ab2b6627 (diff) | |
| parent | 9531b3085b3831108fee3e4d7a8563ac79dc4225 (diff) | |
| download | mullvadvpn-6b7c1fcf2b28e624d90480bab480bfd7404fc499.tar.xz mullvadvpn-6b7c1fcf2b28e624d90480bab480bfd7404fc499.zip | |
Merge branch 'fix-flaky-test_custom_access_methods-des-778'
| -rw-r--r-- | test/Cargo.lock | 5 | ||||
| -rw-r--r-- | test/socks-server/src/lib.rs | 8 | ||||
| -rw-r--r-- | test/test-manager/Cargo.toml | 2 | ||||
| -rw-r--r-- | test/test-manager/src/mullvad_daemon.rs | 15 | ||||
| -rw-r--r-- | test/test-manager/src/run_tests.rs | 18 | ||||
| -rw-r--r-- | test/test-manager/src/tests/access_methods.rs | 94 | ||||
| -rw-r--r-- | test/test-manager/src/tests/dns.rs | 39 | ||||
| -rw-r--r-- | test/test-manager/src/tests/mod.rs | 38 | ||||
| -rw-r--r-- | test/test-manager/test_macro/src/lib.rs | 20 | ||||
| -rw-r--r-- | test/test-rpc/src/client.rs | 14 | ||||
| -rw-r--r-- | test/test-rpc/src/lib.rs | 12 | ||||
| -rw-r--r-- | test/test-runner/src/main.rs | 7 | ||||
| -rw-r--r-- | test/test-runner/src/sys.rs | 223 |
13 files changed, 363 insertions, 132 deletions
diff --git a/test/Cargo.lock b/test/Cargo.lock index eb38b5f37a..6d55901090 100644 --- a/test/Cargo.lock +++ b/test/Cargo.lock @@ -2123,15 +2123,16 @@ dependencies = [ [[package]] name = "pcap" -version = "0.10.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da544c8115cc65b474554569c7654fc94a4f6a167f79192536e148fd654e17a" +checksum = "99e935fc73d54a89fff576526c2ccd42bbf8247aae05b358693475b14fd4ff79" dependencies = [ "bitflags 1.3.2", "errno 0.2.8", "futures", "libc", "libloading", + "pkg-config", "regex", "tokio", "windows-sys 0.36.1", diff --git a/test/socks-server/src/lib.rs b/test/socks-server/src/lib.rs index 1b3e712643..cc8847db1b 100644 --- a/test/socks-server/src/lib.rs +++ b/test/socks-server/src/lib.rs @@ -1,3 +1,4 @@ +use fast_socks5::server::{AcceptAuthentication, Socks5Server}; use futures::StreamExt; use std::{io, net::SocketAddr}; @@ -13,10 +14,9 @@ pub struct Handle { /// Spawn a SOCKS server bound to `bind_addr` pub async fn spawn(bind_addr: SocketAddr) -> Result<Handle, Error> { - let socks_server: fast_socks5::server::Socks5Server = - fast_socks5::server::Socks5Server::bind(bind_addr) - .await - .map_err(Error::StartSocksServer)?; + let socks_server: Socks5Server<AcceptAuthentication> = Socks5Server::bind(bind_addr) + .await + .map_err(Error::StartSocksServer)?; let handle = tokio::spawn(async move { let mut incoming = socks_server.incoming(); diff --git a/test/test-manager/Cargo.toml b/test/test-manager/Cargo.toml index 37cfbc5a85..fa86db329e 100644 --- a/test/test-manager/Cargo.toml +++ b/test/test-manager/Cargo.toml @@ -39,7 +39,7 @@ serde_json = { workspace = true } tokio-serde = { workspace = true } log = { workspace = true } -pcap = { version = "0.10.1", features = ["capture-stream"] } +pcap = { version = "1.3", features = ["capture-stream"] } pnet_packet = "0.31.0" test-rpc = { path = "../test-rpc" } diff --git a/test/test-manager/src/mullvad_daemon.rs b/test/test-manager/src/mullvad_daemon.rs index e1a7680cb4..115a09e049 100644 --- a/test/test-manager/src/mullvad_daemon.rs +++ b/test/test-manager/src/mullvad_daemon.rs @@ -51,14 +51,17 @@ pub struct RpcClientProvider { service: DummyService, } +pub enum MullvadClientArgument { + WithClient(MullvadProxyClient), + None, +} + impl RpcClientProvider { - pub async fn as_type( - &self, - client_type: MullvadClientVersion, - ) -> Box<dyn std::any::Any + Send> { + /// Whether a [test case](test_macro::test_function) needs a [`MullvadProxyClient`]. + pub async fn mullvad_client(&self, client_type: MullvadClientVersion) -> MullvadClientArgument { match client_type { - MullvadClientVersion::New => Box::new(self.new_client().await), - MullvadClientVersion::None => Box::new(()), + MullvadClientVersion::New => MullvadClientArgument::WithClient(self.new_client().await), + MullvadClientVersion::None => MullvadClientArgument::None, } } diff --git a/test/test-manager/src/run_tests.rs b/test/test-manager/src/run_tests.rs index 0c7868e529..73c72ebc2e 100644 --- a/test/test-manager/src/run_tests.rs +++ b/test/test-manager/src/run_tests.rs @@ -1,6 +1,6 @@ use crate::{ logging::{panic_as_string, TestOutput}, - mullvad_daemon, + mullvad_daemon::{self, MullvadClientArgument}, summary::{self, maybe_log_test_result}, tests::{self, config::TEST_CONFIG, get_tests, TestContext}, vm, @@ -80,9 +80,9 @@ pub async fn run( let logger = super::logging::Logger::get_or_init(); for test in tests { - let mclient = test_context + let mullvad_client = test_context .rpc_provider - .as_type(test.mullvad_client_version) + .mullvad_client(test.mullvad_client_version) .await; log::info!("Running {}", test.name); @@ -94,7 +94,7 @@ pub async fn run( let test_result = run_test( client.clone(), - mclient, + mullvad_client, &test.func, test.name, test_context.clone(), @@ -105,8 +105,8 @@ pub async fn run( // Try to reset the daemon state if the test failed OR if the test doesn't explicitly // disabled cleanup. if test.cleanup || matches!(test_result.result, Err(_) | Ok(Err(_))) { - let mut client = test_context.rpc_provider.new_client().await; - crate::tests::cleanup_after_test(&mut client).await?; + crate::tests::cleanup_after_test(client.clone(), &test_context.rpc_provider) + .await?; } } @@ -175,15 +175,15 @@ pub async fn run( final_result } -pub async fn run_test<F, R, MullvadClient>( +pub async fn run_test<F, R>( runner_rpc: ServiceClient, - mullvad_rpc: MullvadClient, + mullvad_rpc: MullvadClientArgument, test: &F, test_name: &'static str, test_context: super::tests::TestContext, ) -> TestOutput where - F: Fn(super::tests::TestContext, ServiceClient, MullvadClient) -> R, + F: Fn(super::tests::TestContext, ServiceClient, MullvadClientArgument) -> R, R: Future<Output = anyhow::Result<()>>, { let _flushed = runner_rpc.try_poll_output().await; diff --git a/test/test-manager/src/tests/access_methods.rs b/test/test-manager/src/tests/access_methods.rs index 1d5660f4b0..747ebd23e5 100644 --- a/test/test-manager/src/tests/access_methods.rs +++ b/test/test-manager/src/tests/access_methods.rs @@ -1,50 +1,30 @@ //! Integration tests for API access methods. -use super::{Error, TestContext}; +//! +//! The tested access methods are: +//! * Shadowsocks +//! * SOCKS5 in remote mode +//! +//! These tests rely on working proxies to exist *somewhere* for all tested protocols. +//! If the proxies themselves are bad/not running, this test will fail due to issues +//! that are out of the test manager's control. +use anyhow::{anyhow, ensure, Context}; + use mullvad_management_interface::MullvadProxyClient; +use talpid_types::net::proxy::CustomProxy; use test_macro::test_function; use test_rpc::ServiceClient; -/// Assert that custom access methods may be used to access the Mullvad API. -/// -/// The tested access methods are: -/// * Shadowsocks -/// * Socks5 in remote mode -/// -/// # Note -/// -/// This tests assume that there exists working proxies *somewhere* for all -/// tested protocols. If the proxies themselves are bad/not running, this test -/// will fail due to issues that are out of the test manager's control. +use super::TestContext; + +/// Assert that API traffic can be proxied via a custom Shadowsocks proxy. #[test_function] -pub async fn test_custom_access_methods( +async fn test_access_method_shadowsocks( _: TestContext, _rpc: ServiceClient, - mullvad_client: MullvadProxyClient, -) -> Result<(), Error> { - log::info!("Testing Shadowsocks access method"); - test_shadowsocks(mullvad_client.clone()).await?; - log::info!("Testing SOCKS5 (Remote) access method"); - test_socks_remote(mullvad_client.clone()).await?; - Ok(()) -} - -macro_rules! assert_access_method_works { - ($mullvad_client:expr, $access_method:expr) => { - let successful = $mullvad_client - .test_custom_api_access_method($access_method.clone().into()) - .await - .expect("Failed to test custom API access method"); - - assert!( - successful, - "Failed while testing access method - {:?}", - $access_method - ); - }; -} - -async fn test_shadowsocks(mut mullvad_client: MullvadProxyClient) -> Result<(), Error> { + mut mullvad_client: MullvadProxyClient, +) -> anyhow::Result<()> { use mullvad_relay_selector::{RelaySelector, SelectorConfig}; + log::info!("Testing Shadowsocks access method"); // Set up all the parameters needed to create a custom Shadowsocks access method. // // Since Mullvad's bridge servers host Shadowsocks relays, we can simply @@ -53,22 +33,48 @@ async fn test_shadowsocks(mut mullvad_client: MullvadProxyClient) -> Result<(), let relay_selector = RelaySelector::from_list(SelectorConfig::default(), relay_list); let access_method = relay_selector .get_bridge_forced() - .expect("`test_shadowsocks` needs at least one shadowsocks relay to execute. Found none in relay list."); - assert_access_method_works!(mullvad_client, access_method); + .context("`test_shadowsocks` needs at least one shadowsocks relay to execute. Found none in relay list.")?; + log::info!("Selected shadowsocks bridge: {access_method:?}"); + assert_access_method_works(mullvad_client, access_method.clone()) + .await + .with_context(|| anyhow!("Access method {access_method:?} did not work!"))?; Ok(()) } -async fn test_socks_remote(mut mullvad_client: MullvadProxyClient) -> Result<(), Error> { +/// Assert that API traffic can be proxied via a custom SOCKS5 proxy. +#[test_function] +async fn test_access_method_socks5_remote( + _: TestContext, + _rpc: ServiceClient, + mullvad_client: MullvadProxyClient, +) -> anyhow::Result<()> { use crate::vm::network::{NON_TUN_GATEWAY, SOCKS5_PORT}; use std::net::SocketAddr; - use talpid_types::net::proxy::{CustomProxy, Socks5Remote}; + use talpid_types::net::proxy::Socks5Remote; + log::info!("Testing SOCKS5 (Remote) access method"); // Set up all the parameters needed to create a custom SOCKS5 access method. // // The remote SOCKS5 proxy is assumed to be running on the test manager. On // which port it listens to is defined as a constant in the `test-manager` // crate. let endpoint = SocketAddr::from((NON_TUN_GATEWAY, SOCKS5_PORT)); - let access_method = CustomProxy::from(Socks5Remote::new(endpoint)); - assert_access_method_works!(mullvad_client, access_method); + let access_method = Socks5Remote::new(endpoint); + log::info!("Testing SOCKS5-proxy: {access_method:?}"); + assert_access_method_works(mullvad_client, access_method.clone()) + .await + .with_context(|| anyhow!("Access method {access_method:?} did not work!"))?; + Ok(()) +} + +async fn assert_access_method_works( + mut mullvad_client: MullvadProxyClient, + access_method: impl Into<CustomProxy> + std::fmt::Debug, +) -> anyhow::Result<()> { + let successful = mullvad_client + .test_custom_api_access_method(access_method.into()) + .await + .context("Failed to test custom API access method")?; + + ensure!(successful, "Failed while testing access method"); Ok(()) } diff --git a/test/test-manager/src/tests/dns.rs b/test/test-manager/src/tests/dns.rs index bff7059aeb..a6f564139f 100644 --- a/test/test-manager/src/tests/dns.rs +++ b/test/test-manager/src/tests/dns.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, Context}; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::atomic::{AtomicUsize, Ordering}, @@ -334,7 +335,7 @@ pub async fn test_dns_config_default( _: TestContext, rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, -) -> Result<(), Error> { +) -> anyhow::Result<()> { run_dns_config_tunnel_test( &rpc, &mut mullvad_client, @@ -353,7 +354,7 @@ pub async fn test_dns_config_custom_private( _: TestContext, rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, -) -> Result<(), Error> { +) -> anyhow::Result<()> { log::debug!("Setting custom DNS resolver to {NON_TUN_GATEWAY}"); mullvad_client @@ -365,7 +366,7 @@ pub async fn test_dns_config_custom_private( state: settings::DnsState::Custom, }) .await - .expect("failed to configure DNS server"); + .context("failed to configure DNS server")?; run_dns_config_non_tunnel_test(&rpc, &mut mullvad_client, IpAddr::V4(NON_TUN_GATEWAY)).await } @@ -380,7 +381,7 @@ pub async fn test_dns_config_custom_public( _: TestContext, rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, -) -> Result<(), Error> { +) -> anyhow::Result<()> { let custom_ip = IpAddr::V4(Ipv4Addr::new(1, 3, 3, 7)); log::debug!("Setting custom DNS resolver to {custom_ip}"); @@ -394,7 +395,7 @@ pub async fn test_dns_config_custom_public( state: settings::DnsState::Custom, }) .await - .expect("failed to configure DNS server"); + .context("failed to configure DNS server")?; run_dns_config_tunnel_test(&rpc, &mut mullvad_client, custom_ip).await } @@ -406,7 +407,7 @@ pub async fn test_content_blockers( _: TestContext, rpc: ServiceClient, mut mullvad_client: MullvadProxyClient, -) -> Result<(), Error> { +) -> anyhow::Result<()> { const DNS_BLOCKING_IP_BASE: Ipv4Addr = Ipv4Addr::new(100, 64, 0, 0); let content_blockers = [ ( @@ -498,7 +499,7 @@ pub async fn test_content_blockers( state: settings::DnsState::Default, }) .await - .expect("failed to configure DNS server"); + .context("failed to configure DNS server")?; run_dns_config_tunnel_test(&rpc, &mut mullvad_client, test_ip).await?; } @@ -510,7 +511,7 @@ async fn run_dns_config_tunnel_test( rpc: &ServiceClient, mullvad_client: &mut MullvadProxyClient, expected_dns_resolver: IpAddr, -) -> Result<(), Error> { +) -> anyhow::Result<()> { run_dns_config_test( rpc, || { @@ -534,7 +535,7 @@ async fn run_dns_config_non_tunnel_test( rpc: &ServiceClient, mullvad_client: &mut MullvadProxyClient, expected_dns_resolver: IpAddr, -) -> Result<(), Error> { +) -> anyhow::Result<()> { run_dns_config_test( rpc, || { @@ -561,33 +562,33 @@ async fn run_dns_config_test< create_monitor: impl FnOnce() -> F, mullvad_client: &mut MullvadProxyClient, expected_dns_resolver: IpAddr, -) -> Result<(), Error> { +) -> anyhow::Result<()> { match mullvad_client.get_tunnel_state().await { // prevent reconnect Ok(mullvad_types::states::TunnelState::Connected { .. }) => (), _ => { connect_local_wg_relay(mullvad_client) .await - .expect("failed to connect to custom wg relay"); + .context("failed to connect to custom wg relay")?; } } let nontun_iface = rpc .get_default_interface() .await - .expect("failed to find non-tun interface"); + .context("failed to find non-tun interface")?; let tunnel_iface = helpers::get_tunnel_interface(mullvad_client) .await - .expect("failed to find tunnel interface"); + .context("failed to find tunnel interface")?; let nontun_ip = rpc .get_interface_ip(nontun_iface) .await - .expect("failed to obtain guest IP"); + .context("failed to obtain guest IP")?; let tunnel_ip = rpc .get_interface_ip(tunnel_iface) .await - .expect("failed to obtain tunnel IP"); + .context("failed to obtain tunnel IP")?; log::debug!("Tunnel (guest) IP: {tunnel_ip}"); log::debug!("Non-tunnel (guest) IP: {nontun_ip}"); @@ -612,7 +613,13 @@ async fn run_dns_config_test< }); assert_eq!( - monitor.wait().await.unwrap().packets[0].destination, + monitor + .wait() + .await? + .packets + .first() + .ok_or(anyhow!("Expected at least one packet, saw none"))? + .destination, SocketAddr::new(expected_dns_resolver, 53), "expected tunnel packet to expected destination {expected_dns_resolver}" ); diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs index f4c4563bde..ce38308b0d 100644 --- a/test/test-manager/src/tests/mod.rs +++ b/test/test-manager/src/tests/mod.rs @@ -13,14 +13,16 @@ mod tunnel; mod tunnel_state; mod ui; -use crate::mullvad_daemon::RpcClientProvider; +use crate::{ + mullvad_daemon::{MullvadClientArgument, RpcClientProvider}, + tests::helpers::get_app_env, +}; use anyhow::Context; pub use test_metadata::TestMetadata; use test_rpc::ServiceClient; use futures::future::BoxFuture; -use mullvad_management_interface::MullvadProxyClient; use std::time::Duration; const WAIT_FOR_TUNNEL_STATE_TIMEOUT: Duration = Duration::from_secs(40); @@ -30,11 +32,8 @@ pub struct TestContext { pub rpc_provider: RpcClientProvider, } -pub type TestWrapperFunction = fn( - TestContext, - ServiceClient, - Box<dyn std::any::Any + Send>, -) -> BoxFuture<'static, anyhow::Result<()>>; +pub type TestWrapperFunction = + fn(TestContext, ServiceClient, MullvadClientArgument) -> BoxFuture<'static, anyhow::Result<()>>; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -72,10 +71,16 @@ pub fn get_tests() -> Vec<&'static TestMetadata> { } /// Restore settings to the defaults. -pub async fn cleanup_after_test(mullvad_client: &mut MullvadProxyClient) -> anyhow::Result<()> { +pub async fn cleanup_after_test( + rpc: ServiceClient, + rpc_provider: &RpcClientProvider, +) -> anyhow::Result<()> { log::debug!("Cleaning up daemon in test cleanup"); + // Check if daemon should be restarted + restart_daemon(rpc).await?; + let mut mullvad_client = rpc_provider.new_client().await; - helpers::disconnect_and_wait(mullvad_client).await?; + helpers::disconnect_and_wait(&mut mullvad_client).await?; // Bring all the settings into scope so we remember to reset them. let mullvad_types::settings::Settings { @@ -179,3 +184,18 @@ pub async fn cleanup_after_test(mullvad_client: &mut MullvadProxyClient) -> anyh Ok(()) } + +/// Conditionally restart the running daemon +/// +/// If the daemon was started with non-standard environment variables, subsequent tests may break +/// due to assuming a default configuration. In that case, reset the environment variables and +/// restart. +async fn restart_daemon(rpc: ServiceClient) -> anyhow::Result<()> { + let current_env = rpc.get_daemon_environment().await?; + let default_env = get_app_env(); + if current_env != default_env { + log::debug!("Restarting daemon due changed environment variables. Values since last test {current_env:?}"); + rpc.set_daemon_environment(default_env).await?; + } + Ok(()) +} diff --git a/test/test-manager/test_macro/src/lib.rs b/test/test-manager/test_macro/src/lib.rs index 161605e8ca..7e19990b97 100644 --- a/test/test-manager/test_macro/src/lib.rs +++ b/test/test-manager/test_macro/src/lib.rs @@ -199,20 +199,18 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream { let func_name = test_function.name; let function_mullvad_version = test_function.function_parameters.mullvad_client.version(); let wrapper_closure = match test_function.function_parameters.mullvad_client { - MullvadClient::New { - mullvad_client_type, - .. - } => { - let mullvad_client_type = *mullvad_client_type; + MullvadClient::New { .. } => { quote! { |test_context: crate::tests::TestContext, rpc: test_rpc::ServiceClient, - mullvad_client: Box<dyn std::any::Any + Send>,| + mullvad_client: crate::mullvad_daemon::MullvadClientArgument| { - use std::any::Any; - let mullvad_client = mullvad_client.downcast::<#mullvad_client_type>().expect("invalid mullvad client"); + let mullvad_client = match mullvad_client { + crate::mullvad_daemon::MullvadClientArgument::WithClient(client) => client, + crate::mullvad_daemon::MullvadClientArgument::None => unreachable!("invalid mullvad client") + }; Box::pin(async move { - #func_name(test_context, rpc, *mullvad_client).await.map_err(Into::into) + #func_name(test_context, rpc, mullvad_client).await.map_err(Into::into) }) } } @@ -221,7 +219,7 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream { quote! { |test_context: crate::tests::TestContext, rpc: test_rpc::ServiceClient, - _mullvad_client: Box<dyn std::any::Any + Send>| { + _mullvad_client: crate::mullvad_daemon::MullvadClientArgument| { Box::pin(async move { #func_name(test_context, rpc).await.map_err(Into::into) }) @@ -264,7 +262,6 @@ enum MullvadClient { mullvad_client_version: proc_macro2::TokenStream, }, New { - mullvad_client_type: Box<syn::Type>, mullvad_client_version: proc_macro2::TokenStream, }, } @@ -314,7 +311,6 @@ fn get_test_function_parameters( let mullvad_client_version = quote! { test_rpc::mullvad_daemon::MullvadClientVersion::New }; MullvadClient::New { - mullvad_client_type: pat_type.ty, mullvad_client_version, } } diff --git a/test/test-rpc/src/client.rs b/test/test-rpc/src/client.rs index 79c6a038ca..84d923a826 100644 --- a/test/test-rpc/src/client.rs +++ b/test/test-rpc/src/client.rs @@ -314,6 +314,20 @@ impl ServiceClient { Ok(()) } + /// Get the current daemon's environment variables. + /// + /// # Returns + /// - `Result::Ok(env)` if the current environment variables could be read. + /// - `Result::Err(Error)` if communication with the daemon failed or the environment values + /// could not be parsed. + pub async fn get_daemon_environment(&self) -> Result<HashMap<String, String>, Error> { + let env = self + .client + .get_daemon_environment(tarpc::context::current()) + .await??; + Ok(env) + } + pub async fn copy_file(&self, src: String, dest: String) -> Result<(), Error> { log::debug!("Copying \"{src}\" to \"{dest}\""); self.client diff --git a/test/test-rpc/src/lib.rs b/test/test-rpc/src/lib.rs index fc9ea67c84..82fed91541 100644 --- a/test/test-rpc/src/lib.rs +++ b/test/test-rpc/src/lib.rs @@ -59,10 +59,19 @@ pub enum Error { TcpForward, #[error("Unknown process ID: {0}")] UnknownPid(u32), + #[error("Failed to join tokio task: {0}")] + TokioJoinError(String), #[error("{0}")] Other(String), } +impl Error { + /// Convenient mapping from a Tokio error to the test_rpc Error type. + pub fn from_tokio_join_error(error: tokio::task::JoinError) -> Error { + Error::TokioJoinError(error.to_string()) + } +} + /// Response from am.i.mullvad.net #[derive(Debug, Serialize, Deserialize)] pub struct AmIMullvad { @@ -213,6 +222,9 @@ mod service { /// service. async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), Error>; + /// Get the environment variables for the running daemon service. + async fn get_daemon_environment() -> Result<HashMap<String, String>, Error>; + /// Copy a file from `src` to `dest` on the test runner. async fn copy_file(src: String, dest: String) -> Result<(), Error>; diff --git a/test/test-runner/src/main.rs b/test/test-runner/src/main.rs index 29f0f936d3..79e11e0506 100644 --- a/test/test-runner/src/main.rs +++ b/test/test-runner/src/main.rs @@ -309,6 +309,13 @@ impl Service for TestServer { sys::set_daemon_environment(env).await } + async fn get_daemon_environment( + self, + _: context::Context, + ) -> Result<HashMap<String, String>, test_rpc::Error> { + sys::get_daemon_environment().await + } + async fn copy_file( self, _: context::Context, diff --git a/test/test-runner/src/sys.rs b/test/test-runner/src/sys.rs index 5015f26ca1..0a176b1c3d 100644 --- a/test/test-runner/src/sys.rs +++ b/test/test-runner/src/sys.rs @@ -4,13 +4,22 @@ use std::io; use test_rpc::{meta::OsVersion, mullvad_daemon::Verbosity}; #[cfg(target_os = "windows")] -use std::ffi::OsString; +use std::{ffi::OsString, path::Path}; #[cfg(target_os = "windows")] use windows_service::{ service::{ServiceAccess, ServiceInfo}, 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::{ @@ -154,9 +163,6 @@ pub fn reboot() -> Result<(), test_rpc::Error> { #[cfg(target_os = "linux")] pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> { use tokio::io::AsyncWriteExt; - const SYSTEMD_OVERRIDE_FILE: &str = - "/etc/systemd/system/mullvad-daemon.service.d/override.conf"; - log::debug!("Setting log level"); let verbosity = match verbosity_level { @@ -389,17 +395,57 @@ pub async fn set_daemon_log_level(_verbosity_level: Verbosity) -> Result<(), tes } #[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" => { + // Proccess 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; - use tokio::io::AsyncWriteExt; - - const SYSTEMD_OVERRIDE_FILE: &str = "/etc/systemd/system/mullvad-daemon.service.d/env.conf"; let mut override_content = String::new(); override_content.push_str("[Service]\n"); - for (k, v) in env { - writeln!(&mut override_content, "Environment=\"{k}={v}\"").unwrap(); + 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::Service(err.to_string()))?; } let override_path = std::path::Path::new(SYSTEMD_OVERRIDE_FILE); @@ -409,15 +455,7 @@ pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), .map_err(|e| test_rpc::Error::Service(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::Service(e.to_string()))?; - - file.write_all(override_content.as_bytes()) + tokio::fs::write(override_path, override_content) .await .map_err(|e| test_rpc::Error::Service(e.to_string()))?; @@ -440,7 +478,7 @@ pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), #[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 { + for (k, v) in env.clone() { tokio::process::Command::new("setx") .arg("/m") .args([k, v]) @@ -448,6 +486,18 @@ pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), .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::{enums::*, RegKey}; + 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?; @@ -464,7 +514,7 @@ pub fn get_system_path_var() -> Result<String, test_rpc::Error> { 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)) + test_rpc::Error::Registry(format!("Failed to open Environment subkey: {}", error)) })?; let path: String = key @@ -476,14 +526,11 @@ pub fn get_system_path_var() -> Result<String, test_rpc::Error> { #[cfg(target_os = "macos")] pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> { - const PLIST_PATH: &str = "/Library/LaunchDaemons/net.mullvad.daemon.plist"; - tokio::task::spawn_blocking(|| { - let mut parsed_plist: plist::Value = plist::from_file(PLIST_PATH) + let mut parsed_plist = plist::Value::from_file(PLIST_OVERRIDE_FILE) .map_err(|error| test_rpc::Error::Service(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") @@ -503,10 +550,7 @@ pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), plist::Value::Dictionary(vars), ); - let daemon_plist = std::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(PLIST_PATH) + let daemon_plist = std::fs::File::create(PLIST_OVERRIDE_FILE) .map_err(|e| test_rpc::Error::Service(format!("failed to open plist: {e}")))?; parsed_plist @@ -532,7 +576,7 @@ async fn set_launch_daemon_state(on: bool) -> Result<(), test_rpc::Error> { .args([ if on { "load" } else { "unload" }, "-w", - "/Library/LaunchDaemons/net.mullvad.daemon.plist", + PLIST_OVERRIDE_FILE, ]) .status() .await @@ -541,6 +585,99 @@ async fn set_launch_daemon_state(on: bool) -> Result<(), test_rpc::Error> { } #[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::{enums::*, RegKey}; + + 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::Service(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::Service("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::Service("`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, @@ -618,3 +755,31 @@ pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { pub fn get_os_version() -> Result<OsVersion, test_rpc::Error> { Ok(OsVersion::Linux) } + +#[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"); + } +} |
