summaryrefslogtreecommitdiffhomepage
path: root/test
diff options
context:
space:
mode:
authorJoakim Hulthe <joakim@hulthe.net>2024-03-12 11:21:19 +0100
committerJoakim Hulthe <joakim@hulthe.net>2024-03-20 16:50:15 +0100
commit6594c6de52763ab313d99edecf9231596a003e1f (patch)
treed7079f9f6775583a0e4da4a061b672372e7af2b3 /test
parente5487658fb6065f5f41acdc7970235b64641b6bd (diff)
downloadmullvadvpn-6594c6de52763ab313d99edecf9231596a003e1f.tar.xz
mullvadvpn-6594c6de52763ab313d99edecf9231596a003e1f.zip
Test leaking TCP/UDP/ICMP packets in split tunnel
Diffstat (limited to 'test')
-rw-r--r--test/Cargo.lock35
-rw-r--r--test/Cargo.toml2
-rw-r--r--test/am-i-mullvad/src/main.rs33
-rwxr-xr-xtest/build.sh4
-rw-r--r--test/connection-checker/Cargo.toml (renamed from test/am-i-mullvad/Cargo.toml)5
-rw-r--r--test/connection-checker/src/cli.rs36
-rw-r--r--test/connection-checker/src/lib.rs2
-rw-r--r--test/connection-checker/src/main.rs73
-rw-r--r--test/connection-checker/src/net.rs78
-rwxr-xr-xtest/scripts/build-runner-image.sh2
-rw-r--r--test/scripts/ssh-setup.sh2
-rw-r--r--test/test-manager/src/tests/split_tunnel.rs400
-rw-r--r--test/test-manager/src/tests/test_metadata.rs6
-rw-r--r--test/test-manager/src/vm/provision.rs5
-rw-r--r--test/test-manager/test_macro/src/lib.rs36
-rw-r--r--test/test-rpc/src/client.rs22
-rw-r--r--test/test-rpc/src/lib.rs47
-rw-r--r--test/test-runner/Cargo.toml2
-rw-r--r--test/test-runner/src/main.rs222
-rw-r--r--test/test-runner/src/util.rs23
20 files changed, 863 insertions, 172 deletions
diff --git a/test/Cargo.lock b/test/Cargo.lock
index 09ec93e782..5a7771fb47 100644
--- a/test/Cargo.lock
+++ b/test/Cargo.lock
@@ -62,16 +62,6 @@ dependencies = [
]
[[package]]
-name = "am-i-mullvad"
-version = "0.0.0"
-dependencies = [
- "color-eyre",
- "eyre",
- "reqwest",
- "serde",
-]
-
-[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -528,6 +518,19 @@ dependencies = [
]
[[package]]
+name = "connection-checker"
+version = "0.0.0"
+dependencies = [
+ "clap",
+ "color-eyre",
+ "eyre",
+ "ping",
+ "reqwest",
+ "serde",
+ "socket2 0.5.4",
+]
+
+[[package]]
name = "const-oid"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2158,6 +2161,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "ping"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "122ee1f5a6843bec84fcbd5c6ba3622115337a6b8965b93a61aad347648f4e8d"
+dependencies = [
+ "rand 0.8.5",
+ "socket2 0.4.9",
+ "thiserror",
+]
+
+[[package]]
name = "pkcs8"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3172,6 +3186,7 @@ dependencies = [
"base64 0.13.1",
"ipnetwork 0.16.0",
"jnix",
+ "log",
"serde",
"thiserror",
"x25519-dalek",
diff --git a/test/Cargo.toml b/test/Cargo.toml
index 4c23a55eb0..0fd7b4a2e9 100644
--- a/test/Cargo.toml
+++ b/test/Cargo.toml
@@ -12,7 +12,7 @@ members = [
"test-runner",
"test-rpc",
"socks-server",
- "am-i-mullvad",
+ "connection-checker",
]
[workspace.lints.rust]
diff --git a/test/am-i-mullvad/src/main.rs b/test/am-i-mullvad/src/main.rs
deleted file mode 100644
index c6cc272d30..0000000000
--- a/test/am-i-mullvad/src/main.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-use eyre::{eyre, Context};
-use reqwest::blocking::get;
-use serde::Deserialize;
-use std::process;
-
-#[derive(Debug, Deserialize)]
-struct Response {
- ip: String,
- mullvad_exit_ip_hostname: Option<String>,
-}
-
-fn main() -> eyre::Result<()> {
- color_eyre::install()?;
-
- let url = "https://am.i.mullvad.net/json";
- let response: Response = get(url)
- .and_then(|r| r.json())
- .wrap_err_with(|| eyre!("Failed to GET {url}"))?;
-
- if let Some(server) = &response.mullvad_exit_ip_hostname {
- println!(
- "You are connected to Mullvad (server {}). Your IP address is {}",
- server, response.ip
- );
- Ok(())
- } else {
- println!(
- "You are not connected to Mullvad. Your IP address is {}",
- response.ip
- );
- process::exit(1)
- }
-}
diff --git a/test/build.sh b/test/build.sh
index 1f0099ccf4..d3a3c17470 100755
--- a/test/build.sh
+++ b/test/build.sh
@@ -17,11 +17,11 @@ if [[ $TARGET == x86_64-unknown-linux-gnu ]]; then
-e CARGO_HOME=/root/.cargo/registry \
-e CARGO_TARGET_DIR=/src/test/target \
mullvadvpn-app-tests \
- /bin/bash -c "cd /src/test/; cargo build --bin test-runner --release --target ${TARGET}"
+ /bin/bash -c "cd /src/test/; cargo build --bin test-runner --bin connection-checker --release --target ${TARGET}"
else
cargo build \
--bin test-runner \
- --bin am-i-mullvad \
+ --bin connection-checker \
--release --target "${TARGET}"
fi
diff --git a/test/am-i-mullvad/Cargo.toml b/test/connection-checker/Cargo.toml
index c3bda1b1cb..d579510bd1 100644
--- a/test/am-i-mullvad/Cargo.toml
+++ b/test/connection-checker/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "am-i-mullvad"
+name = "connection-checker"
description = "Simple cli for testing Mullvad VPN connections"
authors.workspace = true
repository.workspace = true
@@ -11,7 +11,10 @@ rust-version.workspace = true
workspace = true
[dependencies]
+clap = { workspace = true, features = ["derive"] }
color-eyre = "0.6.2"
eyre = "0.6.12"
+ping = "0.5.2"
reqwest = { version = "0.11.24", default-features = false, features = ["blocking", "rustls-tls", "json"] }
serde = { version = "1.0.197", features = ["derive"] }
+socket2 = { version = "0.5.4", features = ["all"] }
diff --git a/test/connection-checker/src/cli.rs b/test/connection-checker/src/cli.rs
new file mode 100644
index 0000000000..dddb348b25
--- /dev/null
+++ b/test/connection-checker/src/cli.rs
@@ -0,0 +1,36 @@
+use std::net::SocketAddr;
+
+use clap::Parser;
+
+/// CLI tool that queries <https://am.i.mullvad.net> to check if the machine is connected to
+/// Mullvad VPN.
+#[derive(Parser)]
+pub struct Opt {
+ /// Interactive mode, press enter to check if you are Mullvad.
+ #[clap(short, long)]
+ pub interactive: bool,
+
+ /// Timeout for network connection to am.i.mullvad (in millis).
+ #[clap(short, long, default_value = "3000")]
+ pub timeout: u64,
+
+ /// Try to send some junk data over TCP to <leak>.
+ #[clap(long, requires = "leak")]
+ pub leak_tcp: bool,
+
+ /// Try to send some junk data over UDP to <leak>.
+ #[clap(long, requires = "leak")]
+ pub leak_udp: bool,
+
+ /// Try to send ICMP request to <leak>.
+ #[clap(long, requires = "leak")]
+ pub leak_icmp: bool,
+
+ /// Target of <leak_tcp>, <leak_udp> or <leak_icmp>.
+ #[clap(long)]
+ pub leak: Option<SocketAddr>,
+
+ /// Timeout for leak check network connections (in millis).
+ #[clap(long, default_value = "1000")]
+ pub leak_timeout: u64,
+}
diff --git a/test/connection-checker/src/lib.rs b/test/connection-checker/src/lib.rs
new file mode 100644
index 0000000000..cb36c236b0
--- /dev/null
+++ b/test/connection-checker/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod cli;
+pub mod net;
diff --git a/test/connection-checker/src/main.rs b/test/connection-checker/src/main.rs
new file mode 100644
index 0000000000..ed48999970
--- /dev/null
+++ b/test/connection-checker/src/main.rs
@@ -0,0 +1,73 @@
+use clap::Parser;
+use eyre::{eyre, Context};
+use reqwest::blocking::Client;
+use serde::Deserialize;
+use std::{io::stdin, time::Duration};
+
+use connection_checker::cli::Opt;
+use connection_checker::net::{send_ping, send_tcp, send_udp};
+
+fn main() -> eyre::Result<()> {
+ let opt = Opt::parse();
+ color_eyre::install()?;
+
+ if opt.interactive {
+ let stdin = stdin();
+ for line in stdin.lines() {
+ let _ = line.wrap_err("Failed to read from stdin")?;
+ test_connection(&opt)?;
+ }
+ } else {
+ test_connection(&opt)?;
+ }
+
+ Ok(())
+}
+
+fn test_connection(opt: &Opt) -> eyre::Result<bool> {
+ if let Some(destination) = opt.leak {
+ if opt.leak_tcp {
+ let _ = send_tcp(opt, destination);
+ }
+ if opt.leak_udp {
+ let _ = send_udp(opt, destination);
+ }
+ if opt.leak_icmp {
+ let _ = send_ping(opt, destination.ip());
+ }
+ }
+ am_i_mullvad(opt)
+}
+
+/// Check if connected to Mullvad and print the result to stdout
+fn am_i_mullvad(opt: &Opt) -> eyre::Result<bool> {
+ #[derive(Debug, Deserialize)]
+ struct Response {
+ ip: String,
+ mullvad_exit_ip_hostname: Option<String>,
+ }
+
+ let url = "https://am.i.mullvad.net/json";
+
+ let client = Client::new();
+ let response: Response = client
+ .get(url)
+ .timeout(Duration::from_millis(opt.timeout))
+ .send()
+ .and_then(|r| r.json())
+ .wrap_err_with(|| eyre!("Failed to GET {url}"))?;
+
+ if let Some(server) = &response.mullvad_exit_ip_hostname {
+ println!(
+ "You are connected to Mullvad (server {}). Your IP address is {}",
+ server, response.ip
+ );
+ Ok(true)
+ } else {
+ println!(
+ "You are not connected to Mullvad. Your IP address is {}",
+ response.ip
+ );
+ Ok(false)
+ }
+}
diff --git a/test/connection-checker/src/net.rs b/test/connection-checker/src/net.rs
new file mode 100644
index 0000000000..6634be41b0
--- /dev/null
+++ b/test/connection-checker/src/net.rs
@@ -0,0 +1,78 @@
+use eyre::{eyre, Context};
+use std::{
+ io::Write,
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ time::Duration,
+};
+
+use crate::cli::Opt;
+
+pub fn send_tcp(opt: &Opt, destination: SocketAddr) -> eyre::Result<()> {
+ let bind_addr: SocketAddr = SocketAddr::new(Ipv4Addr::new(0, 0, 0, 0).into(), 0);
+
+ let family = match &destination {
+ SocketAddr::V4(_) => socket2::Domain::IPV4,
+ SocketAddr::V6(_) => socket2::Domain::IPV6,
+ };
+ let sock = socket2::Socket::new(family, socket2::Type::STREAM, Some(socket2::Protocol::TCP))
+ .wrap_err(eyre!("Failed to create TCP socket"))?;
+
+ eprintln!("Leaking TCP packets to {destination}");
+
+ sock.bind(&socket2::SockAddr::from(bind_addr))
+ .wrap_err(eyre!("Failed to bind TCP socket to {bind_addr}"))?;
+
+ let timeout = Duration::from_millis(opt.leak_timeout);
+ sock.set_write_timeout(Some(timeout))?;
+ sock.set_read_timeout(Some(timeout))?;
+
+ sock.connect_timeout(&socket2::SockAddr::from(destination), timeout)
+ .wrap_err(eyre!("Failed to connect to {destination}"))?;
+
+ let mut stream = std::net::TcpStream::from(sock);
+ stream
+ .write_all(b"hello there")
+ .wrap_err(eyre!("Failed to send message to {destination}"))?;
+
+ Ok(())
+}
+
+pub fn send_udp(_opt: &Opt, destination: SocketAddr) -> Result<(), eyre::Error> {
+ let bind_addr: SocketAddr = SocketAddr::new(Ipv4Addr::new(0, 0, 0, 0).into(), 0);
+
+ eprintln!("Leaking UDP packets to {destination}");
+
+ let family = match &destination {
+ SocketAddr::V4(_) => socket2::Domain::IPV4,
+ SocketAddr::V6(_) => socket2::Domain::IPV6,
+ };
+ let sock = socket2::Socket::new(family, socket2::Type::DGRAM, Some(socket2::Protocol::UDP))
+ .wrap_err("Failed to create UDP socket")?;
+
+ sock.bind(&socket2::SockAddr::from(bind_addr))
+ .wrap_err(eyre!("Failed to bind UDP socket to {bind_addr}"))?;
+
+ //log::debug!("Send message from {bind_addr} to {destination}/UDP");
+
+ let std_socket = std::net::UdpSocket::from(sock);
+ std_socket
+ .send_to(b"Hello there!", destination)
+ .wrap_err(eyre!("Failed to send message to {destination}"))?;
+
+ Ok(())
+}
+
+pub fn send_ping(opt: &Opt, destination: IpAddr) -> eyre::Result<()> {
+ eprintln!("Leaking IMCP packets to {destination}");
+
+ ping::ping(
+ destination,
+ Some(Duration::from_millis(opt.leak_timeout)),
+ None,
+ None,
+ None,
+ None,
+ )?;
+
+ Ok(())
+}
diff --git a/test/scripts/build-runner-image.sh b/test/scripts/build-runner-image.sh
index be0d637323..30252d8445 100755
--- a/test/scripts/build-runner-image.sh
+++ b/test/scripts/build-runner-image.sh
@@ -33,7 +33,7 @@ case $TARGET in
mcopy \
-i "${TEST_RUNNER_IMAGE_PATH}" \
"${SCRIPT_DIR}/../target/$TARGET/release/test-runner.exe" \
- "${SCRIPT_DIR}/../target/$TARGET/release/am-i-mullvad.exe" \
+ "${SCRIPT_DIR}/../target/$TARGET/release/connection-checker.exe" \
"${PACKAGES_DIR}/"*.exe \
"${SCRIPT_DIR}/../openvpn.ca.crt" \
"::"
diff --git a/test/scripts/ssh-setup.sh b/test/scripts/ssh-setup.sh
index a3809e0230..b3d358f5a0 100644
--- a/test/scripts/ssh-setup.sh
+++ b/test/scripts/ssh-setup.sh
@@ -16,7 +16,7 @@ echo "Copying test-runner to $RUNNER_DIR"
mkdir -p "$RUNNER_DIR"
-for file in test-runner $CURRENT_APP $PREVIOUS_APP $UI_RUNNER openvpn.ca.crt; do
+for file in test-runner connection-checker $CURRENT_APP $PREVIOUS_APP $UI_RUNNER openvpn.ca.crt; do
echo "Moving $file to $RUNNER_DIR"
cp -f "$SCRIPT_DIR/$file" "$RUNNER_DIR"
done
diff --git a/test/test-manager/src/tests/split_tunnel.rs b/test/test-manager/src/tests/split_tunnel.rs
index 9902dec231..336ee5b5ab 100644
--- a/test/test-manager/src/tests/split_tunnel.rs
+++ b/test/test-manager/src/tests/split_tunnel.rs
@@ -1,145 +1,357 @@
+use anyhow::{anyhow, bail, ensure, Context};
use mullvad_management_interface::MullvadProxyClient;
-use std::str;
+use pcap::Direction;
+use pnet_packet::ip::IpNextHeaderProtocols;
+use std::{
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ str,
+ time::Duration,
+};
use test_macro::test_function;
-use test_rpc::{meta::Os, ExecResult, ServiceClient};
+use test_rpc::{meta::Os, ServiceClient, SpawnOpts};
+use tokio::time::{sleep, timeout};
+
+use crate::network_monitor::{start_packet_monitor, MonitorOptions};
use super::{config::TEST_CONFIG, helpers, TestContext};
-#[test_function]
+const CHECKER_FILENAME_WINDOWS: &str = "connection-checker.exe";
+const CHECKER_FILENAME_UNIX: &str = "connection-checker";
+const LEAK_DESTINATION: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 1337);
+
+/// Test that split tunneling works by asserting the following:
+/// - Splitting a process shouldn't do anything if tunnel is not connected.
+/// - A split process should never push traffic through the tunnel.
+/// - Splitting/unsplitting should work regardless if process is running.
+#[test_function(target_os = "linux", target_os = "windows")]
pub async fn test_split_tunnel(
- ctx: TestContext,
+ _ctx: TestContext,
rpc: ServiceClient,
- mullvad_client: MullvadProxyClient,
+ mut mullvad_client: MullvadProxyClient,
) -> anyhow::Result<()> {
- match TEST_CONFIG.os {
- Os::Linux => test_split_tunnel_linux(ctx, rpc, mullvad_client).await,
- Os::Windows => test_split_tunnel_windows(ctx, rpc, mullvad_client).await,
- Os::Macos => todo!("MacOS"),
- }
+ let mut checker = ConnChecker::new(rpc.clone(), mullvad_client.clone());
+
+ // Test that program is behaving when we are disconnected
+ (checker.spawn().await?.assert_insecure().await)
+ .with_context(|| "Test disconnected and unsplit")?;
+ checker.split().await?;
+ (checker.spawn().await?.assert_insecure().await)
+ .with_context(|| "Test disconnected and split")?;
+ checker.unsplit().await?;
+
+ // Test that program is behaving being split/unsplit while running and we are disconnected
+ let mut handle = checker.spawn().await?;
+ handle.split().await?;
+ (handle.assert_insecure().await)
+ .with_context(|| "Test disconnected and being split while running")?;
+ handle.unsplit().await?;
+ (handle.assert_insecure().await)
+ .with_context(|| "Test disconnected and being unsplit while running")?;
+ drop(handle);
+
+ helpers::connect_and_wait(&mut mullvad_client).await?;
+
+ // Test running an unsplit program
+ checker
+ .spawn()
+ .await?
+ .assert_secure()
+ .await
+ .with_context(|| "Test connected and unsplit")?;
+
+ // Test running a split program
+ checker.split().await?;
+ checker
+ .spawn()
+ .await?
+ .assert_insecure()
+ .await
+ .with_context(|| "Test connected and split")?;
+
+ checker.unsplit().await?;
+
+ // Test splitting and unsplitting a program while it's running
+ let mut handle = checker.spawn().await?;
+ (handle.assert_secure().await).with_context(|| "Test connected and unsplit (again)")?;
+ handle.split().await?;
+ (handle.assert_insecure().await)
+ .with_context(|| "Test connected and being split while running")?;
+ handle.unsplit().await?;
+ (handle.assert_secure().await)
+ .with_context(|| "Test connected and being unsplit while running")?;
+
+ Ok(())
}
-pub async fn test_split_tunnel_windows(
- _: TestContext,
+/// This helper spawns a seperate process which checks if we are connected to Mullvad, and tries to
+/// leak traffic outside the tunnel by sending TCP, UDP, and ICMP packets to [LEAK_DESTINATION].
+struct ConnChecker {
rpc: ServiceClient,
- mut mullvad_client: MullvadProxyClient,
-) -> anyhow::Result<()> {
- const AM_I_MULLVAD_EXE: &str = "E:\\am-i-mullvad.exe";
+ mullvad_client: MullvadProxyClient,
+
+ /// Path to the process binary.
+ executable_path: String,
+
+ /// Whether the process should be split when spawned. Needed on Linux.
+ split: bool,
+}
+
+struct ConnCheckerHandle<'a> {
+ checker: &'a mut ConnChecker,
+
+ /// ID of the spawned process.
+ pid: u32,
+}
+
+struct ConnectionStatus {
+ /// True if <https://am.i.mullvad.net/> reported we are connected.
+ am_i_mullvad: bool,
+
+ /// True if we sniffed TCP packets going outside the tunnel.
+ leaked_tcp: bool,
+
+ /// True if we sniffed UDP packets going outside the tunnel.
+ leaked_udp: bool,
+
+ /// True if we sniffed ICMP packets going outside the tunnel.
+ leaked_icmp: bool,
+}
+
+impl ConnChecker {
+ pub fn new(rpc: ServiceClient, mullvad_client: MullvadProxyClient) -> Self {
+ let artifacts_dir = &TEST_CONFIG.artifacts_dir;
+ let executable_path = match TEST_CONFIG.os {
+ Os::Linux | Os::Macos => format!("{artifacts_dir}/{CHECKER_FILENAME_UNIX}"),
+ Os::Windows => format!("{artifacts_dir}\\{CHECKER_FILENAME_WINDOWS}"),
+ };
- async fn am_i_mullvad(rpc: &ServiceClient) -> anyhow::Result<bool> {
- parse_am_i_mullvad(rpc.exec(AM_I_MULLVAD_EXE, []).await?)
+ Self {
+ rpc,
+ mullvad_client,
+ split: false,
+ executable_path,
+ }
}
- let mut errored = false;
+ /// Spawn the connecton checker process and return a handle to it.
+ ///
+ /// Dropping the handle will stop the process.
+ /// **NOTE**: The handle must be dropped from a tokio runtime context.
+ pub async fn spawn(&mut self) -> anyhow::Result<ConnCheckerHandle<'_>> {
+ log::debug!("spawning connection checker");
- helpers::disconnect_and_wait(&mut mullvad_client).await?;
+ let opts = SpawnOpts {
+ attach_stdin: true,
+ attach_stdout: true,
+ args: [
+ "--interactive",
+ "--timeout",
+ "10000",
+ // try to leak traffic to LEAK_DESTINATION
+ "--leak",
+ &LEAK_DESTINATION.to_string(),
+ "--leak-timeout",
+ "500",
+ "--leak-tcp",
+ "--leak-udp",
+ "--leak-icmp",
+ ]
+ .map(String::from)
+ .to_vec(),
+ ..SpawnOpts::new(&self.executable_path)
+ };
+
+ let pid = self.rpc.spawn(opts).await?;
- if am_i_mullvad(&rpc).await? {
- log::error!("We should be disconnected, but `{AM_I_MULLVAD_EXE}` reported that it was connected to Mullvad.");
- log::error!("Host machine is probably connected to Mullvad, this will throw off results");
- errored = true
+ if self.split && TEST_CONFIG.os == Os::Linux {
+ self.mullvad_client
+ .add_split_tunnel_process(pid as i32)
+ .await?;
+ }
+
+ Ok(ConnCheckerHandle { pid, checker: self })
}
- helpers::connect_and_wait(&mut mullvad_client).await?;
+ /// Enable split tunneling for the connection checker.
+ pub async fn split(&mut self) -> anyhow::Result<()> {
+ log::debug!("enable split tunnel");
+ self.split = true;
- if !am_i_mullvad(&rpc).await? {
- log::error!(
- "We should be connected, but `{AM_I_MULLVAD_EXE}` reported no connection to Mullvad."
- );
- errored = true
+ match TEST_CONFIG.os {
+ Os::Linux => { /* linux programs can't be split until they are spawned */ }
+ Os::Windows => {
+ self.mullvad_client
+ .add_split_tunnel_app(&self.executable_path)
+ .await?;
+ self.mullvad_client.set_split_tunnel_state(true).await?;
+ }
+ Os::Macos => unimplemented!("MacOS"),
+ }
+
+ Ok(())
}
- mullvad_client
- .add_split_tunnel_app(AM_I_MULLVAD_EXE)
- .await?;
- mullvad_client.set_split_tunnel_state(true).await?;
+ /// Disable split tunneling for the connection checker.
+ pub async fn unsplit(&mut self) -> anyhow::Result<()> {
+ log::debug!("disable split tunnel");
+ self.split = false;
+
+ match TEST_CONFIG.os {
+ Os::Linux => {}
+ Os::Windows => {
+ self.mullvad_client.set_split_tunnel_state(false).await?;
+ self.mullvad_client
+ .remove_split_tunnel_app(&self.executable_path)
+ .await?;
+ }
+ Os::Macos => unimplemented!("MacOS"),
+ }
- if am_i_mullvad(&rpc).await? {
- log::error!(
- "`{AM_I_MULLVAD_EXE}` should have been split, but it reported a connection to Mullvad"
- );
- errored = true
+ Ok(())
}
+}
- helpers::disconnect_and_wait(&mut mullvad_client).await?;
+impl ConnCheckerHandle<'_> {
+ pub async fn split(&mut self) -> anyhow::Result<()> {
+ if TEST_CONFIG.os == Os::Linux {
+ self.checker
+ .mullvad_client
+ .add_split_tunnel_process(self.pid as i32)
+ .await?;
+ }
- if am_i_mullvad(&rpc).await? {
- log::error!(
- "`{AM_I_MULLVAD_EXE}` reported a connection to Mullvad while split and disconnected"
- );
- errored = true
+ self.checker.split().await
}
- mullvad_client.set_split_tunnel_state(false).await?;
- mullvad_client
- .remove_split_tunnel_app(AM_I_MULLVAD_EXE)
- .await?;
+ pub async fn unsplit(&mut self) -> anyhow::Result<()> {
+ if TEST_CONFIG.os == Os::Linux {
+ self.checker
+ .mullvad_client
+ .remove_split_tunnel_process(self.pid as i32)
+ .await?;
+ }
- if errored {
- anyhow::bail!("test_split_tunnel failed, see log output for details.");
+ self.checker.unsplit().await
}
- Ok(())
-}
+ /// Assert that traffic is flowing through the Mullvad tunnel and that no packets are leaked.
+ pub async fn assert_secure(&mut self) -> anyhow::Result<()> {
+ log::info!("checking that connection is secure");
+ let status = self.check_connection().await?;
+ ensure!(status.am_i_mullvad);
+ ensure!(!status.leaked_tcp);
+ ensure!(!status.leaked_udp);
+ ensure!(!status.leaked_icmp);
-pub async fn test_split_tunnel_linux(
- _: TestContext,
- rpc: ServiceClient,
- mut mullvad_client: MullvadProxyClient,
-) -> anyhow::Result<()> {
- const AM_I_MULLVAD_URL: &str = "https://am.i.mullvad.net/connected";
+ Ok(())
+ }
- async fn am_i_mullvad(rpc: &ServiceClient, split_tunnel: bool) -> anyhow::Result<bool> {
- let result = if split_tunnel {
- rpc.exec("mullvad-exclude", ["curl", AM_I_MULLVAD_URL])
- .await?
- } else {
- rpc.exec("curl", [AM_I_MULLVAD_URL]).await?
- };
+ /// Assert that traffic is NOT flowing through the Mullvad tunnel and that packets ARE leaked.
+ pub async fn assert_insecure(&mut self) -> anyhow::Result<()> {
+ log::info!("checking that connection is not secure");
+ let status = self.check_connection().await?;
+ ensure!(!status.am_i_mullvad);
+ ensure!(status.leaked_tcp);
+ ensure!(status.leaked_udp);
+ ensure!(status.leaked_icmp);
- parse_am_i_mullvad(result)
+ Ok(())
}
- let mut errored = false;
+ async fn check_connection(&mut self) -> anyhow::Result<ConnectionStatus> {
+ // Monitor all pakets going to LEAK_DESTINATION during the check.
+ let monitor = start_packet_monitor(
+ |packet| packet.destination.ip() == LEAK_DESTINATION.ip(),
+ MonitorOptions {
+ direction: Some(Direction::In),
+ ..MonitorOptions::default()
+ },
+ )
+ .await;
- helpers::connect_and_wait(&mut mullvad_client).await?;
+ // Write a newline to the connection checker to prompt it to perform the check.
+ self.checker
+ .rpc
+ .write_child_stdin(self.pid, "Say the line, Bart!\r\n".into())
+ .await?;
- if !am_i_mullvad(&rpc, false).await? {
- log::error!("We should be connected, but `am.i.mullvad` reported that it was not connected to Mullvad.");
- errored = true;
- }
+ // The checker responds when the check is complete.
+ let line = self.read_stdout_line().await?;
- if am_i_mullvad(&rpc, true).await? {
- log::error!(
- "`mullvad-exclude curl {AM_I_MULLVAD_URL}` reported that it was connected to Mullvad."
- );
- log::error!("`curl` does not appear to have been split correctly.");
- errored = true;
- }
+ let monitor_result = monitor
+ .into_result()
+ .await
+ .map_err(|_e| anyhow!("Packet monitor unexpectedly stopped"))?;
- helpers::disconnect_and_wait(&mut mullvad_client).await?;
+ Ok(ConnectionStatus {
+ am_i_mullvad: parse_am_i_mullvad(line)?,
- if am_i_mullvad(&rpc, false).await? {
- log::error!("We should be disconnected, but `curl {AM_I_MULLVAD_URL}` reported that it was connected to Mullvad.");
- log::error!("Host machine is probably connected to Mullvad. This may affect test results.");
- errored = true;
- }
+ leaked_tcp: (monitor_result.packets.iter())
+ .any(|pkt| pkt.protocol == IpNextHeaderProtocols::Tcp),
+
+ leaked_udp: (monitor_result.packets.iter())
+ .any(|pkt| pkt.protocol == IpNextHeaderProtocols::Udp),
- if errored {
- anyhow::bail!("test_split_tunnel failed, see log output for details.");
+ leaked_icmp: (monitor_result.packets.iter())
+ .any(|pkt| pkt.protocol == IpNextHeaderProtocols::Icmp),
+ })
}
- Ok(())
+ /// Try to a single line of output from the spawned process
+ async fn read_stdout_line(&mut self) -> anyhow::Result<String> {
+ // Add a timeout to avoid waiting forever.
+ timeout(Duration::from_secs(8), async {
+ let mut line = String::new();
+
+ // tarpc doesn't support streams, so we poll the checker process in a loop instead
+ loop {
+ let Some(output) = self.checker.rpc.read_child_stdout(self.pid).await? else {
+ bail!("got EOF from connection checker process");
+ };
+
+ if output.is_empty() {
+ sleep(Duration::from_millis(500)).await;
+ continue;
+ }
+
+ line.push_str(&output);
+
+ if line.contains('\n') {
+ log::info!("output from child process: {output:?}");
+ return Ok(line);
+ }
+ }
+ })
+ .await
+ .with_context(|| "Timeout reading stdout from connection checker")?
+ }
}
-/// Parse output from am-i-mullvad. Returns true if connected to Mullvad.
-fn parse_am_i_mullvad(result: ExecResult) -> anyhow::Result<bool> {
- let stdout = str::from_utf8(&result.stdout).expect("curl output is UTF-8");
+impl Drop for ConnCheckerHandle<'_> {
+ fn drop(&mut self) {
+ let rpc = self.checker.rpc.clone();
+ let pid = self.pid;
+
+ let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
+ log::error!("ConnCheckerHandle dropped outside of a tokio runtime.");
+ return;
+ };
+
+ runtime_handle.spawn(async move {
+ // Make sure child process is stopped when this handle is dropped.
+ // Closing stdin does the trick.
+ let _ = rpc.close_child_stdin(pid).await;
+ });
+ }
+}
- Ok(if stdout.contains("You are connected") {
+/// Parse output from connection-checker. Returns true if connected to Mullvad.
+fn parse_am_i_mullvad(result: String) -> anyhow::Result<bool> {
+ Ok(if result.contains("You are connected") {
true
- } else if stdout.contains("You are not connected") {
+ } else if result.contains("You are not connected") {
false
} else {
- anyhow::bail!("Unexpected output from am-i-mullvad: {stdout:?}")
+ bail!("Unexpected output from connection-checker: {result:?}")
})
}
diff --git a/test/test-manager/src/tests/test_metadata.rs b/test/test-manager/src/tests/test_metadata.rs
index 3e28a4380b..d4ffa9bfd0 100644
--- a/test/test-manager/src/tests/test_metadata.rs
+++ b/test/test-manager/src/tests/test_metadata.rs
@@ -5,7 +5,7 @@ use test_rpc::mullvad_daemon::MullvadClientVersion;
pub struct TestMetadata {
pub name: &'static str,
pub command: &'static str,
- pub target_os: Option<Os>,
+ pub targets: &'static [Os],
pub mullvad_client_version: MullvadClientVersion,
pub func: TestWrapperFunction,
pub priority: Option<i32>,
@@ -16,9 +16,7 @@ pub struct TestMetadata {
impl TestMetadata {
pub fn should_run_on_os(&self, os: Os) -> bool {
- self.target_os
- .map(|target_os| target_os == os)
- .unwrap_or(true)
+ self.targets.is_empty() || self.targets.contains(&os)
}
}
diff --git a/test/test-manager/src/vm/provision.rs b/test/test-manager/src/vm/provision.rs
index 5f01e8f192..8667b6c133 100644
--- a/test/test-manager/src/vm/provision.rs
+++ b/test/test-manager/src/vm/provision.rs
@@ -106,6 +106,11 @@ fn blocking_ssh(
ssh_send_file_path(&session, &source, temp_dir)
.context("Failed to send test runner to remote")?;
+ // Transfer connection-checker
+ let source = local_runner_dir.join("connection-checker");
+ ssh_send_file_path(&session, &source, temp_dir)
+ .context("Failed to send connection-checker to remote")?;
+
// Transfer app packages
ssh_send_file_path(&session, &local_app_manifest.current_app_path, temp_dir)
.context("Failed to send current app package to remote")?;
diff --git a/test/test-manager/test_macro/src/lib.rs b/test/test-manager/test_macro/src/lib.rs
index fdf7e5539c..7cb8407230 100644
--- a/test/test-manager/test_macro/src/lib.rs
+++ b/test/test-manager/test_macro/src/lib.rs
@@ -121,7 +121,7 @@ fn get_test_macro_parameters(attributes: &syn::AttributeArgs) -> Result<MacroPar
let mut cleanup = true;
let mut always_run = false;
let mut must_succeed = false;
- let mut target_os = None;
+ let mut targets = vec![];
for attribute in attributes {
// we only use name-value attributes
@@ -155,14 +155,16 @@ fn get_test_macro_parameters(attributes: &syn::AttributeArgs) -> Result<MacroPar
bail!(nv, "'target_os' should have a string value");
};
- if target_os.is_some() {
- bail!(nv, "can't specify multiple targets");
- }
-
- target_os = match lit_str.value().parse() {
- Ok(os) => Some(os),
+ let target = match lit_str.value().parse() {
+ Ok(os) => os,
Err(e) => bail!(lit_str, "{e}"),
+ };
+
+ if targets.contains(&target) {
+ bail!(nv, "Duplicate target");
}
+
+ targets.push(target);
} else {
bail!(nv, "unknown attribute");
}
@@ -173,7 +175,7 @@ fn get_test_macro_parameters(attributes: &syn::AttributeArgs) -> Result<MacroPar
cleanup,
always_run,
must_succeed,
- target_os,
+ targets,
})
}
@@ -182,12 +184,14 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream {
Some(priority) => quote! { Some(#priority) },
None => quote! { None },
};
- let target_os = match test_function.macro_parameters.target_os {
- Some(Os::Linux) => quote! { Some(::test_rpc::meta::Os::Linux) },
- Some(Os::Macos) => quote! { Some(::test_rpc::meta::Os::Macos) },
- Some(Os::Windows) => quote! { Some(::test_rpc::meta::Os::Windows) },
- None => quote! { None },
- };
+ let targets: proc_macro2::TokenStream = (test_function.macro_parameters.targets.iter())
+ .map(|&os| match os {
+ Os::Linux => quote! { ::test_rpc::meta::Os::Linux, },
+ Os::Macos => quote! { ::test_rpc::meta::Os::Macos, },
+ Os::Windows => quote! { ::test_rpc::meta::Os::Windows, },
+ })
+ .collect();
+
let should_cleanup = test_function.macro_parameters.cleanup;
let always_run = test_function.macro_parameters.always_run;
let must_succeed = test_function.macro_parameters.must_succeed;
@@ -230,7 +234,7 @@ fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream {
inventory::submit!(crate::tests::test_metadata::TestMetadata {
name: stringify!(#func_name),
command: stringify!(#func_name),
- target_os: #target_os,
+ targets: &[#targets],
mullvad_client_version: #function_mullvad_version,
func: #wrapper_closure,
priority: #test_function_priority,
@@ -252,7 +256,7 @@ struct MacroParameters {
cleanup: bool,
always_run: bool,
must_succeed: bool,
- target_os: Option<Os>,
+ targets: Vec<Os>,
}
enum MullvadClient {
diff --git a/test/test-rpc/src/client.rs b/test/test-rpc/src/client.rs
index b4fb67f5c0..324669de3f 100644
--- a/test/test-rpc/src/client.rs
+++ b/test/test-rpc/src/client.rs
@@ -351,4 +351,26 @@ impl ServiceClient {
.make_device_json_old(tarpc::context::current())
.await?
}
+
+ pub async fn spawn(&self, opts: SpawnOpts) -> Result<u32, Error> {
+ self.client.spawn(tarpc::context::current(), opts).await?
+ }
+
+ pub async fn read_child_stdout(&self, pid: u32) -> Result<Option<String>, Error> {
+ self.client
+ .read_child_stdout(tarpc::context::current(), pid)
+ .await?
+ }
+
+ pub async fn write_child_stdin(&self, pid: u32, data: String) -> Result<(), Error> {
+ self.client
+ .write_child_stdin(tarpc::context::current(), pid, data)
+ .await?
+ }
+
+ pub async fn close_child_stdin(&self, pid: u32) -> Result<(), Error> {
+ self.client
+ .close_child_stdin(tarpc::context::current(), pid)
+ .await?
+ }
}
diff --git a/test/test-rpc/src/lib.rs b/test/test-rpc/src/lib.rs
index d151520601..e0088a67b5 100644
--- a/test/test-rpc/src/lib.rs
+++ b/test/test-rpc/src/lib.rs
@@ -57,6 +57,10 @@ pub enum Error {
Timeout,
#[error("TCP forward error")]
TcpForward,
+ #[error("Unknown process ID: {0}")]
+ UnknownPid(u32),
+ #[error("{0}")]
+ Other(String),
}
/// Response from am.i.mullvad.net
@@ -80,6 +84,27 @@ impl ExecResult {
}
}
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct SpawnOpts {
+ pub path: String,
+ pub args: Vec<String>,
+ pub env: BTreeMap<String, String>,
+ pub attach_stdin: bool,
+ pub attach_stdout: bool,
+}
+
+impl SpawnOpts {
+ pub fn new(path: impl Into<String>) -> SpawnOpts {
+ SpawnOpts {
+ path: path.into(),
+ args: Default::default(),
+ env: Default::default(),
+ attach_stdin: Default::default(),
+ attach_stdout: Default::default(),
+ }
+ }
+}
+
#[derive(Debug, Serialize, Deserialize)]
pub enum AppTrace {
Path(PathBuf),
@@ -197,6 +222,28 @@ mod service {
async fn reboot() -> Result<(), Error>;
async fn make_device_json_old() -> Result<(), Error>;
+
+ /// Spawn a child process and return the PID.
+ async fn spawn(opts: SpawnOpts) -> Result<u32, Error>;
+
+ /// Read from stdout of a process spawned through [Service::spawn].
+ ///
+ /// Process must have been spawned with `attach_stdout`.
+ /// Returns `None` if process stdout is closed.
+ async fn read_child_stdout(pid: u32) -> Result<Option<String>, Error>;
+
+ /// Write to stdin of a process spawned through [Service::spawn].
+ ///
+ /// Process must have been spawned with `attach_stdin`.
+ async fn write_child_stdin(pid: u32, data: String) -> Result<(), Error>;
+
+ /// Close stdin of a process spawned through [Service::spawn].
+ ///
+ /// Process must have been spawned with `attach_stdin`.
+ async fn close_child_stdin(pid: u32) -> Result<(), Error>;
+
+ /// Kill a process spawned through [Service::spawn].
+ async fn kill_child(pid: u32) -> Result<(), Error>;
}
}
diff --git a/test/test-runner/Cargo.toml b/test/test-runner/Cargo.toml
index 8e2ae8cbf6..50f3ddda6a 100644
--- a/test/test-runner/Cargo.toml
+++ b/test/test-runner/Cargo.toml
@@ -33,7 +33,7 @@ test-rpc = { path = "../test-rpc" }
mullvad-paths = { path = "../../mullvad-paths" }
talpid-platform-metadata = { path = "../../talpid-platform-metadata" }
-socket2 = { version = "0.5", features = ["all"] }
+socket2 = { version = "0.5.4", features = ["all"] }
[target."cfg(target_os=\"windows\")".dependencies]
talpid-windows = { path = "../../talpid-windows" }
diff --git a/test/test-runner/src/main.rs b/test/test-runner/src/main.rs
index 3511d78cec..d864968bbe 100644
--- a/test/test-runner/src/main.rs
+++ b/test/test-runner/src/main.rs
@@ -1,10 +1,14 @@
-use futures::{pin_mut, SinkExt, StreamExt};
+use futures::{pin_mut, select, select_biased, FutureExt, SinkExt, StreamExt};
use logging::LOGGER;
use std::{
collections::{BTreeMap, HashMap},
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
+ process::Stdio,
+ sync::Arc,
+ time::Duration,
};
+use util::OnDrop;
use tarpc::{context, server::Channel};
use test_rpc::{
@@ -12,12 +16,14 @@ use test_rpc::{
net::SockHandleId,
package::Package,
transport::GrpcForwarder,
- AppTrace, Service,
+ AppTrace, Service, SpawnOpts,
};
use tokio::{
- io::{AsyncReadExt, AsyncWriteExt},
- process::Command,
- sync::broadcast::error::TryRecvError,
+ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
+ process::{ChildStdin, ChildStdout, Command},
+ sync::{broadcast::error::TryRecvError, oneshot, Mutex},
+ task,
+ time::sleep,
};
use tokio_util::codec::{Decoder, LengthDelimitedCodec};
@@ -27,9 +33,23 @@ mod logging;
mod net;
mod package;
mod sys;
+mod util;
-#[derive(Clone)]
-pub struct TestServer(pub ());
+#[derive(Clone, Default)]
+pub struct TestServer(Arc<Mutex<State>>);
+
+#[derive(Default)]
+struct State {
+ spawned_procs: HashMap<u32, SpawnedProcess>,
+}
+
+struct SpawnedProcess {
+ stdout: Option<ChildStdout>,
+ stdin: Option<ChildStdin>,
+
+ #[allow(dead_code)]
+ abort_handle: OnDrop,
+}
#[tarpc::server]
impl Service for TestServer {
@@ -319,6 +339,192 @@ impl Service for TestServer {
async fn make_device_json_old(self, _: context::Context) -> Result<(), test_rpc::Error> {
app::make_device_json_old().await
}
+
+ async fn spawn(self, _: context::Context, opts: SpawnOpts) -> Result<u32, test_rpc::Error> {
+ let mut cmd = Command::new(&opts.path);
+ cmd.args(&opts.args);
+
+ // Make sure that PATH is updated
+ // TODO: We currently do not need this on non-Windows
+ #[cfg(target_os = "windows")]
+ cmd.env("PATH", sys::get_system_path_var()?);
+
+ cmd.envs(opts.env);
+
+ if opts.attach_stdin {
+ cmd.stdin(Stdio::piped());
+ } else {
+ cmd.stdin(Stdio::null());
+ }
+
+ if opts.attach_stdout {
+ cmd.stdout(Stdio::piped());
+ }
+
+ cmd.stderr(Stdio::piped());
+
+ let mut child = cmd.kill_on_drop(true).spawn().map_err(|error| {
+ log::error!("Failed to spawn {}: {error}", opts.path);
+ test_rpc::Error::Syscall
+ })?;
+
+ let pid = child
+ .id()
+ .expect("Child hasn't been polled to completion yet");
+
+ log::info!("spawned {} (args={:?}) (pid={pid})", opts.path, opts.args);
+
+ let (abort_tx, abort_rx) = oneshot::channel();
+ let abort_handle = || {
+ let _ = abort_tx.send(());
+ };
+
+ let spawned_process = SpawnedProcess {
+ stdout: child.stdout.take(),
+ stdin: child.stdin.take(),
+ abort_handle: OnDrop::new(Box::new(abort_handle)),
+ };
+
+ let mut state = self.0.lock().await;
+ state.spawned_procs.insert(pid, spawned_process);
+ drop(state);
+
+ // spawn a task to log child stdout
+ if let Some(stderr) = child.stderr.take() {
+ task::spawn(async move {
+ let mut stderr = BufReader::new(stderr);
+ let mut line = String::new();
+ loop {
+ match stderr.read_line(&mut line).await {
+ Ok(0) => break,
+ Ok(_) => {
+ let trimmed = line.trim_end_matches(&['\r', '\n']);
+ log::info!("child stderr (pid={pid}): {trimmed}");
+ line.clear();
+ }
+ Err(e) => {
+ log::error!("failed to read child stderr (pid={pid}): {e}");
+ break;
+ }
+ }
+ }
+ });
+ }
+
+ // spawn a task to monitor if the child exits
+ task::spawn(async move {
+ select! {
+ result = child.wait().fuse() => match result {
+ Err(e) => {
+ log::error!("failed to await child process (pid={pid}): {e}");
+ }
+ Ok(status) => {
+ log::info!("child process (pid={pid}) exited with status: {status}");
+ }
+ },
+
+ _ = abort_rx.fuse() => {
+ if let Err(e) = child.kill().await {
+ log::error!("failed to kill child process (pid={pid}): {e}");
+ }
+ }
+ }
+
+ let mut state = self.0.lock().await;
+ state.spawned_procs.remove(&pid);
+ });
+
+ Ok(pid)
+ }
+
+ async fn read_child_stdout(
+ self,
+ _: context::Context,
+ pid: u32,
+ ) -> Result<Option<String>, test_rpc::Error> {
+ let mut state = self.0.lock().await;
+ let child = state
+ .spawned_procs
+ .get_mut(&pid)
+ .ok_or(test_rpc::Error::UnknownPid(pid))?;
+
+ let Some(stdout) = child.stdout.as_mut() else {
+ return Ok(None);
+ };
+
+ let mut buf = vec![0u8; 512];
+
+ let n = select_biased! {
+ result = stdout.read(&mut buf).fuse() => result
+ .map_err(|e| format!("Failed to read from child stdout: {e}"))
+ .map_err(test_rpc::Error::Other)?,
+
+ _ = sleep(Duration::from_millis(500)).fuse() => return Ok(Some(String::new())),
+ };
+
+ // check for EOF
+ if n == 0 {
+ child.stdout = None;
+ return Ok(None);
+ }
+
+ buf.truncate(n);
+ let output = String::from_utf8(buf)
+ .map_err(|_| test_rpc::Error::Other("Child wrote non UTF-8 to stdout".into()))?;
+
+ Ok(Some(output))
+ }
+
+ async fn write_child_stdin(
+ self,
+ _: context::Context,
+ pid: u32,
+ data: String,
+ ) -> Result<(), test_rpc::Error> {
+ let mut state = self.0.lock().await;
+ let child = state
+ .spawned_procs
+ .get_mut(&pid)
+ .ok_or(test_rpc::Error::UnknownPid(pid))?;
+
+ let Some(stdin) = child.stdin.as_mut() else {
+ return Err(test_rpc::Error::Other("Child stdin is closed.".into()));
+ };
+
+ stdin
+ .write_all(data.as_bytes())
+ .await
+ .map_err(|e| format!("Error writing to child stdin: {e}"))
+ .map_err(test_rpc::Error::Other)?;
+
+ log::debug!("wrote {} bytes to pid {pid}", data.len());
+
+ Ok(())
+ }
+
+ async fn close_child_stdin(self, _: context::Context, pid: u32) -> Result<(), test_rpc::Error> {
+ let mut state = self.0.lock().await;
+ let child = state
+ .spawned_procs
+ .get_mut(&pid)
+ .ok_or(test_rpc::Error::UnknownPid(pid))?;
+
+ child.stdin = None;
+
+ Ok(())
+ }
+
+ async fn kill_child(self, _: context::Context, pid: u32) -> Result<(), test_rpc::Error> {
+ let mut state = self.0.lock().await;
+ let child = state
+ .spawned_procs
+ .remove(&pid)
+ .ok_or(test_rpc::Error::UnknownPid(pid))?;
+
+ drop(child); // I swear officer, it's not what you think!
+
+ Ok(())
+ }
}
fn get_pipe_status() -> ServiceStatus {
@@ -364,7 +570,7 @@ async fn main() -> Result<(), Error> {
));
let server = tarpc::server::BaseChannel::with_defaults(runner_transport);
- server.execute(TestServer(()).serve()).await;
+ server.execute(TestServer::default().serve()).await;
log::error!("Restarting server since it stopped");
}
diff --git a/test/test-runner/src/util.rs b/test/test-runner/src/util.rs
new file mode 100644
index 0000000000..03a3343214
--- /dev/null
+++ b/test/test-runner/src/util.rs
@@ -0,0 +1,23 @@
+/// Drop guard that executes the provided callback function when dropped.
+pub struct OnDrop<F = Box<dyn FnOnce() + Send>>
+where
+ F: FnOnce() + Send,
+{
+ callback: Option<F>,
+}
+
+impl<F: FnOnce() + Send> Drop for OnDrop<F> {
+ fn drop(&mut self) {
+ if let Some(callback) = self.callback.take() {
+ callback();
+ }
+ }
+}
+
+impl<F: FnOnce() + Send> OnDrop<F> {
+ pub fn new(callback: F) -> Self {
+ Self {
+ callback: Some(callback),
+ }
+ }
+}