summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/desktop-e2e.yml51
-rw-r--r--talpid-platform-metadata/src/lib.rs2
-rw-r--r--talpid-platform-metadata/src/windows.rs2
-rw-r--r--test/.cargo/config.toml2
-rw-r--r--test/.gitignore6
-rw-r--r--test/BUILD_OS_IMAGE.md237
-rw-r--r--test/Cargo.lock4101
-rw-r--r--test/Cargo.toml42
-rw-r--r--test/Dockerfile6
-rw-r--r--test/README.md156
-rwxr-xr-xtest/build.sh25
-rwxr-xr-xtest/ci-runtests.sh242
-rw-r--r--test/docs/REMOTE_MANAGEMENT.md16
-rw-r--r--test/openvpn.ca.crt121
-rwxr-xr-xtest/scripts/build-runner-image.sh64
-rw-r--r--test/scripts/ssh-setup.sh110
-rw-r--r--test/test-manager/Cargo.toml60
-rw-r--r--test/test-manager/README.me47
-rw-r--r--test/test-manager/build.rs4
-rw-r--r--test/test-manager/src/config.rs229
-rw-r--r--test/test-manager/src/container.rs34
-rw-r--r--test/test-manager/src/logging.rs201
-rw-r--r--test/test-manager/src/main.rs311
-rw-r--r--test/test-manager/src/mullvad_daemon.rs180
-rw-r--r--test/test-manager/src/network_monitor.rs331
-rw-r--r--test/test-manager/src/package.rs158
-rw-r--r--test/test-manager/src/run_tests.rs222
-rw-r--r--test/test-manager/src/summary.rs285
-rw-r--r--test/test-manager/src/tests/account.rs381
-rw-r--r--test/test-manager/src/tests/config.rs47
-rw-r--r--test/test-manager/src/tests/dns.rs698
-rw-r--r--test/test-manager/src/tests/helpers.rs480
-rw-r--r--test/test-manager/src/tests/install.rs357
-rw-r--r--test/test-manager/src/tests/mod.rs162
-rw-r--r--test/test-manager/src/tests/settings.rs211
-rw-r--r--test/test-manager/src/tests/test_metadata.rs16
-rw-r--r--test/test-manager/src/tests/tunnel.rs627
-rw-r--r--test/test-manager/src/tests/tunnel_state.rs355
-rw-r--r--test/test-manager/src/tests/ui.rs139
-rw-r--r--test/test-manager/src/vm/logging.rs9
-rw-r--r--test/test-manager/src/vm/mod.rs87
-rw-r--r--test/test-manager/src/vm/network/linux.rs369
-rw-r--r--test/test-manager/src/vm/network/macos.rs175
-rw-r--r--test/test-manager/src/vm/network/mod.rs17
-rw-r--r--test/test-manager/src/vm/provision.rs207
-rw-r--r--test/test-manager/src/vm/qemu.rs358
-rw-r--r--test/test-manager/src/vm/ssh.rs45
-rw-r--r--test/test-manager/src/vm/tart.rs204
-rw-r--r--test/test-manager/src/vm/update.rs70
-rw-r--r--test/test-manager/src/vm/util.rs42
-rw-r--r--test/test-manager/test_macro/Cargo.toml12
-rw-r--r--test/test-manager/test_macro/src/lib.rs278
-rw-r--r--test/test-rpc/Cargo.toml29
-rw-r--r--test/test-rpc/src/client.rs289
-rw-r--r--test/test-rpc/src/lib.rs179
-rw-r--r--test/test-rpc/src/logging.rs43
-rw-r--r--test/test-rpc/src/meta.rs27
-rw-r--r--test/test-rpc/src/mullvad_daemon.rs34
-rw-r--r--test/test-rpc/src/net.rs65
-rw-r--r--test/test-rpc/src/package.rs46
-rw-r--r--test/test-rpc/src/transport.rs492
-rw-r--r--test/test-runner/Cargo.toml58
-rw-r--r--test/test-runner/src/app.rs144
-rw-r--r--test/test-runner/src/logging.rs111
-rw-r--r--test/test-runner/src/main.rs409
-rw-r--r--test/test-runner/src/net.rs353
-rw-r--r--test/test-runner/src/package.rs292
-rw-r--r--test/test-runner/src/sys.rs531
68 files changed, 15692 insertions, 1 deletions
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
new file mode 100644
index 0000000000..b28e70232d
--- /dev/null
+++ b/.github/workflows/desktop-e2e.yml
@@ -0,0 +1,51 @@
+name: Desktop - End-to-end tests
+on:
+ schedule:
+ - cron: '0 0 * * *'
+ workflow_dispatch:
+jobs:
+ e2e-test-linux:
+ name: Linux end-to-end tests
+ runs-on: [self-hosted, desktop-test, Linux] # app-test-linux
+ timeout-minutes: 240
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [debian11, debian12, ubuntu2004, ubuntu2204, ubuntu2304, fedora38, fedora37, fedora36]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Run end-to-end tests
+ shell: bash -ieo pipefail {0}
+ run: |
+ ./test/ci-runtests.sh ${{ matrix.os }}
+ e2e-test-windows:
+ name: Windows end-to-end tests
+ runs-on: [self-hosted, desktop-test, Linux] # app-test-linux
+ timeout-minutes: 240
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows10, windows11]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Run end-to-end tests
+ shell: bash -ieo pipefail {0}
+ run: |
+ ./test/ci-runtests.sh ${{ matrix.os }}
+ e2e-test-macos:
+ name: macOS end-to-end tests
+ runs-on: [self-hosted, desktop-test, macOS] # app-test-macos-arm
+ timeout-minutes: 240
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-14, macos-13, macos-12]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ - name: Run end-to-end tests
+ shell: bash -ieo pipefail {0}
+ run: |
+ ./test/ci-runtests.sh ${{ matrix.os }}
diff --git a/talpid-platform-metadata/src/lib.rs b/talpid-platform-metadata/src/lib.rs
index f4ba78eaeb..d13dd526cc 100644
--- a/talpid-platform-metadata/src/lib.rs
+++ b/talpid-platform-metadata/src/lib.rs
@@ -14,4 +14,6 @@ mod imp;
#[path = "android.rs"]
mod imp;
+#[cfg(windows)]
+pub use self::imp::WindowsVersion;
pub use self::imp::{extra_metadata, short_version, version};
diff --git a/talpid-platform-metadata/src/windows.rs b/talpid-platform-metadata/src/windows.rs
index e2e6ccbd2b..6dc474227b 100644
--- a/talpid-platform-metadata/src/windows.rs
+++ b/talpid-platform-metadata/src/windows.rs
@@ -37,7 +37,7 @@ pub fn extra_metadata() -> impl Iterator<Item = (String, String)> {
std::iter::empty()
}
-struct WindowsVersion {
+pub struct WindowsVersion {
inner: RTL_OSVERSIONINFOEXW,
}
diff --git a/test/.cargo/config.toml b/test/.cargo/config.toml
new file mode 100644
index 0000000000..599d9caa35
--- /dev/null
+++ b/test/.cargo/config.toml
@@ -0,0 +1,2 @@
+[target.'cfg(target_os = "windows")']
+rustflags = ["-Ctarget-feature=+crt-static"]
diff --git a/test/.gitignore b/test/.gitignore
new file mode 100644
index 0000000000..8d9cb336ec
--- /dev/null
+++ b/test/.gitignore
@@ -0,0 +1,6 @@
+/target
+/packages
+/os-images
+/testrunner-images
+/.ci-logs
+/config.json
diff --git a/test/BUILD_OS_IMAGE.md b/test/BUILD_OS_IMAGE.md
new file mode 100644
index 0000000000..fbe249e656
--- /dev/null
+++ b/test/BUILD_OS_IMAGE.md
@@ -0,0 +1,237 @@
+This document explains how to create base OS images and run test runners on them.
+
+For macOS, the host machine must be macOS. All other platforms assume that the host is Linux.
+
+# Configuring a user in the image
+
+`test-manager` assumes that a dedicated user named `test` (with password `test`) is configured in any guest system which it should control.
+Also, it is strongly recommended that a new image should have passwordless `sudo` set up and `sshd` running on boot,
+since this will greatly simplify the bootstrapping of `test-runner` and all needed binary artifacts (MullvadVPN App, GUI tests ..).
+The legacy method of pre-building a test-runner image is detailed [further down in this document](#).
+
+# Creating a base Linux image
+
+These instructions use Debian, but the process is pretty much the same for any other distribution.
+
+On the host, start by creating a disk image and installing Debian on it:
+
+```
+wget https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-11.5.0-amd64-netinst.iso
+mkdir -p os-images
+qemu-img create -f qcow2 ./os-images/debian.qcow2 5G
+qemu-system-x86_64 -cpu host -accel kvm -m 4096 -smp 2 -cdrom debian-11.5.0-amd64-netinst.iso -drive file=./os-images/debian.qcow2
+```
+
+## Dependencies to install in the image
+
+`xvfb` must be installed on the guest system. You will also need
+`wireguard-tools` and some additional libraries. They are likely already
+installed if gnome is installed.
+
+### Debian/Ubuntu
+
+```bash
+apt install libnss3 libgbm1 libasound2 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 wireguard-tools xvfb
+```
+
+### Fedora
+
+```bash
+dnf install libnss3 libgbm1 libasound2 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 wireguard-tools xorg-x11-server-Xvfb
+```
+
+# Creating a base Windows image
+
+## Windows 10
+
+* Download a Windows 10 ISO: https://www.microsoft.com/software-download/windows10
+
+* On the host, create a new disk image and install Windows on it:
+
+ ```
+ mkdir -p os-images
+ qemu-img create -f qcow2 ./os-images/windows10.qcow2 32G
+ qemu-system-x86_64 -cpu host -accel kvm -m 4096 -smp 2 -cdrom <YOUR ISO HERE> -drive file=./os-images/windows10.qcow2
+ ```
+
+ (For Windows 11, see the notes below.)
+
+## Windows 11
+
+* Download an ISO: https://www.microsoft.com/software-download/windows11
+
+* Create a disk image with at least 64GB of space:
+
+ ```
+ mkdir -p os-images
+ qemu-img create -f qcow2 ./os-images/windows11.qcow2 64G
+ ```
+
+* Windows 11 requires a TPM as well as secure boot to be enabled (and thus UEFI). For TPM, use the
+ emulator SWTPM:
+
+ ```
+ mkdir -p .tpm
+ swtpm socket -t --ctrl type=unixio,path=".tpm/tpmsock" --tpmstate ".tpm" --tpm2 -d
+ ```
+
+* For UEFI, use OVMF, which is available in the `edk2-ovmf` package.
+
+ `OVMF_VARS` is used writeable UEFI variables. Copy it to the root directory:
+
+ ```
+ cp /usr/share/OVMF/OVMF_VARS.secboot.fd .
+ ```
+
+* Launch the VM and install Windows:
+
+ ```
+ qemu-system-x86_64 -cpu host -accel kvm -m 4096 -smp 2 -cdrom <YOUR ISO HERE> -drive file=./os-images/windows11.qcow2 \
+ -tpmdev emulator,id=tpm0,chardev=chrtpm -chardev socket,id=chrtpm,path=".tpm/tpmsock" -device tpm-tis,tpmdev=tpm0 \
+ -global driver=cfi.pflash01,property=secure,value=on \
+ -drive if=pflash,format=raw,unit=0,file=/usr/share/OVMF/OVMF_CODE.secboot.fd,readonly=on \
+ -drive if=pflash,format=raw,unit=1,file=./OVMF_VARS.secboot.fd \
+ -machine q35,smm=on
+ ```
+
+## Notes on local accounts
+
+Logging in on a Microsoft account should not be necessary. A local account is sufficient.
+
+If you are asked to log in and there is no option to create a local account, try to disconnect
+from the network before trying again:
+
+1. Press shift-F10 to open a command prompt.
+1. Type `ipconfig /release` and press enter.
+
+If you are forced to connect to a network during the install, and cannot opt to use a local account,
+do the following:
+
+1. Press shift-F10 to open a command prompt.
+1. Type `oobe\BypassNRO` and press enter.
+
+# Creating a testrunner image (Legacy method)
+
+The [build-runner-image.sh](./scripts/build-runner-image.sh) script produces a
+virtual disk containing the test runner binaries, which must be mounted when
+starting the guest OS. They are used `build-runner-image.sh` assumes that an environment
+variable `$TARGET` is set to one of the following values:
+`x86_64-unknown-linux-gnu`, `x86_64-pc-windows-gnu` depending on which platform
+you want to build a testrunner-image for.
+
+## Bootstrapping test runner (Legacy method)
+
+### Linux
+
+The testing image needs to be mounted to `/opt/testing`, and the test runner needs to be started on
+boot.
+
+* In the guest, create a mount point for the runner: `mkdir -p /opt/testing`.
+
+* Add an entry to `/etc/fstab`:
+
+ ```
+ # Mount testing image
+ /dev/sdb /opt/testing ext4 defaults 0 1
+ ```
+
+* Create a systemd service that starts the test runner, `/etc/systemd/system/testrunner.service`:
+
+ ```
+ [Unit]
+ Description=Mullvad Test Runner
+
+ [Service]
+ ExecStart=/opt/testing/test-runner /dev/ttyS0 serve
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+
+* Enable the service: `systemctl enable testrunner.service`.
+
+### Note about SELinux (Fedora)
+
+SELinux prevents services from executing files that do not have the `bin_t` attribute set. Building
+the test runner image stripts extended file attributes, and `e2tools` does not yet support setting
+these. As a workaround, we currently need to reapply these on each boot.
+
+First, set `bin_t` for all files in `/opt/testing`:
+
+```
+semanage fcontext -a -t bin_t "/opt/testing/.*"
+```
+
+Secondly, update the systemd unit file to run `restorecon` before the `test-runner`, using the
+`ExecStartPre` option:
+
+```
+[Unit]
+Description=Mullvad Test Runner
+
+[Service]
+ExecStartPre=restorecon -v "/opt/testing/*"
+ExecStart=/opt/testing/test-runner /dev/ttyS0 serve
+
+[Install]
+WantedBy=multi-user.target
+```
+
+### Windows
+
+The test runner needs to be started on boot, with the test runner image mounted at `E:`.
+This can be achieved as follows:
+
+* Restart the VM:
+
+ ```
+ qemu-system-x86_64 -cpu host -accel kvm -m 4096 -smp 2 -drive file="./os-images/windows10.qcow2"
+ ```
+
+* In the guest admin `cmd`, add the test runner as a scheduled task:
+
+ ```
+ schtasks /create /tn "Mullvad Test Runner" /sc onlogon /tr "\"E:\test-runner.exe\" \\.\COM1 serve" /rl highest
+ ```
+
+ Further changes might be required to prevent the task from stopping unexpectedly. In the
+ Task Scheduler (`taskschd.msc`), change the following settings for the runner task:
+
+ * Disable "Start the task only if the computer is on AC power".
+ * Disable "Stop task if it runs longer than ...".
+ * Enable "Run task as soon as possible after a scheduled start is missed".
+ * Enable "If the task fails, restart every: 1 minute".
+
+* In the guest, disable Windows Update.
+
+ * Open `services.msc`.
+
+ * Open the properties for `Windows Update`.
+
+ * Set "Startup type" to "Disabled". Also, click "stop".
+
+* In the guest, disable SmartScreen.
+
+ * Go to "Reputation-based protection settings" under
+ Start > Settings > Update & Security > Windows Security > App & browser control.
+
+ * Set "Check apps and files" to off.
+
+* (Windows 11) In the guest, disable Smart App Control
+
+ * Go to "Smart App Control" under
+ Start > Settings > Privacy & security > Windows Security > App & browser control.
+
+ * Set it to off.
+
+* Enable autologon by creating or editing the following registry values (all of type REG_SZ):
+
+ * Set the current user in
+ `HKLM\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DefaultUserName`.
+
+ * Set the password in
+ `HKLM\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\DefaultPassword`.
+
+ * Set `HKLM\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\AutoAdminLogon` to 1.
+
+* Shut down.
diff --git a/test/Cargo.lock b/test/Cargo.lock
new file mode 100644
index 0000000000..1b2e195e9a
--- /dev/null
+++ b/test/Cargo.lock
@@ -0,0 +1,4101 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "CoreFoundation-sys"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0e9889e6db118d49d88d84728d0e964d973a5680befb5f85f55141beea5c20b"
+dependencies = [
+ "libc",
+ "mach 0.1.2",
+]
+
+[[package]]
+name = "IOKit-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99696c398cbaf669d2368076bdb3d627fb0ce51a26899d7c61228c5c0af3bf4a"
+dependencies = [
+ "CoreFoundation-sys",
+ "libc",
+ "mach 0.1.2",
+]
+
+[[package]]
+name = "addr2line"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5d730647d4fadd988536d06fecce94b7b4f2a7efdae548f1cf4b63205518ab"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+dependencies = [
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
+dependencies = [
+ "backtrace",
+]
+
+[[package]]
+name = "arc-swap"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
+
+[[package]]
+name = "arrayref"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
+
+[[package]]
+name = "async-stream"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "async-tempfile"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121280bd2055a6bfbc7ff5a14f700a38b2e127cb8b4066b7ef7320421600dff0"
+dependencies = [
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "axum"
+version = "0.6.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "bitflags 1.3.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "sync_wrapper",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "mime",
+ "rustversion",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "backtrace"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
+
+[[package]]
+name = "base64ct"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
+
+[[package]]
+name = "blake3"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0231f06152bf547e9c2b5194f247cd97aacf6dcd8b15d8e5ec0663f64580da87"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
+
+[[package]]
+name = "byte_string"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed"
+
+[[package]]
+name = "bytes"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
+
+[[package]]
+name = "cc"
+version = "1.0.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "clap"
+version = "4.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d04704f56c2cde07f43e8e2c154b43f216dc5c92fc98ada720177362f953b956"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e231faeaca65ebd1ea3c737966bf858971cd38c3849107aa3ea7de90a804e45"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "colored"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6"
+dependencies = [
+ "is-terminal",
+ "lazy_static",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "typenum",
+]
+
+[[package]]
+name = "ctor"
+version = "0.1.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096"
+dependencies = [
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89b8c6a2e4b1f45971ad09761aafb85514a84744b67a95e32c3cc1352d1f65c"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "fiat-crypto",
+ "platforms",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fdaf97f4804dcebfa5862639bc9ce4121e82140bec2a987ac5140294865b5b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308"
+
+[[package]]
+name = "data-encoding-macro"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c904b33cc60130e1aeea4956ab803d08a3f4a0ca82d64ed757afac3891f2bb99"
+dependencies = [
+ "data-encoding",
+ "data-encoding-macro-internal",
+]
+
+[[package]]
+name = "data-encoding-macro-internal"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fdf3fce3ce863539ec1d7fd1b6dcc3c645663376b43ed376bbf887733e4f772"
+dependencies = [
+ "data-encoding",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "winapi",
+]
+
+[[package]]
+name = "der"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.14.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c"
+dependencies = [
+ "der",
+ "elliptic-curve",
+ "signature",
+]
+
+[[package]]
+name = "ed25519"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7"
+dependencies = [
+ "signature",
+]
+
+[[package]]
+name = "educe"
+version = "0.4.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f"
+dependencies = [
+ "enum-ordinalize",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "der",
+ "digest",
+ "generic-array",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "enum-ordinalize"
+version = "3.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "err-derive"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34a887c8df3ed90498c1c437ce21f211c8e27672921a8ffa293cb8d6d4caa9e"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 1.0.109",
+ "synstructure",
+]
+
+[[package]]
+name = "errno"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "add4f07d43996f76ef320709726a556a9d4f965d9410d8d0271132d2f8293480"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "errno-dragonfly"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0870c84016d4b481be5c9f323c24f65e31e901ae618f0e80f4308fb00de1d2d"
+
+[[package]]
+name = "filetime"
+version = "0.2.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.3.5",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65"
+
+[[package]]
+name = "futures-util"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "ghash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "ghost"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba330b70a5341d3bc730b8e205aaee97ddab5d9c448c4f51a7c2d924266fa8f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "gimli"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
+
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
+[[package]]
+name = "h2"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap 1.9.3",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
+
+[[package]]
+name = "hkdf"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb"
+dependencies = [
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "hostname"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
+dependencies = [
+ "libc",
+ "match_cfg",
+ "winapi",
+]
+
+[[package]]
+name = "http"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
+dependencies = [
+ "bytes",
+ "http",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "hyper"
+version = "0.14.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.4.9",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97"
+dependencies = [
+ "futures-util",
+ "http",
+ "hyper",
+ "log",
+ "rustls",
+ "rustls-native-certs",
+ "tokio",
+ "tokio-rustls",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-timeout"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1"
+dependencies = [
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tokio-io-timeout",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "idna"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.14.1",
+]
+
+[[package]]
+name = "inotify"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
+dependencies = [
+ "bitflags 1.3.2",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "inventory"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0eb5160c60ba1e809707918ee329adb99d222888155835c6feedba19f6c3fd4"
+dependencies = [
+ "ctor",
+ "ghost",
+ "inventory-impl",
+]
+
+[[package]]
+name = "inventory-impl"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e41b53715c6f0c4be49510bb82dee2c1e51c8586d885abe65396e82ed518548"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "ioctl-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c429fffa658f288669529fc26565f728489a2e39bc7b24a428aaaf51355182e"
+
+[[package]]
+name = "ipconfig"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
+dependencies = [
+ "socket2 0.5.4",
+ "widestring",
+ "windows-sys 0.48.0",
+ "winreg",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
+
+[[package]]
+name = "ipnetwork"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8eca9f51da27bc908ef3dd85c21e1bbba794edaf94d7841e37356275b82d31e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "ipnetwork"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
+dependencies = [
+ "hermit-abi",
+ "rustix",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+
+[[package]]
+name = "jni"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "jnix"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aecfa741840d59de75e6e9e2985ee44b6794cbc0b2074899089be6bf7ffa0afc"
+dependencies = [
+ "jni",
+ "jnix-macros",
+ "once_cell",
+ "parking_lot 0.12.1",
+]
+
+[[package]]
+name = "jnix-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "002f4dfe6d97ae88c33f3489c0d31ffc6f81d9a492de98ff113b127d73bafff8"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.148"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "351a32417a12d5f7e82c368a66781e307834dae04c6ce0cd4456d52989229883"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "line-wrap"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
+dependencies = [
+ "safemem",
+]
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3852614a3bd9ca9804678ba6be5e3b8ce76dfc902cae004e3e0c44051b6e88db"
+
+[[package]]
+name = "lock_api"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+
+[[package]]
+name = "lru-cache"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "mach"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd13ee2dd61cc82833ba05ade5a30bb3d63f7ced605ef827063c63078302de9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "mach"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "match_cfg"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
+
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
+
+[[package]]
+name = "memoffset"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mio-serial"
+version = "5.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20a4c60ca5c9c0e114b3bd66ff4aa5f9b2b175442be51ca6c4365d687a97a2ac"
+dependencies = [
+ "log",
+ "mio",
+ "nix 0.26.4",
+ "serialport",
+ "winapi",
+]
+
+[[package]]
+name = "mullvad-api"
+version = "0.0.0"
+dependencies = [
+ "chrono",
+ "err-derive",
+ "futures",
+ "http",
+ "hyper",
+ "ipnetwork 0.16.0",
+ "log",
+ "mullvad-fs",
+ "mullvad-types",
+ "once_cell",
+ "rustls-pemfile 1.0.3",
+ "serde",
+ "serde_json",
+ "shadowsocks",
+ "talpid-time",
+ "talpid-types",
+ "tokio",
+ "tokio-rustls",
+ "tokio-socks",
+]
+
+[[package]]
+name = "mullvad-fs"
+version = "0.0.0"
+dependencies = [
+ "log",
+ "talpid-types",
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "mullvad-management-interface"
+version = "0.0.0"
+dependencies = [
+ "chrono",
+ "err-derive",
+ "futures",
+ "log",
+ "mullvad-paths",
+ "mullvad-types",
+ "nix 0.23.2",
+ "once_cell",
+ "parity-tokio-ipc",
+ "prost",
+ "prost-types",
+ "talpid-types",
+ "tokio",
+ "tonic",
+ "tonic-build",
+ "tower",
+]
+
+[[package]]
+name = "mullvad-paths"
+version = "0.0.0"
+dependencies = [
+ "err-derive",
+ "log",
+ "once_cell",
+ "widestring",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "mullvad-types"
+version = "0.0.0"
+dependencies = [
+ "chrono",
+ "err-derive",
+ "ipnetwork 0.16.0",
+ "jnix",
+ "log",
+ "once_cell",
+ "regex",
+ "serde",
+ "talpid-types",
+ "uuid",
+]
+
+[[package]]
+name = "multimap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
+
+[[package]]
+name = "nix"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c"
+dependencies = [
+ "bitflags 1.3.2",
+ "cc",
+ "cfg-if",
+ "libc",
+ "memoffset 0.6.5",
+]
+
+[[package]]
+name = "nix"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.6.5",
+ "pin-utils",
+]
+
+[[package]]
+name = "nix"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+ "memoffset 0.7.1",
+ "pin-utils",
+]
+
+[[package]]
+name = "no-std-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
+
+[[package]]
+name = "notify"
+version = "6.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
+dependencies = [
+ "bitflags 2.4.0",
+ "crossbeam-channel",
+ "filetime",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "walkdir",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
+dependencies = [
+ "autocfg",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.93"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "opentelemetry"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6105e89802af13fdf48c49d7646d3b533a70e536d818aae7e78ba0433d01acb8"
+dependencies = [
+ "async-trait",
+ "crossbeam-channel",
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "js-sys",
+ "lazy_static",
+ "percent-encoding",
+ "pin-project",
+ "rand 0.8.5",
+ "thiserror",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "p256"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+]
+
+[[package]]
+name = "p384"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc8c5bf642dde52bb9e87c0ecd8ca5a76faac2eeed98dedb7c717997e1080aa"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+]
+
+[[package]]
+name = "parity-tokio-ipc"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9981e32fb75e004cc148f5fb70342f393830e0a4aa62e3cc93b50976218d42b6"
+dependencies = [
+ "futures",
+ "libc",
+ "log",
+ "rand 0.7.3",
+ "tokio",
+ "winapi",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core 0.8.6",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core 0.9.8",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc"
+dependencies = [
+ "cfg-if",
+ "instant",
+ "libc",
+ "redox_syscall 0.2.16",
+ "smallvec",
+ "winapi",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.3.5",
+ "smallvec",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "pcap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2da544c8115cc65b474554569c7654fc94a4f6a167f79192536e148fd654e17a"
+dependencies = [
+ "bitflags 1.3.2",
+ "errno 0.2.8",
+ "futures",
+ "libc",
+ "libloading",
+ "regex",
+ "tokio",
+ "windows-sys 0.36.1",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
+
+[[package]]
+name = "petgraph"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.0.2",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkcs8"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964"
+
+[[package]]
+name = "platforms"
+version = "3.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4503fa043bf02cee09a9582e9554b4c6403b2ef55e4612e96561d294419429f8"
+
+[[package]]
+name = "plist"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdc0001cfea3db57a2e24bc0d818e9e20e554b5f97fabb9bc231dc240269ae06"
+dependencies = [
+ "base64 0.21.4",
+ "indexmap 1.9.3",
+ "line-wrap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "pnet_base"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d3a993d49e5fd5d4d854d6999d4addca1f72d86c65adf224a36757161c02b6"
+dependencies = [
+ "no-std-net",
+]
+
+[[package]]
+name = "pnet_macros"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48dd52a5211fac27e7acb14cfc9f30ae16ae0e956b7b779c8214c74559cef4c3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "regex",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "pnet_macros_support"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89de095dc7739349559913aed1ef6a11e73ceade4897dadc77c5e09de6740750"
+dependencies = [
+ "pnet_base",
+]
+
+[[package]]
+name = "pnet_packet"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc3b5111e697c39c8b9795b9fdccbc301ab696699e88b9ea5a4e4628978f495f"
+dependencies = [
+ "glob",
+ "pnet_base",
+ "pnet_macros",
+ "pnet_macros_support",
+]
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "polyval"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d433d9f1a3e8c1263d9456598b16fec66f4acc9a74dacffd35c7bb09b3a1328"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "prost"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4fdd22f3b9c31b53c060df4a0613a1c7f062d4115a2b984dd15b1858f7e340d"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bdf592881d821b83d471f8af290226c8d51402259e9bb5be7f9f8bdebbb11ac"
+dependencies = [
+ "bytes",
+ "heck",
+ "itertools 0.11.0",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn 2.0.37",
+ "tempfile",
+ "which",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "265baba7fabd416cf5078179f7d2cbeca4ce7a9041111900675ea7c4cb8a4c32"
+dependencies = [
+ "anyhow",
+ "itertools 0.11.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e081b29f63d83a4bc75cfc9f3fe424f9156cf92d8a4f0c9407cce9a1b67327cf"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[package]]
+name = "quick-xml"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81b9228215d82c7b61490fec1de287136b5de6f5700f6e58ea9ad61a7964ca51"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.16",
+ "libc",
+ "rand_chacha 0.2.2",
+ "rand_core 0.5.1",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.10",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom 0.2.10",
+ "redox_syscall 0.2.16",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
+
+[[package]]
+name = "resolv-conf"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00"
+dependencies = [
+ "hostname",
+ "quick-error",
+]
+
+[[package]]
+name = "ring"
+version = "0.16.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
+dependencies = [
+ "cc",
+ "libc",
+ "once_cell",
+ "spin 0.5.2",
+ "untrusted",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
+name = "ring-compat"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "333b9bf6765e0141324d95b5375bb1aa5267865bb4bc0281c22aff22f5d37746"
+dependencies = [
+ "aead",
+ "digest",
+ "ecdsa",
+ "ed25519",
+ "generic-array",
+ "opaque-debug",
+ "p256",
+ "p384",
+ "pkcs8",
+ "ring",
+ "signature",
+]
+
+[[package]]
+name = "rs-release"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21efba391745f92fc14a5cccb008e711a1a3708d8dacd2e69d88d5de513c117a"
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f9da0cbd88f9f09e7814e388301c8414c51c62aa6ce1e4b5c551d49d96e531"
+dependencies = [
+ "bitflags 2.4.0",
+ "errno 0.3.4",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.6",
+ "sct",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
+dependencies = [
+ "openssl-probe",
+ "rustls-pemfile 1.0.3",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9"
+dependencies = [
+ "base64 0.13.1",
+]
+
+[[package]]
+name = "rustls-pemfile"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2"
+dependencies = [
+ "base64 0.21.4",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.100.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.101.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
+[[package]]
+name = "safemem"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
+dependencies = [
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sct"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "sec1"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad977052201c6de01a8ef2aa3378c4bd23217a056337d1d6da40468d267a4fb0"
+
+[[package]]
+name = "sendfd"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604b71b8fc267e13bb3023a2c901126c8f349393666a6d98ac1ae5729b701798"
+dependencies = [
+ "libc",
+ "tokio",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serialport"
+version = "4.2.0"
+source = "git+https://github.com/mullvad/serialport-rs?rev=1401c9d39e4a89685e3506a7160869b2c8e9ceb0#1401c9d39e4a89685e3506a7160869b2c8e9ceb0"
+dependencies = [
+ "CoreFoundation-sys",
+ "IOKit-sys",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "mach 0.3.2",
+ "nix 0.24.3",
+ "regex",
+ "winapi",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shadowsocks"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5d4aadc3b1b38e760533d4060a1aa53a2d754f073389f5aafe6bf7b579c4f97"
+dependencies = [
+ "arc-swap",
+ "async-trait",
+ "base64 0.21.4",
+ "blake3",
+ "byte_string",
+ "bytes",
+ "cfg-if",
+ "futures",
+ "libc",
+ "log",
+ "notify",
+ "once_cell",
+ "percent-encoding",
+ "pin-project",
+ "sendfd",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "shadowsocks-crypto",
+ "socket2 0.5.4",
+ "spin 0.9.8",
+ "thiserror",
+ "tokio",
+ "tokio-tfo",
+ "trust-dns-resolver",
+ "url",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "shadowsocks-crypto"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfb488687e398030dd9c9396e119ddbc6952bdeaefe2168943b5b2ddaa54f2e6"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "cfg-if",
+ "chacha20",
+ "chacha20poly1305",
+ "ctr",
+ "hkdf",
+ "md-5",
+ "rand 0.8.5",
+ "ring-compat",
+ "sha1",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1b21f559e07218024e7e9f90f96f601825397de0e25420135f7f952453fed0b"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "1.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a"
+
+[[package]]
+name = "socket2"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
+dependencies = [
+ "libc",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "ssh2"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7fe461910559f6d5604c3731d00d2aafc4a83d1665922e280f42f9a168d5455"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "libssh2-sys",
+ "parking_lot 0.11.2",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "subtle"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7303ef2c05cd654186cb250d29049a24840ca25d2747c25c0381c8d9e2f582e8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
+[[package]]
+name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "unicode-xid",
+]
+
+[[package]]
+name = "talpid-dbus"
+version = "0.0.0"
+dependencies = [
+ "dbus",
+ "err-derive",
+ "libc",
+ "log",
+ "once_cell",
+ "tokio",
+]
+
+[[package]]
+name = "talpid-platform-metadata"
+version = "0.0.0"
+dependencies = [
+ "rs-release",
+ "talpid-dbus",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "talpid-time"
+version = "0.0.0"
+dependencies = [
+ "libc",
+ "tokio",
+]
+
+[[package]]
+name = "talpid-types"
+version = "0.0.0"
+dependencies = [
+ "base64 0.13.1",
+ "err-derive",
+ "ipnetwork 0.16.0",
+ "jnix",
+ "serde",
+ "x25519-dalek",
+ "zeroize",
+]
+
+[[package]]
+name = "talpid-windows-net"
+version = "0.0.0"
+dependencies = [
+ "err-derive",
+ "futures",
+ "socket2 0.5.4",
+ "talpid-types",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "tarpc"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dd84a0fdd485d04b67be6009a04603489c8cb00ade830e4dd2e3660bef855b1"
+dependencies = [
+ "anyhow",
+ "fnv",
+ "futures",
+ "humantime",
+ "opentelemetry",
+ "pin-project",
+ "rand 0.8.5",
+ "serde",
+ "static_assertions",
+ "tarpc-plugins",
+ "thiserror",
+ "tokio",
+ "tokio-serde",
+ "tokio-util",
+ "tracing",
+ "tracing-opentelemetry",
+]
+
+[[package]]
+name = "tarpc-plugins"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee42b4e559f17bce0385ebf511a7beb67d5cc33c12c96b7f4e9789919d9c10f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "redox_syscall 0.3.5",
+ "rustix",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "test-manager"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-tempfile",
+ "async-trait",
+ "bytes",
+ "chrono",
+ "clap",
+ "colored",
+ "data-encoding-macro",
+ "dirs",
+ "env_logger",
+ "err-derive",
+ "futures",
+ "inventory",
+ "ipnetwork 0.20.0",
+ "itertools 0.10.5",
+ "libc",
+ "log",
+ "mullvad-api",
+ "mullvad-management-interface",
+ "mullvad-types",
+ "nix 0.25.1",
+ "once_cell",
+ "pcap",
+ "pnet_packet",
+ "regex",
+ "serde",
+ "serde_json",
+ "ssh2",
+ "talpid-types",
+ "tarpc",
+ "test-rpc",
+ "test_macro",
+ "tokio",
+ "tokio-serde",
+ "tokio-serial",
+ "tokio-util",
+ "tonic",
+ "tower",
+ "tun",
+ "uuid",
+]
+
+[[package]]
+name = "test-rpc"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "colored",
+ "err-derive",
+ "futures",
+ "hyper",
+ "hyper-rustls",
+ "log",
+ "once_cell",
+ "rustls-pemfile 0.2.1",
+ "serde",
+ "serde_json",
+ "tarpc",
+ "tokio",
+ "tokio-rustls",
+ "tokio-serde",
+ "tokio-util",
+]
+
+[[package]]
+name = "test-runner"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "chrono",
+ "err-derive",
+ "futures",
+ "libc",
+ "log",
+ "mullvad-paths",
+ "nix 0.25.1",
+ "once_cell",
+ "parity-tokio-ipc",
+ "plist",
+ "rs-release",
+ "serde",
+ "serde_json",
+ "socket2 0.5.4",
+ "talpid-platform-metadata",
+ "talpid-windows-net",
+ "tarpc",
+ "test-rpc",
+ "tokio",
+ "tokio-serde",
+ "tokio-serial",
+ "tokio-util",
+ "windows-service",
+ "windows-sys 0.45.0",
+ "winreg",
+]
+
+[[package]]
+name = "test_macro"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "time"
+version = "0.3.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe"
+dependencies = [
+ "deranged",
+ "itoa",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20"
+dependencies = [
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.32.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot 0.12.1",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.5.4",
+ "tokio-macros",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "tokio-io-timeout"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-serde"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "911a61637386b789af998ee23f50aa30d5fd7edcec8d6d3dedae5e5815205466"
+dependencies = [
+ "bytes",
+ "educe",
+ "futures-core",
+ "futures-sink",
+ "pin-project",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "tokio-serial"
+version = "5.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa6e2e4cf0520a99c5f87d5abb24172b5bd220de57c3181baaaa5440540c64aa"
+dependencies = [
+ "cfg-if",
+ "futures",
+ "log",
+ "mio-serial",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-socks"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51165dfa029d2a65969413a6cc96f354b86b464498702f174a4efa13608fd8c0"
+dependencies = [
+ "either",
+ "futures-util",
+ "thiserror",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-tfo"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30b433f102de6c9b0546dc73398ba3d38d8a556f29f731268451e0b1b3aab9e"
+dependencies = [
+ "cfg-if",
+ "futures",
+ "libc",
+ "log",
+ "once_cell",
+ "pin-project",
+ "socket2 0.5.4",
+ "tokio",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d68074620f57a0b21594d9735eb2e98ab38b17f80d3fcb189fca266771ca60d"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "slab",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "tonic"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "axum",
+ "base64 0.21.4",
+ "bytes",
+ "h2",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-timeout",
+ "percent-encoding",
+ "pin-project",
+ "prost",
+ "tokio",
+ "tokio-stream",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-build"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d021fc044c18582b9a2408cd0dd05b1596e3ecdb5c4df822bb0183545683889"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "prost-build",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 1.9.3",
+ "pin-project",
+ "pin-project-lite",
+ "rand 0.8.5",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
+dependencies = [
+ "cfg-if",
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-opentelemetry"
+version = "0.17.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbbe89715c1dbbb790059e2565353978564924ee85017b5fff365c872ff6721f"
+dependencies = [
+ "once_cell",
+ "opentelemetry",
+ "tracing",
+ "tracing-core",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
+dependencies = [
+ "sharded-slab",
+ "thread_local",
+ "tracing-core",
+]
+
+[[package]]
+name = "trust-dns-proto"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc775440033cb114085f6f2437682b194fa7546466024b1037e82a48a052a69"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "once_cell",
+ "rand 0.8.5",
+ "smallvec",
+ "thiserror",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "trust-dns-resolver"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff7aed33ef3e8bf2c9966fccdfed93f93d46f432282ea875cd66faabc6ef2f"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "ipconfig",
+ "lru-cache",
+ "once_cell",
+ "parking_lot 0.12.1",
+ "rand 0.8.5",
+ "resolv-conf",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "trust-dns-proto",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
+
+[[package]]
+name = "tun"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbc25e23adc6cac7dd895ce2780f255902290fc39b00e1ae3c33e89f3d20fa66"
+dependencies = [
+ "ioctl-sys",
+ "libc",
+ "thiserror",
+]
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "url"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "uuid"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
+dependencies = [
+ "getrandom 0.2.10",
+ "serde",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
+
+[[package]]
+name = "web-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338"
+dependencies = [
+ "rustls-webpki 0.100.3",
+]
+
+[[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix",
+]
+
+[[package]]
+name = "widestring"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-service"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd9db37ecb5b13762d95468a2fc6009d4b2c62801243223aabd44fca13ad13c8"
+dependencies = [
+ "bitflags 1.3.2",
+ "widestring",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2"
+dependencies = [
+ "windows_aarch64_msvc 0.36.1",
+ "windows_i686_gnu 0.36.1",
+ "windows_i686_msvc 0.36.1",
+ "windows_x86_64_gnu 0.36.1",
+ "windows_x86_64_msvc 0.36.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "x25519-dalek"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb66477291e7e8d2b0ff1bcb900bf29489a9692816d79874bea351e7a8b6de96"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.6.4",
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.37",
+]
diff --git a/test/Cargo.toml b/test/Cargo.toml
new file mode 100644
index 0000000000..6eac149434
--- /dev/null
+++ b/test/Cargo.toml
@@ -0,0 +1,42 @@
+[workspace]
+resolver = "2"
+members = [
+ "test-manager",
+ "test-runner",
+ "test-rpc",
+]
+
+[patch.crates-io]
+serialport = { git = "https://github.com/mullvad/serialport-rs", rev = "1401c9d39e4a89685e3506a7160869b2c8e9ceb0" }
+
+[workspace.dependencies]
+futures = "0.3"
+tokio = { version = "1.8", features = ["macros", "rt", "process", "time", "fs", "io-util", "rt-multi-thread"] }
+tokio-serial = "5.4.1"
+# Serde and related crates
+serde = "1.0"
+serde_json = "1.0"
+tokio-serde = { version = "0.8.0", features = ["json"] }
+# Tonic and related crates
+tonic = "0.10.0"
+tonic-build = { version = "0.10.0", default-features = false }
+tower = "0.4"
+prost = "0.12.0"
+prost-types = "0.12.0"
+tarpc = { version = "0.30", features = ["tokio1", "serde-transport", "serde1"] }
+# Logging
+env_logger = "0.10.0"
+err-derive = "0.3.1"
+log = "0.4"
+colored = "2.0.0"
+# Proxy protocols
+shadowsocks = { version = "1.16" }
+shadowsocks-service = { version = "1.16" }
+
+windows-sys = "0.48.0"
+
+chrono = { version = "0.4.26", default-features = false}
+clap = { version = "4.2.7", features = ["cargo", "derive"] }
+once_cell = "1.16.0"
+bytes = "1.3.0"
+async-trait = "0.1.58"
diff --git a/test/Dockerfile b/test/Dockerfile
new file mode 100644
index 0000000000..59aa5bb776
--- /dev/null
+++ b/test/Dockerfile
@@ -0,0 +1,6 @@
+FROM debian:stable
+
+RUN apt-get update && apt-get install -y \
+ gcc curl libdbus-1-dev protobuf-compiler
+RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
+ENV PATH="/root/.cargo/bin:${PATH}"
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000000..c39785e9a9
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,156 @@
+# Project structure
+
+## test-manager
+
+The client part of the testing environment. This program runs on the host and connects over a
+virtual serial port to the `test-runner`.
+
+The tests themselves are defined in this package, using the interface provided by `test-runner`.
+
+## test-runner
+
+The server part of the testing environment. This program runs in guest VMs and provides the
+`test-manager` with the building blocks (RPCs) needed to create tests.
+
+## test-rpc
+
+A support library for the other two packages. Defines an RPC interface, transports, shared types,
+etc.
+
+# Prerequisities
+
+For macOS, the host machine must be macOS. All other platforms assume that the host is Linux.
+
+## All platforms
+
+* Get the latest stable Rust from https://rustup.rs/.
+
+## macOS
+
+Normally, you would use Tart here. It can be installed with Homebrew. You'll also need
+`wireguard-tools`, a protobuf compiler, and OpenSSL:
+
+```bash
+brew install cirruslabs/cli/tart wireguard-tools pkg-config openssl protobuf
+```
+
+### Wireshark
+
+Wireshark is also required. More specifically, you'll need `wireshark-chmodbpf`, which can be found
+in the Wireshark installer here: https://www.wireshark.org/download.html
+
+You also need to add the current user to the `access_bpf` group:
+
+```bash
+dseditgroup -o edit -a THISUSER -t user access_bpf
+```
+
+This lets us monitor traffic on network interfaces without root access.
+
+## Linux
+
+For running tests on Linux and Windows guests, you will need these tools and libraries:
+
+```bash
+dnf install git gcc protobuf-devel libpcap-devel qemu \
+ podman e2tools mingw64-gcc mingw64-winpthreads-static mtools \
+ golang-github-rootless-containers-rootlesskit slirp4netns dnsmasq \
+ dbus-devel pkgconf-pkg-config swtpm edk2-ovmf \
+ wireguard-tools
+
+rustup target add x86_64-pc-windows-gnu
+```
+
+# Building the test runner
+
+Building the `test-runner` binary is done with the `build.sh` script.
+Currently, only `x86_64` platforms are supported for Windows/Linux and `ARM64` (Apple Silicon) for macOS.
+
+The `build.sh` requires the `$TARGET` environment variable to be set.
+For example, building `test-runner` for Linux would look like this:
+
+``` bash
+TARGET=x86_64-unknown-linux-gnu ./build.sh
+```
+
+## Linux
+For a Linux target `podman` is required to build the `test-runner`. See the [Linux section under Prerequisities](#Prerequisities) for more details.
+
+``` bash
+TARGET=x86_64-unknown-linux-gnu ./build.sh
+```
+
+## macOS
+
+``` bash
+TARGET=aarch64-apple-darwin ./build.sh
+```
+
+## Windows
+The `test-runner` binary for Windows may be cross-compiled from a Linux host.
+
+``` bash
+TARGET=x86_64-pc-windows-gnu ./build.sh
+```
+
+# Building base images
+
+See [`BUILD_OS_IMAGE.md`](./BUILD_OS_IMAGE.md) for how to build images for running tests on.
+
+# Running tests
+
+See `cargo run --bin test-manager` for details.
+
+## Linux
+
+Here is an example of how to create a new OS configuration and then run all tests:
+
+```bash
+# Create or edit configuration
+# The image is assumed to contain a test runner service set up as described in ./BUILD_OS_IMAGE.md
+cargo run --bin test-manager set debian11 qemu ./os-images/debian11.qcow2 linux \
+ --package-type deb --architecture x64 \
+ --artifacts-dir /opt/testing \
+ --disks ./testrunner-images/linux-test-runner.img
+
+# Try it out to see if it works
+cargo run --bin test-manager run-vm debian11
+
+# Run all tests
+cargo run --bin test-manager run-tests debian11 \
+ --display \
+ --account 0123456789 \
+ --current-app <git hash or tag> \
+ --previous-app 2023.2
+```
+
+## macOS
+
+Here is an example of how to create a new OS configuration (on Apple Silicon) and then run all
+tests:
+
+```bash
+# Download some VM image
+tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
+
+# Create or edit configuration
+# Use SSH to deploy the test runner since the image doesn't contain a runner
+cargo run --bin test-manager set macos-ventura tart ventura-base macos \
+ --architecture aarch64 \
+ --provisioner ssh --ssh-user admin --ssh-password admin
+
+# Try it out to see if it works
+#cargo run -p test-manager run-vm macos-ventura
+
+# Run all tests
+cargo run --bin test-manager run-tests macos-ventura \
+ --display \
+ --account 0123456789 \
+ --current-app <git hash or tag> \
+ --previous-app 2023.2
+```
+
+## Note on `ci-runtests.sh`
+
+Account tokens are read (newline-delimited) from the path specified by the environment variable
+`ACCOUNT_TOKENS`. Round robin is used to select an account for each VM.
diff --git a/test/build.sh b/test/build.sh
new file mode 100755
index 0000000000..8832d931ab
--- /dev/null
+++ b/test/build.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+if [[ $TARGET == x86_64-unknown-linux-gnu ]]; then
+ mkdir -p .container/cargo-registry
+ podman build -t mullvadvpn-app-tests .
+
+ podman run --rm -it \
+ -v "${SCRIPT_DIR}/.container/cargo-registry":/root/.cargo/registry \
+ -v "${SCRIPT_DIR}/..":/src:Z \
+ -e CARGO_HOME=/root/.cargo/registry \
+ mullvadvpn-app-tests \
+ /bin/bash -c "cd /src/test/; cargo build --bin test-runner --release --target ${TARGET}"
+else
+ cargo build --bin test-runner --release --target "${TARGET}"
+fi
+
+# Don't build a runner image for macOS.
+if [[ $TARGET != aarch64-apple-darwin ]]; then
+ ./scripts/build-runner-image.sh
+fi
diff --git a/test/ci-runtests.sh b/test/ci-runtests.sh
new file mode 100755
index 0000000000..95e8340309
--- /dev/null
+++ b/test/ci-runtests.sh
@@ -0,0 +1,242 @@
+#!/usr/bin/env bash
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+APP_DIR="$SCRIPT_DIR/../"
+cd "$SCRIPT_DIR"
+
+BUILD_RELEASE_REPOSITORY="https://releases.mullvad.net/desktop/releases"
+BUILD_DEV_REPOSITORY="https://releases.mullvad.net/desktop/builds"
+
+if [[ ("$(uname -s)" == "Darwin") ]]; then
+ export PACKAGES_DIR=$HOME/Library/Caches/mullvad-test/packages
+elif [[ ("$(uname -s)" == "Linux") ]]; then
+ export PACKAGES_DIR=$HOME/.cache/mullvad-test/packages
+else
+ echo "Unsupported OS" 1>&2
+ exit 1
+fi
+
+if [[ "$#" -lt 1 ]]; then
+ echo "usage: $0 TEST_OS" 1>&2
+ exit 1
+fi
+
+TEST_OS=$1
+
+# Infer stable version from GitHub repo
+RELEASES=$(curl -sf https://api.github.com/repos/mullvad/mullvadvpn-app/releases | jq -r '[.[] | select(((.tag_name|(startswith("android") or startswith("ios"))) | not))]')
+OLD_APP_VERSION=$(jq -r '[.[] | select(.prerelease==false)] | .[0].tag_name' <<<"$RELEASES")
+
+NEW_APP_VERSION=$(cargo run -q --manifest-path="$APP_DIR/Cargo.toml" --bin mullvad-version)
+commit=$(git rev-parse HEAD^\{commit\})
+commit=${commit:0:6}
+
+TAG=$(git describe --exact-match HEAD 2>/dev/null || echo "")
+
+if [[ -n "$TAG" && ${NEW_APP_VERSION} =~ -dev- ]]; then
+ NEW_APP_VERSION+="+${TAG}"
+fi
+
+echo "**********************************"
+echo "* Version to upgrade from: $OLD_APP_VERSION"
+echo "* Version to test: $NEW_APP_VERSION"
+echo "**********************************"
+
+
+if [[ -z "${ACCOUNT_TOKENS+x}" ]]; then
+ echo "'ACCOUNT_TOKENS' must be specified" 1>&2
+ exit 1
+fi
+if ! readarray -t tokens < "${ACCOUNT_TOKENS}"; then
+ echo "Specify account tokens in 'ACCOUNT_TOKENS' file" 1>&2
+ exit 1
+fi
+
+mkdir -p "$SCRIPT_DIR/.ci-logs"
+echo "$NEW_APP_VERSION" > "$SCRIPT_DIR/.ci-logs/last-version.log"
+
+function nice_time {
+ SECONDS=0
+ if $@; then
+ result=0
+ else
+ result=$?
+ fi
+ s=$SECONDS
+ echo "\"$@\" completed in $(($s/60))m:$(($s%60))s"
+ return $result
+}
+
+# Returns 0 if $1 is a development build. `BASH_REMATCH` contains match groups
+# if that is the case.
+function is_dev_version {
+ local pattern="(^[0-9.]+(-beta[0-9]+)?-dev-)([0-9a-z]+)(\+[0-9a-z|-]+)?$"
+ if [[ "$1" =~ $pattern ]]; then
+ return 0
+ fi
+ return 1
+}
+
+function get_app_filename {
+ local version=$1
+ local os=$2
+ if is_dev_version $version; then
+ # only save 6 chars of the hash
+ local commit="${BASH_REMATCH[3]}"
+ version="${BASH_REMATCH[1]}${commit}"
+ # If the dev-version includes a tag, we need to append it to the app filename
+ if [[ -n ${BASH_REMATCH[4]} ]]; then
+ version="${version}${BASH_REMATCH[4]}"
+ fi
+ fi
+ case $os in
+ debian*|ubuntu*)
+ echo "MullvadVPN-${version}_amd64.deb"
+ ;;
+ fedora*)
+ echo "MullvadVPN-${version}_x86_64.rpm"
+ ;;
+ windows*)
+ echo "MullvadVPN-${version}.exe"
+ ;;
+ macos*)
+ echo "MullvadVPN-${version}.pkg"
+ ;;
+ *)
+ echo "Unsupported target: $os" 1>&2
+ return 1
+ ;;
+ esac
+}
+
+function download_app_package {
+ local version=$1
+ local os=$2
+ local package_repo=""
+
+ if is_dev_version $version; then
+ package_repo="${BUILD_DEV_REPOSITORY}"
+ else
+ package_repo="${BUILD_RELEASE_REPOSITORY}"
+ fi
+
+ local filename=$(get_app_filename $version $os)
+ local url="${package_repo}/$version/$filename"
+
+ # TODO: integrity check
+
+ echo "Downloading build for $version ($os) from $url"
+ mkdir -p "$PACKAGES_DIR"
+ if [[ ! -f "$PACKAGES_DIR/$filename" ]]; then
+ curl -sf -o "$PACKAGES_DIR/$filename" $url
+ fi
+}
+
+function get_e2e_filename {
+ local version=$1
+ local os=$2
+ if is_dev_version $version; then
+ # only save 6 chars of the hash
+ local commit="${BASH_REMATCH[3]}"
+ version="${BASH_REMATCH[1]}${commit}"
+ fi
+ case $os in
+ debian*|ubuntu*|fedora*)
+ echo "app-e2e-tests-${version}-x86_64-unknown-linux-gnu"
+ ;;
+ windows*)
+ echo "app-e2e-tests-${version}-x86_64-pc-windows-msvc.exe"
+ ;;
+ macos*)
+ echo "app-e2e-tests-${version}-aarch64-apple-darwin"
+ ;;
+ *)
+ echo "Unsupported target: $os" 1>&2
+ return 1
+ ;;
+ esac
+}
+
+function download_e2e_executable {
+ local version=$1
+ local os=$2
+ local package_repo=""
+
+ if is_dev_version $version; then
+ package_repo="${BUILD_DEV_REPOSITORY}"
+ else
+ package_repo="${BUILD_RELEASE_REPOSITORY}"
+ fi
+
+ local filename=$(get_e2e_filename $version $os)
+ local url="${package_repo}/$version/additional-files/$filename"
+
+ echo "Downloading e2e executable for $version ($os) from $url"
+ mkdir -p $PACKAGES_DIR
+ if [[ ! -f "$PACKAGES_DIR/$filename" ]]; then
+ curl -sf -o "$PACKAGES_DIR/$filename" $url
+ fi
+}
+
+function run_tests_for_os {
+ local os=$1
+
+ local prev_filename=$(get_app_filename $OLD_APP_VERSION $os)
+ local cur_filename=$(get_app_filename $NEW_APP_VERSION $os)
+
+ rm -f "$SCRIPT_DIR/.ci-logs/${os}_report"
+
+ RUST_LOG=debug cargo run --bin test-manager \
+ run-tests \
+ --account "${ACCOUNT_TOKEN}" \
+ --current-app "${cur_filename}" \
+ --previous-app "${prev_filename}" \
+ --test-report "$SCRIPT_DIR/.ci-logs/${os}_report" \
+ "$os" 2>&1 | sed "s/${ACCOUNT_TOKEN}/\{ACCOUNT_TOKEN\}/g"
+}
+
+echo "**********************************"
+echo "* Downloading app packages"
+echo "**********************************"
+
+mkdir -p $PACKAGES_DIR
+nice_time download_app_package $OLD_APP_VERSION $TEST_OS
+nice_time download_app_package $NEW_APP_VERSION $TEST_OS
+nice_time download_e2e_executable $NEW_APP_VERSION $TEST_OS
+
+echo "**********************************"
+echo "* Building test runner"
+echo "**********************************"
+
+# Clean up packages. Try to keep ones that match the versions we're testing
+find "$PACKAGES_DIR/" -type f ! \( -name "*${OLD_APP_VERSION}_*" -o -name "*${OLD_APP_VERSION}.*" -o -name "*${NEW_APP_VERSION}*" \) -delete || true
+
+function build_test_runner {
+ local target=""
+ if [[ "${TEST_OS}" =~ "debian"|"ubuntu"|"fedora" ]]; then
+ target="x86_64-unknown-linux-gnu"
+ elif [[ "${TEST_OS}" =~ "windows" ]]; then
+ target="x86_64-pc-windows-gnu"
+ elif [[ "${TEST_OS}" =~ "macos" ]]; then
+ target="aarch64-apple-darwin"
+ fi
+ TARGET=$target ./build.sh
+}
+
+nice_time build_test_runner
+
+echo "**********************************"
+echo "* Building test manager"
+echo "**********************************"
+
+cargo build -p test-manager
+
+echo "**********************************"
+echo "* Running tests"
+echo "**********************************"
+
+mkdir -p "$SCRIPT_DIR/.ci-logs/os/"
+set -o pipefail
+ACCOUNT_TOKEN=${tokens[0]} nice_time run_tests_for_os "${TEST_OS}"
diff --git a/test/docs/REMOTE_MANAGEMENT.md b/test/docs/REMOTE_MANAGEMENT.md
new file mode 100644
index 0000000000..9555fde9d1
--- /dev/null
+++ b/test/docs/REMOTE_MANAGEMENT.md
@@ -0,0 +1,16 @@
+You can connect to a guest VM remotely by forwarding a VNC server port over SSH. QEMU comes with a
+built-in VNC server. This example starts a Debian 11 VM as the `test` user:
+
+```
+ssh -L 5933:127.0.0.1:5933 -tt $SSH_HOST "sudo -u test bash -c 'cd $TEST_APP_PATH; \
+ cargo run --bin test-manager run-vm debian11 --vnc 5933'"
+```
+
+Replace `$SSH_HOST` with the server that you wish to connect to, and `$TEST_APP_PATH` with the path
+to the copy of this repository on the server.
+
+**NOTE**: In the above example, any changes made to the image will be lost. To make permanent
+changes, remove the `-snapshot` option.
+
+Afterwards, use a VNC client such as the TigerVNC client to connect to the given port on localhost.
+In this example: `127.0.0.1:5933` \ No newline at end of file
diff --git a/test/openvpn.ca.crt b/test/openvpn.ca.crt
new file mode 100644
index 0000000000..851cb353e2
--- /dev/null
+++ b/test/openvpn.ca.crt
@@ -0,0 +1,121 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ 25:94:dc:48:ce:32:bd:4d:c9:b5:31:05:4f:18:63:13:d7:c2:f9:ff
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: C = NA, ST = None, L = None, CN = test.stagemole.eu, O = TestMullvad
+ Validity
+ Not Before: Aug 1 13:54:50 2023 GMT
+ Not After : Jul 31 13:54:50 2024 GMT
+ Subject: C = NA, ST = None, L = None, CN = test.stagemole.eu, O = TestMullvad
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ Public-Key: (4096 bit)
+ Modulus:
+ 00:c5:f9:26:a7:ea:77:d3:a4:dc:79:31:13:c1:be:
+ c5:2d:59:8d:07:14:09:bc:e4:06:c4:e7:91:16:1b:
+ ae:31:26:76:84:10:7d:89:b3:dd:c6:a3:79:c6:0c:
+ fe:1f:dd:fe:62:26:21:9d:c9:08:40:94:b8:9a:3c:
+ 1c:ce:4c:fe:dc:ac:d7:fe:47:84:53:94:e5:2b:07:
+ 13:3e:85:7b:83:3e:0e:f7:13:6c:65:f0:da:9d:26:
+ bd:c4:b8:16:53:6b:8e:38:d0:ef:53:f0:35:54:0f:
+ d3:dc:89:1c:20:fa:35:84:fe:ed:ee:f0:1e:54:9e:
+ 09:76:95:62:f7:1a:69:7c:fa:47:88:f8:ca:75:90:
+ e2:b0:af:d3:18:d9:e2:64:95:73:1f:09:e5:4e:aa:
+ d3:68:c9:96:83:c3:74:82:52:c3:cb:89:95:d1:32:
+ c2:cb:ec:2c:a0:de:6a:55:9a:66:2a:c8:c4:08:09:
+ 5e:3d:68:b5:87:fa:dc:e6:ce:71:47:ed:5b:f5:df:
+ 12:23:27:a2:78:15:ea:bb:72:b7:3d:7e:6a:cf:20:
+ 0a:76:3a:3f:d9:d7:ee:65:30:66:f5:f2:f2:11:93:
+ d4:dd:92:33:0d:c5:db:6e:67:02:7d:d8:6b:a7:bf:
+ c6:9e:ec:17:68:18:c0:7d:84:63:ac:f6:7b:f6:8d:
+ de:b4:46:c2:1f:97:6e:ea:05:dc:b2:4d:73:32:03:
+ c4:9f:d5:c4:ab:97:ad:17:16:88:27:ae:aa:93:13:
+ 81:51:eb:d5:70:9f:08:b6:45:77:ca:02:42:a2:60:
+ 95:da:bb:63:45:78:67:94:8d:28:c4:3e:74:81:79:
+ 3e:77:0e:e8:81:9c:75:f4:53:b4:9f:9e:ba:c7:bd:
+ b7:7e:a1:3c:41:fa:4c:94:af:c0:ae:a3:ca:e7:b4:
+ 7e:8a:50:7e:de:a7:b6:69:f5:17:f8:2b:9b:1a:ae:
+ ec:a0:e2:46:49:0d:39:1c:5c:6d:c5:69:2c:b3:fd:
+ 9d:fd:11:6c:8a:bc:7f:8a:15:ca:ed:07:c1:eb:d7:
+ 1d:cb:dc:7a:8d:58:b4:83:1c:74:ed:37:ca:e0:68:
+ 9a:ce:ae:70:e7:4d:4b:bc:82:6a:59:6e:a7:0d:9c:
+ 79:28:46:96:9e:f8:56:49:50:f3:d6:32:b0:10:c2:
+ 21:ee:d4:c8:fe:7e:6d:b2:c4:91:3b:60:4d:14:6f:
+ 82:21:d5:1c:30:3e:5c:d9:94:e7:cc:17:32:d2:f4:
+ 7d:31:7f:ba:7c:02:74:98:75:c0:48:b3:05:c3:29:
+ 12:33:94:1f:45:12:5c:76:4a:a6:b7:6a:6b:51:8f:
+ 62:e4:53:b4:95:95:81:4a:d7:d6:a9:44:51:73:f2:
+ d1:24:a9
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Subject Key Identifier:
+ 87:81:07:76:DE:E5:B1:71:75:9F:C9:91:90:91:12:97:8F:92:12:A7
+ X509v3 Authority Key Identifier:
+ 87:81:07:76:DE:E5:B1:71:75:9F:C9:91:90:91:12:97:8F:92:12:A7
+ X509v3 Basic Constraints: critical
+ CA:TRUE
+ Signature Algorithm: sha256WithRSAEncryption
+ Signature Value:
+ 7d:76:4c:6b:57:8e:b4:82:3f:00:95:eb:9d:81:eb:9e:be:5c:
+ 60:c7:af:39:ed:c3:f3:45:78:5a:af:83:c7:a1:fd:38:6e:87:
+ 66:9e:66:39:da:9b:d7:21:31:d7:0f:a3:d2:26:12:81:f0:4f:
+ ca:61:66:26:3c:54:b0:05:e4:68:6b:e9:45:77:6e:f6:28:dd:
+ 74:18:66:05:4e:59:a6:eb:eb:5c:85:5e:e1:51:ce:7b:91:0f:
+ d4:e5:e4:09:d2:6a:a6:2c:99:40:fa:b6:c2:22:e7:78:de:7a:
+ 7a:1a:fb:54:f2:80:00:bc:ee:5b:91:29:9b:74:24:04:c0:c3:
+ 62:81:f2:2d:8f:b8:af:07:88:4e:c9:ef:02:ae:9d:d7:fb:5a:
+ bf:8d:26:98:e5:8a:fc:b6:0e:b4:89:fa:8e:ab:5f:2a:d7:23:
+ 27:db:02:f8:5d:c8:62:90:a3:48:f6:8d:5f:b6:ac:be:16:22:
+ a0:c7:32:d1:99:42:a0:f4:fe:40:f7:0d:80:26:28:99:50:f8:
+ 32:a1:7f:58:29:cc:e2:ea:c1:7a:05:cd:7f:6e:c3:b0:f7:bc:
+ 53:b3:fe:74:72:0d:fd:78:a4:84:d2:d4:2f:4a:04:09:d0:82:
+ 69:f9:3a:ae:40:86:4d:72:e4:a5:67:12:28:58:89:ea:07:01:
+ 73:e6:7b:a3:e1:90:c0:d4:e8:73:ff:49:df:c7:39:33:e1:23:
+ c6:5b:80:19:9a:8d:1a:fc:c9:4c:ab:93:e6:ce:4b:c7:3d:80:
+ 39:bc:19:fa:5a:82:2c:db:d0:1d:2c:05:45:d4:01:6e:cd:54:
+ e2:6f:8f:2d:a9:83:d7:30:d5:e4:6c:bc:af:be:d4:10:60:a0:
+ be:d8:79:e5:eb:02:09:38:c2:92:81:2e:59:e7:88:70:61:f3:
+ 50:10:89:40:45:1b:e0:df:74:fe:fd:dd:2b:2a:68:a1:8b:8a:
+ e8:28:44:2e:07:1c:e3:e5:03:e0:35:ab:25:c8:3a:84:0d:47:
+ 67:e7:39:14:47:db:8e:8a:47:ae:fe:2d:55:59:4f:06:17:a0:
+ ed:da:88:67:99:43:06:fb:6f:4b:48:cc:92:81:cd:1e:16:4b:
+ d1:b9:f5:e5:a0:3f:69:94:fe:85:7e:7e:d5:56:49:10:38:b2:
+ 88:f8:49:70:3f:7b:af:bf:9c:d7:52:e8:74:20:de:84:4f:1f:
+ 6e:5b:20:df:a9:b8:6a:33:e9:55:dc:bd:78:7c:23:72:77:f6:
+ 97:40:62:7b:2c:3e:61:aa:80:63:4b:89:41:52:1a:5a:31:b0:
+ 69:ca:af:40:49:a1:27:06:81:9f:d6:34:f8:36:55:72:03:a5:
+ 2e:0c:f8:17:74:8d:8f:57
+-----BEGIN CERTIFICATE-----
+MIIFmzCCA4OgAwIBAgIUJZTcSM4yvU3JtTEFTxhjE9fC+f8wDQYJKoZIhvcNAQEL
+BQAwXTELMAkGA1UEBhMCTkExDTALBgNVBAgMBE5vbmUxDTALBgNVBAcMBE5vbmUx
+GjAYBgNVBAMMEXRlc3Quc3RhZ2Vtb2xlLmV1MRQwEgYDVQQKDAtUZXN0TXVsbHZh
+ZDAeFw0yMzA4MDExMzU0NTBaFw0yNDA3MzExMzU0NTBaMF0xCzAJBgNVBAYTAk5B
+MQ0wCwYDVQQIDAROb25lMQ0wCwYDVQQHDAROb25lMRowGAYDVQQDDBF0ZXN0LnN0
+YWdlbW9sZS5ldTEUMBIGA1UECgwLVGVzdE11bGx2YWQwggIiMA0GCSqGSIb3DQEB
+AQUAA4ICDwAwggIKAoICAQDF+San6nfTpNx5MRPBvsUtWY0HFAm85AbE55EWG64x
+JnaEEH2Js93Go3nGDP4f3f5iJiGdyQhAlLiaPBzOTP7crNf+R4RTlOUrBxM+hXuD
+Pg73E2xl8NqdJr3EuBZTa4440O9T8DVUD9PciRwg+jWE/u3u8B5Ungl2lWL3Gml8
++keI+Mp1kOKwr9MY2eJklXMfCeVOqtNoyZaDw3SCUsPLiZXRMsLL7Cyg3mpVmmYq
+yMQICV49aLWH+tzmznFH7Vv13xIjJ6J4Feq7crc9fmrPIAp2Oj/Z1+5lMGb18vIR
+k9TdkjMNxdtuZwJ92Gunv8ae7BdoGMB9hGOs9nv2jd60RsIfl27qBdyyTXMyA8Sf
+1cSrl60XFognrqqTE4FR69Vwnwi2RXfKAkKiYJXau2NFeGeUjSjEPnSBeT53DuiB
+nHX0U7SfnrrHvbd+oTxB+kyUr8Cuo8rntH6KUH7ep7Zp9Rf4K5saruyg4kZJDTkc
+XG3FaSyz/Z39EWyKvH+KFcrtB8Hr1x3L3HqNWLSDHHTtN8rgaJrOrnDnTUu8gmpZ
+bqcNnHkoRpae+FZJUPPWMrAQwiHu1Mj+fm2yxJE7YE0Ub4Ih1RwwPlzZlOfMFzLS
+9H0xf7p8AnSYdcBIswXDKRIzlB9FElx2Sqa3amtRj2LkU7SVlYFK19apRFFz8tEk
+qQIDAQABo1MwUTAdBgNVHQ4EFgQUh4EHdt7lsXF1n8mRkJESl4+SEqcwHwYDVR0j
+BBgwFoAUh4EHdt7lsXF1n8mRkJESl4+SEqcwDwYDVR0TAQH/BAUwAwEB/zANBgkq
+hkiG9w0BAQsFAAOCAgEAfXZMa1eOtII/AJXrnYHrnr5cYMevOe3D80V4Wq+Dx6H9
+OG6HZp5mOdqb1yEx1w+j0iYSgfBPymFmJjxUsAXkaGvpRXdu9ijddBhmBU5Zpuvr
+XIVe4VHOe5EP1OXkCdJqpiyZQPq2wiLneN56ehr7VPKAALzuW5Epm3QkBMDDYoHy
+LY+4rweITsnvAq6d1/tav40mmOWK/LYOtIn6jqtfKtcjJ9sC+F3IYpCjSPaNX7as
+vhYioMcy0ZlCoPT+QPcNgCYomVD4MqF/WCnM4urBegXNf27DsPe8U7P+dHIN/Xik
+hNLUL0oECdCCafk6rkCGTXLkpWcSKFiJ6gcBc+Z7o+GQwNToc/9J38c5M+EjxluA
+GZqNGvzJTKuT5s5Lxz2AObwZ+lqCLNvQHSwFRdQBbs1U4m+PLamD1zDV5Gy8r77U
+EGCgvth55esCCTjCkoEuWeeIcGHzUBCJQEUb4N90/v3dKypooYuK6ChELgcc4+UD
+4DWrJcg6hA1HZ+c5FEfbjopHrv4tVVlPBheg7dqIZ5lDBvtvS0jMkoHNHhZL0bn1
+5aA/aZT+hX5+1VZJEDiyiPhJcD97r7+c11LodCDehE8fblsg36m4ajPpVdy9eHwj
+cnf2l0Bieyw+YaqAY0uJQVIaWjGwacqvQEmhJwaBn9Y0+DZVcgOlLgz4F3SNj1c=
+-----END CERTIFICATE-----
diff --git a/test/scripts/build-runner-image.sh b/test/scripts/build-runner-image.sh
new file mode 100755
index 0000000000..1cb5e6bb26
--- /dev/null
+++ b/test/scripts/build-runner-image.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+
+# This script produces a virtual disk containing the test runner binaries.
+
+set -eu
+
+TEST_RUNNER_IMAGE_SIZE_MB=1000
+
+case $TARGET in
+ "x86_64-unknown-linux-gnu")
+ TEST_RUNNER_IMAGE_FILENAME=linux-test-runner.img
+ ;;
+ "x86_64-pc-windows-gnu")
+ TEST_RUNNER_IMAGE_FILENAME=windows-test-runner.img
+ ;;
+ *)
+ echo "Unknown target: $TARGET"
+ exit 1
+ ;;
+esac
+
+echo "************************************************************"
+echo "* Preparing test runner image: $TARGET"
+echo "************************************************************"
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+mkdir -p "${SCRIPT_DIR}/../testrunner-images/"
+TEST_RUNNER_IMAGE_PATH="${SCRIPT_DIR}/../testrunner-images/${TEST_RUNNER_IMAGE_FILENAME}"
+
+case $TARGET in
+
+ "x86_64-unknown-linux-gnu")
+ truncate -s "${TEST_RUNNER_IMAGE_SIZE_MB}M" "${TEST_RUNNER_IMAGE_PATH}"
+ mkfs.ext4 -F "${TEST_RUNNER_IMAGE_PATH}"
+ e2cp \
+ -P 500 \
+ "${SCRIPT_DIR}/../target/$TARGET/release/test-runner" \
+ "${PACKAGES_DIR}/"app-e2e-* \
+ "${TEST_RUNNER_IMAGE_PATH}:/"
+ e2cp \
+ "${PACKAGES_DIR}/"*.deb \
+ "${PACKAGES_DIR}/"*.rpm \
+ "${SCRIPT_DIR}/../openvpn.ca.crt" \
+ "${TEST_RUNNER_IMAGE_PATH}:/"
+ ;;
+
+ "x86_64-pc-windows-gnu")
+ truncate -s "${TEST_RUNNER_IMAGE_SIZE_MB}M" "${TEST_RUNNER_IMAGE_PATH}"
+ mformat -F -i "${TEST_RUNNER_IMAGE_PATH}" "::"
+ mcopy \
+ -i "${TEST_RUNNER_IMAGE_PATH}" \
+ "${SCRIPT_DIR}/../target/$TARGET/release/test-runner.exe" \
+ "${PACKAGES_DIR}/"*.exe \
+ "${SCRIPT_DIR}/../openvpn.ca.crt" \
+ "::"
+ mdir -i "${TEST_RUNNER_IMAGE_PATH}"
+ ;;
+
+esac
+
+echo "************************************************************"
+echo "* Success! Built test runner image: $TARGET"
+echo "************************************************************"
diff --git a/test/scripts/ssh-setup.sh b/test/scripts/ssh-setup.sh
new file mode 100644
index 0000000000..4aefcfdeed
--- /dev/null
+++ b/test/scripts/ssh-setup.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd $SCRIPT_DIR
+
+RUNNER_DIR="$1"
+CURRENT_APP="$2"
+PREVIOUS_APP="$3"
+UI_RUNNER="$4"
+
+# Copy over test runner to correct place
+
+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
+ echo "Moving $file to $RUNNER_DIR"
+ cp -f "$SCRIPT_DIR/$file" $RUNNER_DIR
+done
+
+chown -R root "$RUNNER_DIR/"
+
+# Create service
+
+function setup_macos {
+ RUNNER_PLIST_PATH="/Library/LaunchDaemons/net.mullvad.testunner.plist"
+
+ echo "Creating test runner service as $RUNNER_PLIST_PATH"
+
+ cat > $RUNNER_PLIST_PATH << EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>net.mullvad.testrunner</string>
+
+ <key>ProgramArguments</key>
+ <array>
+ <string>$RUNNER_DIR/test-runner</string>
+ <string>/dev/tty.virtio</string>
+ <string>serve</string>
+ </array>
+
+ <key>UserName</key>
+ <string>root</string>
+
+ <key>RunAtLoad</key>
+ <true/>
+
+ <key>KeepAlive</key>
+ <true/>
+
+ <key>StandardOutPath</key>
+ <string>/tmp/runner.out</string>
+
+ <key>StandardErrorPath</key>
+ <string>/tmp/runner.err</string>
+
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/sbin</string>
+ </dict>
+</dict>
+</plist>
+EOF
+
+ echo "Starting test runner service"
+
+ launchctl load -w $RUNNER_PLIST_PATH
+}
+
+function setup_systemd {
+ RUNNER_SERVICE_PATH="/etc/systemd/system/testrunner.service"
+
+ echo "Creating test runner service as $RUNNER_SERVICE_PATH"
+
+ cat > $RUNNER_SERVICE_PATH << EOF
+[Unit]
+Description=Mullvad Test Runner
+
+[Service]
+ExecStart=$RUNNER_DIR/test-runner /dev/ttyS0 serve
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+ echo "Starting test runner service"
+
+ semanage fcontext -a -t bin_t "$RUNNER_DIR/.*" &> /dev/null || true
+
+ systemctl enable testrunner.service
+ systemctl start testrunner.service
+}
+
+if [[ "$(uname -s)" == "Darwin" ]]; then
+ setup_macos
+ exit 0
+fi
+
+setup_systemd
+
+# Install required packages
+which apt &>/dev/null && apt install -f xvfb wireguard-tools
+which dnf &>/dev/null && dnf install -y xorg-x11-server-Xvfb wireguard-tools
diff --git a/test/test-manager/Cargo.toml b/test/test-manager/Cargo.toml
new file mode 100644
index 0000000000..9e319bdf33
--- /dev/null
+++ b/test/test-manager/Cargo.toml
@@ -0,0 +1,60 @@
+[package]
+name = "test-manager"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow = { version = "1", features = ["backtrace"] }
+futures = { workspace = true }
+regex = "1"
+chrono = { workspace = true }
+tarpc = { workspace = true }
+tokio = { workspace = true }
+tokio-serial = { workspace = true }
+err-derive = { workspace = true }
+bytes = { workspace = true }
+test_macro = { path = "./test_macro" }
+ipnetwork = "0.20"
+once_cell = { workspace = true }
+inventory = "0.1"
+data-encoding-macro = "0.1.12"
+itertools = "0.10.5"
+libc = "0.2.14"
+clap = { version = "4.1", features = ["derive"] }
+async-tempfile = "0.2"
+async-trait = { workspace = true }
+uuid = "1.3"
+dirs = "5.0.1"
+
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio-serde = { workspace = true }
+log = { workspace = true }
+
+pcap = { version = "0.10.1", features = ["capture-stream"] }
+pnet_packet = "0.31.0"
+
+test-rpc = { path = "../test-rpc" }
+
+env_logger = { workspace = true }
+
+tonic = { workspace = true }
+tower = { workspace = true }
+colored = { workspace = true }
+
+mullvad-management-interface = { path = "../../mullvad-management-interface" }
+talpid-types = { path = "../../talpid-types" }
+mullvad-types = { path = "../../mullvad-types" }
+mullvad-api = { path = "../../mullvad-api", features = ["api-override"] }
+
+ssh2 = "0.9.4"
+
+nix = { version = "0.25", features = ["net"] }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+tun = "0.5.1"
+
+[dependencies.tokio-util]
+version = "0.7"
+features = ["codec"]
+default-features = false
diff --git a/test/test-manager/README.me b/test/test-manager/README.me
new file mode 100644
index 0000000000..8e0da31375
--- /dev/null
+++ b/test/test-manager/README.me
@@ -0,0 +1,47 @@
+# Writing tests for [MullvadVPN App](https://github.com/mullvad/mullvadvpn-app/)
+
+The `test-manager` crate is where end-to-end tests for the [MullvadVPN
+App](https://github.com/mullvad/mullvadvpn-app/) resides. The tests are located
+in different modules under `test-manager/src/tests/`.
+
+## Getting started
+
+Tests are regular Rust functions! Except that they are also `async` and marked
+with the `#[test_function]` attribute
+
+```rust
+#[test_function]
+pub async fn test(
+ rpc: ServiceClient,
+ mut mullvad_client: mullvad_management_interface::ManagementServiceClient,
+) -> Result<(), Error> {
+ Ok(())
+}
+```
+
+The `test_function` macro allows you to write tests for the MullvadVPN App in a
+format which is very similiar to [standard Rust unit
+tests](https://doc.rust-lang.org/book/ch11-01-writing-tests.html). A more
+detailed writeup on how the `#[test_function]` macro works is given as a
+doc-comment in [test_macro::test_function](./test_macro/src/lib.rs).
+
+If a new module is created, make sure to add it in
+`test-manager/src/tests/mod.rs`.
+
+### UI/Graphical tests
+
+It is possible to write tests for asserting graphical properties in the app, but
+this is a slightly more involved process. GUI tests are written in `Typescript`,
+and reside in the `gui/test/e2e` folder in the app repository. Packaging of
+these tests is also done from the `gui/` folder.
+
+Assuming that a graphical test `gui-test.spec` has been bundled correctly, it
+can be invoked from any Rust function by calling
+`test_manager::tests::ui::run_test(rpc:
+.., params: ..) -> Result<ExecResult,
+Error>`
+
+```rust
+// Run a UI test. Panic if any assertion in it fails!
+test_manager::tests::ui::run_test(&rpc, &["gui-test.spec"]).await.unwrap()
+```
diff --git a/test/test-manager/build.rs b/test/test-manager/build.rs
new file mode 100644
index 0000000000..07be8b9677
--- /dev/null
+++ b/test/test-manager/build.rs
@@ -0,0 +1,4 @@
+fn main() {
+ // Rebuild if SSH provision script changes
+ println!("cargo:rerun-if-changed=../scripts/ssh-setup.sh");
+}
diff --git a/test/test-manager/src/config.rs b/test/test-manager/src/config.rs
new file mode 100644
index 0000000000..7145dca8a5
--- /dev/null
+++ b/test/test-manager/src/config.rs
@@ -0,0 +1,229 @@
+//! Test manager configuration.
+
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::BTreeMap,
+ io,
+ ops::Deref,
+ path::{Path, PathBuf},
+};
+
+#[derive(err_derive::Error, Debug)]
+pub enum Error {
+ #[error(display = "Failed to read config")]
+ Read(io::Error),
+ #[error(display = "Failed to parse config")]
+ InvalidConfig(serde_json::Error),
+ #[error(display = "Failed to write config")]
+ Write(io::Error),
+}
+
+#[derive(Default, Serialize, Deserialize, Clone)]
+pub struct Config {
+ #[serde(skip)]
+ pub runtime_opts: RuntimeOptions,
+ pub vms: BTreeMap<String, VmConfig>,
+ pub mullvad_host: Option<String>,
+}
+
+#[derive(Default, Serialize, Deserialize, Clone)]
+pub struct RuntimeOptions {
+ pub display: Display,
+ pub keep_changes: bool,
+}
+
+#[derive(Default, Serialize, Deserialize, Clone)]
+pub enum Display {
+ #[default]
+ None,
+ Local,
+ Vnc,
+}
+
+impl Config {
+ async fn load_or_default<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+ Self::load(path).await.or_else(|error| match error {
+ Error::Read(ref io_err) if io_err.kind() == io::ErrorKind::NotFound => {
+ Ok(Self::default())
+ }
+ error => Err(error),
+ })
+ }
+
+ async fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+ let data = tokio::fs::read(path).await.map_err(Error::Read)?;
+ serde_json::from_slice(&data).map_err(Error::InvalidConfig)
+ }
+
+ async fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
+ let data = serde_json::to_vec_pretty(self).unwrap();
+ tokio::fs::write(path, &data).await.map_err(Error::Write)
+ }
+
+ pub fn get_vm(&self, name: &str) -> Option<&VmConfig> {
+ self.vms.get(name)
+ }
+}
+
+pub struct ConfigFile {
+ path: PathBuf,
+ config: Config,
+}
+
+impl ConfigFile {
+ /// Make config changes and save them to disk
+ pub async fn load_or_default<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
+ Ok(Self {
+ path: path.as_ref().to_path_buf(),
+ config: Config::load_or_default(path).await?,
+ })
+ }
+
+ /// Make config changes and save them to disk
+ pub async fn edit(&mut self, edit: impl FnOnce(&mut Config)) -> Result<(), Error> {
+ edit(&mut self.config);
+ self.config.save(&self.path).await
+ }
+}
+
+impl Deref for ConfigFile {
+ type Target = Config;
+
+ fn deref(&self) -> &Self::Target {
+ &self.config
+ }
+}
+
+#[derive(clap::Args, Debug, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "snake_case")]
+pub struct VmConfig {
+ /// Type of virtual machine to use
+ pub vm_type: VmType,
+
+ /// Path to a VM disk image
+ pub image_path: String,
+
+ /// Type of operating system.
+ pub os_type: OsType,
+
+ /// Package type to use, e.g. deb or rpm
+ #[arg(long, required_if_eq("os_type", "linux"))]
+ pub package_type: Option<PackageType>,
+
+ /// CPU architecture
+ #[arg(long, required_if_eq("os_type", "linux"))]
+ pub architecture: Option<Architecture>,
+
+ /// Tool to use for provisioning
+ #[arg(long, default_value = "noop")]
+ pub provisioner: Provisioner,
+
+ /// Username to use for SSH
+ #[arg(long, required_if_eq("provisioner", "ssh"))]
+ pub ssh_user: Option<String>,
+
+ /// Password to use for SSH
+ #[arg(long, required_if_eq("provisioner", "ssh"))]
+ pub ssh_password: Option<String>,
+
+ /// Additional disk images to mount/include
+ #[arg(long)]
+ pub disks: Vec<String>,
+
+ /// Where artifacts, such as app packages, are stored.
+ /// Usually /opt/testing on Linux.
+ #[arg(long)]
+ pub artifacts_dir: Option<String>,
+
+ /// Emulate a TPM. This also enables UEFI implicitly
+ #[serde(default)]
+ #[arg(long)]
+ pub tpm: bool,
+}
+
+impl VmConfig {
+ /// Combine authentication details, if all are present
+ pub fn get_ssh_options(&self) -> Option<(&str, &str)> {
+ Some((self.ssh_user.as_ref()?, self.ssh_password.as_ref()?))
+ }
+
+ pub fn get_runner_dir(&self) -> &Path {
+ match self.architecture {
+ None | Some(Architecture::X64) => self.get_x64_runner_dir(),
+ Some(Architecture::Aarch64) => self.get_aarch64_runner_dir(),
+ }
+ }
+
+ fn get_x64_runner_dir(&self) -> &Path {
+ pub const X64_LINUX_TARGET_DIR: &str = "./target/x86_64-unknown-linux-gnu/release";
+ pub const X64_WINDOWS_TARGET_DIR: &str = "./target/x86_64-pc-windows-gnu/release";
+ pub const X64_MACOS_TARGET_DIR: &str = "./target/x86_64-apple-darwin/release";
+
+ match self.os_type {
+ OsType::Linux => Path::new(X64_LINUX_TARGET_DIR),
+ OsType::Windows => Path::new(X64_WINDOWS_TARGET_DIR),
+ OsType::Macos => Path::new(X64_MACOS_TARGET_DIR),
+ }
+ }
+
+ fn get_aarch64_runner_dir(&self) -> &Path {
+ pub const AARCH64_LINUX_TARGET_DIR: &str = "./target/aarch64-unknown-linux-gnu/release";
+ pub const AARCH64_MACOS_TARGET_DIR: &str = "./target/aarch64-apple-darwin/release";
+
+ match self.os_type {
+ OsType::Linux => Path::new(AARCH64_LINUX_TARGET_DIR),
+ OsType::Macos => Path::new(AARCH64_MACOS_TARGET_DIR),
+ _ => unimplemented!(),
+ }
+ }
+}
+
+#[derive(clap::ValueEnum, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum VmType {
+ /// QEMU VM
+ Qemu,
+ /// Tart VM
+ Tart,
+}
+
+#[derive(clap::ValueEnum, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum OsType {
+ Windows,
+ Linux,
+ Macos,
+}
+
+#[derive(clap::ValueEnum, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum PackageType {
+ Deb,
+ Rpm,
+}
+
+#[derive(clap::ValueEnum, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Architecture {
+ X64,
+ Aarch64,
+}
+
+impl Architecture {
+ pub fn get_identifiers(&self) -> &[&'static str] {
+ match self {
+ Architecture::X64 => &["x86_64", "amd64"],
+ Architecture::Aarch64 => &["arm64", "aarch64"],
+ }
+ }
+}
+
+#[derive(clap::ValueEnum, Default, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum Provisioner {
+ /// Do nothing: The image already includes a test runner service
+ #[default]
+ Noop,
+ /// Set up test runner over SSH.
+ Ssh,
+}
diff --git a/test/test-manager/src/container.rs b/test/test-manager/src/container.rs
new file mode 100644
index 0000000000..84b80282c2
--- /dev/null
+++ b/test/test-manager/src/container.rs
@@ -0,0 +1,34 @@
+#![cfg(target_os = "linux")]
+
+use tokio::process::Command;
+
+/// Re-launch self with rootlesskit if we're not root.
+/// Allows for rootless and containerized networking.
+/// The VNC port is published to localhost.
+pub async fn relaunch_with_rootlesskit(vnc_port: Option<u16>) {
+ if unsafe { libc::geteuid() } == 0 {
+ return;
+ }
+
+ let mut cmd = Command::new("rootlesskit");
+ cmd.args(["--net", "slirp4netns", "--copy-up=/etc"]);
+
+ if let Some(port) = vnc_port {
+ log::debug!("VNC port: {port} -> 5901/tcp");
+
+ cmd.args([
+ "--port-driver",
+ "slirp4netns",
+ "-p",
+ &format!("127.0.0.1:{port}:5901/tcp"),
+ ]);
+ } else {
+ cmd.arg("--disable-host-loopback");
+ }
+
+ cmd.args(std::env::args());
+
+ let status = cmd.status().await.unwrap();
+
+ std::process::exit(status.code().unwrap_or(1));
+}
diff --git a/test/test-manager/src/logging.rs b/test/test-manager/src/logging.rs
new file mode 100644
index 0000000000..c11fdf28bf
--- /dev/null
+++ b/test/test-manager/src/logging.rs
@@ -0,0 +1,201 @@
+use crate::tests::Error;
+use colored::Colorize;
+use std::sync::{Arc, Mutex};
+use test_rpc::logging::{LogOutput, Output};
+
+/// Logger that optionally supports logging records to a buffer
+#[derive(Clone)]
+pub struct Logger {
+ inner: Arc<Mutex<LoggerInner>>,
+}
+
+struct LoggerInner {
+ env_logger: env_logger::Logger,
+ buffer: bool,
+ stored_records: Vec<StoredRecord>,
+}
+
+struct StoredRecord {
+ level: log::Level,
+ time: chrono::DateTime<chrono::Local>,
+ mod_path: String,
+ text: String,
+}
+
+impl Logger {
+ pub fn get_or_init() -> Self {
+ static LOGGER: once_cell::sync::Lazy<Logger> = once_cell::sync::Lazy::new(|| {
+ let mut logger = env_logger::Builder::new();
+ logger.filter_module("h2", log::LevelFilter::Info);
+ logger.filter_module("tower", log::LevelFilter::Info);
+ logger.filter_module("hyper", log::LevelFilter::Info);
+ logger.filter_module("rustls", log::LevelFilter::Info);
+ logger.filter_level(log::LevelFilter::Debug);
+ logger.parse_env(env_logger::DEFAULT_FILTER_ENV);
+
+ let env_logger = logger.build();
+ let max_level = env_logger.filter();
+
+ let logger = Logger {
+ inner: Arc::new(Mutex::new(LoggerInner {
+ env_logger,
+ buffer: false,
+ stored_records: vec![],
+ })),
+ };
+
+ if log::set_boxed_logger(Box::new(logger.clone())).is_ok() {
+ log::set_max_level(max_level);
+ }
+
+ logger
+ });
+
+ LOGGER.clone()
+ }
+
+ /// Set whether to buffer logs instead of printing them to stdout and stderr
+ pub fn store_records(&self, state: bool) {
+ let mut inner = self.inner.lock().unwrap();
+ inner.buffer = state;
+ }
+
+ /// Flush and print all buffered records
+ pub fn print_stored_records(&self) {
+ let mut inner = self.inner.lock().unwrap();
+ for stored_record in std::mem::take(&mut inner.stored_records) {
+ println!(
+ "[{} {} {}] {}",
+ stored_record.time, stored_record.level, stored_record.mod_path, stored_record.text
+ );
+ }
+ }
+
+ /// Remove all stored logs
+ pub fn flush_records(&self) {
+ let mut inner = self.inner.lock().unwrap();
+ inner.stored_records.clear();
+ }
+}
+
+impl log::Log for Logger {
+ fn enabled(&self, metadata: &log::Metadata) -> bool {
+ let inner = self.inner.lock().unwrap();
+ inner.env_logger.enabled(metadata)
+ }
+
+ fn log(&self, record: &log::Record) {
+ if !self.enabled(record.metadata()) {
+ return;
+ }
+
+ let mut inner = self.inner.lock().unwrap();
+
+ if inner.buffer {
+ let mod_path = record.module_path().unwrap_or("");
+ inner.stored_records.push(StoredRecord {
+ level: record.level(),
+ time: chrono::Local::now(),
+ mod_path: mod_path.to_owned(),
+ text: record.args().to_string(),
+ });
+ } else {
+ inner.env_logger.log(record);
+ }
+ }
+
+ fn flush(&self) {}
+}
+
+#[derive(Debug, err_derive::Error)]
+#[error(display = "Test panic: {}", _0)]
+pub struct PanicMessage(String);
+
+pub struct TestOutput {
+ pub error_messages: Vec<Output>,
+ pub test_name: &'static str,
+ pub result: Result<Result<(), Error>, PanicMessage>,
+ pub log_output: Option<LogOutput>,
+}
+
+impl TestOutput {
+ pub fn print(&self) {
+ match &self.result {
+ Ok(Ok(_)) => {
+ println!("{}", format!("TEST {} SUCCEEDED!", self.test_name).green());
+ return;
+ }
+ Ok(Err(e)) => {
+ println!(
+ "{}",
+ format!(
+ "TEST {} RETURNED ERROR: {}",
+ self.test_name,
+ format!("{:?}", e).bold()
+ )
+ .red()
+ );
+ }
+ Err(panic_msg) => {
+ println!(
+ "{}",
+ format!(
+ "TEST {} PANICKED WITH MESSAGE: {}",
+ self.test_name,
+ panic_msg.0.bold()
+ )
+ .red()
+ );
+ }
+ }
+
+ println!("{}", format!("TEST {} HAD LOGS:", self.test_name).red());
+ match &self.log_output {
+ Some(log) => {
+ match &log.settings_json {
+ Ok(settings) => println!("settings.json: {}", settings),
+ Err(e) => println!("Could not get settings.json: {}", e),
+ }
+
+ match &log.log_files {
+ Ok(log_files) => {
+ for log in log_files {
+ match log {
+ Ok(log) => {
+ println!("Log {}:\n{}", log.name.to_str().unwrap(), log.content)
+ }
+ Err(e) => println!("Could not get log: {}", e),
+ }
+ }
+ }
+ Err(e) => println!("Could not get logs: {}", e),
+ }
+ }
+ None => println!("Missing logs for {}", self.test_name),
+ }
+
+ println!(
+ "{}",
+ format!("TEST RUNNER {} HAD RUNTIME OUTPUT:", self.test_name).red()
+ );
+ if self.error_messages.is_empty() {
+ println!("<no output>");
+ } else {
+ for msg in &self.error_messages {
+ println!("{}", msg);
+ }
+ }
+
+ println!("{}", format!("TEST {} END OF OUTPUT", self.test_name).red());
+ }
+}
+
+pub fn panic_as_string(error: Box<dyn std::any::Any + Send + 'static>) -> PanicMessage {
+ if let Some(result) = error.downcast_ref::<String>() {
+ return PanicMessage(result.clone());
+ }
+ match error.downcast_ref::<&str>() {
+ Some(s) => PanicMessage(String::from(*s)),
+ None => PanicMessage(String::from("unknown message")),
+ }
+}
diff --git a/test/test-manager/src/main.rs b/test/test-manager/src/main.rs
new file mode 100644
index 0000000000..37a46c2580
--- /dev/null
+++ b/test/test-manager/src/main.rs
@@ -0,0 +1,311 @@
+mod config;
+mod container;
+mod logging;
+mod mullvad_daemon;
+mod network_monitor;
+mod package;
+mod run_tests;
+mod summary;
+mod tests;
+mod vm;
+
+use std::path::PathBuf;
+
+use anyhow::Context;
+use anyhow::Result;
+use clap::Parser;
+use tests::config::DEFAULT_MULLVAD_HOST;
+
+/// Test manager for Mullvad VPN app
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+struct Args {
+ #[clap(subcommand)]
+ cmd: Commands,
+}
+
+#[derive(clap::Subcommand, Debug)]
+enum Commands {
+ /// Create or edit a VM config
+ Set {
+ /// Name of the config
+ name: String,
+
+ /// VM config
+ #[clap(flatten)]
+ config: config::VmConfig,
+ },
+
+ /// Remove specified configuration
+ Remove {
+ /// Name of the config
+ name: String,
+ },
+
+ /// List available configurations
+ List,
+
+ /// Spawn a runner instance without running any tests
+ RunVm {
+ /// Name of the runner config
+ name: String,
+
+ /// Run VNC server on a specified port
+ #[arg(long)]
+ vnc: Option<u16>,
+
+ /// Make permanent changes to image
+ #[arg(long)]
+ keep_changes: bool,
+ },
+
+ /// Spawn a runner instance and run tests
+ RunTests {
+ /// Name of the runner config
+ name: String,
+
+ /// Show display of guest
+ #[arg(long, group = "display_args")]
+ display: bool,
+
+ /// Run VNC server on a specified port
+ #[arg(long, group = "display_args")]
+ vnc: Option<u16>,
+
+ /// Account number to use for testing
+ #[arg(long, short)]
+ account: String,
+
+ /// App package to test.
+ ///
+ /// # Note
+ ///
+ /// The gRPC interface must be compatible with the version specified for `mullvad-management-interface` in Cargo.toml.
+ #[arg(long, short)]
+ current_app: String,
+
+ /// App package to upgrade from.
+ ///
+ /// # Note
+ ///
+ /// The CLI interface must be compatible with the upgrade test.
+ #[arg(long, short)]
+ previous_app: String,
+
+ /// Only run tests matching substrings
+ test_filters: Vec<String>,
+
+ /// Print results live
+ #[arg(long, short)]
+ verbose: bool,
+
+ /// Output test results in a structured format.
+ #[arg(long)]
+ test_report: Option<PathBuf>,
+ },
+
+ /// Output an HTML-formatted summary of one or more reports
+ FormatTestReports {
+ /// One or more test reports output by 'test-manager run-tests --test-report'
+ reports: Vec<PathBuf>,
+ },
+
+ /// Update the system image
+ ///
+ /// Note that in order for the updates to take place, the VM's config need
+ /// to have `provisioner` set to `ssh`, `ssh_user` & `ssh_password` set and
+ /// the `ssh_user` should be able to execute commands with sudo/ as root.
+ Update {
+ /// Name of the runner config
+ name: String,
+ },
+}
+
+#[cfg(target_os = "linux")]
+impl Args {
+ fn get_vnc_port(&self) -> Option<u16> {
+ match self.cmd {
+ Commands::RunTests { vnc, .. } | Commands::RunVm { vnc, .. } => vnc,
+ _ => None,
+ }
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ logging::Logger::get_or_init();
+
+ let args = Args::parse();
+
+ #[cfg(target_os = "linux")]
+ container::relaunch_with_rootlesskit(args.get_vnc_port()).await;
+
+ let config_path = dirs::config_dir()
+ .context("Config directory not found. Can not load VM config")?
+ .join("mullvad-test")
+ .join("config.json");
+
+ let mut config = config::ConfigFile::load_or_default(config_path)
+ .await
+ .context("Failed to load config")?;
+ match args.cmd {
+ Commands::Set {
+ name,
+ config: vm_config,
+ } => vm::set_config(&mut config, &name, vm_config)
+ .await
+ .context("Failed to edit or create VM config"),
+ Commands::Remove { name } => {
+ if config.get_vm(&name).is_none() {
+ println!("No such configuration");
+ return Ok(());
+ }
+ config
+ .edit(|config| {
+ config.vms.remove_entry(&name);
+ })
+ .await
+ .context("Failed to remove config entry")?;
+ println!("Removed configuration \"{name}\"");
+ Ok(())
+ }
+ Commands::List => {
+ println!("Available configurations:");
+ for name in config.vms.keys() {
+ println!("{}", name);
+ }
+ Ok(())
+ }
+ Commands::RunVm {
+ name,
+ vnc,
+ keep_changes,
+ } => {
+ let mut config = config.clone();
+ config.runtime_opts.keep_changes = keep_changes;
+ config.runtime_opts.display = if vnc.is_some() {
+ config::Display::Vnc
+ } else {
+ config::Display::Local
+ };
+
+ let mut instance = vm::run(&config, &name)
+ .await
+ .context("Failed to start VM")?;
+
+ instance.wait().await;
+
+ Ok(())
+ }
+ Commands::RunTests {
+ name,
+ display,
+ vnc,
+ account,
+ current_app,
+ previous_app,
+ test_filters,
+ verbose,
+ test_report,
+ } => {
+ let summary_logger = match test_report {
+ Some(path) => Some(
+ summary::SummaryLogger::new(&name, &path)
+ .await
+ .context("Failed to create summary logger")?,
+ ),
+ None => None,
+ };
+
+ let mut config = config.clone();
+ config.runtime_opts.display = match (display, vnc.is_some()) {
+ (false, false) => config::Display::None,
+ (true, false) => config::Display::Local,
+ (false, true) => config::Display::Vnc,
+ (true, true) => unreachable!("invalid combination"),
+ };
+
+ let mullvad_host = config
+ .mullvad_host
+ .clone()
+ .unwrap_or(DEFAULT_MULLVAD_HOST.to_owned());
+ log::debug!("Mullvad host: {mullvad_host}");
+
+ let vm_config = vm::get_vm_config(&config, &name).context("Cannot get VM config")?;
+
+ let manifest = package::get_app_manifest(vm_config, current_app, previous_app)
+ .await
+ .context("Could not find the specified app packages")?;
+
+ let mut instance = vm::run(&config, &name)
+ .await
+ .context("Failed to start VM")?;
+ let artifacts_dir = vm::provision(&config, &name, &*instance, &manifest)
+ .await
+ .context("Failed to run provisioning for VM")?;
+
+ let skip_wait = vm_config.provisioner != config::Provisioner::Noop;
+
+ let result = run_tests::run(
+ tests::config::TestConfig {
+ account_number: account,
+ artifacts_dir,
+ current_app_filename: manifest
+ .current_app_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .into_owned(),
+ previous_app_filename: manifest
+ .previous_app_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .into_owned(),
+ ui_e2e_tests_filename: manifest
+ .ui_e2e_tests_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .into_owned(),
+ mullvad_host,
+ #[cfg(target_os = "macos")]
+ host_bridge_name: crate::vm::network::macos::find_vm_bridge()?,
+ #[cfg(not(target_os = "macos"))]
+ host_bridge_name: crate::vm::network::linux::BRIDGE_NAME.to_owned(),
+ },
+ &*instance,
+ &test_filters,
+ skip_wait,
+ !verbose,
+ summary_logger,
+ )
+ .await
+ .context("Tests failed");
+
+ if display {
+ instance.wait().await;
+ }
+ result
+ }
+ Commands::FormatTestReports { reports } => {
+ summary::print_summary_table(&reports).await;
+ Ok(())
+ }
+ Commands::Update { name } => {
+ let vm_config = vm::get_vm_config(&config, &name).context("Cannot get VM config")?;
+
+ let instance = vm::run(&config, &name)
+ .await
+ .context("Failed to start VM")?;
+
+ let update_output = vm::update_packages(vm_config.clone(), &*instance)
+ .await
+ .context("Failed to update packages to the VM image")?;
+ log::info!("Update command finished with output: {}", &update_output);
+ // TODO: If the update was successful, commit the changes to the VM image.
+ log::info!("Note: updates have not been persisted to the image");
+ Ok(())
+ }
+ }
+}
diff --git a/test/test-manager/src/mullvad_daemon.rs b/test/test-manager/src/mullvad_daemon.rs
new file mode 100644
index 0000000000..2bfff38dde
--- /dev/null
+++ b/test/test-manager/src/mullvad_daemon.rs
@@ -0,0 +1,180 @@
+use std::{io, time::Duration};
+
+use futures::{channel::mpsc, future::BoxFuture, pin_mut, FutureExt, SinkExt, StreamExt};
+use mullvad_management_interface::ManagementServiceClient;
+use test_rpc::{
+ mullvad_daemon::MullvadClientVersion,
+ transport::{ConnectionHandle, GrpcForwarder},
+};
+use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream};
+use tokio_util::codec::{Decoder, LengthDelimitedCodec};
+use tower::Service;
+
+const GRPC_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+const CONVERTER_BUF_SIZE: usize = 16 * 1024;
+
+#[derive(Clone)]
+struct DummyService {
+ management_channel_provider_tx: mpsc::UnboundedSender<DuplexStream>,
+}
+
+impl<Request> Service<Request> for DummyService {
+ type Response = DuplexStream;
+ type Error = std::io::Error;
+ type Future = BoxFuture<'static, Result<DuplexStream, Self::Error>>;
+
+ fn poll_ready(
+ &mut self,
+ _: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<Result<(), Self::Error>> {
+ std::task::Poll::Ready(Ok(()))
+ }
+
+ fn call(&mut self, _: Request) -> Self::Future {
+ log::trace!("DummyService::call");
+
+ let (channel_in, channel_out) = tokio::io::duplex(CONVERTER_BUF_SIZE);
+ let notifier_tx = self.management_channel_provider_tx.clone();
+
+ Box::pin(async move {
+ notifier_tx
+ .unbounded_send(channel_in)
+ .map_err(|_| io::Error::new(io::ErrorKind::Other, "stream receiver is down"))?;
+ Ok(channel_out)
+ })
+ }
+}
+
+#[derive(Clone)]
+pub struct RpcClientProvider {
+ service: DummyService,
+}
+
+impl RpcClientProvider {
+ pub async fn as_type(
+ &self,
+ client_type: MullvadClientVersion,
+ ) -> Box<dyn std::any::Any + Send> {
+ match client_type {
+ MullvadClientVersion::New => Box::new(self.new_client().await),
+ MullvadClientVersion::None => Box::new(()),
+ }
+ }
+
+ pub async fn new_client(&self) -> ManagementServiceClient {
+ // FIXME: Ugly workaround to ensure that we don't receive stuff from a
+ // previous RPC session.
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ log::debug!("Mullvad daemon: connecting");
+ let channel = tonic::transport::Endpoint::from_static("serial://placeholder")
+ .timeout(GRPC_REQUEST_TIMEOUT)
+ .connect_with_connector(self.service.clone())
+ .await
+ .unwrap();
+
+ ManagementServiceClient::new(channel)
+ }
+}
+
+pub async fn new_rpc_client(
+ connection_handle: ConnectionHandle,
+ mullvad_daemon_transport: GrpcForwarder,
+) -> RpcClientProvider {
+ let mut framed_transport = LengthDelimitedCodec::new().framed(mullvad_daemon_transport);
+ let (management_channel_provider_tx, mut management_channel_provider_rx) = mpsc::unbounded();
+
+ tokio::spawn(async move {
+ let mut read_buf = [0u8; CONVERTER_BUF_SIZE];
+ loop {
+ log::trace!("waiting for management interface client");
+
+ let mut management_channel_in: DuplexStream =
+ match management_channel_provider_rx.next().await {
+ Some(channel) => channel,
+ None => {
+ log::trace!("exiting management interface forward loop");
+ break;
+ }
+ };
+
+ // clear data from last session
+ while let Some(_next) = framed_transport.next().now_or_never() {}
+
+ loop {
+ let proxy_read = management_channel_in.read(&mut read_buf);
+ pin_mut!(proxy_read);
+
+ let reset_notified = connection_handle.notified_reset();
+ pin_mut!(reset_notified);
+
+ match futures::future::select(
+ reset_notified,
+ futures::future::select(framed_transport.next(), proxy_read),
+ )
+ .await
+ {
+ futures::future::Either::Left(_) => {
+ log::debug!("Restarting daemon RPC client");
+ break;
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Left((Some(Ok(bytes)), _)),
+ _,
+ )) => {
+ if bytes.is_empty() {
+ log::trace!("Management channel EOF");
+
+ if let Err(error) = management_channel_in.shutdown().await {
+ log::error!("Failed to shut down forwarder stream: {}", error);
+ }
+ break;
+ }
+ if management_channel_in.write_all(&bytes).await.is_err() {
+ break;
+ }
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Left((Some(Err(error)), _)),
+ _,
+ )) => {
+ log::debug!("Management channel stream errored: {}", error);
+ break;
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Left((None, _)),
+ _,
+ )) => break,
+ futures::future::Either::Right((
+ futures::future::Either::Right((Ok(num_bytes), _)),
+ _,
+ )) => {
+ if framed_transport
+ .send(read_buf[..num_bytes].to_vec().into())
+ .await
+ .is_err()
+ {
+ break;
+ }
+ if num_bytes == 0 {
+ log::trace!("Mullvad daemon connection EOF");
+ break;
+ }
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Right((Err(_), _)),
+ _,
+ )) => {
+ let _ = framed_transport.send(bytes::Bytes::new()).await;
+ break;
+ }
+ }
+ }
+ }
+ });
+
+ let service = DummyService {
+ management_channel_provider_tx,
+ };
+
+ RpcClientProvider { service }
+}
diff --git a/test/test-manager/src/network_monitor.rs b/test/test-manager/src/network_monitor.rs
new file mode 100644
index 0000000000..02c1e24d9e
--- /dev/null
+++ b/test/test-manager/src/network_monitor.rs
@@ -0,0 +1,331 @@
+use std::{
+ future::poll_fn,
+ net::{IpAddr, SocketAddr},
+ time::Duration,
+};
+
+use futures::{
+ channel::oneshot,
+ future::{select, Either},
+ pin_mut, StreamExt,
+};
+pub use pcap::Direction;
+use pcap::PacketCodec;
+use pnet_packet::{
+ ethernet::EtherTypes, ip::IpNextHeaderProtocol, ipv4::Ipv4Packet, ipv6::Ipv6Packet,
+ tcp::TcpPacket, udp::UdpPacket, Packet,
+};
+
+pub use pnet_packet::ip::IpNextHeaderProtocols as IpHeaderProtocols;
+
+use crate::tests::config::TEST_CONFIG;
+use crate::vm::network::CUSTOM_TUN_INTERFACE_NAME;
+
+struct Codec {
+ no_frame: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ParsedPacket {
+ pub source: SocketAddr,
+ pub destination: SocketAddr,
+ pub protocol: IpNextHeaderProtocol,
+}
+
+impl PacketCodec for Codec {
+ type Item = Option<ParsedPacket>;
+
+ fn decode(&mut self, packet: pcap::Packet) -> Self::Item {
+ if self.no_frame {
+ // skip utun header specifying an address family
+ #[cfg(target_os = "macos")]
+ let data = &packet.data[4..];
+ #[cfg(not(target_os = "macos"))]
+ let data = packet.data;
+ let ip_version = (data[0] & 0xf0) >> 4;
+
+ return match ip_version {
+ 4 => Self::parse_ipv4(data),
+ 6 => Self::parse_ipv6(data),
+ version => {
+ log::debug!("Ignoring unknown IP version: {version}");
+ None
+ }
+ };
+ }
+
+ let frame = pnet_packet::ethernet::EthernetPacket::new(packet.data).or_else(|| {
+ log::error!("Received invalid ethernet frame");
+ None
+ })?;
+
+ match frame.get_ethertype() {
+ EtherTypes::Ipv4 => Self::parse_ipv4(frame.payload()),
+ EtherTypes::Ipv6 => Self::parse_ipv6(frame.payload()),
+ ethertype => {
+ log::debug!("Ignoring unknown ethertype: {ethertype}");
+ None
+ }
+ }
+ }
+}
+
+impl Codec {
+ fn parse_ipv4(payload: &[u8]) -> Option<ParsedPacket> {
+ let packet = Ipv4Packet::new(payload).or_else(|| {
+ log::error!("invalid v4 packet");
+ None
+ })?;
+
+ let mut source = SocketAddr::new(IpAddr::V4(packet.get_source()), 0);
+ let mut destination = SocketAddr::new(IpAddr::V4(packet.get_destination()), 0);
+
+ let protocol = packet.get_next_level_protocol();
+
+ match protocol {
+ IpHeaderProtocols::Tcp => {
+ let seg = TcpPacket::new(packet.payload()).or_else(|| {
+ log::error!("invalid TCP segment");
+ None
+ })?;
+ source.set_port(seg.get_source());
+ destination.set_port(seg.get_destination());
+ }
+ IpHeaderProtocols::Udp => {
+ let seg = UdpPacket::new(packet.payload()).or_else(|| {
+ log::error!("invalid UDP fragment");
+ None
+ })?;
+ source.set_port(seg.get_source());
+ destination.set_port(seg.get_destination());
+ }
+ IpHeaderProtocols::Icmp => {}
+ proto => log::debug!("ignoring v4 packet, transport/protocol type {proto}"),
+ }
+
+ Some(ParsedPacket {
+ source,
+ destination,
+ protocol,
+ })
+ }
+
+ fn parse_ipv6(payload: &[u8]) -> Option<ParsedPacket> {
+ let packet = Ipv6Packet::new(payload).or_else(|| {
+ log::error!("invalid v6 packet");
+ None
+ })?;
+
+ let mut source = SocketAddr::new(IpAddr::V6(packet.get_source()), 0);
+ let mut destination = SocketAddr::new(IpAddr::V6(packet.get_destination()), 0);
+
+ let protocol = packet.get_next_header();
+ match protocol {
+ IpHeaderProtocols::Tcp => {
+ let seg = TcpPacket::new(packet.payload()).or_else(|| {
+ log::error!("invalid TCP segment");
+ None
+ })?;
+ source.set_port(seg.get_source());
+ destination.set_port(seg.get_destination());
+ }
+ IpHeaderProtocols::Udp => {
+ let seg = UdpPacket::new(packet.payload()).or_else(|| {
+ log::error!("invalid UDP fragment");
+ None
+ })?;
+ source.set_port(seg.get_source());
+ destination.set_port(seg.get_destination());
+ }
+ IpHeaderProtocols::Icmpv6 => {}
+ proto => log::debug!("ignoring v6 packet, transport/protocol type {proto}"),
+ }
+
+ Some(ParsedPacket {
+ source,
+ destination,
+ protocol,
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct MonitorUnexpectedlyStopped(());
+
+pub struct PacketMonitor {
+ handle: tokio::task::JoinHandle<Result<MonitorResult, MonitorUnexpectedlyStopped>>,
+ stop_tx: oneshot::Sender<()>,
+}
+
+pub struct MonitorResult {
+ pub packets: Vec<ParsedPacket>,
+ pub discarded_packets: usize,
+}
+
+impl PacketMonitor {
+ /// Stop monitoring and return the result.
+ pub async fn into_result(self) -> Result<MonitorResult, MonitorUnexpectedlyStopped> {
+ let _ = self.stop_tx.send(());
+ self.handle.await.expect("monitor panicked")
+ }
+
+ /// Wait for monitor to stop on its own.
+ pub async fn wait(self) -> Result<MonitorResult, MonitorUnexpectedlyStopped> {
+ self.handle.await.expect("monitor panicked")
+ }
+}
+
+#[derive(Default)]
+pub struct MonitorOptions {
+ pub timeout: Option<Duration>,
+ pub direction: Option<Direction>,
+ pub no_frame: bool,
+}
+
+pub async fn start_packet_monitor(
+ filter_fn: impl Fn(&ParsedPacket) -> bool + Send + 'static,
+ monitor_options: MonitorOptions,
+) -> PacketMonitor {
+ start_packet_monitor_until(filter_fn, |_| true, monitor_options).await
+}
+
+pub async fn start_packet_monitor_until(
+ filter_fn: impl Fn(&ParsedPacket) -> bool + Send + 'static,
+ should_continue_fn: impl FnMut(&ParsedPacket) -> bool + Send + 'static,
+ monitor_options: MonitorOptions,
+) -> PacketMonitor {
+ start_packet_monitor_for_interface(
+ &TEST_CONFIG.host_bridge_name,
+ filter_fn,
+ should_continue_fn,
+ monitor_options,
+ )
+ .await
+}
+
+pub async fn start_tunnel_packet_monitor_until(
+ filter_fn: impl Fn(&ParsedPacket) -> bool + Send + 'static,
+ should_continue_fn: impl FnMut(&ParsedPacket) -> bool + Send + 'static,
+ mut monitor_options: MonitorOptions,
+) -> PacketMonitor {
+ monitor_options.no_frame = true;
+ start_packet_monitor_for_interface(
+ CUSTOM_TUN_INTERFACE_NAME,
+ filter_fn,
+ should_continue_fn,
+ monitor_options,
+ )
+ .await
+}
+
+async fn start_packet_monitor_for_interface(
+ interface: &str,
+ filter_fn: impl Fn(&ParsedPacket) -> bool + Send + 'static,
+ mut should_continue_fn: impl FnMut(&ParsedPacket) -> bool + Send + 'static,
+ monitor_options: MonitorOptions,
+) -> PacketMonitor {
+ let dev = pcap::Capture::from_device(interface)
+ .expect("Failed to open capture handle")
+ .immediate_mode(true)
+ .open()
+ .expect("Failed to activate capture");
+
+ if let Some(direction) = monitor_options.direction {
+ dev.direction(direction).unwrap();
+ }
+
+ let dev = dev.setnonblock().unwrap();
+
+ let (is_receiving_tx, is_receiving_rx) = oneshot::channel();
+
+ let packet_stream = dev
+ .stream(Codec {
+ no_frame: monitor_options.no_frame,
+ })
+ .unwrap();
+ let (stop_tx, stop_rx) = oneshot::channel();
+
+ let interface = interface.to_owned();
+
+ let handle = tokio::spawn(async move {
+ let mut monitor_result = MonitorResult {
+ packets: vec![],
+ discarded_packets: 0,
+ };
+ let mut packet_stream = packet_stream.fuse();
+
+ let timeout = async move {
+ if let Some(timeout) = monitor_options.timeout {
+ tokio::time::sleep(timeout).await
+ } else {
+ futures::future::pending().await
+ }
+ };
+
+ pin_mut!(timeout);
+ pin_mut!(stop_rx);
+
+ let mut is_receiving_tx = Some(is_receiving_tx);
+
+ loop {
+ let mut next_packet_fut = packet_stream.next();
+ let next_packet =
+ poll_fn(|ctx| poll_and_notify(ctx, &mut next_packet_fut, &mut is_receiving_tx));
+
+ match select(select(next_packet, &mut stop_rx), &mut timeout).await {
+ Either::Left((Either::Left((Some(Ok(packet)), _)), _)) => {
+ if let Some(packet) = packet {
+ if !filter_fn(&packet) {
+ log::debug!(
+ "{interface} \"{packet:?}\" does not match closure conditions"
+ );
+ monitor_result.discarded_packets =
+ monitor_result.discarded_packets.saturating_add(1);
+ } else {
+ log::debug!("{interface} \"{packet:?}\" matches closure conditions");
+
+ let should_continue = should_continue_fn(&packet);
+
+ monitor_result.packets.push(packet);
+
+ if !should_continue {
+ break Ok(monitor_result);
+ }
+ }
+ }
+ }
+ Either::Left((Either::Left(_), _)) => {
+ log::error!("lost packet stream");
+ break Err(MonitorUnexpectedlyStopped(()));
+ }
+ Either::Left((Either::Right(_), _)) => {
+ log::trace!("stopping packet monitor");
+ break Ok(monitor_result);
+ }
+ Either::Right(_) => {
+ log::info!("monitor timed out");
+ break Ok(monitor_result);
+ }
+ }
+ }
+ });
+
+ // Wait for the loop to start receiving its first packet
+ let _ = is_receiving_rx.await;
+
+ PacketMonitor { stop_tx, handle }
+}
+
+/// Poll the future once and notify `tx` that it has been polled. Then return
+/// the result of this polling.
+fn poll_and_notify<F: std::future::Future<Output = O> + Unpin, O>(
+ context: &mut std::task::Context<'_>,
+ fut: &mut F,
+ tx: &mut Option<oneshot::Sender<()>>,
+) -> std::task::Poll<O> {
+ let result = std::pin::Pin::new(fut).poll(context);
+ if let Some(tx) = tx.take() {
+ let _ = tx.send(());
+ }
+ result
+}
diff --git a/test/test-manager/src/package.rs b/test/test-manager/src/package.rs
new file mode 100644
index 0000000000..3f35163e5e
--- /dev/null
+++ b/test/test-manager/src/package.rs
@@ -0,0 +1,158 @@
+use crate::config::{Architecture, OsType, PackageType, VmConfig};
+use anyhow::{Context, Result};
+use once_cell::sync::Lazy;
+use regex::Regex;
+use std::path::{Path, PathBuf};
+use tokio::fs;
+
+static VERSION_REGEX: Lazy<Regex> =
+ Lazy::new(|| Regex::new(r"\d{4}\.\d+(-beta\d+)?(-dev)?-([0-9a-z])+").unwrap());
+
+#[derive(Debug, Clone)]
+pub struct Manifest {
+ pub current_app_path: PathBuf,
+ pub previous_app_path: PathBuf,
+ pub ui_e2e_tests_path: PathBuf,
+}
+
+/// Obtain app packages and their filenames
+/// If it's a path, use the path.
+/// If it corresponds to a file in packages/, use that package.
+/// TODO: If it's a git tag or rev, download it.
+pub async fn get_app_manifest(
+ config: &VmConfig,
+ current_app: String,
+ previous_app: String,
+) -> Result<Manifest> {
+ let package_type = (config.os_type, config.package_type, config.architecture);
+
+ let current_app_path = find_app(&current_app, false, package_type).await?;
+ log::info!("Current app: {}", current_app_path.display());
+
+ let previous_app_path = find_app(&previous_app, false, package_type).await?;
+ log::info!("Previous app: {}", previous_app_path.display());
+
+ let capture = VERSION_REGEX
+ .captures(current_app_path.to_str().unwrap())
+ .with_context(|| format!("Cannot parse version: {}", current_app_path.display()))?
+ .get(0)
+ .map(|c| c.as_str())
+ .expect("Could not parse version from package name: {current_app}");
+
+ let ui_e2e_tests_path = find_app(capture, true, package_type).await?;
+ log::info!("Runner executable: {}", ui_e2e_tests_path.display());
+
+ Ok(Manifest {
+ current_app_path,
+ previous_app_path,
+ ui_e2e_tests_path,
+ })
+}
+
+async fn find_app(
+ app: &str,
+ e2e_bin: bool,
+ package_type: (OsType, Option<PackageType>, Option<Architecture>),
+) -> Result<PathBuf> {
+ // If it's a path, use that path
+ let app_path = Path::new(app);
+ if app_path.is_file() {
+ // TODO: Copy to packages?
+ return Ok(app_path.to_path_buf());
+ }
+
+ let mut app = app.to_owned();
+ app.make_ascii_lowercase();
+
+ let packages_dir = dirs::cache_dir()
+ .context("Could not find cache directory")?
+ .join("mullvad-test")
+ .join("packages");
+ fs::create_dir_all(&packages_dir).await?;
+ let mut dir = fs::read_dir(packages_dir)
+ .await
+ .context("Failed to list packages")?;
+
+ let mut matches = vec![];
+
+ while let Ok(Some(entry)) = dir.next_entry().await {
+ let path = entry.path();
+ if !path.is_file() {
+ continue;
+ }
+
+ // Filter out irrelevant platforms
+ if !e2e_bin {
+ let ext = get_ext(package_type);
+
+ // Skip file if wrong file extension
+ if !path
+ .extension()
+ .map(|m_ext| m_ext.eq_ignore_ascii_case(ext))
+ .unwrap_or(false)
+ {
+ continue;
+ }
+ }
+
+ let mut u8_path = path.as_os_str().to_string_lossy().into_owned();
+ u8_path.make_ascii_lowercase();
+
+ // Skip non-UI-e2e binaries or vice versa
+ if e2e_bin ^ u8_path.contains("app-e2e-tests") {
+ continue;
+ }
+
+ // Filter out irrelevant platforms
+ if e2e_bin && !u8_path.contains(get_os_name(package_type)) {
+ continue;
+ }
+
+ // Skip file if it doesn't match the architecture
+ if let Some(arch) = package_type.2 {
+ // Skip for non-e2e bin on non-Linux, because there's only one package
+ if (e2e_bin || package_type.0 == OsType::Linux)
+ && !arch.get_identifiers().iter().any(|id| u8_path.contains(id))
+ {
+ continue;
+ }
+ }
+
+ if u8_path.contains(&app) {
+ matches.push(path);
+ }
+ }
+
+ // TODO: Search for package in git repository if not found
+
+ // Take the shortest match
+ matches.sort_unstable_by_key(|path| path.as_os_str().len());
+ matches.into_iter().next().context(if e2e_bin {
+ format!(
+ "Could not find UI/e2e test for package: {app}.\n\
+ Expecting a binary named like `app-e2e-tests-{app}_ARCH` to exist in packages/\n\
+ Example ARCH: `amd64-unknown-linux-gnu`, `x86_64-unknown-linux-gnu`"
+ )
+ } else {
+ format!("Could not find package for app: {app}")
+ })
+}
+
+fn get_ext(package_type: (OsType, Option<PackageType>, Option<Architecture>)) -> &'static str {
+ match package_type.0 {
+ OsType::Windows => "exe",
+ OsType::Macos => "pkg",
+ OsType::Linux => match package_type.1.expect("must specify package type") {
+ PackageType::Deb => "deb",
+ PackageType::Rpm => "rpm",
+ },
+ }
+}
+
+fn get_os_name(package_type: (OsType, Option<PackageType>, Option<Architecture>)) -> &'static str {
+ match package_type.0 {
+ OsType::Windows => "windows",
+ OsType::Macos => "apple",
+ OsType::Linux => "linux",
+ }
+}
diff --git a/test/test-manager/src/run_tests.rs b/test/test-manager/src/run_tests.rs
new file mode 100644
index 0000000000..f0ff402034
--- /dev/null
+++ b/test/test-manager/src/run_tests.rs
@@ -0,0 +1,222 @@
+use crate::summary::{self, maybe_log_test_result};
+use crate::tests::TestContext;
+use crate::{
+ logging::{panic_as_string, TestOutput},
+ mullvad_daemon, tests,
+ tests::Error,
+ vm,
+};
+use anyhow::{Context, Result};
+use futures::FutureExt;
+use mullvad_management_interface::ManagementServiceClient;
+use std::future::Future;
+use std::panic;
+use std::time::Duration;
+use test_rpc::logging::Output;
+use test_rpc::{mullvad_daemon::MullvadClientVersion, ServiceClient};
+
+const BAUD: u32 = 115200;
+
+pub async fn run(
+ config: tests::config::TestConfig,
+ instance: &dyn vm::VmInstance,
+ test_filters: &[String],
+ skip_wait: bool,
+ print_failed_tests_only: bool,
+ mut summary_logger: Option<summary::SummaryLogger>,
+) -> Result<()> {
+ log::trace!("Setting test constants");
+ tests::config::TEST_CONFIG.init(config);
+
+ let pty_path = instance.get_pty();
+
+ log::info!("Connecting to {pty_path}");
+
+ let serial_stream =
+ tokio_serial::SerialStream::open(&tokio_serial::new(pty_path, BAUD)).unwrap();
+ let (runner_transport, mullvad_daemon_transport, mut connection_handle, completion_handle) =
+ test_rpc::transport::create_client_transports(serial_stream).await?;
+
+ if !skip_wait {
+ connection_handle.wait_for_server().await?;
+ }
+
+ log::info!("Running client");
+
+ let client = ServiceClient::new(connection_handle.clone(), runner_transport);
+ let mullvad_client =
+ mullvad_daemon::new_rpc_client(connection_handle, mullvad_daemon_transport).await;
+
+ let mut tests: Vec<_> = inventory::iter::<tests::TestMetadata>().collect();
+ tests.sort_by_key(|test| test.priority.unwrap_or(0));
+
+ if !test_filters.is_empty() {
+ tests.retain(|test| {
+ if test.always_run {
+ return true;
+ }
+ for command in test_filters {
+ let command = command.to_lowercase();
+ if test.command.to_lowercase().contains(&command) {
+ return true;
+ }
+ }
+ false
+ });
+ }
+
+ let mut final_result = Ok(());
+
+ let test_context = TestContext {
+ rpc_provider: mullvad_client,
+ };
+
+ let mut successful_tests = vec![];
+ let mut failed_tests = vec![];
+
+ let logger = super::logging::Logger::get_or_init();
+
+ for test in tests {
+ let mut mclient = test_context
+ .rpc_provider
+ .as_type(test.mullvad_client_version)
+ .await;
+
+ if let Some(client) = mclient.downcast_mut::<ManagementServiceClient>() {
+ crate::tests::init_default_settings(client).await;
+ }
+
+ log::info!("Running {}", test.name);
+
+ if print_failed_tests_only {
+ // Stop live record
+ logger.store_records(true);
+ }
+
+ let test_result = run_test(
+ client.clone(),
+ mclient,
+ &test.func,
+ test.name,
+ test_context.clone(),
+ )
+ .await;
+
+ if test.mullvad_client_version == MullvadClientVersion::New {
+ // 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?;
+ }
+ }
+
+ if print_failed_tests_only {
+ // Print results of failed test
+ if matches!(test_result.result, Err(_) | Ok(Err(_))) {
+ logger.print_stored_records();
+ } else {
+ logger.flush_records();
+ }
+ logger.store_records(false);
+ }
+
+ test_result.print();
+
+ let test_succeeded = matches!(test_result.result, Ok(Ok(_)));
+
+ maybe_log_test_result(
+ summary_logger.as_mut(),
+ test.name,
+ if test_succeeded {
+ summary::TestResult::Pass
+ } else {
+ summary::TestResult::Fail
+ },
+ )
+ .await
+ .context("Failed to log test result")?;
+
+ match test_result.result {
+ Err(panic) => {
+ failed_tests.push(test.name);
+ final_result = Err(panic).context("test panicked");
+ if test.must_succeed {
+ break;
+ }
+ }
+ Ok(Err(failure)) => {
+ failed_tests.push(test.name);
+ final_result = Err(failure).context("test failed");
+ if test.must_succeed {
+ break;
+ }
+ }
+ Ok(Ok(result)) => {
+ successful_tests.push(test.name);
+ final_result = final_result.and(Ok(result));
+ }
+ }
+ }
+
+ log::info!("TESTS THAT SUCCEEDED:");
+ for test in successful_tests {
+ log::info!("{test}");
+ }
+
+ log::info!("TESTS THAT FAILED:");
+ for test in failed_tests {
+ log::info!("{test}");
+ }
+
+ // wait for cleanup
+ drop(test_context);
+ let _ = tokio::time::timeout(Duration::from_secs(5), completion_handle).await;
+
+ final_result
+}
+
+pub async fn run_test<F, R, MullvadClient>(
+ runner_rpc: ServiceClient,
+ mullvad_rpc: MullvadClient,
+ test: &F,
+ test_name: &'static str,
+ test_context: super::tests::TestContext,
+) -> TestOutput
+where
+ F: Fn(super::tests::TestContext, ServiceClient, MullvadClient) -> R,
+ R: Future<Output = Result<(), Error>>,
+{
+ let _flushed = runner_rpc.try_poll_output().await;
+
+ // Assert that the test is unwind safe, this is the same assertion that cargo tests do. This
+ // assertion being incorrect can not lead to memory unsafety however it could theoretically
+ // lead to logic bugs. The problem of forcing the test to be unwind safe is that it causes a
+ // large amount of unergonomic design.
+ let result = panic::AssertUnwindSafe(test(test_context, runner_rpc.clone(), mullvad_rpc))
+ .catch_unwind()
+ .await
+ .map_err(panic_as_string);
+
+ let mut output = vec![];
+ if matches!(result, Ok(Err(_)) | Err(_)) {
+ let output_after_test = runner_rpc.try_poll_output().await;
+ match output_after_test {
+ Ok(mut output_after_test) => {
+ output.append(&mut output_after_test);
+ }
+ Err(e) => {
+ output.push(Output::Other(format!("could not get logs: {:?}", e)));
+ }
+ }
+ }
+
+ let log_output = runner_rpc.get_mullvad_app_logs().await.ok();
+
+ TestOutput {
+ log_output,
+ test_name,
+ error_messages: output,
+ result,
+ }
+}
diff --git a/test/test-manager/src/summary.rs b/test/test-manager/src/summary.rs
new file mode 100644
index 0000000000..cad11aca83
--- /dev/null
+++ b/test/test-manager/src/summary.rs
@@ -0,0 +1,285 @@
+use std::{collections::BTreeMap, io, path::Path};
+use tokio::{
+ fs,
+ io::{AsyncBufReadExt, AsyncWriteExt},
+};
+
+#[derive(err_derive::Error, Debug)]
+#[error(no_from)]
+pub enum Error {
+ #[error(display = "Failed to open log file {:?}", _1)]
+ Open(#[error(source)] io::Error, std::path::PathBuf),
+ #[error(display = "Failed to write to log file")]
+ Write(#[error(source)] io::Error),
+ #[error(display = "Failed to read from log file")]
+ Read(#[error(source)] io::Error),
+ #[error(display = "Failed to parse log file")]
+ Parse,
+}
+
+#[derive(Clone, Copy)]
+pub enum TestResult {
+ Pass,
+ Fail,
+ Unknown,
+}
+
+impl TestResult {
+ const PASS_STR: &'static str = "✅";
+ const FAIL_STR: &'static str = "❌";
+ const UNKNOWN_STR: &'static str = " ";
+}
+
+impl std::str::FromStr for TestResult {
+ type Err = Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ TestResult::PASS_STR => Ok(TestResult::Pass),
+ TestResult::FAIL_STR => Ok(TestResult::Fail),
+ _ => Ok(TestResult::Unknown),
+ }
+ }
+}
+
+impl std::fmt::Display for TestResult {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TestResult::Pass => f.write_str(TestResult::PASS_STR),
+ TestResult::Fail => f.write_str(TestResult::FAIL_STR),
+ TestResult::Unknown => f.write_str(TestResult::UNKNOWN_STR),
+ }
+ }
+}
+
+/// Logger that outputs test results in a structured format
+pub struct SummaryLogger {
+ file: fs::File,
+}
+
+impl SummaryLogger {
+ /// Create a new logger and log to `path`. If `path` does not exist, it will be created. If it
+ /// already exists, it is truncated and overwritten.
+ pub async fn new(name: &str, path: &Path) -> Result<SummaryLogger, Error> {
+ let mut file = fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(path)
+ .await
+ .map_err(|err| Error::Open(err, path.to_path_buf()))?;
+
+ // The first row is the summary name
+ file.write_all(name.as_bytes())
+ .await
+ .map_err(Error::Write)?;
+ file.write_u8(b'\n').await.map_err(Error::Write)?;
+
+ Ok(SummaryLogger { file })
+ }
+
+ pub async fn log_test_result(
+ &mut self,
+ test_name: &str,
+ test_result: TestResult,
+ ) -> Result<(), Error> {
+ self.file
+ .write_all(test_name.as_bytes())
+ .await
+ .map_err(Error::Write)?;
+ self.file.write_u8(b' ').await.map_err(Error::Write)?;
+ self.file
+ .write_all(test_result.to_string().as_bytes())
+ .await
+ .map_err(Error::Write)?;
+ self.file.write_u8(b'\n').await.map_err(Error::Write)?;
+
+ Ok(())
+ }
+}
+
+/// Convenience function that logs when there's a value, and is a no-op otherwise.
+// y u no trait async fn
+pub async fn maybe_log_test_result(
+ summary_logger: Option<&mut SummaryLogger>,
+ test_name: &str,
+ test_result: TestResult,
+) -> Result<(), Error> {
+ match summary_logger {
+ Some(logger) => logger.log_test_result(test_name, test_result).await,
+ None => Ok(()),
+ }
+}
+
+/// Parsed summary results
+pub struct Summary {
+ /// Summary name
+ name: String,
+ /// Pairs of test names mapped to test results
+ results: BTreeMap<String, TestResult>,
+}
+
+impl Summary {
+ /// Read test summary from `path`.
+ pub async fn parse_log<P: AsRef<Path>>(path: P) -> Result<Summary, Error> {
+ let file = fs::OpenOptions::new()
+ .read(true)
+ .open(&path)
+ .await
+ .map_err(|err| Error::Open(err, path.as_ref().to_path_buf()))?;
+
+ let mut lines = tokio::io::BufReader::new(file).lines();
+
+ let name = lines
+ .next_line()
+ .await
+ .map_err(Error::Read)?
+ .ok_or(Error::Parse)?;
+
+ let mut results = BTreeMap::new();
+
+ while let Some(line) = lines.next_line().await.map_err(Error::Read)? {
+ let mut cols = line.split_whitespace();
+
+ let test_name = cols.next().ok_or(Error::Parse)?;
+ let test_result = cols.next().ok_or(Error::Parse)?.parse()?;
+
+ results.insert(test_name.to_owned(), test_result);
+ }
+
+ Ok(Summary { name, results })
+ }
+
+ // Return all tests which passed.
+ fn passed(&self) -> Vec<&TestResult> {
+ self.results
+ .values()
+ .filter(|x| matches!(x, TestResult::Pass))
+ .collect()
+ }
+}
+
+/// Outputs an HTML table, to stdout, containing the results of the given log files.
+///
+/// This is a best effort attempt at summarizing the log files which do
+/// exist. If some log file which is expected to exist, but for any reason fails to
+/// be parsed, we should not abort the entire summarization.
+pub async fn print_summary_table<P: AsRef<Path>>(summary_files: &[P]) {
+ let mut summaries = Vec::new();
+ let mut failed_to_parse = Vec::new();
+ for sumfile in summary_files {
+ match Summary::parse_log(sumfile).await {
+ Ok(summary) => summaries.push(summary),
+ Err(_) => failed_to_parse.push(sumfile),
+ }
+ }
+
+ // Collect test details
+ let tests: Vec<_> = inventory::iter::<crate::tests::TestMetadata>().collect();
+
+ // Add some styling to the summary.
+ println!("<head> <style> table, th, td {{ border: 1px solid black; }} </style> </head>");
+
+ // Print a table
+ println!("<table>");
+
+ // First row: Print summary names
+ println!("<tr>");
+ println!("<td style='text-align: center;'>Test ⬇️ / Platform ➡️ </td>");
+
+ for summary in &summaries {
+ let total_tests = tests.len();
+ let total_passed = summary.passed().len();
+ let counter_text = if total_passed == total_tests {
+ String::from(TestResult::PASS_STR)
+ } else {
+ format!("({}/{})", total_passed, total_tests)
+ };
+ println!(
+ "<td style='text-align: center;'>{} {}</td>",
+ summary.name, counter_text
+ );
+ }
+
+ // A summary of all OSes
+ println!("<td style='text-align: center;'>");
+ println!("{}", {
+ let oses_passed: Vec<_> = summaries
+ .iter()
+ .filter(|summary| summary.passed().len() == tests.len())
+ .collect();
+ if oses_passed.len() == summaries.len() {
+ "🎉 All Platforms passed 🎉".to_string()
+ } else {
+ let failed: usize = summaries
+ .iter()
+ .map(|summary| {
+ if summary.passed().len() == tests.len() {
+ 0
+ } else {
+ 1
+ }
+ })
+ .sum();
+ format!("🌧️ ️ {failed} Platform(s) failed 🌧️")
+ }
+ });
+ println!("</td>");
+
+ // List all tests again
+ println!("<td style='text-align: center;'>Test ⬇️</td>");
+
+ println!("</tr>");
+
+ // Remaining rows: Print results for each test and each summary
+ for test in &tests {
+ println!("<tr>");
+
+ println!(
+ "<td>{}{}</td>",
+ test.name,
+ if test.must_succeed { " *" } else { "" }
+ );
+
+ let mut failed_platforms = vec![];
+ for summary in &summaries {
+ let result = summary
+ .results
+ .get(test.name)
+ .unwrap_or(&TestResult::Unknown);
+ match result {
+ TestResult::Fail | TestResult::Unknown => {
+ failed_platforms.push(summary.name.clone())
+ }
+ TestResult::Pass => (),
+ }
+ println!("<td style='text-align: center;'>{}</td>", result);
+ }
+ // Print a summary of all OSes at the end of the table
+ // For each test, collect the result for each platform.
+ // - If the test passed on all platforms, we print a symbol declaring success
+ // - If the test failed on any platform, we print the platform
+ println!("<td style='text-align: center;'>");
+ print!(
+ "{}",
+ if failed_platforms.is_empty() {
+ TestResult::PASS_STR.to_string()
+ } else {
+ failed_platforms.join(", ")
+ }
+ );
+ println!("</td>");
+
+ // List the test name again (Useful for the summary accross the different platforms)
+ println!("<td>{}</td>", test.name);
+
+ // End row
+ println!("</tr>");
+ }
+
+ println!("</table>");
+
+ // Print explanation of test result
+ println!("<p>{} = Test passed</p>", TestResult::PASS_STR);
+ println!("<p>{} = Test failed</p>", TestResult::FAIL_STR);
+}
diff --git a/test/test-manager/src/tests/account.rs b/test/test-manager/src/tests/account.rs
new file mode 100644
index 0000000000..5b1991abb5
--- /dev/null
+++ b/test/test-manager/src/tests/account.rs
@@ -0,0 +1,381 @@
+use super::config::TEST_CONFIG;
+use super::{helpers, ui, Error, TestContext};
+use mullvad_api::DevicesProxy;
+use mullvad_management_interface::{types, Code, ManagementServiceClient};
+use mullvad_types::device::Device;
+use mullvad_types::states::TunnelState;
+use std::net::ToSocketAddrs;
+use std::time::Duration;
+use talpid_types::net::wireguard;
+use test_macro::test_function;
+use test_rpc::ServiceClient;
+
+const THROTTLE_RETRY_DELAY: Duration = Duration::from_secs(120);
+
+/// Log in and create a new device for the account.
+#[test_function(always_run = true, must_succeed = true, priority = -100)]
+pub async fn test_login(
+ _: TestContext,
+ _rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ //
+ // Instruct daemon to log in
+ //
+
+ clear_devices(&new_device_client().await)
+ .await
+ .expect("failed to clear devices");
+
+ log::info!("Logging in/generating device");
+ login_with_retries(&mut mullvad_client)
+ .await
+ .expect("login failed");
+
+ // Wait for the relay list to be updated
+ helpers::ensure_updated_relay_list(&mut mullvad_client).await;
+
+ Ok(())
+}
+
+/// Log out and remove the current device
+/// from the account.
+#[test_function(priority = 100)]
+pub async fn test_logout(
+ _: TestContext,
+ _rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Removing device");
+
+ mullvad_client
+ .logout_account(())
+ .await
+ .expect("logout failed");
+
+ Ok(())
+}
+
+/// Try to log in when there are too many devices. Make sure it fails as expected.
+#[test_function(priority = -151)]
+pub async fn test_too_many_devices(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Using up all devices");
+
+ let device_client = new_device_client().await;
+
+ const MAX_ATTEMPTS: usize = 15;
+
+ for _ in 0..MAX_ATTEMPTS {
+ let pubkey = wireguard::PrivateKey::new_from_random().public_key();
+
+ match device_client
+ .create(TEST_CONFIG.account_number.clone(), pubkey)
+ .await
+ {
+ Ok(_) => (),
+ Err(mullvad_api::rest::Error::ApiError(_status, ref code))
+ if code == mullvad_api::MAX_DEVICES_REACHED =>
+ {
+ break;
+ }
+ Err(error) => {
+ log::error!(
+ "Failed to generate device: {error:?}. Retrying after {} seconds",
+ THROTTLE_RETRY_DELAY.as_secs()
+ );
+ // Sleep for an overly long time.
+ // TODO: Only sleep for this long if the error is caused by throttling.
+ tokio::time::sleep(THROTTLE_RETRY_DELAY).await;
+ }
+ }
+ }
+
+ log::info!("Log in with too many devices");
+ let login_result = login_with_retries(&mut mullvad_client).await;
+
+ assert!(matches!(login_result, Err(status) if status.code() == Code::ResourceExhausted));
+
+ // Run UI test
+ let ui_result = ui::run_test_env(
+ &rpc,
+ &["too-many-devices.spec"],
+ [("ACCOUNT_NUMBER", &*TEST_CONFIG.account_number)],
+ )
+ .await
+ .unwrap();
+ assert!(ui_result.success());
+
+ if let Err(error) = clear_devices(&device_client).await {
+ log::error!("Failed to clear devices: {error}");
+ }
+
+ Ok(())
+}
+
+/// Test whether the daemon can detect that the current device has been revoked, and enters the
+/// error state in that case.
+///
+/// # Limitations
+///
+/// Currently, this test does not check whether the daemon automatically detects that the device has
+/// been revoked while reconnecting.
+#[test_function(priority = -150)]
+pub async fn test_revoked_device(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Logging in/generating device");
+ login_with_retries(&mut mullvad_client)
+ .await
+ .expect("login failed");
+
+ let device_id = mullvad_client
+ .get_device(())
+ .await
+ .expect("failed to get device data")
+ .into_inner()
+ .device
+ .unwrap()
+ .device
+ .unwrap()
+ .id;
+
+ helpers::connect_and_wait(&mut mullvad_client).await?;
+
+ log::debug!("Removing current device");
+
+ let device_client = new_device_client().await;
+ retry_if_throttled(|| {
+ device_client.remove(TEST_CONFIG.account_number.clone(), device_id.clone())
+ })
+ .await
+ .expect("failed to revoke device");
+
+ // Sleep for a while: the device state is only updated if sufficiently old,
+ // so `update_device` might be a no-op if called too often.
+ const PRE_UPDATE_SLEEP: Duration = Duration::from_secs(12);
+ tokio::time::sleep(PRE_UPDATE_SLEEP).await;
+
+ // Begin listening to tunnel state changes first, so that we catch changes due to
+ // `update_device`.
+ let events = mullvad_client
+ .events_listen(())
+ .await
+ .expect("failed to begin listening for state changes")
+ .into_inner();
+ let next_state =
+ helpers::find_next_tunnel_state(events, |state| matches!(state, TunnelState::Error(..),));
+
+ log::debug!("Update device state");
+
+ let _update_status = mullvad_client.update_device(()).await;
+
+ // Ensure that the tunnel state transitions to "error". Fail if it transitions to some other
+ // state.
+ let new_state = next_state.await?;
+ assert!(
+ matches!(&new_state, TunnelState::Error(error_state) if error_state.is_blocking()),
+ "expected blocking error state, got {new_state:?}"
+ );
+
+ // Verify that the device state is `Revoked`.
+ let device_state = mullvad_client
+ .get_device(())
+ .await
+ .expect("failed to get device data");
+ assert_eq!(
+ device_state.into_inner().state,
+ i32::from(types::device_state::State::Revoked),
+ "expected device to be revoked"
+ );
+
+ // Run UI test
+ let ui_result = ui::run_test(&rpc, &["device-revoked.spec"]).await.unwrap();
+ assert!(ui_result.success());
+
+ Ok(())
+}
+
+/// Remove all devices on the current account
+pub async fn clear_devices(device_client: &DevicesProxy) -> Result<(), mullvad_api::rest::Error> {
+ log::info!("Removing all devices for account");
+
+ for dev in list_devices_with_retries(device_client).await?.into_iter() {
+ if let Err(error) = device_client
+ .remove(TEST_CONFIG.account_number.clone(), dev.id)
+ .await
+ {
+ log::warn!("Failed to remove device: {error}");
+ }
+ }
+ Ok(())
+}
+
+pub async fn new_device_client() -> DevicesProxy {
+ let api_endpoint = mullvad_api::ApiEndpoint::from_env_vars();
+
+ let api_host = format!("api.{}", TEST_CONFIG.mullvad_host);
+ let api_addr = format!("{api_host}:443")
+ .to_socket_addrs()
+ .expect("failed to resolve API host")
+ .next()
+ .unwrap();
+
+ // Override the API endpoint to use the one specified in the test config
+ let _ = mullvad_api::API.override_init(mullvad_api::ApiEndpoint {
+ host: api_host,
+ addr: api_addr,
+ ..api_endpoint
+ });
+
+ let api = mullvad_api::Runtime::new(tokio::runtime::Handle::current())
+ .expect("failed to create api runtime");
+ let rest_handle = api
+ .mullvad_rest_handle(
+ mullvad_api::proxy::ApiConnectionMode::Direct.into_repeat(),
+ |_| async { true },
+ )
+ .await;
+ DevicesProxy::new(rest_handle)
+}
+
+/// Log in and retry if it fails due to throttling
+pub async fn login_with_retries(
+ mullvad_client: &mut ManagementServiceClient,
+) -> Result<(), mullvad_management_interface::Status> {
+ loop {
+ let result = mullvad_client
+ .login_account(TEST_CONFIG.account_number.clone())
+ .await;
+
+ if let Err(error) = result {
+ if !error.message().contains("THROTTLED") {
+ return Err(error);
+ }
+
+ // Work around throttling errors by sleeping
+
+ log::debug!(
+ "Login failed due to throttling. Sleeping for {} seconds",
+ THROTTLE_RETRY_DELAY.as_secs()
+ );
+
+ tokio::time::sleep(THROTTLE_RETRY_DELAY).await;
+ } else {
+ break Ok(());
+ }
+ }
+}
+
+pub async fn list_devices_with_retries(
+ device_client: &DevicesProxy,
+) -> Result<Vec<Device>, mullvad_api::rest::Error> {
+ retry_if_throttled(|| device_client.list(TEST_CONFIG.account_number.clone())).await
+}
+
+pub async fn retry_if_throttled<
+ F: std::future::Future<Output = Result<T, mullvad_api::rest::Error>>,
+ T,
+>(
+ new_attempt: impl Fn() -> F,
+) -> Result<T, mullvad_api::rest::Error> {
+ loop {
+ match new_attempt().await {
+ Ok(val) => break Ok(val),
+ // Work around throttling errors by sleeping
+ Err(mullvad_api::rest::Error::ApiError(
+ mullvad_api::rest::StatusCode::TOO_MANY_REQUESTS,
+ _,
+ )) => {
+ log::debug!(
+ "Device list fetch failed due to throttling. Sleeping for {} seconds",
+ THROTTLE_RETRY_DELAY.as_secs()
+ );
+
+ tokio::time::sleep(THROTTLE_RETRY_DELAY).await;
+ }
+ Err(error) => break Err(error),
+ }
+ }
+}
+
+#[test_function]
+pub async fn test_automatic_wireguard_rotation(
+ ctx: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ // Make note of current WG key
+ let old_key = mullvad_client
+ .get_device(())
+ .await
+ .expect("Could not get device")
+ .into_inner()
+ .device
+ .unwrap()
+ .device
+ .unwrap()
+ .pubkey;
+
+ // Stop daemon
+ rpc.set_mullvad_daemon_service_state(false)
+ .await
+ .expect("Could not stop system service");
+
+ // Open device.json and change created field to more than 7 days ago
+ rpc.make_device_json_old()
+ .await
+ .expect("Could not change device.json to have an old created timestamp");
+
+ // Start daemon
+ rpc.set_mullvad_daemon_service_state(true)
+ .await
+ .expect("Could not start system service");
+
+ // NOTE: Need to create a new `mullvad_client` here after the restart otherwise we can't
+ // communicate with the daemon
+ drop(mullvad_client);
+ let mut mullvad_client = ctx.rpc_provider.new_client().await;
+
+ // Verify rotation has happened after a minute
+ const KEY_ROTATION_TIMEOUT: Duration = Duration::from_secs(100);
+
+ let mut event_stream = mullvad_client.events_listen(()).await.unwrap().into_inner();
+ let get_pub_key_event = async {
+ loop {
+ let message = event_stream.message().await;
+ if let Ok(Some(event)) = message {
+ match event.event.unwrap() {
+ mullvad_management_interface::types::daemon_event::Event::Device(
+ device_event,
+ ) => {
+ let pubkey = device_event
+ .new_state
+ .unwrap()
+ .device
+ .unwrap()
+ .device
+ .unwrap()
+ .pubkey;
+ return Ok(pubkey);
+ }
+ _ => continue,
+ }
+ }
+ return Err(message);
+ }
+ };
+
+ let new_key = tokio::time::timeout(KEY_ROTATION_TIMEOUT, get_pub_key_event)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_ne!(old_key, new_key);
+ Ok(())
+}
diff --git a/test/test-manager/src/tests/config.rs b/test/test-manager/src/tests/config.rs
new file mode 100644
index 0000000000..a0a22368dd
--- /dev/null
+++ b/test/test-manager/src/tests/config.rs
@@ -0,0 +1,47 @@
+use once_cell::sync::OnceCell;
+use std::ops::Deref;
+
+// Default `mullvad_host`. This should match the production env.
+pub const DEFAULT_MULLVAD_HOST: &str = "mullvad.net";
+
+/// Constants that are accessible from each test via `TEST_CONFIG`.
+/// The constants must be initialized before running any tests using `TEST_CONFIG.init()`.
+#[derive(Debug, Clone)]
+pub struct TestConfig {
+ pub account_number: String,
+
+ pub artifacts_dir: String,
+ pub current_app_filename: String,
+ pub previous_app_filename: String,
+ pub ui_e2e_tests_filename: String,
+
+ /// Used to override MULLVAD_API_*, for conncheck,
+ /// and for resolving relay IPs.
+ pub mullvad_host: String,
+
+ pub host_bridge_name: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct TestConfigContainer(OnceCell<TestConfig>);
+
+impl TestConfigContainer {
+ /// Initializes the constants.
+ ///
+ /// # Panics
+ ///
+ /// This panics if the config has already been initialized.
+ pub fn init(&self, inner: TestConfig) {
+ self.0.set(inner).unwrap()
+ }
+}
+
+impl Deref for TestConfigContainer {
+ type Target = TestConfig;
+
+ fn deref(&self) -> &Self::Target {
+ self.0.get().unwrap()
+ }
+}
+
+pub static TEST_CONFIG: TestConfigContainer = TestConfigContainer(OnceCell::new());
diff --git a/test/test-manager/src/tests/dns.rs b/test/test-manager/src/tests/dns.rs
new file mode 100644
index 0000000000..e87da24db0
--- /dev/null
+++ b/test/test-manager/src/tests/dns.rs
@@ -0,0 +1,698 @@
+use std::{
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ sync::atomic::{AtomicUsize, Ordering},
+ time::Duration,
+};
+
+use itertools::Itertools;
+use mullvad_management_interface::{types, ManagementServiceClient};
+use mullvad_types::{
+ relay_constraints::RelaySettingsUpdate, ConnectionConfig, CustomTunnelEndpoint,
+};
+use talpid_types::net::wireguard;
+use test_macro::test_function;
+use test_rpc::{Interface, ServiceClient};
+
+use super::{helpers::connect_and_wait, Error, TestContext};
+use crate::network_monitor::{
+ start_packet_monitor_until, start_tunnel_packet_monitor_until, Direction, IpHeaderProtocols,
+ MonitorOptions,
+};
+use crate::vm::network::{
+ CUSTOM_TUN_GATEWAY, CUSTOM_TUN_LOCAL_PRIVKEY, CUSTOM_TUN_LOCAL_TUN_ADDR,
+ CUSTOM_TUN_REMOTE_PUBKEY, CUSTOM_TUN_REMOTE_REAL_ADDR, CUSTOM_TUN_REMOTE_REAL_PORT,
+ CUSTOM_TUN_REMOTE_TUN_ADDR, NON_TUN_GATEWAY,
+};
+
+use super::helpers::update_relay_settings;
+
+/// How long to wait for expected "DNS queries" to appear
+const MONITOR_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Test whether DNS leaks can be produced when using the default resolver. It does this by
+/// connecting to a custom WireGuard relay on localhost and monitoring outbound DNS traffic in (and
+/// outside of) the tunnel interface.
+///
+/// The test succeeds if and only if expected outbound packets inside the tunnel on port 53 are
+/// observed. If traffic on port 53 is observed outside the tunnel or to an unexpected destination,
+/// the test fails.
+///
+/// # Limitations
+///
+/// This test only detects outbound DNS leaks in the connected state.
+#[test_function]
+pub async fn test_dns_leak_default(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ leak_test_dns(
+ &rpc,
+ &mut mullvad_client,
+ Interface::Tunnel,
+ IpAddr::V4(CUSTOM_TUN_REMOTE_TUN_ADDR),
+ )
+ .await
+}
+
+/// Test whether DNS leaks can be produced when using a custom public IP. This test succeeds if and
+/// only if outgoing packets are only observed on the tunnel interface to the expected IP.
+///
+/// See `test_dns_leak_default` for more details.
+///
+/// # Limitations
+///
+/// This test only detects outbound DNS leaks in the connected state.
+#[test_function]
+pub async fn test_dns_leak_custom_public_ip(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ const CONFIG_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 3, 3, 7));
+
+ log::debug!("Setting custom DNS resolver to {CONFIG_IP}");
+
+ mullvad_client
+ .set_dns_options(types::DnsOptions {
+ default_options: Some(types::DefaultDnsOptions::default()),
+ custom_options: Some(types::CustomDnsOptions {
+ addresses: vec![CONFIG_IP.to_string()],
+ }),
+ state: i32::from(types::dns_options::DnsState::Custom),
+ })
+ .await
+ .expect("failed to configure DNS server");
+
+ leak_test_dns(&rpc, &mut mullvad_client, Interface::Tunnel, CONFIG_IP).await
+}
+
+/// Test whether DNS leaks can be produced when using a custom private IP. This test succeeds if and
+/// only if outgoing packets are only observed on the non-tunnel interface to the expected IP.
+///
+/// See `test_dns_leak_default` for more details.
+///
+/// # Limitations
+///
+/// This test only detects outbound DNS leaks in the connected state.
+#[test_function]
+pub async fn test_dns_leak_custom_private_ip(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ const CONFIG_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(10, 64, 10, 1));
+
+ log::debug!("Setting custom DNS resolver to {CONFIG_IP}");
+
+ mullvad_client
+ .set_dns_options(types::DnsOptions {
+ default_options: Some(types::DefaultDnsOptions::default()),
+ custom_options: Some(types::CustomDnsOptions {
+ addresses: vec![CONFIG_IP.to_string()],
+ }),
+ state: i32::from(types::dns_options::DnsState::Custom),
+ })
+ .await
+ .expect("failed to configure DNS server");
+
+ leak_test_dns(&rpc, &mut mullvad_client, Interface::NonTunnel, CONFIG_IP).await
+}
+
+/// See whether it is possible to send "DNS queries" to a particular whitelisted destination on
+/// either the tunnel interface or a non-tunnel interface on port 53. This test fails if:
+/// * No packets to the whitelisted destination are observed, or
+/// * Packets to any other destination or a non-matching interface are observed.
+async fn leak_test_dns(
+ rpc: &ServiceClient,
+ mullvad_client: &mut ManagementServiceClient,
+ interface: Interface,
+ whitelisted_dest: IpAddr,
+) -> Result<(), Error> {
+ let use_tun = interface == Interface::Tunnel;
+
+ //
+ // Connect to local wireguard relay
+ //
+
+ connect_local_wg_relay(mullvad_client)
+ .await
+ .expect("failed to connect to custom wg relay");
+
+ let guest_ip = rpc
+ .get_interface_ip(Interface::NonTunnel)
+ .await
+ .expect("failed to obtain guest IP");
+ let tunnel_ip = rpc
+ .get_interface_ip(Interface::Tunnel)
+ .await
+ .expect("failed to obtain tunnel IP");
+
+ log::debug!("Tunnel (guest) IP: {tunnel_ip}");
+ log::debug!("Non-tunnel (guest) IP: {guest_ip}");
+
+ //
+ // Spoof DNS packets
+ //
+
+ let tun_bind_addr = SocketAddr::new(tunnel_ip, 0);
+ let guest_bind_addr = SocketAddr::new(guest_ip, 0);
+
+ let whitelisted_dest = SocketAddr::new(whitelisted_dest, 53);
+ let blocked_dest_local = "10.64.100.100:53".parse().unwrap();
+ let blocked_dest_public = "1.1.1.1:53".parse().unwrap();
+
+ // Capture all outgoing DNS
+ let mut pkt_counter = DnsPacketsFound::new(1, 1);
+
+ let (tunnel_monitor, non_tunnel_monitor) = if use_tun {
+ let tunnel_monitor = start_tunnel_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ move |packet| pkt_counter.handle_packet(packet),
+ MonitorOptions {
+ direction: Some(Direction::In),
+ timeout: Some(MONITOR_TIMEOUT),
+ ..Default::default()
+ },
+ )
+ .await;
+ let non_tunnel_monitor = start_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ |_packet| false,
+ MonitorOptions {
+ direction: Some(Direction::In),
+ ..Default::default()
+ },
+ )
+ .await;
+ (tunnel_monitor, non_tunnel_monitor)
+ } else {
+ let tunnel_monitor = start_tunnel_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ |_packet| false,
+ MonitorOptions {
+ direction: Some(Direction::In),
+ ..Default::default()
+ },
+ )
+ .await;
+ let non_tunnel_monitor = start_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ move |packet| pkt_counter.handle_packet(packet),
+ MonitorOptions {
+ direction: Some(Direction::In),
+ timeout: Some(MONITOR_TIMEOUT),
+ ..Default::default()
+ },
+ )
+ .await;
+ (tunnel_monitor, non_tunnel_monitor)
+ };
+
+ // We should observe 2 outgoing packets to the whitelisted destination
+ // on port 53, and only inside the desired interface.
+
+ spoof_packets(
+ rpc,
+ Some(Interface::Tunnel),
+ tun_bind_addr,
+ whitelisted_dest,
+ );
+ spoof_packets(
+ rpc,
+ Some(Interface::NonTunnel),
+ guest_bind_addr,
+ whitelisted_dest,
+ );
+
+ spoof_packets(
+ rpc,
+ Some(Interface::Tunnel),
+ tun_bind_addr,
+ blocked_dest_local,
+ );
+ spoof_packets(
+ rpc,
+ Some(Interface::NonTunnel),
+ guest_bind_addr,
+ blocked_dest_local,
+ );
+
+ spoof_packets(
+ rpc,
+ Some(Interface::Tunnel),
+ tun_bind_addr,
+ blocked_dest_public,
+ );
+ spoof_packets(
+ rpc,
+ Some(Interface::NonTunnel),
+ guest_bind_addr,
+ blocked_dest_public,
+ );
+
+ if use_tun {
+ //
+ // Examine tunnel traffic
+ //
+
+ let tunnel_result = tunnel_monitor.wait().await.unwrap();
+ assert!(
+ tunnel_result.packets.len() >= 2,
+ "expected at least 2 in-tunnel packets to allowed destination only"
+ );
+
+ for pkt in tunnel_result.packets {
+ assert_eq!(
+ pkt.destination, whitelisted_dest,
+ "unexpected tunnel packet on port 53"
+ );
+ }
+
+ //
+ // Examine non-tunnel traffic
+ //
+
+ let non_tunnel_result = non_tunnel_monitor.into_result().await.unwrap();
+ assert_eq!(
+ non_tunnel_result.packets.len(),
+ 0,
+ "expected no non-tunnel packets on port 53"
+ );
+ } else {
+ let non_tunnel_result = non_tunnel_monitor.wait().await.unwrap();
+
+ //
+ // Examine tunnel traffic
+ //
+
+ let tunnel_result = tunnel_monitor.into_result().await.unwrap();
+ assert_eq!(
+ tunnel_result.packets.len(),
+ 0,
+ "expected no tunnel packets on port 53"
+ );
+
+ //
+ // Examine non-tunnel traffic
+ //
+
+ assert!(
+ non_tunnel_result.packets.len() >= 2,
+ "expected at least 2 non-tunnel packets to allowed destination only"
+ );
+
+ for pkt in non_tunnel_result.packets {
+ assert_eq!(
+ pkt.destination, whitelisted_dest,
+ "unexpected non-tunnel packet on port 53"
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// Test whether the expected default DNS resolver is used by `getaddrinfo` (via `ToSocketAddrs`).
+///
+/// # Limitations
+///
+/// This only examines outbound packets.
+#[test_function]
+pub async fn test_dns_config_default(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ run_dns_config_tunnel_test(
+ &rpc,
+ &mut mullvad_client,
+ IpAddr::V4(CUSTOM_TUN_REMOTE_TUN_ADDR),
+ )
+ .await
+}
+
+/// Test whether the expected custom DNS works for private IPs.
+///
+/// # Limitations
+///
+/// This only examines outbound packets.
+#[test_function]
+pub async fn test_dns_config_custom_private(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::debug!("Setting custom DNS resolver to {NON_TUN_GATEWAY}");
+
+ mullvad_client
+ .set_dns_options(types::DnsOptions {
+ default_options: Some(types::DefaultDnsOptions::default()),
+ custom_options: Some(types::CustomDnsOptions {
+ addresses: vec![NON_TUN_GATEWAY.to_string()],
+ }),
+ state: i32::from(types::dns_options::DnsState::Custom),
+ })
+ .await
+ .expect("failed to configure DNS server");
+
+ run_dns_config_non_tunnel_test(&rpc, &mut mullvad_client, IpAddr::V4(NON_TUN_GATEWAY)).await
+}
+
+/// Test whether the expected custom DNS works for public IPs.
+///
+/// # Limitations
+///
+/// This only examines outbound packets.
+#[test_function]
+pub async fn test_dns_config_custom_public(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let custom_ip = IpAddr::V4(Ipv4Addr::new(1, 3, 3, 7));
+
+ log::debug!("Setting custom DNS resolver to {custom_ip}");
+
+ mullvad_client
+ .set_dns_options(types::DnsOptions {
+ default_options: Some(types::DefaultDnsOptions::default()),
+ custom_options: Some(types::CustomDnsOptions {
+ addresses: vec![custom_ip.to_string()],
+ }),
+ state: i32::from(types::dns_options::DnsState::Custom),
+ })
+ .await
+ .expect("failed to configure DNS server");
+
+ run_dns_config_tunnel_test(&rpc, &mut mullvad_client, custom_ip).await
+}
+
+/// Test whether the correct IPs are configured as system resolver when
+/// content blockers are enabled.
+#[test_function]
+pub async fn test_content_blockers(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ const DNS_BLOCKING_IP_BASE: Ipv4Addr = Ipv4Addr::new(100, 64, 0, 0);
+ let content_blockers = [
+ (
+ "adblocking",
+ 1 << 0,
+ types::DefaultDnsOptions {
+ block_ads: true,
+ ..Default::default()
+ },
+ ),
+ (
+ "tracker",
+ 1 << 1,
+ types::DefaultDnsOptions {
+ block_trackers: true,
+ ..Default::default()
+ },
+ ),
+ (
+ "malware",
+ 1 << 2,
+ types::DefaultDnsOptions {
+ block_malware: true,
+ ..Default::default()
+ },
+ ),
+ (
+ "adult",
+ 1 << 3,
+ types::DefaultDnsOptions {
+ block_adult_content: true,
+ ..Default::default()
+ },
+ ),
+ (
+ "gambling",
+ 1 << 4,
+ types::DefaultDnsOptions {
+ block_gambling: true,
+ ..Default::default()
+ },
+ ),
+ ];
+
+ let combine_cases = |v: Vec<&(&str, u8, types::DefaultDnsOptions)>| {
+ let mut combination_name = String::new();
+ let mut last_byte = 0;
+ let mut options = types::DefaultDnsOptions::default();
+
+ for case in v {
+ if !combination_name.is_empty() {
+ combination_name.push_str(" + ");
+ }
+ combination_name.push_str(case.0);
+
+ last_byte |= case.1;
+
+ options.block_ads |= case.2.block_ads;
+ options.block_trackers |= case.2.block_trackers;
+ options.block_malware |= case.2.block_malware;
+ options.block_adult_content |= case.2.block_adult_content;
+ options.block_gambling |= case.2.block_gambling;
+ }
+
+ let mut dns_ip = DNS_BLOCKING_IP_BASE.octets();
+ dns_ip[dns_ip.len() - 1] |= last_byte;
+
+ (
+ combination_name,
+ IpAddr::V4(Ipv4Addr::from(dns_ip)),
+ options,
+ )
+ };
+
+ // Test all combinations
+
+ for case in content_blockers.iter().powerset() {
+ if case.is_empty() {
+ continue;
+ }
+ let (test_name, test_ip, test_opts) = combine_cases(case);
+
+ log::debug!("Testing content blocker: {test_name}, {test_ip}");
+
+ mullvad_client
+ .set_dns_options(types::DnsOptions {
+ default_options: Some(test_opts),
+ custom_options: Some(types::CustomDnsOptions::default()),
+ state: i32::from(types::dns_options::DnsState::Default),
+ })
+ .await
+ .expect("failed to configure DNS server");
+
+ run_dns_config_tunnel_test(&rpc, &mut mullvad_client, test_ip).await?;
+ }
+
+ Ok(())
+}
+
+async fn run_dns_config_tunnel_test(
+ rpc: &ServiceClient,
+ mullvad_client: &mut ManagementServiceClient,
+ expected_dns_resolver: IpAddr,
+) -> Result<(), Error> {
+ run_dns_config_test(
+ rpc,
+ || {
+ start_tunnel_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ |packet| packet.destination.port() != 53,
+ MonitorOptions {
+ direction: Some(Direction::In),
+ timeout: Some(MONITOR_TIMEOUT),
+ ..Default::default()
+ },
+ )
+ },
+ mullvad_client,
+ expected_dns_resolver,
+ )
+ .await
+}
+
+async fn run_dns_config_non_tunnel_test(
+ rpc: &ServiceClient,
+ mullvad_client: &mut ManagementServiceClient,
+ expected_dns_resolver: IpAddr,
+) -> Result<(), Error> {
+ run_dns_config_test(
+ rpc,
+ || {
+ start_packet_monitor_until(
+ move |packet| packet.destination.port() == 53,
+ |packet| packet.destination.port() != 53,
+ MonitorOptions {
+ direction: Some(Direction::In),
+ timeout: Some(MONITOR_TIMEOUT),
+ ..Default::default()
+ },
+ )
+ },
+ mullvad_client,
+ expected_dns_resolver,
+ )
+ .await
+}
+
+async fn run_dns_config_test<
+ F: std::future::Future<Output = crate::network_monitor::PacketMonitor>,
+>(
+ rpc: &ServiceClient,
+ create_monitor: impl FnOnce() -> F,
+ mullvad_client: &mut ManagementServiceClient,
+ expected_dns_resolver: IpAddr,
+) -> Result<(), Error> {
+ match mullvad_client
+ .get_tunnel_state(())
+ .await
+ .unwrap()
+ .into_inner()
+ .state
+ {
+ // prevent reconnect
+ Some(types::tunnel_state::State::Connected(_)) => (),
+ _ => {
+ connect_local_wg_relay(mullvad_client)
+ .await
+ .expect("failed to connect to custom wg relay");
+ }
+ }
+
+ let guest_ip = rpc
+ .get_interface_ip(Interface::NonTunnel)
+ .await
+ .expect("failed to obtain guest IP");
+ let tunnel_ip = rpc
+ .get_interface_ip(Interface::Tunnel)
+ .await
+ .expect("failed to obtain tunnel IP");
+
+ log::debug!("Tunnel (guest) IP: {tunnel_ip}");
+ log::debug!("Non-tunnel (guest) IP: {guest_ip}");
+
+ let monitor = create_monitor().await;
+
+ let next_nonce = {
+ static NONCE: AtomicUsize = AtomicUsize::new(0);
+ || NONCE.fetch_add(1, Ordering::Relaxed)
+ };
+
+ let rpc_client = rpc.clone();
+ let handle = tokio::spawn(async move {
+ // Resolve a "random" domain name to prevent caching.
+ // Try multiple times, as the DNS config change may not take effect immediately.
+ for _ in 0..2 {
+ let _ = rpc_client
+ .resolve_hostname(format!("test{}.mullvad.net", next_nonce()))
+ .await;
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ }
+ });
+
+ assert_eq!(
+ monitor.wait().await.unwrap().packets[0].destination,
+ SocketAddr::new(expected_dns_resolver, 53),
+ "expected tunnel packet to expected destination {expected_dns_resolver}"
+ );
+
+ handle.abort();
+
+ Ok(())
+}
+
+/// Connect to the WireGuard relay that is set up in scripts/setup-network.sh
+/// See that script for details.
+async fn connect_local_wg_relay(mullvad_client: &mut ManagementServiceClient) -> Result<(), Error> {
+ let peer_addr: SocketAddr = SocketAddr::new(
+ IpAddr::V4(CUSTOM_TUN_REMOTE_REAL_ADDR),
+ CUSTOM_TUN_REMOTE_REAL_PORT,
+ );
+
+ let relay_settings = RelaySettingsUpdate::CustomTunnelEndpoint(CustomTunnelEndpoint {
+ host: peer_addr.ip().to_string(),
+ config: ConnectionConfig::Wireguard(wireguard::ConnectionConfig {
+ tunnel: wireguard::TunnelConfig {
+ addresses: vec![IpAddr::V4(CUSTOM_TUN_LOCAL_TUN_ADDR)],
+ private_key: wireguard::PrivateKey::from(CUSTOM_TUN_LOCAL_PRIVKEY),
+ },
+ peer: wireguard::PeerConfig {
+ public_key: wireguard::PublicKey::from(CUSTOM_TUN_REMOTE_PUBKEY),
+ allowed_ips: vec!["0.0.0.0/0".parse().unwrap()],
+ endpoint: peer_addr,
+ psk: None,
+ },
+ ipv4_gateway: CUSTOM_TUN_GATEWAY,
+ exit_peer: None,
+ #[cfg(target_os = "linux")]
+ fwmark: None,
+ ipv6_gateway: None,
+ }),
+ });
+
+ update_relay_settings(mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ connect_and_wait(mullvad_client).await?;
+
+ Ok(())
+}
+
+fn spoof_packets(
+ rpc: &ServiceClient,
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ dest: SocketAddr,
+) {
+ let rpc1 = rpc.clone();
+ let rpc2 = rpc.clone();
+ tokio::spawn(async move {
+ log::debug!("sending to {}/tcp from {}", dest, bind_addr);
+ let _ = rpc1.send_tcp(interface, bind_addr, dest).await;
+ });
+ tokio::spawn(async move {
+ log::debug!("sending to {}/udp from {}", dest, bind_addr);
+ let _ = rpc2.send_udp(interface, bind_addr, dest).await;
+ });
+}
+
+type ShouldContinue = bool;
+
+struct DnsPacketsFound {
+ tcp_count: usize,
+ udp_count: usize,
+ min_tcp_count: usize,
+ min_udp_count: usize,
+}
+
+impl DnsPacketsFound {
+ fn new(min_udp_count: usize, min_tcp_count: usize) -> Self {
+ Self {
+ tcp_count: 0,
+ udp_count: 0,
+ min_tcp_count,
+ min_udp_count,
+ }
+ }
+
+ fn handle_packet(&mut self, pkt: &crate::network_monitor::ParsedPacket) -> ShouldContinue {
+ if pkt.destination.port() != 53 && pkt.source.port() != 53 {
+ return true;
+ }
+ match pkt.protocol {
+ IpHeaderProtocols::Udp => self.udp_count += 1,
+ IpHeaderProtocols::Tcp => self.tcp_count += 1,
+ _ => return true,
+ }
+ self.udp_count < self.min_udp_count || self.tcp_count < self.min_tcp_count
+ }
+}
diff --git a/test/test-manager/src/tests/helpers.rs b/test/test-manager/src/tests/helpers.rs
new file mode 100644
index 0000000000..daef783247
--- /dev/null
+++ b/test/test-manager/src/tests/helpers.rs
@@ -0,0 +1,480 @@
+use super::{config::TEST_CONFIG, Error, PING_TIMEOUT, WAIT_FOR_TUNNEL_STATE_TIMEOUT};
+use crate::network_monitor::{start_packet_monitor, MonitorOptions};
+use futures::StreamExt;
+use mullvad_management_interface::{types, ManagementServiceClient};
+use mullvad_types::{
+ relay_constraints::{
+ Constraint, GeographicLocationConstraint, LocationConstraint, OpenVpnConstraints,
+ RelayConstraintsUpdate, RelaySettingsUpdate, WireguardConstraints,
+ },
+ states::TunnelState,
+};
+use pnet_packet::ip::IpNextHeaderProtocols;
+use std::{
+ net::{IpAddr, Ipv4Addr, SocketAddr},
+ path::Path,
+ time::Duration,
+};
+use talpid_types::net::wireguard::{PeerConfig, PrivateKey, TunnelConfig};
+use test_rpc::{package::Package, AmIMullvad, Interface, ServiceClient};
+use tokio::time::timeout;
+
+#[macro_export]
+macro_rules! assert_tunnel_state {
+ ($mullvad_client:expr, $pattern:pat) => {{
+ let state = get_tunnel_state($mullvad_client).await;
+ assert!(matches!(state, $pattern), "state: {:?}", state);
+ }};
+}
+
+pub fn get_package_desc(name: &str) -> Result<Package, Error> {
+ Ok(Package {
+ path: Path::new(&TEST_CONFIG.artifacts_dir).join(name),
+ })
+}
+
+#[derive(Debug, Default)]
+pub struct ProbeResult {
+ tcp: usize,
+ udp: usize,
+ icmp: usize,
+}
+
+impl ProbeResult {
+ pub fn all(&self) -> bool {
+ self.tcp > 0 && self.udp > 0 && self.icmp > 0
+ }
+
+ pub fn none(&self) -> bool {
+ !self.any()
+ }
+
+ pub fn any(&self) -> bool {
+ self.tcp > 0 || self.udp > 0 || self.icmp > 0
+ }
+}
+
+/// Return whether the guest exit IP is a Mullvad relay
+pub async fn using_mullvad_exit(rpc: &ServiceClient) -> bool {
+ log::info!("Test whether exit IP is a mullvad relay");
+ geoip_lookup_with_retries(rpc)
+ .await
+ .unwrap()
+ .mullvad_exit_ip
+}
+
+/// Sends a number of probes and returns the number of observed packets (UDP, TCP, or ICMP)
+pub async fn send_guest_probes(
+ rpc: ServiceClient,
+ interface: Option<Interface>,
+ destination: SocketAddr,
+) -> Result<ProbeResult, Error> {
+ let pktmon = start_packet_monitor(
+ move |packet| packet.destination.ip() == destination.ip(),
+ MonitorOptions {
+ direction: Some(crate::network_monitor::Direction::In),
+ timeout: Some(Duration::from_secs(3)),
+ ..Default::default()
+ },
+ )
+ .await;
+
+ let bind_addr = if let Some(interface) = interface {
+ SocketAddr::new(
+ rpc.get_interface_ip(interface)
+ .await
+ .expect("failed to obtain interface IP"),
+ 0,
+ )
+ } else {
+ "0.0.0.0:0".parse().unwrap()
+ };
+
+ let send_handle = tokio::spawn(async move {
+ let tcp_rpc = rpc.clone();
+ let udp_rpc = rpc.clone();
+ tokio::spawn(async move {
+ let _ = tcp_rpc.send_tcp(interface, bind_addr, destination).await;
+ });
+ tokio::spawn(async move {
+ let _ = udp_rpc.send_udp(interface, bind_addr, destination).await;
+ });
+ ping_with_timeout(&rpc, destination.ip(), interface).await?;
+ Ok::<(), Error>(())
+ });
+
+ let monitor_result = pktmon.wait().await.unwrap();
+
+ send_handle.abort();
+
+ let mut result = ProbeResult::default();
+
+ for pkt in monitor_result.packets {
+ match pkt.protocol {
+ IpNextHeaderProtocols::Tcp => {
+ result.tcp = result.tcp.saturating_add(1);
+ }
+ IpNextHeaderProtocols::Udp => {
+ result.udp = result.udp.saturating_add(1);
+ }
+ IpNextHeaderProtocols::Icmp => {
+ result.icmp = result.icmp.saturating_add(1);
+ }
+ _ => (),
+ }
+ }
+
+ Ok(result)
+}
+
+pub async fn ping_with_timeout(
+ rpc: &ServiceClient,
+ dest: IpAddr,
+ interface: Option<Interface>,
+) -> Result<(), Error> {
+ timeout(PING_TIMEOUT, rpc.send_ping(interface, dest))
+ .await
+ .map_err(|_| Error::PingTimeout)?
+ .map_err(Error::Rpc)
+}
+
+/// Try to connect to a Mullvad Tunnel.
+///
+/// If that fails for whatever reason, the Mullvad daemon ends up in the
+/// [`TunnelState::Error`] state & [`Error::DaemonError`] is returned.
+pub async fn connect_and_wait(mullvad_client: &mut ManagementServiceClient) -> Result<(), Error> {
+ log::info!("Connecting");
+
+ mullvad_client
+ .connect_tunnel(())
+ .await
+ .map_err(|error| Error::DaemonError(format!("failed to begin connecting: {}", error)))?;
+
+ let new_state = wait_for_tunnel_state(mullvad_client.clone(), |state| {
+ matches!(
+ state,
+ TunnelState::Connected { .. } | TunnelState::Error(..)
+ )
+ })
+ .await?;
+
+ if matches!(new_state, TunnelState::Error(..)) {
+ return Err(Error::DaemonError("daemon entered error state".to_string()));
+ }
+
+ log::info!("Connected");
+
+ Ok(())
+}
+
+pub async fn disconnect_and_wait(
+ mullvad_client: &mut ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Disconnecting");
+
+ mullvad_client
+ .disconnect_tunnel(())
+ .await
+ .map_err(|error| Error::DaemonError(format!("failed to begin disconnecting: {}", error)))?;
+ wait_for_tunnel_state(mullvad_client.clone(), |state| {
+ matches!(state, TunnelState::Disconnected)
+ })
+ .await?;
+
+ log::info!("Disconnected");
+
+ Ok(())
+}
+
+pub async fn wait_for_tunnel_state(
+ mut rpc: mullvad_management_interface::ManagementServiceClient,
+ accept_state_fn: impl Fn(&mullvad_types::states::TunnelState) -> bool,
+) -> Result<mullvad_types::states::TunnelState, Error> {
+ let events = rpc
+ .events_listen(())
+ .await
+ .map_err(|status| Error::DaemonError(format!("Failed to get event stream: {}", status)))?;
+
+ let state = mullvad_types::states::TunnelState::try_from(
+ rpc.get_tunnel_state(())
+ .await
+ .map_err(|error| {
+ Error::DaemonError(format!("Failed to get tunnel state: {:?}", error))
+ })?
+ .into_inner(),
+ )
+ .map_err(|error| Error::DaemonError(format!("Invalid tunnel state: {:?}", error)))?;
+ if accept_state_fn(&state) {
+ return Ok(state);
+ }
+
+ find_next_tunnel_state(events.into_inner(), accept_state_fn).await
+}
+
+pub async fn find_next_tunnel_state(
+ stream: impl futures::Stream<Item = Result<types::DaemonEvent, tonic::Status>> + Unpin,
+ accept_state_fn: impl Fn(&mullvad_types::states::TunnelState) -> bool,
+) -> Result<mullvad_types::states::TunnelState, Error> {
+ tokio::time::timeout(
+ WAIT_FOR_TUNNEL_STATE_TIMEOUT,
+ find_next_tunnel_state_inner(stream, accept_state_fn),
+ )
+ .await
+ .map_err(|_error| Error::DaemonError(String::from("Tunnel event listener timed out")))?
+}
+
+async fn find_next_tunnel_state_inner(
+ mut stream: impl futures::Stream<Item = Result<types::DaemonEvent, tonic::Status>> + Unpin,
+ accept_state_fn: impl Fn(&mullvad_types::states::TunnelState) -> bool,
+) -> Result<mullvad_types::states::TunnelState, Error> {
+ loop {
+ match stream.next().await {
+ Some(Ok(event)) => match event.event.unwrap() {
+ mullvad_management_interface::types::daemon_event::Event::TunnelState(
+ new_state,
+ ) => {
+ let state = mullvad_types::states::TunnelState::try_from(new_state).map_err(
+ |error| Error::DaemonError(format!("Invalid tunnel state: {:?}", error)),
+ )?;
+ if accept_state_fn(&state) {
+ return Ok(state);
+ }
+ }
+ _ => continue,
+ },
+ Some(Err(status)) => {
+ break Err(Error::DaemonError(format!(
+ "Failed to get next event: {}",
+ status
+ )))
+ }
+ None => break Err(Error::DaemonError(String::from("Lost daemon event stream"))),
+ }
+ }
+}
+
+pub async fn geoip_lookup_with_retries(rpc: &ServiceClient) -> Result<AmIMullvad, Error> {
+ const MAX_ATTEMPTS: usize = 5;
+ const BEFORE_RETRY_DELAY: Duration = Duration::from_secs(2);
+
+ let mut attempt = 0;
+
+ loop {
+ let result = rpc
+ .geoip_lookup(TEST_CONFIG.mullvad_host.to_owned())
+ .await
+ .map_err(Error::GeoipError);
+
+ attempt += 1;
+ if result.is_ok() || attempt >= MAX_ATTEMPTS {
+ return result;
+ }
+
+ tokio::time::sleep(BEFORE_RETRY_DELAY).await;
+ }
+}
+
+pub struct AbortOnDrop<T>(pub tokio::task::JoinHandle<T>);
+
+impl<T> Drop for AbortOnDrop<T> {
+ fn drop(&mut self) {
+ self.0.abort();
+ }
+}
+
+/// Disconnect and reset all relay, bridge, and obfuscation settings.
+pub async fn reset_relay_settings(
+ mullvad_client: &mut ManagementServiceClient,
+) -> Result<(), Error> {
+ disconnect_and_wait(mullvad_client).await?;
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Any),
+ openvpn_constraints: Some(OpenVpnConstraints::default()),
+ wireguard_constraints: Some(WireguardConstraints::default()),
+ ..Default::default()
+ });
+
+ update_relay_settings(mullvad_client, relay_settings)
+ .await
+ .map_err(|error| {
+ Error::DaemonError(format!("Failed to reset relay settings: {}", error))
+ })?;
+
+ mullvad_client
+ .set_bridge_state(types::BridgeState {
+ state: i32::from(types::bridge_state::State::Auto),
+ })
+ .await
+ .map_err(|error| Error::DaemonError(format!("Failed to reset bridge mode: {}", error)))?;
+
+ mullvad_client
+ .set_obfuscation_settings(types::ObfuscationSettings {
+ selected_obfuscation: i32::from(types::obfuscation_settings::SelectedObfuscation::Off),
+ udp2tcp: Some(types::Udp2TcpObfuscationSettings { port: None }),
+ })
+ .await
+ .map(|_| ())
+ .map_err(|error| Error::DaemonError(format!("Failed to reset obfuscation: {}", error)))
+}
+
+pub async fn update_relay_settings(
+ mullvad_client: &mut ManagementServiceClient,
+ relay_settings_update: RelaySettingsUpdate,
+) -> Result<(), Error> {
+ let update = types::RelaySettingsUpdate::from(relay_settings_update);
+
+ mullvad_client
+ .update_relay_settings(update)
+ .await
+ .map_err(|error| Error::DaemonError(format!("Failed to set relay settings: {}", error)))?;
+ Ok(())
+}
+
+pub async fn get_tunnel_state(mullvad_client: &mut ManagementServiceClient) -> TunnelState {
+ let state = mullvad_client
+ .get_tunnel_state(())
+ .await
+ .expect("mullvad RPC failed")
+ .into_inner();
+ TunnelState::try_from(state).unwrap()
+}
+
+/// Wait for the relay list to be updated, to make sure we have the overridden one.
+/// Time out after a while.
+pub async fn ensure_updated_relay_list(mullvad_client: &mut ManagementServiceClient) {
+ let mut events = mullvad_client.events_listen(()).await.unwrap().into_inner();
+ mullvad_client.update_relay_locations(()).await.unwrap();
+
+ let wait_for_relay_update = async move {
+ while let Some(Ok(event)) = events.next().await {
+ if matches!(
+ event,
+ mullvad_management_interface::types::DaemonEvent {
+ event: Some(
+ mullvad_management_interface::types::daemon_event::Event::RelayList { .. }
+ )
+ }
+ ) {
+ log::debug!("Received new relay list");
+ break;
+ }
+ }
+ };
+ let _ = tokio::time::timeout(std::time::Duration::from_secs(3), wait_for_relay_update).await;
+}
+
+pub fn unreachable_wireguard_tunnel() -> talpid_types::net::wireguard::ConnectionConfig {
+ talpid_types::net::wireguard::ConnectionConfig {
+ tunnel: TunnelConfig {
+ private_key: PrivateKey::new_from_random(),
+ addresses: vec![IpAddr::V4(Ipv4Addr::new(10, 64, 10, 1))],
+ },
+ peer: PeerConfig {
+ public_key: PrivateKey::new_from_random().public_key(),
+ allowed_ips: vec![
+ "0.0.0.0/0".parse().expect("Failed to parse ipv6 network"),
+ "::0/0".parse().expect("Failed to parse ipv6 network"),
+ ],
+ endpoint: "1.3.3.7:1234".parse().unwrap(),
+ psk: None,
+ },
+ exit_peer: None,
+ ipv4_gateway: Ipv4Addr::new(10, 64, 10, 1),
+ ipv6_gateway: None,
+ #[cfg(target_os = "linux")]
+ fwmark: None,
+ }
+}
+
+/// Randomly select an entry and exit node from the daemon's relay list.
+/// The exit node is distinct from the entry node.
+///
+/// * `mullvad_client` - An interface to the Mullvad daemon.
+/// * `critera` - A function used to determine which relays to include in random selection.
+pub async fn random_entry_and_exit<Filter>(
+ mullvad_client: &mut ManagementServiceClient,
+ criteria: Filter,
+) -> Result<(types::Relay, types::Relay), Error>
+where
+ Filter: Fn(&types::Relay) -> bool,
+{
+ use itertools::Itertools;
+ // Pluck the first 2 relays and return them as a tuple.
+ // This will fail if there are less than 2 relays in the relay list.
+ filter_relays(mullvad_client, criteria)
+ .await?
+ .into_iter()
+ .next_tuple()
+ .ok_or(Error::Other(
+ "failed to randomly select two relays from daemon's relay list".to_string(),
+ ))
+}
+
+/// Return a filtered version of the daemon's relay list.
+///
+/// * `mullvad_client` - An interface to the Mullvad daemon.
+/// * `critera` - A function used to determine which relays to return.
+pub async fn filter_relays<Filter>(
+ mullvad_client: &mut ManagementServiceClient,
+ criteria: Filter,
+) -> Result<Vec<types::Relay>, Error>
+where
+ Filter: Fn(&types::Relay) -> bool,
+{
+ let relaylist = mullvad_client
+ .get_relay_locations(())
+ .await
+ .map_err(|error| Error::DaemonError(format!("Failed to obtain relay list: {}", error)))?
+ .into_inner();
+
+ Ok(flatten_relaylist(relaylist)
+ .into_iter()
+ .filter(criteria)
+ .collect())
+}
+
+/// Dig out the [`Relay`]s contained in a [`RelayList`].
+pub fn flatten_relaylist(relays: types::RelayList) -> Vec<types::Relay> {
+ relays
+ .countries
+ .iter()
+ .flat_map(|country| country.cities.clone())
+ .flat_map(|city| city.relays)
+ .collect()
+}
+
+/// Convenience function for constructing a constraint from a given [`Relay`].
+///
+/// Returns an [`Option`] because a [`Relay`] is not guaranteed to be poplutaed with a location
+/// vaule.
+pub fn into_constraint(relay: &types::Relay) -> Option<Constraint<LocationConstraint>> {
+ into_locationconstraint(relay).map(Constraint::Only)
+}
+
+/// Convenience function for constructing a location constraint from a given [`Relay`].
+///
+/// Returns an [`Option`] because a [`Relay`] is not guaranteed to be poplutaed with a location
+/// vaule.
+pub fn into_locationconstraint(relay: &types::Relay) -> Option<LocationConstraint> {
+ relay
+ .location
+ .as_ref()
+ .map(
+ |types::Location {
+ country_code,
+ city_code,
+ ..
+ }| {
+ GeographicLocationConstraint::Hostname(
+ country_code.to_string(),
+ city_code.to_string(),
+ relay.hostname.to_string(),
+ )
+ },
+ )
+ .map(LocationConstraint::Location)
+}
diff --git a/test/test-manager/src/tests/install.rs b/test/test-manager/src/tests/install.rs
new file mode 100644
index 0000000000..abd105d4dd
--- /dev/null
+++ b/test/test-manager/src/tests/install.rs
@@ -0,0 +1,357 @@
+use super::helpers::{get_package_desc, ping_with_timeout, AbortOnDrop};
+use super::{Error, TestContext};
+
+use super::config::TEST_CONFIG;
+use crate::network_monitor::{start_packet_monitor, MonitorOptions};
+use mullvad_management_interface::types;
+use std::{
+ collections::HashMap,
+ net::{SocketAddr, ToSocketAddrs},
+ time::Duration,
+};
+use test_macro::test_function;
+use test_rpc::meta::Os;
+use test_rpc::{mullvad_daemon::ServiceStatus, Interface, ServiceClient};
+
+/// Install the last stable version of the app and verify that it is running.
+#[test_function(priority = -200)]
+pub async fn test_install_previous_app(_: TestContext, rpc: ServiceClient) -> Result<(), Error> {
+ // verify that daemon is not already running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::NotRunning {
+ return Err(Error::DaemonRunning);
+ }
+
+ // install package
+ log::debug!("Installing old app");
+ rpc.install_app(get_package_desc(&TEST_CONFIG.previous_app_filename)?)
+ .await?;
+
+ // verify that daemon is running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::Running {
+ return Err(Error::DaemonNotRunning);
+ }
+
+ replace_openvpn_cert(&rpc).await?;
+
+ // Override env vars
+ rpc.set_daemon_environment(get_app_env()).await?;
+
+ Ok(())
+}
+
+/// Upgrade to the "version under test". This test fails if:
+///
+/// * Leaks (TCP/UDP/ICMP) to a single public IP address are successfully produced during the
+/// upgrade.
+/// * The installer does not successfully complete.
+/// * The VPN service is not running after the upgrade.
+#[test_function(priority = -190)]
+pub async fn test_upgrade_app(ctx: TestContext, rpc: ServiceClient) -> Result<(), Error> {
+ let inet_destination: SocketAddr = "1.1.1.1:1337".parse().unwrap();
+ let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
+
+ // Verify that daemon is running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::Running {
+ return Err(Error::DaemonNotRunning);
+ }
+
+ super::account::clear_devices(&super::account::new_device_client().await)
+ .await
+ .expect("failed to clear devices");
+
+ // Login to test preservation of device/account
+ // TODO: Cannot do this now because overriding the API is impossible for releases
+ //mullvad_client
+ // .login_account(TEST_CONFIG.account_number.clone())
+ // .await
+ // .expect("login failed");
+
+ //
+ // Start blocking
+ //
+ log::debug!("Entering blocking error state");
+
+ rpc.exec("mullvad", ["relay", "set", "location", "xx"])
+ .await
+ .expect("Failed to set relay location");
+ rpc.exec("mullvad", ["connect"])
+ .await
+ .expect("Failed to begin connecting");
+
+ tokio::time::timeout(super::WAIT_FOR_TUNNEL_STATE_TIMEOUT, async {
+ // use polling for sake of simplicity
+ loop {
+ const FIND_SLICE: &[u8] = b"Blocked:";
+ let result = rpc
+ .exec("mullvad", ["status"])
+ .await
+ .expect("Failed to poll tunnel status");
+ if result
+ .stdout
+ .windows(FIND_SLICE.len())
+ .any(|subslice| subslice == FIND_SLICE)
+ {
+ break;
+ }
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ }
+ })
+ .await
+ .map_err(|_error| Error::DaemonError(String::from("Failed to enter blocking error state")))?;
+
+ //
+ // Begin monitoring outgoing traffic and pinging
+ //
+
+ let guest_ip = rpc
+ .get_interface_ip(Interface::NonTunnel)
+ .await
+ .expect("failed to obtain tunnel IP");
+ log::debug!("Guest IP: {guest_ip}");
+
+ log::debug!("Monitoring outgoing traffic");
+
+ let monitor = start_packet_monitor(
+ move |packet| {
+ // NOTE: Many packets will likely be observed for API traffic. Rather than filtering all
+ // of those specifically, simply fail if our probes are observed.
+ packet.source.ip() == guest_ip && packet.destination.ip() == inet_destination.ip()
+ },
+ MonitorOptions::default(),
+ )
+ .await;
+
+ let ping_rpc = rpc.clone();
+ let abort_on_drop = AbortOnDrop(tokio::spawn(async move {
+ loop {
+ let _ = ping_rpc.send_tcp(None, bind_addr, inet_destination).await;
+ let _ = ping_rpc.send_udp(None, bind_addr, inet_destination).await;
+ let _ = ping_with_timeout(&ping_rpc, inet_destination.ip(), None).await;
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ }
+ }));
+
+ // install new package
+ log::debug!("Installing new app");
+ rpc.install_app(get_package_desc(&TEST_CONFIG.current_app_filename)?)
+ .await?;
+
+ // Give it some time to start
+ tokio::time::sleep(Duration::from_secs(3)).await;
+
+ // verify that daemon is running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::Running {
+ return Err(Error::DaemonNotRunning);
+ }
+
+ //
+ // Check if any traffic was observed
+ //
+ drop(abort_on_drop);
+ let monitor_result = monitor.into_result().await.unwrap();
+ assert_eq!(
+ monitor_result.packets.len(),
+ 0,
+ "observed unexpected packets from {guest_ip}"
+ );
+
+ let mut mullvad_client = ctx.rpc_provider.new_client().await;
+
+ // check if settings were (partially) preserved
+ log::info!("Sanity checking settings");
+
+ let settings = mullvad_client
+ .get_settings(())
+ .await
+ .expect("failed to obtain settings")
+ .into_inner();
+
+ const EXPECTED_COUNTRY: &str = "xx";
+
+ let relay_location_was_preserved = match &settings.relay_settings {
+ Some(types::RelaySettings {
+ endpoint:
+ Some(types::relay_settings::Endpoint::Normal(types::NormalRelaySettings {
+ location:
+ Some(types::LocationConstraint {
+ r#type:
+ Some(types::location_constraint::Type::Location(
+ types::GeographicLocationConstraint { country, .. },
+ )),
+ }),
+ ..
+ })),
+ }) => country == EXPECTED_COUNTRY,
+ _ => false,
+ };
+
+ assert!(
+ relay_location_was_preserved,
+ "relay location was not preserved after upgrade. new settings: {:?}",
+ settings,
+ );
+
+ // check if account history was preserved
+ // TODO: Cannot check account history because overriding the API is impossible for releases
+ /*
+ let history = mullvad_client
+ .get_account_history(())
+ .await
+ .expect("failed to obtain account history");
+ assert_eq!(
+ history.into_inner().token,
+ Some(TEST_CONFIG.account_number.clone()),
+ "lost account history"
+ );
+ */
+
+ Ok(())
+}
+
+/// Uninstall the app version being tested. This verifies
+/// that that the uninstaller works, and also that logs,
+/// application files, system services are removed.
+/// It also tests whether the device is removed from
+/// the account.
+///
+/// # Limitations
+///
+/// Files due to Electron, temporary files, registry
+/// values/keys, and device drivers are not guaranteed
+/// to be deleted.
+#[test_function(priority = -170, cleanup = false)]
+pub async fn test_uninstall_app(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: mullvad_management_interface::ManagementServiceClient,
+) -> Result<(), Error> {
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::Running {
+ return Err(Error::DaemonNotRunning);
+ }
+
+ // Login to test preservation of device/account
+ // TODO: Remove once we can login before upgrade above
+ mullvad_client
+ .login_account(TEST_CONFIG.account_number.clone())
+ .await
+ .expect("login failed");
+
+ // save device to verify that uninstalling removes the device
+ // we should still be logged in after upgrading
+ let uninstalled_device = mullvad_client
+ .get_device(())
+ .await
+ .expect("failed to get device data")
+ .into_inner();
+ let uninstalled_device = uninstalled_device
+ .device
+ .expect("missing account/device")
+ .device
+ .expect("missing device id")
+ .id;
+
+ log::debug!("Uninstalling app");
+ rpc.uninstall_app(get_app_env()).await?;
+
+ let app_traces = rpc
+ .find_mullvad_app_traces()
+ .await
+ .expect("failed to obtain remaining Mullvad files");
+ assert!(
+ app_traces.is_empty(),
+ "found files after uninstall: {app_traces:?}"
+ );
+
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::NotRunning {
+ return Err(Error::DaemonRunning);
+ }
+
+ // verify that device was removed
+ let devices =
+ super::account::list_devices_with_retries(&super::account::new_device_client().await)
+ .await
+ .expect("failed to list devices");
+
+ assert!(
+ !devices.iter().any(|device| device.id == uninstalled_device),
+ "device id {} still exists after uninstall",
+ uninstalled_device,
+ );
+
+ Ok(())
+}
+
+/// Install the app cleanly, failing if the installer doesn't succeed
+/// or if the VPN service is not running afterwards.
+#[test_function(always_run = true, must_succeed = true, priority = -160)]
+pub async fn test_install_new_app(_: TestContext, rpc: ServiceClient) -> Result<(), Error> {
+ // verify that daemon is not already running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::NotRunning {
+ return Err(Error::DaemonRunning);
+ }
+
+ // install package
+ log::debug!("Installing new app");
+ rpc.install_app(get_package_desc(&TEST_CONFIG.current_app_filename)?)
+ .await?;
+
+ // verify that daemon is running
+ if rpc.mullvad_daemon_get_status().await? != ServiceStatus::Running {
+ return Err(Error::DaemonNotRunning);
+ }
+
+ // Set the log level to trace
+ rpc.set_daemon_log_level(test_rpc::mullvad_daemon::Verbosity::Trace)
+ .await?;
+
+ replace_openvpn_cert(&rpc).await?;
+
+ // Override env vars
+ rpc.set_daemon_environment(get_app_env()).await?;
+
+ Ok(())
+}
+
+fn get_app_env() -> HashMap<String, String> {
+ let mut map = HashMap::new();
+
+ let api_host = format!("api.{}", TEST_CONFIG.mullvad_host);
+ let api_addr = format!("{api_host}:443")
+ .to_socket_addrs()
+ .expect("failed to resolve API host")
+ .next()
+ .unwrap();
+
+ map.insert("MULLVAD_API_HOST".to_string(), api_host);
+ map.insert("MULLVAD_API_ADDR".to_string(), api_addr.to_string());
+
+ map
+}
+
+async fn replace_openvpn_cert(rpc: &ServiceClient) -> Result<(), Error> {
+ use std::path::Path;
+
+ const SOURCE_CERT_FILENAME: &str = "openvpn.ca.crt";
+ const DEST_CERT_FILENAME: &str = "ca.crt";
+
+ let dest_dir = match rpc.get_os().await.expect("failed to get OS") {
+ Os::Windows => "C:\\Program Files\\Mullvad VPN\\resources",
+ Os::Linux => "/opt/Mullvad VPN/resources",
+ Os::Macos => "/Applications/Mullvad VPN.app/Contents/Resources",
+ };
+
+ rpc.copy_file(
+ Path::new(&TEST_CONFIG.artifacts_dir)
+ .join(SOURCE_CERT_FILENAME)
+ .as_os_str()
+ .to_string_lossy()
+ .into_owned(),
+ Path::new(dest_dir)
+ .join(DEST_CERT_FILENAME)
+ .as_os_str()
+ .to_string_lossy()
+ .into_owned(),
+ )
+ .await
+ .map_err(Error::Rpc)
+}
diff --git a/test/test-manager/src/tests/mod.rs b/test/test-manager/src/tests/mod.rs
new file mode 100644
index 0000000000..e96e4ad0ba
--- /dev/null
+++ b/test/test-manager/src/tests/mod.rs
@@ -0,0 +1,162 @@
+mod account;
+pub mod config;
+mod dns;
+mod helpers;
+mod install;
+mod settings;
+mod test_metadata;
+mod tunnel;
+mod tunnel_state;
+mod ui;
+
+use crate::mullvad_daemon::RpcClientProvider;
+use anyhow::Context;
+use helpers::reset_relay_settings;
+pub use test_metadata::TestMetadata;
+use test_rpc::ServiceClient;
+
+use futures::future::BoxFuture;
+
+use mullvad_management_interface::{types::Settings, ManagementServiceClient};
+use once_cell::sync::OnceCell;
+use std::time::Duration;
+
+const PING_TIMEOUT: Duration = Duration::from_secs(3);
+const WAIT_FOR_TUNNEL_STATE_TIMEOUT: Duration = Duration::from_secs(40);
+
+#[derive(Clone)]
+pub struct TestContext {
+ pub rpc_provider: RpcClientProvider,
+}
+
+pub type TestWrapperFunction = Box<
+ dyn Fn(
+ TestContext,
+ ServiceClient,
+ Box<dyn std::any::Any + Send>,
+ ) -> BoxFuture<'static, Result<(), Error>>,
+>;
+
+#[derive(err_derive::Error, Debug, PartialEq, Eq)]
+pub enum Error {
+ #[error(display = "RPC call failed")]
+ Rpc(#[source] test_rpc::Error),
+
+ #[error(display = "Timeout waiting for ping")]
+ PingTimeout,
+
+ #[error(display = "geoip lookup failed")]
+ GeoipError(test_rpc::Error),
+
+ #[error(display = "Found running daemon unexpectedly")]
+ DaemonRunning,
+
+ #[error(display = "Daemon unexpectedly not running")]
+ DaemonNotRunning,
+
+ #[error(display = "The daemon returned an error: {}", _0)]
+ DaemonError(String),
+
+ #[error(display = "An error occurred: {}", _0)]
+ Other(String),
+}
+
+static DEFAULT_SETTINGS: OnceCell<Settings> = OnceCell::new();
+
+/// Initializes `DEFAULT_SETTINGS`. This has only has an effect the first time it's called.
+pub async fn init_default_settings(mullvad_client: &mut ManagementServiceClient) {
+ if DEFAULT_SETTINGS.get().is_none() {
+ let settings: Settings = mullvad_client
+ .get_settings(())
+ .await
+ .expect("Failed to obtain settings")
+ .into_inner();
+ DEFAULT_SETTINGS.set(settings).unwrap();
+ }
+}
+
+/// Restore settings to `DEFAULT_SETTINGS`.
+///
+/// # Panics
+///
+/// `DEFAULT_SETTINGS` must be initialized using `init_default_settings` before any settings are
+/// modified, or this function panics.
+pub async fn cleanup_after_test(
+ mullvad_client: &mut ManagementServiceClient,
+) -> anyhow::Result<()> {
+ log::debug!("Cleaning up daemon in test cleanup");
+
+ let default_settings = DEFAULT_SETTINGS
+ .get()
+ .expect("default settings were not initialized");
+
+ reset_relay_settings(mullvad_client).await?;
+
+ mullvad_client
+ .set_auto_connect(default_settings.auto_connect)
+ .await
+ .context("Could not set auto connect in cleanup")?;
+ mullvad_client
+ .set_allow_lan(default_settings.allow_lan)
+ .await
+ .context("Could not set allow lan in cleanup")?;
+ mullvad_client
+ .set_show_beta_releases(default_settings.show_beta_releases)
+ .await
+ .context("Could not set show beta releases in cleanup")?;
+ mullvad_client
+ .set_bridge_state(default_settings.bridge_state.clone().unwrap())
+ .await
+ .context("Could not set bridge state in cleanup")?;
+ mullvad_client
+ .set_bridge_settings(default_settings.bridge_settings.clone().unwrap())
+ .await
+ .context("Could not set bridge settings in cleanup")?;
+ mullvad_client
+ .set_obfuscation_settings(default_settings.obfuscation_settings.clone().unwrap())
+ .await
+ .context("Could set obfuscation settings in cleanup")?;
+ mullvad_client
+ .set_block_when_disconnected(default_settings.block_when_disconnected)
+ .await
+ .context("Could not set block when disconnected setting in cleanup")?;
+ mullvad_client
+ .clear_split_tunnel_apps(())
+ .await
+ .context("Could not clear split tunnel apps in cleanup")?;
+ mullvad_client
+ .clear_split_tunnel_processes(())
+ .await
+ .context("Could not clear split tunnel processes in cleanup")?;
+ mullvad_client
+ .set_dns_options(
+ default_settings
+ .tunnel_options
+ .as_ref()
+ .unwrap()
+ .dns_options
+ .as_ref()
+ .unwrap()
+ .clone(),
+ )
+ .await
+ .context("Could not clear dns options in cleanup")?;
+ mullvad_client
+ .set_quantum_resistant_tunnel(
+ default_settings
+ .tunnel_options
+ .as_ref()
+ .unwrap()
+ .wireguard
+ .as_ref()
+ .unwrap()
+ .quantum_resistant
+ .as_ref()
+ .unwrap()
+ .clone(),
+ )
+ .await
+ .context("Could not clear PQ options in cleanup")?;
+
+ Ok(())
+}
diff --git a/test/test-manager/src/tests/settings.rs b/test/test-manager/src/tests/settings.rs
new file mode 100644
index 0000000000..4c5808f790
--- /dev/null
+++ b/test/test-manager/src/tests/settings.rs
@@ -0,0 +1,211 @@
+use super::helpers;
+use super::helpers::{connect_and_wait, disconnect_and_wait, get_tunnel_state, send_guest_probes};
+use super::{Error, TestContext};
+use crate::assert_tunnel_state;
+use crate::vm::network::DUMMY_LAN_INTERFACE_IP;
+
+use mullvad_management_interface::ManagementServiceClient;
+use mullvad_types::states::TunnelState;
+use std::net::{IpAddr, SocketAddr};
+use test_macro::test_function;
+use test_rpc::{Interface, ServiceClient};
+
+/// Verify that traffic to private IPs is blocked when
+/// "local network sharing" is disabled, but not blocked
+/// when it is enabled.
+/// It only checks whether outgoing UDP, TCP, and ICMP is
+/// blocked for a single arbitrary private IP and port.
+#[test_function]
+pub async fn test_lan(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let lan_destination = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 1234);
+
+ //
+ // Connect
+ //
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ //
+ // Disable LAN sharing
+ //
+
+ log::info!("LAN sharing: disabled");
+
+ mullvad_client
+ .set_allow_lan(false)
+ .await
+ .expect("failed to disable LAN sharing");
+
+ //
+ // Ensure LAN is not reachable
+ //
+
+ log::info!("Test whether outgoing LAN traffic is blocked");
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination).await?;
+ assert!(
+ detected_probes.none(),
+ "observed unexpected outgoing LAN packets"
+ );
+
+ //
+ // Enable LAN sharing
+ //
+
+ log::info!("LAN sharing: enabled");
+
+ mullvad_client
+ .set_allow_lan(true)
+ .await
+ .expect("failed to enable LAN sharing");
+
+ //
+ // Ensure LAN is reachable
+ //
+
+ log::info!("Test whether outgoing LAN traffic is blocked");
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination).await?;
+ assert!(
+ detected_probes.all(),
+ "did not observe all outgoing LAN packets"
+ );
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
+
+/// Enable lockdown mode. This test succeeds if:
+///
+/// * Disconnected state: Outgoing traffic leaks (UDP/TCP/ICMP)
+/// cannot be produced.
+/// * Disconnected state: Outgoing traffic to a single
+/// private IP can be produced, if and only if LAN
+/// sharing is enabled.
+/// * Connected state: Outgoing traffic leaks (UDP/TCP/ICMP)
+/// cannot be produced.
+///
+/// # Limitations
+///
+/// These tests are performed on one single public IP address
+/// and one private IP address. They detect basic leaks but
+/// do not guarantee close conformity with the security
+/// document.
+#[test_function]
+pub async fn test_lockdown(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let lan_destination: SocketAddr = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 1337);
+ let inet_destination: SocketAddr = "1.1.1.1:1337".parse().unwrap();
+
+ log::info!("Verify tunnel state: disconnected");
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Disconnected);
+
+ //
+ // Enable lockdown mode
+ //
+ mullvad_client
+ .set_block_when_disconnected(true)
+ .await
+ .expect("failed to enable lockdown mode");
+
+ //
+ // Disable LAN sharing
+ //
+
+ log::info!("LAN sharing: disabled");
+
+ mullvad_client
+ .set_allow_lan(false)
+ .await
+ .expect("failed to disable LAN sharing");
+
+ //
+ // Ensure all destinations are unreachable
+ //
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination).await?;
+ assert!(detected_probes.none(), "observed outgoing packets to LAN");
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination).await?;
+ assert!(
+ detected_probes.none(),
+ "observed outgoing packets to internet"
+ );
+
+ //
+ // Enable LAN sharing
+ //
+
+ log::info!("LAN sharing: enabled");
+
+ mullvad_client
+ .set_allow_lan(true)
+ .await
+ .expect("failed to enable LAN sharing");
+
+ //
+ // Ensure private IPs are reachable, but not others
+ //
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination).await?;
+ assert!(
+ detected_probes.all(),
+ "did not observe some outgoing packets"
+ );
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination).await?;
+ assert!(
+ detected_probes.none(),
+ "observed outgoing packets to internet"
+ );
+
+ //
+ // Connect
+ //
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ //
+ // Leak test
+ //
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ // Send traffic outside the tunnel to sanity check that the internet is *not* reachable via non-
+ // tunnel interfaces.
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination).await?;
+ assert!(
+ detected_probes.none(),
+ "observed outgoing packets to internet"
+ );
+
+ //
+ // Disable lockdown mode
+ //
+ mullvad_client
+ .set_block_when_disconnected(false)
+ .await
+ .expect("failed to disable lockdown mode");
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
diff --git a/test/test-manager/src/tests/test_metadata.rs b/test/test-manager/src/tests/test_metadata.rs
new file mode 100644
index 0000000000..39d802e5e0
--- /dev/null
+++ b/test/test-manager/src/tests/test_metadata.rs
@@ -0,0 +1,16 @@
+use super::TestWrapperFunction;
+use test_rpc::mullvad_daemon::MullvadClientVersion;
+
+pub struct TestMetadata {
+ pub name: &'static str,
+ pub command: &'static str,
+ pub mullvad_client_version: MullvadClientVersion,
+ pub func: TestWrapperFunction,
+ pub priority: Option<i32>,
+ pub always_run: bool,
+ pub must_succeed: bool,
+ pub cleanup: bool,
+}
+
+// Register our test metadata struct with inventory to allow submitting tests of this type.
+inventory::collect!(TestMetadata);
diff --git a/test/test-manager/src/tests/tunnel.rs b/test/test-manager/src/tests/tunnel.rs
new file mode 100644
index 0000000000..c74518d45b
--- /dev/null
+++ b/test/test-manager/src/tests/tunnel.rs
@@ -0,0 +1,627 @@
+use super::helpers::{self, connect_and_wait, disconnect_and_wait, update_relay_settings};
+use super::{Error, TestContext};
+use std::net::IpAddr;
+
+use crate::network_monitor::{start_packet_monitor, MonitorOptions};
+use mullvad_management_interface::{types, ManagementServiceClient};
+use mullvad_types::relay_constraints::{
+ Constraint, LocationConstraint, OpenVpnConstraints, RelayConstraintsUpdate,
+ RelaySettingsUpdate, WireguardConstraints,
+};
+use mullvad_types::relay_constraints::{GeographicLocationConstraint, TransportPort};
+use pnet_packet::ip::IpNextHeaderProtocols;
+use talpid_types::net::{TransportProtocol, TunnelType};
+use test_macro::test_function;
+use test_rpc::meta::Os;
+use test_rpc::mullvad_daemon::ServiceStatus;
+use test_rpc::{Interface, ServiceClient};
+
+/// Set up an OpenVPN tunnel, UDP as well as TCP.
+/// This test fails if a working tunnel cannot be set up.
+#[test_function]
+pub async fn test_openvpn_tunnel(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ // TODO: observe traffic on the expected destination/port (only)
+
+ const CONSTRAINTS: [(&str, Constraint<TransportPort>); 3] = [
+ ("any", Constraint::Any),
+ (
+ "UDP",
+ Constraint::Only(TransportPort {
+ protocol: TransportProtocol::Udp,
+ port: Constraint::Any,
+ }),
+ ),
+ (
+ "TCP",
+ Constraint::Only(TransportPort {
+ protocol: TransportProtocol::Tcp,
+ port: Constraint::Any,
+ }),
+ ),
+ ];
+
+ for (protocol, constraint) in CONSTRAINTS {
+ log::info!("Connect to {protocol} OpenVPN endpoint");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::OpenVpn)),
+ openvpn_constraints: Some(OpenVpnConstraints { port: constraint }),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+ }
+
+ Ok(())
+}
+
+/// Set up a WireGuard tunnel.
+/// This test fails if a working tunnel cannot be set up.
+/// WARNING: This test will fail if host has something bound to port 53 such as a connected Mullvad
+#[test_function]
+pub async fn test_wireguard_tunnel(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ // TODO: observe UDP traffic on the expected destination/port (only)
+ // TODO: IPv6
+
+ const PORTS: [(u16, bool); 3] = [(53, true), (51820, true), (1, false)];
+
+ for (port, should_succeed) in PORTS {
+ log::info!("Connect to WireGuard endpoint on port {port}");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::Wireguard)),
+ wireguard_constraints: Some(WireguardConstraints {
+ port: Constraint::Only(port),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ let connection_result = connect_and_wait(&mut mullvad_client).await;
+ assert_eq!(
+ connection_result.is_ok(),
+ should_succeed,
+ "unexpected result for port {port}: {connection_result:?}",
+ );
+
+ if should_succeed {
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+ }
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+ }
+
+ Ok(())
+}
+
+/// Use udp2tcp obfuscation. This test connects to a
+/// WireGuard relay over TCP. It fails if no outgoing TCP
+/// traffic to the relay is observed on the expected port.
+#[test_function]
+pub async fn test_udp2tcp_tunnel(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ // TODO: check if src <-> target / tcp is observed (only)
+ // TODO: ping a public IP on the fake network (not possible using real relay)
+
+ mullvad_client
+ .set_obfuscation_settings(types::ObfuscationSettings {
+ selected_obfuscation: i32::from(
+ types::obfuscation_settings::SelectedObfuscation::Udp2tcp,
+ ),
+ udp2tcp: Some(types::Udp2TcpObfuscationSettings { port: None }),
+ })
+ .await
+ .expect("failed to enable udp2tcp");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::Wireguard)),
+ wireguard_constraints: Some(WireguardConstraints::default()),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ log::info!("Connect to WireGuard via tcp2udp endpoint");
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ //
+ // Set up packet monitor
+ //
+
+ let guest_ip = rpc
+ .get_interface_ip(Interface::NonTunnel)
+ .await
+ .expect("failed to obtain inet interface IP");
+
+ let monitor = start_packet_monitor(
+ move |packet| {
+ packet.source.ip() != guest_ip || (packet.protocol == IpNextHeaderProtocols::Tcp)
+ },
+ MonitorOptions::default(),
+ )
+ .await;
+
+ //
+ // Verify that we can reach stuff
+ //
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ let monitor_result = monitor.into_result().await.unwrap();
+ assert_eq!(monitor_result.discarded_packets, 0);
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
+
+/// Test whether bridge mode works. This fails if:
+/// * No outgoing traffic to the bridge/entry relay is
+/// observed from the SUT.
+/// * The conncheck reports an unexpected exit relay.
+#[test_function]
+pub async fn test_bridge(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Select relay");
+ let bridge_filter = |bridge: &types::Relay| {
+ bridge.active && bridge.endpoint_type == i32::from(types::relay::RelayType::Bridge)
+ };
+ let ovpn_filter = |relay: &types::Relay| {
+ relay.active && relay.endpoint_type == i32::from(types::relay::RelayType::Openvpn)
+ };
+ let entry = helpers::filter_relays(&mut mullvad_client, bridge_filter)
+ .await?
+ .pop()
+ .unwrap();
+ let exit = helpers::filter_relays(&mut mullvad_client, ovpn_filter)
+ .await?
+ .pop()
+ .unwrap();
+
+ //
+ // Enable bridge mode
+ //
+
+ log::info!("Updating bridge settings");
+
+ mullvad_client
+ .set_bridge_state(types::BridgeState {
+ state: i32::from(types::bridge_state::State::On),
+ })
+ .await
+ .expect("failed to enable bridge mode");
+
+ mullvad_client
+ .set_bridge_settings(types::BridgeSettings {
+ r#type: Some(types::bridge_settings::Type::Normal(
+ types::bridge_settings::BridgeConstraints {
+ location: helpers::into_locationconstraint(&entry)
+ .map(types::LocationConstraint::from),
+ providers: vec![],
+ ownership: i32::from(types::Ownership::Any),
+ },
+ )),
+ })
+ .await
+ .expect("failed to update bridge settings");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: helpers::into_constraint(&exit),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::OpenVpn)),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ //
+ // Connect to VPN
+ //
+
+ log::info!("Connect to OpenVPN relay via bridge");
+
+ let monitor = start_packet_monitor(
+ move |packet| packet.destination.ip() == entry.ipv4_addr_in.parse::<IpAddr>().unwrap(),
+ MonitorOptions::default(),
+ )
+ .await;
+
+ connect_and_wait(&mut mullvad_client)
+ .await
+ .expect("connect_and_wait");
+
+ //
+ // Verify entry IP
+ //
+
+ log::info!("Verifying entry server");
+
+ let monitor_result = monitor.into_result().await.unwrap();
+ assert!(
+ !monitor_result.packets.is_empty(),
+ "detected no traffic to entry server",
+ );
+
+ //
+ // Verify exit IP
+ //
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
+
+/// Test whether WireGuard multihop works. This fails if:
+/// * No outgoing traffic to the entry relay is
+/// observed from the SUT.
+/// * The conncheck reports an unexpected exit relay.
+#[test_function]
+pub async fn test_multihop(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ //
+ // Set relays to use
+ //
+
+ log::info!("Select relay");
+ let relay_filter = |relay: &types::Relay| {
+ relay.active && relay.endpoint_type == i32::from(types::relay::RelayType::Wireguard)
+ };
+ let (entry, exit) = helpers::random_entry_and_exit(&mut mullvad_client, relay_filter).await?;
+ let exit_constraint = helpers::into_constraint(&exit);
+ let entry_constraint =
+ helpers::into_constraint(&entry).map(|entry_location| WireguardConstraints {
+ use_multihop: true,
+ entry_location,
+ ..Default::default()
+ });
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: exit_constraint,
+ wireguard_constraints: entry_constraint,
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ //
+ // Connect
+ //
+
+ let monitor = start_packet_monitor(
+ move |packet| {
+ packet.destination.ip() == entry.ipv4_addr_in.parse::<IpAddr>().unwrap()
+ && packet.protocol == IpNextHeaderProtocols::Udp
+ },
+ MonitorOptions::default(),
+ )
+ .await;
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ //
+ // Verify entry IP
+ //
+
+ log::info!("Verifying entry server");
+
+ let monitor_result = monitor.into_result().await.unwrap();
+ assert!(!monitor_result.packets.is_empty(), "no matching packets",);
+
+ //
+ // Verify exit IP
+ //
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
+
+/// Test whether the daemon automatically connects on reboot when using
+/// WireGuard.
+///
+/// # Limitations
+///
+/// This test does not guarantee that nothing leaks during boot or shutdown.
+#[test_function]
+pub async fn test_wireguard_autoconnect(
+ _: TestContext,
+ mut rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Setting tunnel protocol to WireGuard");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::Wireguard)),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ mullvad_client
+ .set_auto_connect(true)
+ .await
+ .expect("failed to enable auto-connect");
+
+ reboot(&mut rpc).await?;
+ rpc.mullvad_daemon_wait_for_state(|state| state == ServiceStatus::Running)
+ .await?;
+
+ log::info!("Waiting for daemon to connect");
+
+ helpers::wait_for_tunnel_state(mullvad_client, |state| {
+ matches!(state, mullvad_types::states::TunnelState::Connected { .. })
+ })
+ .await?;
+
+ Ok(())
+}
+
+/// Test whether the daemon automatically connects on reboot when using
+/// OpenVPN.
+///
+/// # Limitations
+///
+/// This test does not guarantee that nothing leaks during boot or shutdown.
+#[test_function]
+pub async fn test_openvpn_autoconnect(
+ _: TestContext,
+ mut rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ log::info!("Setting tunnel protocol to OpenVPN");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::OpenVpn)),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ mullvad_client
+ .set_auto_connect(true)
+ .await
+ .expect("failed to enable auto-connect");
+
+ reboot(&mut rpc).await?;
+ rpc.mullvad_daemon_wait_for_state(|state| state == ServiceStatus::Running)
+ .await?;
+
+ log::info!("Waiting for daemon to connect");
+
+ helpers::wait_for_tunnel_state(mullvad_client, |state| {
+ matches!(state, mullvad_types::states::TunnelState::Connected { .. })
+ })
+ .await?;
+
+ Ok(())
+}
+
+async fn reboot(rpc: &mut ServiceClient) -> Result<(), Error> {
+ rpc.reboot().await?;
+
+ // The tunnel must be reconfigured after the virtual machine is up,
+ // or macOS refuses to assign an IP. The reasons for this are poorly understood.
+ #[cfg(target_os = "macos")]
+ crate::vm::network::macos::configure_tunnel()
+ .await
+ .map_err(|error| Error::Other(format!("Failed to recreate custom wg tun: {error}")))?;
+
+ Ok(())
+}
+
+/// Test whether quantum-resistant tunnels can be set up.
+///
+/// # Limitations
+///
+/// This only checks whether we have a working tunnel and a PSK. It does not determine whether the
+/// exchange part is correct.
+///
+/// We only check whether there is a PSK on Linux.
+#[test_function]
+pub async fn test_quantum_resistant_tunnel(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ mullvad_client
+ .set_quantum_resistant_tunnel(types::QuantumResistantState {
+ state: i32::from(types::quantum_resistant_state::State::Off),
+ })
+ .await
+ .expect("Failed to disable PQ tunnels");
+
+ //
+ // PQ disabled: Find no "preshared key"
+ //
+
+ connect_and_wait(&mut mullvad_client).await?;
+ check_tunnel_psk(&rpc, false).await;
+
+ log::info!("Setting tunnel protocol to WireGuard");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ tunnel_protocol: Some(Constraint::Only(TunnelType::Wireguard)),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("Failed to update relay settings");
+
+ mullvad_client
+ .set_quantum_resistant_tunnel(types::QuantumResistantState {
+ state: i32::from(types::quantum_resistant_state::State::On),
+ })
+ .await
+ .expect("Failed to enable PQ tunnels");
+
+ //
+ // PQ enabled: Find "preshared key"
+ //
+
+ connect_and_wait(&mut mullvad_client).await?;
+ check_tunnel_psk(&rpc, true).await;
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ Ok(())
+}
+
+async fn check_tunnel_psk(rpc: &ServiceClient, should_have_psk: bool) {
+ match rpc.get_os().await.expect("failed to get OS") {
+ Os::Linux => {
+ let name = rpc
+ .get_interface_name(Interface::Tunnel)
+ .await
+ .expect("failed to get tun name");
+ let output = rpc
+ .exec("wg", vec!["show", &name].into_iter())
+ .await
+ .expect("failed to run wg");
+ let parsed_output = std::str::from_utf8(&output.stdout).expect("non-utf8 output");
+ assert!(
+ parsed_output.contains("preshared key: ") == should_have_psk,
+ "expected to NOT find preshared key"
+ );
+ }
+ os => {
+ log::warn!("Not checking if there is a PSK on {os}");
+ }
+ }
+}
+
+/// Test whether a PQ tunnel can be set up with multihop and UDP-over-TCP enabled.
+///
+/// # Limitations
+///
+/// This is not testing any of the individual components, just whether the daemon can connect when
+/// all of these features are combined.
+#[test_function]
+pub async fn test_quantum_resistant_multihop_udp2tcp_tunnel(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ mullvad_client
+ .set_quantum_resistant_tunnel(types::QuantumResistantState {
+ state: i32::from(types::quantum_resistant_state::State::On),
+ })
+ .await
+ .expect("Failed to enable PQ tunnels");
+
+ mullvad_client
+ .set_obfuscation_settings(types::ObfuscationSettings {
+ selected_obfuscation: i32::from(
+ types::obfuscation_settings::SelectedObfuscation::Udp2tcp,
+ ),
+ udp2tcp: Some(types::Udp2TcpObfuscationSettings { port: None }),
+ })
+ .await
+ .expect("Failed to enable obfuscation");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ ))),
+ wireguard_constraints: Some(WireguardConstraints {
+ use_multihop: true,
+ entry_location: Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("se".to_string()),
+ )),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("Failed to update relay settings");
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ Ok(())
+}
diff --git a/test/test-manager/src/tests/tunnel_state.rs b/test/test-manager/src/tests/tunnel_state.rs
new file mode 100644
index 0000000000..55b1d2074e
--- /dev/null
+++ b/test/test-manager/src/tests/tunnel_state.rs
@@ -0,0 +1,355 @@
+use super::helpers::{
+ self, connect_and_wait, disconnect_and_wait, get_tunnel_state, send_guest_probes,
+ unreachable_wireguard_tunnel, update_relay_settings, wait_for_tunnel_state,
+};
+use super::{ui, Error, TestContext};
+use crate::assert_tunnel_state;
+use crate::vm::network::DUMMY_LAN_INTERFACE_IP;
+
+use mullvad_management_interface::{types, ManagementServiceClient};
+use mullvad_types::relay_constraints::GeographicLocationConstraint;
+use mullvad_types::CustomTunnelEndpoint;
+use mullvad_types::{
+ relay_constraints::{
+ Constraint, LocationConstraint, RelayConstraintsUpdate, RelaySettingsUpdate,
+ },
+ states::TunnelState,
+};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use talpid_types::net::{Endpoint, TransportProtocol, TunnelEndpoint, TunnelType};
+use test_macro::test_function;
+use test_rpc::{Interface, ServiceClient};
+
+/// Verify that outgoing TCP, UDP, and ICMP packets can be observed
+/// in the disconnected state. The purpose is mostly to rule prevent
+/// false negatives in other tests.
+/// This also ensures that the disconnected view is shown in the Electron app.
+#[test_function]
+pub async fn test_disconnected_state(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let inet_destination = "1.3.3.7:1337".parse().unwrap();
+
+ log::info!("Verify tunnel state: disconnected");
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Disconnected);
+
+ //
+ // Test whether outgoing packets can be observed
+ //
+
+ log::info!("Sending packets to {inet_destination}");
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination).await?;
+ assert!(
+ detected_probes.all(),
+ "did not see (all) outgoing packets to destination: {detected_probes:?}",
+ );
+
+ //
+ // Test UI view
+ //
+
+ log::info!("UI: Test disconnected state");
+ let ui_result = ui::run_test(&rpc, &["disconnected.spec"]).await.unwrap();
+ assert!(ui_result.success());
+
+ Ok(())
+}
+
+/// Try to produce leaks in the connecting state by forcing
+/// the app into the connecting state and trying to leak,
+/// failing if any the following outbound traffic is
+/// detected:
+///
+/// * TCP on port 53 and one other port
+/// * UDP on port 53 and one other port
+/// * ICMP (by pinging)
+///
+/// # Limitations
+///
+/// These tests are performed on one single public IP address
+/// and one private IP address. They detect basic leaks but
+/// do not guarantee close conformity with the security
+/// document.
+#[test_function]
+pub async fn test_connecting_state(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let inet_destination = "1.1.1.1:1337".parse().unwrap();
+ let lan_destination: SocketAddr = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 1337);
+ let inet_dns = "1.1.1.1:53".parse().unwrap();
+ let lan_dns: SocketAddr = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 53);
+
+ log::info!("Verify tunnel state: disconnected");
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Disconnected);
+
+ let relay_settings = RelaySettingsUpdate::CustomTunnelEndpoint(CustomTunnelEndpoint {
+ host: "1.3.3.7".to_owned(),
+ config: mullvad_types::ConnectionConfig::Wireguard(unreachable_wireguard_tunnel()),
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ mullvad_client
+ .connect_tunnel(())
+ .await
+ .expect("failed to begin connecting");
+ let new_state = wait_for_tunnel_state(mullvad_client.clone(), |state| {
+ matches!(
+ state,
+ TunnelState::Connecting { .. } | TunnelState::Error(..)
+ )
+ })
+ .await?;
+
+ assert!(
+ matches!(new_state, TunnelState::Connecting { .. }),
+ "failed to enter connecting state: {:?}",
+ new_state
+ );
+
+ //
+ // Leak test
+ //
+
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (inet)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (lan)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_dns)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (DNS, inet)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_dns)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (DNS, lan)"
+ );
+
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Connecting { .. });
+
+ //
+ // Disconnect
+ //
+
+ log::info!("Disconnecting");
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Any),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ Ok(())
+}
+
+/// Try to produce leaks in the error state. Refer to the
+/// `test_connecting_state` documentation for details.
+#[test_function]
+pub async fn test_error_state(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let inet_destination = "1.1.1.1:1337".parse().unwrap();
+ let lan_destination: SocketAddr = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 1337);
+ let inet_dns = "1.1.1.1:53".parse().unwrap();
+ let lan_dns: SocketAddr = SocketAddr::new(IpAddr::V4(DUMMY_LAN_INTERFACE_IP), 53);
+
+ log::info!("Verify tunnel state: disconnected");
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Disconnected);
+
+ //
+ // Connect to non-existent location
+ //
+
+ log::info!("Enter error state");
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Only(LocationConstraint::Location(
+ GeographicLocationConstraint::Country("xx".to_string()),
+ ))),
+ ..Default::default()
+ });
+
+ mullvad_client
+ .set_allow_lan(false)
+ .await
+ .expect("failed to disable LAN sharing");
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ let _ = connect_and_wait(&mut mullvad_client).await;
+ assert_tunnel_state!(&mut mullvad_client, TunnelState::Error { .. });
+
+ //
+ // Leak test
+ //
+
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (inet)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_destination)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (lan)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_dns)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (DNS, inet)"
+ );
+ assert!(
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), lan_dns)
+ .await?
+ .none(),
+ "observed unexpected outgoing packets (DNS, lan)"
+ );
+
+ //
+ // Disconnect
+ //
+
+ log::info!("Disconnecting");
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: Some(Constraint::Any),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ Ok(())
+}
+
+/// Connect to a single relay and verify that:
+/// * Traffic can be sent and received in the tunnel.
+/// This is done by pinging a single public IP address
+/// and failing if there is no response.
+/// * The correct relay is used.
+/// * Leaks outside the tunnel are blocked. Refer to the
+/// `test_connecting_state` documentation for details.
+#[test_function]
+pub async fn test_connected_state(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ let inet_destination = "1.1.1.1:1337".parse().unwrap();
+
+ //
+ // Set relay to use
+ //
+
+ log::info!("Select relay");
+
+ let relay_filter = |relay: &types::Relay| {
+ relay.active && relay.endpoint_type == i32::from(types::relay::RelayType::Wireguard)
+ };
+
+ let relay = helpers::filter_relays(&mut mullvad_client, relay_filter)
+ .await?
+ .pop()
+ .unwrap();
+
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: helpers::into_constraint(&relay),
+ ..Default::default()
+ });
+
+ update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ //
+ // Connect
+ //
+
+ connect_and_wait(&mut mullvad_client).await?;
+
+ let state = get_tunnel_state(&mut mullvad_client).await;
+
+ //
+ // Verify that endpoint was selected
+ //
+
+ match state {
+ TunnelState::Connected {
+ endpoint:
+ TunnelEndpoint {
+ endpoint:
+ Endpoint {
+ address: SocketAddr::V4(addr),
+ protocol: TransportProtocol::Udp,
+ },
+ // TODO: Consider the type of `relay` / `relay_filter` instead
+ tunnel_type: TunnelType::Wireguard,
+ quantum_resistant: false,
+ proxy: None,
+ obfuscation: None,
+ entry_endpoint: None,
+ tunnel_interface: _,
+ },
+ ..
+ } => {
+ assert_eq!(*addr.ip(), relay.ipv4_addr_in.parse::<Ipv4Addr>().unwrap());
+ }
+ actual => panic!("unexpected tunnel state: {:?}", actual),
+ }
+
+ //
+ // Ping outside of tunnel while connected
+ //
+
+ log::info!("Test whether outgoing non-tunnel traffic is blocked");
+
+ let detected_probes =
+ send_guest_probes(rpc.clone(), Some(Interface::NonTunnel), inet_destination).await?;
+ assert!(
+ detected_probes.none(),
+ "observed unexpected outgoing packets"
+ );
+
+ assert!(
+ helpers::using_mullvad_exit(&rpc).await,
+ "expected Mullvad exit IP"
+ );
+
+ disconnect_and_wait(&mut mullvad_client).await?;
+
+ Ok(())
+}
diff --git a/test/test-manager/src/tests/ui.rs b/test/test-manager/src/tests/ui.rs
new file mode 100644
index 0000000000..1e97430061
--- /dev/null
+++ b/test/test-manager/src/tests/ui.rs
@@ -0,0 +1,139 @@
+use super::config::TEST_CONFIG;
+use super::helpers;
+use super::{Error, TestContext};
+use mullvad_management_interface::{types, ManagementServiceClient};
+use mullvad_types::relay_constraints::{RelayConstraintsUpdate, RelaySettingsUpdate};
+use std::{
+ collections::BTreeMap,
+ fmt::Debug,
+ path::{Path, PathBuf},
+};
+use test_macro::test_function;
+use test_rpc::{meta::Os, ExecResult, ServiceClient};
+
+pub async fn run_test<T: AsRef<str> + Debug>(
+ rpc: &ServiceClient,
+ params: &[T],
+) -> Result<ExecResult, Error> {
+ let env: [(&str, T); 0] = [];
+ run_test_env(rpc, params, env).await
+}
+
+pub async fn run_test_env<
+ I: IntoIterator<Item = (K, T)> + Debug,
+ K: AsRef<str> + Debug,
+ T: AsRef<str> + Debug,
+>(
+ rpc: &ServiceClient,
+ params: &[T],
+ env: I,
+) -> Result<ExecResult, Error> {
+ let new_params: Vec<String>;
+ let bin_path;
+
+ match rpc.get_os().await? {
+ Os::Linux => {
+ bin_path = PathBuf::from("/usr/bin/xvfb-run");
+
+ let ui_runner_path =
+ Path::new(&TEST_CONFIG.artifacts_dir).join(&TEST_CONFIG.ui_e2e_tests_filename);
+ new_params = std::iter::once(ui_runner_path.to_string_lossy().into_owned())
+ .chain(params.iter().map(|param| param.as_ref().to_owned()))
+ .collect();
+ }
+ _ => {
+ bin_path =
+ Path::new(&TEST_CONFIG.artifacts_dir).join(&TEST_CONFIG.ui_e2e_tests_filename);
+ new_params = params
+ .iter()
+ .map(|param| param.as_ref().to_owned())
+ .collect();
+ }
+ }
+
+ let env: BTreeMap<String, String> = env
+ .into_iter()
+ .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
+ .collect();
+
+ // env may contain sensitive info
+ //log::info!("Running UI tests: {params:?}, env: {env:?}");
+ log::info!("Running UI tests: {params:?}");
+
+ let result = rpc
+ .exec_env(
+ bin_path.to_string_lossy().into_owned(),
+ new_params.into_iter(),
+ env,
+ )
+ .await?;
+
+ if !result.success() {
+ let stdout = std::str::from_utf8(&result.stdout).unwrap_or("invalid utf8");
+ let stderr = std::str::from_utf8(&result.stderr).unwrap_or("invalid utf8");
+
+ log::debug!("UI test failed:\n\nstdout:\n\n{stdout}\n\n{stderr}\n");
+ }
+
+ Ok(result)
+}
+
+/// Test how various tunnel settings are handled and displayed by the GUI
+#[test_function]
+pub async fn test_ui_tunnel_settings(
+ _: TestContext,
+ rpc: ServiceClient,
+ mut mullvad_client: ManagementServiceClient,
+) -> Result<(), Error> {
+ // tunnel-state.spec precondition: a single WireGuard relay should be selected
+ log::info!("Select WireGuard relay");
+ let entry = helpers::filter_relays(&mut mullvad_client, |relay: &types::Relay| {
+ relay.active && relay.endpoint_type == i32::from(types::relay::RelayType::Wireguard)
+ })
+ .await?
+ .pop()
+ .unwrap();
+
+ // The test expects us to be disconnected and logged in but to have a specific relay selected
+ let relay_settings = RelaySettingsUpdate::Normal(RelayConstraintsUpdate {
+ location: helpers::into_constraint(&entry),
+ ..Default::default()
+ });
+
+ helpers::update_relay_settings(&mut mullvad_client, relay_settings)
+ .await
+ .expect("failed to update relay settings");
+
+ let ui_result = run_test_env(
+ &rpc,
+ &["tunnel-state.spec"],
+ [
+ ("HOSTNAME", entry.hostname.as_str()),
+ ("IN_IP", entry.ipv4_addr_in.as_str()),
+ (
+ "CONNECTION_CHECK_URL",
+ &format!("https://am.i.{}", TEST_CONFIG.mullvad_host),
+ ),
+ ],
+ )
+ .await
+ .unwrap();
+ assert!(ui_result.success());
+
+ Ok(())
+}
+
+/// Test whether logging in and logging out work in the GUI
+#[test_function(priority = 500)]
+pub async fn test_ui_login(_: TestContext, rpc: ServiceClient) -> Result<(), Error> {
+ let ui_result = run_test_env(
+ &rpc,
+ &["login.spec"],
+ [("ACCOUNT_NUMBER", &*TEST_CONFIG.account_number)],
+ )
+ .await
+ .unwrap();
+ assert!(ui_result.success());
+
+ Ok(())
+}
diff --git a/test/test-manager/src/vm/logging.rs b/test/test-manager/src/vm/logging.rs
new file mode 100644
index 0000000000..98223f01d5
--- /dev/null
+++ b/test/test-manager/src/vm/logging.rs
@@ -0,0 +1,9 @@
+use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
+
+pub async fn forward_logs<T: AsyncRead + Unpin>(prefix: &str, stdio: T, level: log::Level) {
+ let reader = BufReader::new(stdio);
+ let mut lines = reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ log::log!(level, "{prefix}{line}");
+ }
+}
diff --git a/test/test-manager/src/vm/mod.rs b/test/test-manager/src/vm/mod.rs
new file mode 100644
index 0000000000..a5c794a58a
--- /dev/null
+++ b/test/test-manager/src/vm/mod.rs
@@ -0,0 +1,87 @@
+use crate::{
+ config::{Config, ConfigFile, VmConfig, VmType},
+ package,
+};
+use anyhow::{Context, Result};
+use std::net::IpAddr;
+
+mod logging;
+pub mod network;
+mod provision;
+mod qemu;
+mod ssh;
+#[cfg(target_os = "macos")]
+mod tart;
+mod update;
+mod util;
+
+#[async_trait::async_trait]
+pub trait VmInstance {
+ /// Path to pty on the host that corresponds to the serial device
+ fn get_pty(&self) -> &str;
+
+ /// Get initial IP address of guest
+ fn get_ip(&self) -> &IpAddr;
+
+ /// Wait for VM to destruct
+ async fn wait(&mut self);
+}
+
+pub async fn set_config(config: &mut ConfigFile, vm_name: &str, vm_config: VmConfig) -> Result<()> {
+ config
+ .edit(|config| {
+ config.vms.insert(vm_name.to_owned(), vm_config);
+ })
+ .await
+ .context("Failed to update VM config")
+}
+
+pub async fn run(config: &Config, name: &str) -> Result<Box<dyn VmInstance>> {
+ let vm_conf = get_vm_config(config, name)?;
+
+ log::info!("Starting \"{name}\"");
+
+ let instance = match vm_conf.vm_type {
+ VmType::Qemu => Box::new(
+ qemu::run(config, vm_conf)
+ .await
+ .context("Failed to run QEMU VM")?,
+ ) as Box<_>,
+ #[cfg(target_os = "macos")]
+ VmType::Tart => Box::new(
+ tart::run(config, vm_conf)
+ .await
+ .context("Failed to run Tart VM")?,
+ ) as Box<_>,
+ #[cfg(not(target_os = "macos"))]
+ VmType::Tart => return Err(anyhow::anyhow!("Failed to run Tart VM on a non-macOS host")),
+ };
+
+ log::info!("Started instance of \"{name}\" vm");
+
+ Ok(instance)
+}
+
+pub async fn provision(
+ config: &Config,
+ name: &str,
+ instance: &dyn VmInstance,
+ app_manifest: &package::Manifest,
+) -> Result<String> {
+ let vm_config = get_vm_config(config, name)?;
+ provision::provision(vm_config, instance, app_manifest).await
+}
+
+pub async fn update_packages(
+ config: VmConfig,
+ instance: &dyn VmInstance,
+) -> Result<crate::vm::update::Update> {
+ let guest_ip = *instance.get_ip();
+ tokio::task::spawn_blocking(move || update::packages(&config, guest_ip)).await?
+}
+
+pub fn get_vm_config<'a>(config: &'a Config, name: &str) -> Result<&'a VmConfig> {
+ config
+ .get_vm(name)
+ .with_context(|| format!("Could not find config: {name}"))
+}
diff --git a/test/test-manager/src/vm/network/linux.rs b/test/test-manager/src/vm/network/linux.rs
new file mode 100644
index 0000000000..ae4d708c01
--- /dev/null
+++ b/test/test-manager/src/vm/network/linux.rs
@@ -0,0 +1,369 @@
+use ipnetwork::Ipv4Network;
+use once_cell::sync::Lazy;
+use std::{
+ ffi::OsStr,
+ io,
+ net::{IpAddr, Ipv4Addr},
+ process::Stdio,
+ str::FromStr,
+};
+use tokio::{
+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+ process::{Child, Command},
+};
+
+/// (Contained) test subnet for the test runner: 172.29.1.1/24
+pub static TEST_SUBNET: Lazy<Ipv4Network> =
+ Lazy::new(|| Ipv4Network::new(Ipv4Addr::new(172, 29, 1, 1), 24).unwrap());
+/// Range of IPs returned by the DNS server: TEST_SUBNET_DHCP_FIRST to TEST_SUBNET_DHCP_LAST
+pub const TEST_SUBNET_DHCP_FIRST: Ipv4Addr = Ipv4Addr::new(172, 29, 1, 2);
+/// Range of IPs returned by the DNS server: TEST_SUBNET_DHCP_FIRST to TEST_SUBNET_DHCP_LAST
+pub const TEST_SUBNET_DHCP_LAST: Ipv4Addr = Ipv4Addr::new(172, 29, 1, 128);
+
+/// Bridge interface on the host
+pub const BRIDGE_NAME: &str = "br-mullvadtest";
+/// TAP interface used by the guest
+pub const TAP_NAME: &str = "tap-mullvadtest";
+
+/// Pingable dummy LAN interface (name)
+pub const DUMMY_LAN_INTERFACE_NAME: &str = "lan-mullvadtest";
+/// Pingable dummy LAN interface (IP)
+pub const DUMMY_LAN_INTERFACE_IP: Ipv4Addr = Ipv4Addr::new(172, 29, 1, 200);
+/// Pingable dummy interface with public IP (name)
+pub const DUMMY_INET_INTERFACE_NAME: &str = "net-mullvadtest";
+/// Pingable dummy interface with public IP (IP)
+pub const DUMMY_INET_INTERFACE_IP: Ipv4Addr = Ipv4Addr::new(1, 3, 3, 7);
+
+// Private key of the wireguard remote peer on host.
+const CUSTOM_TUN_REMOTE_PRIVKEY: &str = "gLvQuyqazziyf+pUCAFUgTnWIwn6fPE5MOReOqPEGHU=";
+// Public key of the wireguard remote peer on host.
+data_encoding_macro::base64_array!(
+ "pub const CUSTOM_TUN_REMOTE_PUBKEY" = "7svBwGBefP7KVmH/yes+pZCfO6uSOYeGieYYa1+kZ0E="
+);
+// Private key of the wireguard local peer on guest.
+const CUSTOM_TUN_LOCAL_PUBKEY: &str = "h6elqt3dfamtS/p9jxJ8bIYs8UW9YHfTFhvx0fabTFo=";
+// Private key of the wireguard local peer on guest.
+data_encoding_macro::base64_array!(
+ "pub const CUSTOM_TUN_LOCAL_PRIVKEY" = "mPue6Xt0pdz4NRAhfQSp/SLKo7kV7DW+2zvBq0N9iUI="
+);
+
+/// "Real" (non-tunnel) IP of the wireguard remote peer as defined in `setup-network.sh`.
+#[allow(dead_code)]
+pub const CUSTOM_TUN_REMOTE_REAL_ADDR: Ipv4Addr = Ipv4Addr::new(172, 29, 1, 200);
+/// Port of the wireguard remote peer as defined in `setup-network.sh`.
+#[allow(dead_code)]
+pub const CUSTOM_TUN_REMOTE_REAL_PORT: u16 = 51820;
+/// Tunnel address of the wireguard local peer as defined in `setup-network.sh`.
+pub const CUSTOM_TUN_LOCAL_TUN_ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 15, 2);
+/// Tunnel address of the wireguard remote peer as defined in `setup-network.sh`.
+pub const CUSTOM_TUN_REMOTE_TUN_ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 15, 1);
+/// Gateway (and default DNS resolver) of the wireguard tunnel.
+#[allow(dead_code)]
+pub const CUSTOM_TUN_GATEWAY: Ipv4Addr = CUSTOM_TUN_REMOTE_TUN_ADDR;
+/// Gateway of the non-tunnel interface.
+#[allow(dead_code)]
+pub const NON_TUN_GATEWAY: Ipv4Addr = Ipv4Addr::new(172, 29, 1, 1);
+/// Name of the wireguard interface on the host
+pub const CUSTOM_TUN_INTERFACE_NAME: &str = "wg-relay0";
+
+#[derive(err_derive::Error, Debug)]
+#[error(no_from)]
+pub enum Error {
+ #[error(display = "Failed to start 'ip'")]
+ IpStart(io::Error),
+ #[error(display = "'ip' command failed: {}", _0)]
+ IpFailed(i32),
+ #[error(display = "Failed to start 'sysctl'")]
+ SysctlStart(io::Error),
+ #[error(display = "'sysctl' failed: {}", _0)]
+ SysctlFailed(i32),
+ #[error(display = "Failed to start 'nft'")]
+ NftStart(io::Error),
+ #[error(display = "Failed to wait for 'nft'")]
+ NftRun(io::Error),
+ #[error(display = "'nft' command failed: {}", _0)]
+ NftFailed(i32),
+ #[error(display = "Failed to create wg config")]
+ CreateWireguardConfig(#[error(source)] async_tempfile::Error),
+ #[error(display = "Failed to write wg config")]
+ WriteWireguardConfig(#[error(source)] io::Error),
+ #[error(display = "Failed to start 'wg'")]
+ WgStart(io::Error),
+ #[error(display = "'wg' failed: {}", _0)]
+ WgFailed(i32),
+ #[error(display = "Failed to start 'dnsmasq'")]
+ DnsmasqStart(io::Error),
+ #[error(display = "Failed to create dnsmasq tempfile")]
+ CreateDnsmasqFile(#[error(source)] async_tempfile::Error),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+// TODO: probably provider dependent
+pub struct NetworkHandle {
+ dhcp_proc: DhcpProcHandle,
+}
+
+struct DhcpProcHandle {
+ child: Child,
+ _leases_file: async_tempfile::TempFile,
+ _pid_file: async_tempfile::TempFile,
+}
+
+/// Create a bridge network and hosts
+pub async fn setup_test_network() -> Result<NetworkHandle> {
+ enable_forwarding().await?;
+
+ let test_subnet = TEST_SUBNET.to_string();
+
+ log::info!("Create bridge network: dev {BRIDGE_NAME}, net {test_subnet}");
+
+ run_ip_cmd(["link", "add", BRIDGE_NAME, "type", "bridge"]).await?;
+ run_ip_cmd(["addr", "add", "dev", BRIDGE_NAME, &test_subnet]).await?;
+ run_ip_cmd(["link", "set", "dev", BRIDGE_NAME, "up"]).await?;
+
+ log::debug!("Masquerade traffic from bridge to internet");
+
+ run_nft(&format!(
+ "
+table ip mullvad_test_nat {{
+ chain POSTROUTING {{
+ type nat hook postrouting priority srcnat; policy accept;
+ ip saddr {test_subnet} ip daddr != {test_subnet} counter masquerade
+ }}
+}}"
+ ))
+ .await?;
+
+ log::debug!("Set up pingable hosts");
+
+ run_ip_cmd(["link", "add", DUMMY_LAN_INTERFACE_NAME, "type", "dummy"]).await?;
+ run_ip_cmd([
+ "addr",
+ "add",
+ "dev",
+ DUMMY_LAN_INTERFACE_NAME,
+ &DUMMY_LAN_INTERFACE_IP.to_string(),
+ ])
+ .await?;
+
+ run_ip_cmd(["link", "add", DUMMY_INET_INTERFACE_NAME, "type", "dummy"]).await?;
+ run_ip_cmd([
+ "addr",
+ "add",
+ "dev",
+ DUMMY_INET_INTERFACE_NAME,
+ &DUMMY_INET_INTERFACE_IP.to_string(),
+ ])
+ .await?;
+
+ log::debug!("Create WireGuard peer");
+
+ create_local_wireguard_peer().await?;
+
+ log::debug!("Start DHCP server for {BRIDGE_NAME}");
+
+ let dhcp_proc = start_dnsmasq().await?;
+
+ log::debug!("Create TAP interface {TAP_NAME} for guest");
+
+ run_ip_cmd(["tuntap", "add", TAP_NAME, "mode", "tap"]).await?;
+ run_ip_cmd(["link", "set", TAP_NAME, "master", BRIDGE_NAME]).await?;
+ run_ip_cmd(["link", "set", TAP_NAME, "up"]).await?;
+
+ Ok(NetworkHandle { dhcp_proc })
+}
+
+impl NetworkHandle {
+ /// Return the first IP address acknowledged by the DHCP server. This can only be called once.
+ pub async fn first_dhcp_ack(&mut self) -> Option<IpAddr> {
+ const LOG_PREFIX: &str = "[dnsmasq] ";
+ const LOG_LEVEL: log::Level = log::Level::Debug;
+
+ // dnsmasq-dhcp: DHCPACK(br-mullvadtest) 172.29.1.112 52:54:00:12:34:56 debian
+ let re = regex::Regex::new(r"DHCPACK.*\) ([0-9.]+)").unwrap();
+
+ let stderr = self.dhcp_proc.child.stderr.take();
+
+ let reader = BufReader::new(stderr?);
+ let mut lines = reader.lines();
+
+ while let Ok(Some(line)) = lines.next_line().await {
+ log::log!(LOG_LEVEL, "{LOG_PREFIX}{}", line);
+
+ if let Some(addr) = re
+ .captures(&line)
+ .and_then(|cap| cap.get(1))
+ .map(|addr| addr.as_str())
+ {
+ if let Ok(parsed_addr) = IpAddr::from_str(addr) {
+ log::debug!("Captured DHCPACK: {}", parsed_addr);
+ return Some(parsed_addr);
+ }
+ }
+ }
+
+ tokio::spawn(crate::vm::logging::forward_logs(
+ LOG_PREFIX,
+ lines.into_inner().into_inner(),
+ LOG_LEVEL,
+ ));
+
+ None
+ }
+}
+
+async fn start_dnsmasq() -> Result<DhcpProcHandle> {
+ // dnsmasq -i BRIDGE_NAME -F TEST_SUBNET_DHCP_FIRST,TEST_SUBNET_DHCP_LAST ...
+ let mut cmd = Command::new("dnsmasq");
+
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+
+ cmd.args([
+ "--bind-interfaces",
+ "-C",
+ "/dev/null",
+ "-i",
+ BRIDGE_NAME,
+ "-F",
+ &format!("{},{}", TEST_SUBNET_DHCP_FIRST, TEST_SUBNET_DHCP_LAST),
+ "--no-daemon",
+ ]);
+
+ let leases_file = async_tempfile::TempFile::new()
+ .await
+ .map_err(Error::CreateDnsmasqFile)?;
+ cmd.args(["-l", leases_file.file_path().to_str().unwrap()]);
+
+ let pid_file = async_tempfile::TempFile::new()
+ .await
+ .map_err(Error::CreateDnsmasqFile)?;
+ cmd.args(["-x", pid_file.file_path().to_str().unwrap()]);
+
+ let child = cmd.spawn().map_err(Error::DnsmasqStart)?;
+
+ Ok(DhcpProcHandle {
+ child,
+ _leases_file: leases_file,
+ _pid_file: pid_file,
+ })
+}
+
+/// Creates a WireGuard peer on the host.
+///
+/// This relay does not support PQ handshakes, etc.
+///
+/// The client should connect to `CUSTOM_TUN_REMOTE_REAL_ADDR` on port `CUSTOM_TUN_REMOTE_REAL_PORT`
+/// using the private key `CUSTOM_TUN_LOCAL_PRIVKEY`, and tunnel IP `CUSTOM_TUN_LOCAL_TUN_ADDR`.
+///
+/// The public key of the peer is `CUSTOM_TUN_REMOTE_PUBKEY`. The tunnel IP of the host peer is
+/// `CUSTOM_TUN_REMOTE_TUN_ADDR`.
+async fn create_local_wireguard_peer() -> Result<()> {
+ run_ip_cmd([
+ "link",
+ "add",
+ "dev",
+ CUSTOM_TUN_INTERFACE_NAME,
+ "type",
+ "wireguard",
+ ])
+ .await?;
+ run_ip_cmd([
+ "addr",
+ "add",
+ "dev",
+ CUSTOM_TUN_INTERFACE_NAME,
+ &CUSTOM_TUN_REMOTE_TUN_ADDR.to_string(),
+ "peer",
+ &CUSTOM_TUN_LOCAL_TUN_ADDR.to_string(),
+ ])
+ .await?;
+
+ let mut tempfile = async_tempfile::TempFile::new()
+ .await
+ .map_err(Error::CreateWireguardConfig)?;
+
+ tempfile
+ .write_all(
+ format!(
+ "
+
+[Interface]
+PrivateKey = {CUSTOM_TUN_REMOTE_PRIVKEY}
+ListenPort = {CUSTOM_TUN_REMOTE_REAL_PORT}
+
+[Peer]
+PublicKey = {CUSTOM_TUN_LOCAL_PUBKEY}
+AllowedIPs = {CUSTOM_TUN_LOCAL_TUN_ADDR}
+
+"
+ )
+ .as_bytes(),
+ )
+ .await
+ .map_err(Error::WriteWireguardConfig)?;
+
+ let mut cmd = Command::new("wg");
+ cmd.args([
+ "setconf",
+ CUSTOM_TUN_INTERFACE_NAME,
+ tempfile.file_path().to_str().unwrap(),
+ ]);
+ let output = cmd.output().await.map_err(Error::WgStart)?;
+ if !output.status.success() {
+ return Err(Error::WgFailed(output.status.code().unwrap()));
+ }
+
+ run_ip_cmd(["link", "set", "dev", CUSTOM_TUN_INTERFACE_NAME, "up"]).await?;
+
+ Ok(())
+}
+
+async fn run_ip_cmd<I, S>(args: I) -> Result<()>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<OsStr>,
+{
+ let mut cmd = Command::new("ip");
+ cmd.args(args);
+ let output = cmd.output().await.map_err(Error::IpStart)?;
+ if !output.status.success() {
+ return Err(Error::IpFailed(output.status.code().unwrap()));
+ }
+ Ok(())
+}
+
+async fn run_nft(input: &str) -> Result<()> {
+ let mut cmd = Command::new("nft");
+ cmd.args(["-f", "-"]);
+
+ cmd.stdin(Stdio::piped());
+
+ let mut child = cmd.spawn().map_err(Error::NftStart)?;
+ let mut stdin = child.stdin.take().unwrap();
+
+ stdin
+ .write_all(input.as_bytes())
+ .await
+ .expect("write to nft failed");
+
+ drop(stdin);
+
+ let output = child.wait_with_output().await.map_err(Error::NftRun)?;
+ if !output.status.success() {
+ return Err(Error::NftFailed(output.status.code().unwrap()));
+ }
+ Ok(())
+}
+
+async fn enable_forwarding() -> Result<()> {
+ let mut cmd = Command::new("sysctl");
+ cmd.arg("net.ipv4.ip_forward=1");
+ let output = cmd.output().await.map_err(Error::SysctlStart)?;
+ if !output.status.success() {
+ return Err(Error::SysctlFailed(output.status.code().unwrap()));
+ }
+ Ok(())
+}
diff --git a/test/test-manager/src/vm/network/macos.rs b/test/test-manager/src/vm/network/macos.rs
new file mode 100644
index 0000000000..5e4bdae786
--- /dev/null
+++ b/test/test-manager/src/vm/network/macos.rs
@@ -0,0 +1,175 @@
+use std::net::{Ipv4Addr, SocketAddrV4};
+
+use anyhow::{anyhow, Context, Result};
+use tokio::{io::AsyncWriteExt, process::Command};
+
+/// Pingable dummy LAN interface (IP)
+/// TODO: This should probably be a different host, not the gateway
+pub const DUMMY_LAN_INTERFACE_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
+
+// Private key of the wireguard remote peer on host.
+const CUSTOM_TUN_REMOTE_PRIVKEY: &str = "gLvQuyqazziyf+pUCAFUgTnWIwn6fPE5MOReOqPEGHU=";
+// Public key of the wireguard remote peer on host.
+data_encoding_macro::base64_array!(
+ "pub const CUSTOM_TUN_REMOTE_PUBKEY" = "7svBwGBefP7KVmH/yes+pZCfO6uSOYeGieYYa1+kZ0E="
+);
+// Private key of the wireguard local peer on guest.
+const CUSTOM_TUN_LOCAL_PUBKEY: &str = "h6elqt3dfamtS/p9jxJ8bIYs8UW9YHfTFhvx0fabTFo=";
+// Private key of the wireguard local peer on guest.
+data_encoding_macro::base64_array!(
+ "pub const CUSTOM_TUN_LOCAL_PRIVKEY" = "mPue6Xt0pdz4NRAhfQSp/SLKo7kV7DW+2zvBq0N9iUI="
+);
+/// "Real" (non-tunnel) IP of the wireguard remote peer as defined in `setup-network.sh`.
+/// TODO: This should not be hardcoded. Set by tart.
+pub const CUSTOM_TUN_REMOTE_REAL_ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
+/// Port of the wireguard remote peer as defined in `setup-network.sh`.
+pub const CUSTOM_TUN_REMOTE_REAL_PORT: u16 = 51820;
+/// Tunnel address of the wireguard local peer as defined in `setup-network.sh`.
+pub const CUSTOM_TUN_LOCAL_TUN_ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 15, 2);
+/// Tunnel address of the wireguard remote peer as defined in `setup-network.sh`.
+pub const CUSTOM_TUN_REMOTE_TUN_ADDR: Ipv4Addr = Ipv4Addr::new(192, 168, 15, 1);
+/// Gateway (and default DNS resolver) of the wireguard tunnel.
+pub const CUSTOM_TUN_GATEWAY: Ipv4Addr = CUSTOM_TUN_REMOTE_TUN_ADDR;
+/// Gateway of the non-tunnel interface.
+/// TODO: This should not be hardcoded. Set by tart.
+pub const NON_TUN_GATEWAY: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
+/// Name of the wireguard interface on the host
+pub const CUSTOM_TUN_INTERFACE_NAME: &str = "utun123";
+
+/// Set up WireGuard relay and dummy hosts.
+pub async fn setup_test_network() -> Result<()> {
+ log::debug!("Setting up test network");
+
+ enable_forwarding().await?;
+ create_wireguard_interface()
+ .await
+ .context("Failed to create WireGuard interface")?;
+
+ Ok(())
+}
+
+/// A hack to find the Tart bridge interface using `NON_TUN_GATEWAY`.
+/// It should be possible to retrieve this using the virtualization framework instead,
+/// but that requires an entitlement.
+pub fn find_vm_bridge() -> Result<String> {
+ for addr in nix::ifaddrs::getifaddrs().unwrap() {
+ if !addr.interface_name.starts_with("bridge") {
+ continue;
+ }
+ if let Some(address) = addr.address.as_ref().and_then(|addr| addr.as_sockaddr_in()) {
+ let interface_ip = *SocketAddrV4::from(*address).ip();
+ if interface_ip == NON_TUN_GATEWAY {
+ return Ok(addr.interface_name.to_owned());
+ }
+ }
+ }
+
+ // This is probably either due to IP mismatch or Tart not running
+ Err(anyhow!(
+ "Failed to identify bridge used by tart -- not running?"
+ ))
+}
+
+async fn enable_forwarding() -> Result<()> {
+ // Enable forwarding
+ let mut cmd = Command::new("/usr/bin/sudo");
+ cmd.args(["/usr/sbin/sysctl", "net.inet.ip.forwarding=1"]);
+ let output = cmd.output().await.context("Run sysctl")?;
+ if !output.status.success() {
+ return Err(anyhow!("sysctl failed: {}", output.status.code().unwrap()));
+ }
+ Ok(())
+}
+
+async fn create_wireguard_interface() -> Result<()> {
+ log::debug!("Creating custom WireGuard tunnel");
+
+ // Check if the tunnel already exists
+ let mut cmd = Command::new("/sbin/ifconfig");
+ cmd.arg(CUSTOM_TUN_INTERFACE_NAME);
+ let output = cmd
+ .output()
+ .await
+ .context("Check if wireguard tunnel exists")?;
+ if output.status.success() {
+ log::debug!("Tunnel {CUSTOM_TUN_INTERFACE_NAME} already exists");
+ } else {
+ let mut cmd = Command::new("/usr/bin/sudo");
+ cmd.args(["wireguard-go", CUSTOM_TUN_INTERFACE_NAME]);
+ let output = cmd.output().await.context("Run wireguard-go")?;
+ if !output.status.success() {
+ return Err(anyhow!(
+ "wireguard-go failed: {}",
+ output.status.code().unwrap()
+ ));
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn configure_tunnel() -> Result<()> {
+ // Check if the tunnel device is configured
+ let mut cmd = Command::new("/usr/sbin/ipconfig");
+ cmd.args(["getifaddr", CUSTOM_TUN_INTERFACE_NAME]);
+ let output = cmd
+ .output()
+ .await
+ .context("Check if wireguard tunnel has IP")?;
+ if output.status.success() {
+ log::debug!("Tunnel {CUSTOM_TUN_INTERFACE_NAME} already configured");
+ return Ok(());
+ }
+
+ // Set wireguard config
+ let mut tempfile = async_tempfile::TempFile::new()
+ .await
+ .context("Failed to create temporary wireguard config")?;
+
+ tempfile
+ .write_all(
+ format!(
+ "
+
+[Interface]
+PrivateKey = {CUSTOM_TUN_REMOTE_PRIVKEY}
+ListenPort = {CUSTOM_TUN_REMOTE_REAL_PORT}
+
+[Peer]
+PublicKey = {CUSTOM_TUN_LOCAL_PUBKEY}
+AllowedIPs = {CUSTOM_TUN_LOCAL_TUN_ADDR}
+
+"
+ )
+ .as_bytes(),
+ )
+ .await
+ .context("Failed to write wireguard config")?;
+
+ let mut cmd = Command::new("/usr/bin/sudo");
+ cmd.args([
+ "wg",
+ "setconf",
+ CUSTOM_TUN_INTERFACE_NAME,
+ tempfile.file_path().to_str().unwrap(),
+ ]);
+ let output = cmd.output().await.context("Run wg")?;
+ if !output.status.success() {
+ return Err(anyhow!("wg failed: {}", output.status.code().unwrap()));
+ }
+
+ // Set tunnel IP address
+ let mut cmd = Command::new("/usr/bin/sudo");
+ cmd.args([
+ "/usr/sbin/ipconfig",
+ "set",
+ CUSTOM_TUN_INTERFACE_NAME,
+ "manual",
+ &CUSTOM_TUN_REMOTE_TUN_ADDR.to_string(),
+ ]);
+ let status = cmd.status().await.context("Run ipconfig")?;
+ if !status.success() {
+ return Err(anyhow!("ipconfig failed: {}", status.code().unwrap()));
+ }
+ Ok(())
+}
diff --git a/test/test-manager/src/vm/network/mod.rs b/test/test-manager/src/vm/network/mod.rs
new file mode 100644
index 0000000000..e5db39a42a
--- /dev/null
+++ b/test/test-manager/src/vm/network/mod.rs
@@ -0,0 +1,17 @@
+// #[cfg(target_os = "linux")]
+pub mod linux;
+#[cfg(target_os = "linux")]
+pub use linux as platform;
+
+#[cfg(target_os = "macos")]
+pub mod macos;
+#[cfg(target_os = "macos")]
+pub use macos as platform;
+
+// Import shared constants and functions
+pub use platform::{
+ setup_test_network, CUSTOM_TUN_GATEWAY, CUSTOM_TUN_INTERFACE_NAME, CUSTOM_TUN_LOCAL_PRIVKEY,
+ CUSTOM_TUN_LOCAL_TUN_ADDR, CUSTOM_TUN_REMOTE_PUBKEY, CUSTOM_TUN_REMOTE_REAL_ADDR,
+ CUSTOM_TUN_REMOTE_REAL_PORT, CUSTOM_TUN_REMOTE_TUN_ADDR, DUMMY_LAN_INTERFACE_IP,
+ NON_TUN_GATEWAY,
+};
diff --git a/test/test-manager/src/vm/provision.rs b/test/test-manager/src/vm/provision.rs
new file mode 100644
index 0000000000..b3b39a2c18
--- /dev/null
+++ b/test/test-manager/src/vm/provision.rs
@@ -0,0 +1,207 @@
+use crate::config::{OsType, Provisioner, VmConfig};
+use crate::package;
+use anyhow::{Context, Result};
+use ssh2::Session;
+use std::fs::File;
+use std::io::{self, Read};
+use std::net::IpAddr;
+use std::net::TcpStream;
+use std::{net::SocketAddr, path::Path};
+
+pub async fn provision(
+ config: &VmConfig,
+ instance: &dyn super::VmInstance,
+ app_manifest: &package::Manifest,
+) -> Result<String> {
+ match config.provisioner {
+ Provisioner::Ssh => {
+ log::info!("SSH provisioning");
+
+ let (user, password) = config.get_ssh_options().context("missing SSH config")?;
+ ssh(
+ instance,
+ config.os_type,
+ config.get_runner_dir(),
+ app_manifest,
+ user,
+ password,
+ )
+ .await
+ .context("Failed to provision runner over SSH")
+ }
+ Provisioner::Noop => {
+ let dir = config
+ .artifacts_dir
+ .as_ref()
+ .context("'artifacts_dir' must be set to a mountpoint")?;
+ Ok(dir.clone())
+ }
+ }
+}
+
+async fn ssh(
+ instance: &dyn super::VmInstance,
+ os_type: OsType,
+ local_runner_dir: &Path,
+ local_app_manifest: &package::Manifest,
+ user: &str,
+ password: &str,
+) -> Result<String> {
+ let guest_ip = *instance.get_ip();
+
+ let user = user.to_owned();
+ let password = password.to_owned();
+
+ let remote_dir = match os_type {
+ OsType::Windows => r"C:\testing",
+ OsType::Macos | OsType::Linux => r"/opt/testing",
+ };
+
+ let local_runner_dir = local_runner_dir.to_owned();
+ let local_app_manifest = local_app_manifest.to_owned();
+
+ tokio::task::spawn_blocking(move || {
+ blocking_ssh(
+ user,
+ password,
+ guest_ip,
+ &local_runner_dir,
+ local_app_manifest,
+ remote_dir,
+ )
+ })
+ .await
+ .context("Failed to join SSH task")??;
+
+ Ok(remote_dir.to_string())
+}
+
+fn blocking_ssh(
+ user: String,
+ password: String,
+ guest_ip: IpAddr,
+ local_runner_dir: &Path,
+ local_app_manifest: package::Manifest,
+ remote_dir: &str,
+) -> Result<()> {
+ // Directory that receives the payload. Any directory that the SSH user has access to.
+ const REMOTE_TEMP_DIR: &str = "/tmp/";
+ const SCRIPT_PAYLOAD: &[u8] = include_bytes!("../../../scripts/ssh-setup.sh");
+ const OPENVPN_CERT: &[u8] = include_bytes!("../../../openvpn.ca.crt");
+
+ let temp_dir = Path::new(REMOTE_TEMP_DIR);
+
+ let stream = TcpStream::connect(SocketAddr::new(guest_ip, 22)).context("TCP connect failed")?;
+
+ let mut session = Session::new().context("Failed to connect to SSH server")?;
+ session.set_tcp_stream(stream);
+ session.handshake()?;
+
+ session
+ .userauth_password(&user, &password)
+ .context("SSH auth failed")?;
+
+ // Transfer a test runner
+ let source = local_runner_dir.join("test-runner");
+ ssh_send_file_path(&session, &source, temp_dir)
+ .context("Failed to send test runner 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")?;
+ ssh_send_file_path(&session, &local_app_manifest.previous_app_path, temp_dir)
+ .context("Failed to send previous app package to remote")?;
+ ssh_send_file_path(&session, &local_app_manifest.ui_e2e_tests_path, temp_dir)
+ .context("Failed to send UI test runner to remote")?;
+
+ // Transfer openvpn cert
+ let dest: std::path::PathBuf = temp_dir.join("openvpn.ca.crt");
+ log::debug!("Copying remote openvpn.ca.crt -> {}", dest.display());
+ #[allow(const_item_mutation)]
+ ssh_send_file(
+ &session,
+ &mut OPENVPN_CERT,
+ u64::try_from(OPENVPN_CERT.len()).expect("cert too long"),
+ &dest,
+ )
+ .context("failed to send openvpn crt to remote")?;
+
+ // Transfer setup script
+ let dest = temp_dir.join("ssh-setup.sh");
+ log::debug!("Copying remote setup script -> {}", dest.display());
+ #[allow(const_item_mutation)]
+ ssh_send_file(
+ &session,
+ &mut SCRIPT_PAYLOAD,
+ u64::try_from(SCRIPT_PAYLOAD.len()).expect("script too long"),
+ &dest,
+ )
+ .context("failed to send bootstrap script to remote")?;
+
+ // Run setup script
+
+ let args = format!(
+ "{remote_dir} \"{}\" \"{}\" \"{}\"",
+ local_app_manifest
+ .current_app_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy(),
+ local_app_manifest
+ .previous_app_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy(),
+ local_app_manifest
+ .ui_e2e_tests_path
+ .file_name()
+ .unwrap()
+ .to_string_lossy(),
+ );
+
+ log::debug!("Running setup script on remote, args: {args}");
+ ssh_exec(&session, &format!("sudo {} {args}", dest.display()))
+ .map(drop)
+ .context("Failed to run setup script")
+}
+
+fn ssh_send_file_path(session: &Session, source: &Path, dest_dir: &Path) -> Result<()> {
+ let dest = dest_dir.join(source.file_name().context("Missing source file name")?);
+
+ log::debug!(
+ "Copying file to remote: {} -> {}",
+ source.display(),
+ dest.display(),
+ );
+
+ let mut file = File::open(source).context("Failed to open file")?;
+ let file_len = file.metadata().context("Failed to get file size")?.len();
+ ssh_send_file(session, &mut file, file_len, &dest)
+}
+
+fn ssh_send_file<R: Read>(
+ session: &Session,
+ source: &mut R,
+ source_len: u64,
+ dest: &Path,
+) -> Result<()> {
+ let mut remote_file = session.scp_send(dest, 0o744, source_len, None)?;
+ io::copy(source, &mut remote_file).context("failed to write file")?;
+ remote_file.send_eof()?;
+ remote_file.wait_eof()?;
+ remote_file.close()?;
+ remote_file.wait_close()?;
+ Ok(())
+}
+
+/// Execute an arbitrary string of commands via ssh.
+fn ssh_exec(session: &Session, command: &str) -> Result<String> {
+ let mut channel = session.channel_session()?;
+ channel.exec(command)?;
+ let mut output = String::new();
+ channel.read_to_string(&mut output)?;
+ channel.send_eof()?;
+ channel.wait_eof()?;
+ channel.wait_close()?;
+ Ok(output)
+}
diff --git a/test/test-manager/src/vm/qemu.rs b/test/test-manager/src/vm/qemu.rs
new file mode 100644
index 0000000000..88f7f95430
--- /dev/null
+++ b/test/test-manager/src/vm/qemu.rs
@@ -0,0 +1,358 @@
+use crate::{
+ config::{self, Config, VmConfig},
+ vm::{logging::forward_logs, util::find_pty},
+};
+use async_tempfile::TempFile;
+use regex::Regex;
+use std::{
+ io,
+ net::IpAddr,
+ path::PathBuf,
+ process::{ExitStatus, Stdio},
+ time::Duration,
+};
+use tokio::{
+ fs,
+ process::{Child, Command},
+ time::timeout,
+};
+use uuid::Uuid;
+
+use super::{network, VmInstance};
+
+const LOG_PREFIX: &str = "[qemu] ";
+const STDERR_LOG_LEVEL: log::Level = log::Level::Error;
+const STDOUT_LOG_LEVEL: log::Level = log::Level::Debug;
+const OBTAIN_IP_TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(err_derive::Error, Debug)]
+pub enum Error {
+ #[error(display = "Failed to set up network")]
+ Network(network::linux::Error),
+ #[error(display = "Failed to start QEMU")]
+ StartQemu(io::Error),
+ #[error(display = "QEMU exited unexpectedly")]
+ QemuFailed(Option<ExitStatus>),
+ #[error(display = "Could not find pty")]
+ NoPty,
+ #[error(display = "Could not find IP address of guest")]
+ NoIpAddr,
+ #[error(display = "Failed to copy OVMF vars")]
+ CopyOvmfVars(io::Error),
+ #[error(display = "Failed to wrap OVMF vars copy in tempfile object")]
+ WrapOvmfVars,
+ #[error(display = "Failed to start swtpm")]
+ StartTpmEmulator(io::Error),
+ #[error(display = "swtpm failed")]
+ TpmEmulator(io::Error),
+ #[error(display = "Timed out waiting for swtpm socket")]
+ TpmSocketTimeout,
+ #[error(display = "Failed to create temp dir")]
+ MkTempDir(io::Error),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+pub struct QemuInstance {
+ pub pty_path: String,
+ pub ip_addr: IpAddr,
+ child: Child,
+ _network_handle: network::linux::NetworkHandle,
+ _ovmf_handle: Option<OvmfHandle>,
+ _tpm_emulator: Option<TpmEmulator>,
+}
+
+#[async_trait::async_trait]
+impl VmInstance for QemuInstance {
+ fn get_pty(&self) -> &str {
+ &self.pty_path
+ }
+
+ fn get_ip(&self) -> &IpAddr {
+ &self.ip_addr
+ }
+
+ async fn wait(&mut self) {
+ let _ = self.child.wait().await;
+ }
+}
+
+pub async fn run(config: &Config, vm_config: &VmConfig) -> Result<QemuInstance> {
+ let mut network_handle = network::linux::setup_test_network()
+ .await
+ .map_err(Error::Network)?;
+
+ let mut qemu_cmd = Command::new("qemu-system-x86_64");
+ qemu_cmd.args([
+ "-cpu",
+ "host",
+ "-accel",
+ "kvm",
+ "-m",
+ "4096",
+ "-smp",
+ "2",
+ "-drive",
+ &format!("file={}", vm_config.image_path),
+ "-device",
+ "virtio-serial-pci",
+ "-serial",
+ "pty",
+ // attach to TAP interface
+ "-nic",
+ &format!(
+ "tap,ifname={},script=no,downscript=no",
+ network::linux::TAP_NAME
+ ),
+ "-device",
+ "nec-usb-xhci,id=xhci",
+ ]);
+
+ if !config.runtime_opts.keep_changes {
+ qemu_cmd.arg("-snapshot");
+ }
+
+ match config.runtime_opts.display {
+ config::Display::None => {
+ qemu_cmd.args(["-display", "none"]);
+ }
+ config::Display::Local => (),
+ config::Display::Vnc => {
+ log::debug!("Running VNC server on :1");
+ qemu_cmd.args(["-display", "vnc=:1"]);
+ }
+ }
+
+ for (i, disk) in vm_config.disks.iter().enumerate() {
+ qemu_cmd.args([
+ "-drive",
+ &format!("if=none,id=disk{i},file={disk}"),
+ "-device",
+ &format!("usb-storage,drive=disk{i},bus=xhci.0"),
+ ]);
+ }
+
+ // Configure OVMF. Currently, this is enabled implicitly if using a TPM
+ let ovmf_handle = if vm_config.tpm {
+ let handle = OvmfHandle::new().await?;
+ handle.append_qemu_args(&mut qemu_cmd);
+ Some(handle)
+ } else {
+ None
+ };
+
+ // Run software TPM emulator
+ let tpm_emulator = if vm_config.tpm {
+ let handle = TpmEmulator::run().await?;
+ handle.append_qemu_args(&mut qemu_cmd);
+ Some(handle)
+ } else {
+ None
+ };
+
+ qemu_cmd.stdin(Stdio::piped());
+ qemu_cmd.stdout(Stdio::piped());
+ qemu_cmd.stderr(Stdio::piped());
+
+ qemu_cmd.kill_on_drop(true);
+
+ let mut child = qemu_cmd.spawn().map_err(Error::StartQemu)?;
+
+ tokio::spawn(forward_logs(
+ LOG_PREFIX,
+ child.stderr.take().unwrap(),
+ STDERR_LOG_LEVEL,
+ ));
+
+ // find pty in stdout
+ // match: char device redirected to /dev/pts/0 (label serial0)
+ let re = Regex::new(r"char device redirected to ([/a-zA-Z0-9]+) \(").unwrap();
+ let pty_path = find_pty(re, &mut child, STDOUT_LOG_LEVEL, LOG_PREFIX)
+ .await
+ .map_err(|_error| {
+ if let Ok(status) = child.try_wait() {
+ return Error::QemuFailed(status);
+ }
+ Error::NoPty
+ })?;
+
+ tokio::spawn(forward_logs(
+ LOG_PREFIX,
+ child.stdout.take().unwrap(),
+ STDOUT_LOG_LEVEL,
+ ));
+
+ log::debug!("Waiting for IP address");
+ let ip_addr = timeout(OBTAIN_IP_TIMEOUT, network_handle.first_dhcp_ack())
+ .await
+ .map_err(|_| Error::NoIpAddr)?
+ .ok_or(Error::NoIpAddr)?;
+ log::debug!("Guest IP: {ip_addr}");
+
+ Ok(QemuInstance {
+ pty_path,
+ ip_addr,
+ child,
+ _network_handle: network_handle,
+ _ovmf_handle: ovmf_handle,
+ _tpm_emulator: tpm_emulator,
+ })
+}
+
+/// Used to set up UEFI and append options to the QEMU command
+struct OvmfHandle {
+ temp_vars: TempFile,
+}
+
+impl OvmfHandle {
+ pub async fn new() -> Result<Self> {
+ const OVMF_VARS_PATH: &str = "/usr/share/OVMF/OVMF_VARS.secboot.fd";
+
+ // Create a local copy of OVMF_VARS
+ let temp_vars_path = random_tempfile_name();
+ fs::copy(OVMF_VARS_PATH, &temp_vars_path)
+ .await
+ .map_err(Error::CopyOvmfVars)?;
+
+ let temp_vars = TempFile::from_existing(temp_vars_path, async_tempfile::Ownership::Owned)
+ .await
+ .map_err(|_| Error::WrapOvmfVars)?;
+ Ok(OvmfHandle { temp_vars })
+ }
+
+ pub fn append_qemu_args(&self, qemu_cmd: &mut Command) {
+ const OVMF_CODE_PATH: &str = "/usr/share/OVMF/OVMF_CODE.secboot.fd";
+
+ qemu_cmd.args([
+ "-global",
+ "driver=cfi.pflash01,property=secure,value=on",
+ "-drive",
+ &format!("if=pflash,format=raw,unit=0,file={OVMF_CODE_PATH},readonly=on"),
+ "-drive",
+ &format!(
+ "if=pflash,format=raw,unit=1,file={}",
+ self.temp_vars.file_path().display()
+ ),
+ // Q35 supports secure boot
+ "-machine",
+ "q35,smm=on",
+ ]);
+ }
+}
+
+/// Runs a TPM emulator
+struct TpmEmulator {
+ handle: tokio::task::JoinHandle<Result<()>>,
+ sock_path: PathBuf,
+}
+
+impl TpmEmulator {
+ pub async fn run() -> Result<Self> {
+ let temp_dir = TempDir::new().await?;
+ let mut cmd = Command::new("swtpm");
+
+ let sock_path = temp_dir.0.join("tpmsock");
+
+ cmd.args([
+ "socket",
+ "-t",
+ "--ctrl",
+ &format!("type=unixio,path={}", sock_path.display()),
+ "--tpmstate",
+ &format!("dir={}", temp_dir.0.display()),
+ "--tpm2",
+ ]);
+
+ cmd.kill_on_drop(true);
+
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+
+ // Start swtpm
+ let mut child = cmd.spawn().map_err(Error::StartTpmEmulator)?;
+
+ tokio::spawn(forward_logs(
+ "[swtpm] ",
+ child.stdout.take().unwrap(),
+ STDOUT_LOG_LEVEL,
+ ));
+ tokio::spawn(forward_logs(
+ "[swtpm] ",
+ child.stderr.take().unwrap(),
+ STDERR_LOG_LEVEL,
+ ));
+
+ let handle = tokio::spawn(async move {
+ let output = child.wait().await.map_err(Error::TpmEmulator)?;
+
+ if !output.success() {
+ log::error!("swtpm failed: {}", output);
+ }
+
+ temp_dir.delete().await;
+
+ Ok(())
+ });
+
+ const SOCKET_TIMEOUT: Duration = Duration::from_secs(10);
+
+ // Wait for socket to be created
+ timeout(SOCKET_TIMEOUT, async {
+ if sock_path.exists() {
+ return;
+ }
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ })
+ .await
+ .map_err(|_| {
+ handle.abort();
+ Error::TpmSocketTimeout
+ })?;
+
+ Ok(Self { handle, sock_path })
+ }
+
+ pub fn append_qemu_args(&self, qemu_cmd: &mut Command) {
+ qemu_cmd.args([
+ "-tpmdev",
+ "emulator,id=tpm0,chardev=chrtpm",
+ "-chardev",
+ &format!("socket,id=chrtpm,path={}", self.sock_path.display()),
+ "-device",
+ "tpm-tis,tpmdev=tpm0",
+ ]);
+ }
+}
+
+impl Drop for TpmEmulator {
+ fn drop(&mut self) {
+ self.handle.abort();
+ }
+}
+
+struct TempDir(PathBuf);
+
+impl TempDir {
+ pub async fn new() -> Result<Self> {
+ let temp_dir = std::env::temp_dir().join(Uuid::new_v4().to_string());
+ tokio::fs::create_dir_all(&temp_dir)
+ .await
+ .map_err(Error::MkTempDir)?;
+ Ok(Self(temp_dir))
+ }
+
+ pub async fn delete(self) {
+ let _ = fs::remove_dir_all(&self.0).await;
+ std::mem::forget(self);
+ }
+}
+
+impl Drop for TempDir {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
+fn random_tempfile_name() -> PathBuf {
+ std::env::temp_dir().join(format!("tmp{}", Uuid::new_v4()))
+}
diff --git a/test/test-manager/src/vm/ssh.rs b/test/test-manager/src/vm/ssh.rs
new file mode 100644
index 0000000000..008045fc2b
--- /dev/null
+++ b/test/test-manager/src/vm/ssh.rs
@@ -0,0 +1,45 @@
+/// A very thin wrapper on top of `ssh2`.
+use anyhow::{Context, Result};
+use ssh2::Session;
+use std::io::Read;
+use std::net::{IpAddr, SocketAddr, TcpStream};
+
+/// Default `ssh` port.
+const PORT: u16 = 22;
+
+/// Handle to an `ssh` session.
+pub struct SSHSession {
+ session: ssh2::Session,
+}
+
+impl SSHSession {
+ /// Create a new `ssh` session.
+ /// This function is blocking while connecting.
+ ///
+ /// The tunnel is closed when the `SSHSession` is dropped.
+ pub fn connect(username: String, password: String, ip: IpAddr) -> Result<Self> {
+ // Set up the SSH connection
+ log::info!("initializing a new SSH session ..");
+ let stream = TcpStream::connect(SocketAddr::new(ip, PORT)).context("TCP connect failed")?;
+ let mut session = Session::new().context("Failed to connect to SSH server")?;
+ session.set_tcp_stream(stream);
+ session.handshake()?;
+ session
+ .userauth_password(&username, &password)
+ .context("SSH auth failed")?;
+ Ok(Self { session })
+ }
+
+ /// Execute an arbitrary string of commands via ssh.
+ pub fn exec_blocking(&self, command: &str) -> Result<String> {
+ let session = &self.session;
+ let mut channel = session.channel_session()?;
+ channel.exec(command)?;
+ let mut output = String::new();
+ channel.read_to_string(&mut output)?;
+ channel.send_eof()?;
+ channel.wait_eof()?;
+ channel.wait_close()?;
+ Ok(output)
+ }
+}
diff --git a/test/test-manager/src/vm/tart.rs b/test/test-manager/src/vm/tart.rs
new file mode 100644
index 0000000000..1df55845ed
--- /dev/null
+++ b/test/test-manager/src/vm/tart.rs
@@ -0,0 +1,204 @@
+use crate::config::{self, Config, VmConfig};
+use anyhow::{anyhow, Context, Result};
+use regex::Regex;
+use std::{net::IpAddr, process::Stdio, time::Duration};
+use tokio::process::{Child, Command};
+use uuid::Uuid;
+
+use super::{logging::forward_logs, util::find_pty, VmInstance};
+
+const LOG_PREFIX: &str = "[tart] ";
+const STDERR_LOG_LEVEL: log::Level = log::Level::Error;
+const STDOUT_LOG_LEVEL: log::Level = log::Level::Debug;
+const OBTAIN_IP_TIMEOUT: Duration = Duration::from_secs(60);
+
+pub struct TartInstance {
+ pub pty_path: String,
+ pub ip_addr: IpAddr,
+ child: Child,
+ machine_copy: Option<MachineCopy>,
+}
+
+#[async_trait::async_trait]
+impl VmInstance for TartInstance {
+ fn get_pty(&self) -> &str {
+ &self.pty_path
+ }
+
+ fn get_ip(&self) -> &IpAddr {
+ &self.ip_addr
+ }
+
+ async fn wait(&mut self) {
+ let _ = self.child.wait().await;
+ if let Some(machine) = self.machine_copy.take() {
+ machine.cleanup().await;
+ }
+ }
+}
+
+pub async fn run(config: &Config, vm_config: &VmConfig) -> Result<TartInstance> {
+ super::network::macos::setup_test_network()
+ .await
+ .context("Failed to set up networking")?;
+
+ // Create a temporary clone of the machine
+ let machine_copy = if config.runtime_opts.keep_changes {
+ MachineCopy::borrow_vm(&vm_config.image_path)
+ } else {
+ MachineCopy::clone_vm(&vm_config.image_path).await?
+ };
+
+ // Start VM
+ let mut tart_cmd = Command::new("tart");
+ tart_cmd.args(["run", &machine_copy.name, "--serial"]);
+
+ if !vm_config.disks.is_empty() {
+ log::warn!("Mounting disks is not yet supported")
+ }
+
+ match config.runtime_opts.display {
+ config::Display::None => {
+ tart_cmd.arg("--no-graphics");
+ }
+ config::Display::Local => (),
+ config::Display::Vnc => {
+ //tart_cmd.args(["--vnc-experimental", "--no-graphics"]);
+ tart_cmd.args(["--vnc", "--no-graphics"]);
+ }
+ }
+
+ tart_cmd.stdin(Stdio::piped());
+ tart_cmd.stdout(Stdio::piped());
+ tart_cmd.stderr(Stdio::piped());
+
+ tart_cmd.kill_on_drop(true);
+
+ let mut child = tart_cmd.spawn().context("Failed to start Tart")?;
+
+ tokio::spawn(forward_logs(
+ LOG_PREFIX,
+ child.stderr.take().unwrap(),
+ STDERR_LOG_LEVEL,
+ ));
+
+ // find pty in stdout
+ // match: Successfully open pty /dev/ttys001
+ let re = Regex::new(r"Successfully open pty ([/a-zA-Z0-9]+)$").unwrap();
+ let pty_path = find_pty(re, &mut child, STDOUT_LOG_LEVEL, LOG_PREFIX)
+ .await
+ .map_err(|_error| {
+ if let Ok(Some(status)) = child.try_wait() {
+ return anyhow!("'tart start' failed: {status}");
+ }
+ anyhow!("Could not find pty")
+ })?;
+
+ tokio::spawn(forward_logs(
+ LOG_PREFIX,
+ child.stdout.take().unwrap(),
+ STDOUT_LOG_LEVEL,
+ ));
+
+ // Get IP address of VM
+ log::debug!("Waiting for IP address");
+
+ let mut tart_cmd = Command::new("tart");
+ tart_cmd.args([
+ "ip",
+ &machine_copy.name,
+ "--wait",
+ &format!("{}", OBTAIN_IP_TIMEOUT.as_secs()),
+ ]);
+ let output = tart_cmd.output().await.context("Could not obtain VM IP")?;
+ let ip_addr = std::str::from_utf8(&output.stdout)
+ .context("'tart ip' returned non-UTF8")?
+ .trim()
+ .parse()
+ .context("Could not parse IP address from 'tart ip'")?;
+
+ log::debug!("Guest IP: {ip_addr}");
+
+ // The tunnel must be configured after the virtual machine is up, or macOS refuses to assign an
+ // IP. The reasons for this are poorly understood.
+ crate::vm::network::macos::configure_tunnel().await?;
+
+ Ok(TartInstance {
+ child,
+ pty_path,
+ ip_addr,
+ machine_copy: Some(machine_copy),
+ })
+}
+
+/// Handle for a transient or borrowed Tart VM.
+/// TODO: Prune VMs we fail to delete them somehow.
+pub struct MachineCopy {
+ name: String,
+ should_destroy: bool,
+}
+
+impl MachineCopy {
+ /// Use an existing VM and save all changes to it.
+ pub fn borrow_vm(name: &str) -> Self {
+ Self {
+ name: name.to_owned(),
+ should_destroy: false,
+ }
+ }
+
+ /// Clone an existing VM and destroy changes when self is dropped.
+ pub async fn clone_vm(name: &str) -> Result<Self> {
+ let clone_name = format!("test-{}", Uuid::new_v4());
+
+ let mut tart_cmd = Command::new("tart");
+ tart_cmd.args(["clone", name, &clone_name]);
+ let output = tart_cmd
+ .status()
+ .await
+ .context("failed to run 'tart clone'")?;
+ if !output.success() {
+ return Err(anyhow!("'tart clone' failed: {output}"));
+ }
+
+ Ok(Self {
+ name: clone_name,
+ should_destroy: true,
+ })
+ }
+
+ pub async fn cleanup(mut self) {
+ let _ = tokio::task::spawn_blocking(move || self.try_destroy()).await;
+ }
+
+ fn try_destroy(&mut self) {
+ if !self.should_destroy {
+ return;
+ }
+
+ if let Err(error) = self.destroy_inner() {
+ log::error!("Failed to destroy Tart clone: {error}");
+ } else {
+ self.should_destroy = false;
+ }
+ }
+
+ fn destroy_inner(&mut self) -> Result<()> {
+ use std::process::Command;
+
+ let mut tart_cmd = Command::new("tart");
+ tart_cmd.args(["delete", &self.name]);
+ let output = tart_cmd.status().context("Failed to run 'tart delete'")?;
+ if !output.success() {
+ return Err(anyhow!("'tart delete' failed: {output}"));
+ }
+
+ Ok(())
+ }
+}
+
+impl Drop for MachineCopy {
+ fn drop(&mut self) {
+ self.try_destroy();
+ }
+}
diff --git a/test/test-manager/src/vm/update.rs b/test/test-manager/src/vm/update.rs
new file mode 100644
index 0000000000..f2ffef0051
--- /dev/null
+++ b/test/test-manager/src/vm/update.rs
@@ -0,0 +1,70 @@
+use crate::config::{OsType, PackageType, Provisioner, VmConfig};
+use crate::vm::ssh::SSHSession;
+use anyhow::{Context, Result};
+use std::fmt;
+
+#[derive(Debug)]
+pub enum Update {
+ Logs(Vec<String>),
+ Nothing,
+}
+
+/// Update system packages in a VM.
+///
+/// Note that this function is blocking.
+pub fn packages(config: &VmConfig, guest_ip: std::net::IpAddr) -> Result<Update> {
+ match config.provisioner {
+ Provisioner::Noop => return Ok(Update::Nothing),
+ Provisioner::Ssh => (),
+ }
+ // User SSH session to execute package manager update command.
+ // This will of course be dependant on the target platform.
+ let commands = match (config.os_type, config.package_type) {
+ (OsType::Linux, Some(PackageType::Deb)) => {
+ Some(vec!["sudo apt-get update", "sudo apt-get upgrade"])
+ }
+ (OsType::Linux, Some(PackageType::Rpm)) => Some(vec!["sudo dnf update"]),
+ (OsType::Linux, _) => None,
+ (OsType::Macos | OsType::Windows, _) => None,
+ };
+
+ // Issue the update command(s).
+ let result = match commands {
+ None => {
+ log::info!("No update command was found");
+ log::debug!(
+ "Tried to invoke package update for platform {:?} with package type {:?}",
+ config.os_type,
+ config.package_type
+ );
+ Update::Nothing
+ }
+ Some(commands) => {
+ log::info!("retrieving SSH credentials");
+ let (username, password) = config.get_ssh_options().context("missing SSH config")?;
+ let ssh = SSHSession::connect(username.to_string(), password.to_string(), guest_ip)?;
+ let output: Result<Vec<_>> = commands
+ .iter()
+ .map(|command| {
+ log::info!("Running {command} in guest");
+ ssh.exec_blocking(command)
+ })
+ .collect();
+ Update::Logs(output?)
+ }
+ };
+
+ Ok(result)
+}
+
+// Pretty-printing for an `Update` action.
+impl fmt::Display for Update {
+ fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ Update::Nothing => write!(formatter, "Nothing was updated"),
+ Update::Logs(output) => output
+ .iter()
+ .try_for_each(|output| formatter.write_str(output)),
+ }
+ }
+}
diff --git a/test/test-manager/src/vm/util.rs b/test/test-manager/src/vm/util.rs
new file mode 100644
index 0000000000..b6eb610f21
--- /dev/null
+++ b/test/test-manager/src/vm/util.rs
@@ -0,0 +1,42 @@
+use std::time::Duration;
+
+use regex::Regex;
+use tokio::{
+ io::{AsyncBufReadExt, BufReader},
+ time::timeout,
+};
+
+const OBTAIN_PTY_TIMEOUT: Duration = Duration::from_secs(5);
+
+pub struct NoPty;
+
+/// Extract pty path from stdout
+pub async fn find_pty(
+ re: Regex,
+ process: &mut tokio::process::Child,
+ log_level: log::Level,
+ log_prefix: &str,
+) -> Result<String, NoPty> {
+ let stdout = process.stdout.take().unwrap();
+ let stdout_reader = BufReader::new(stdout);
+
+ let (pty_path, reader) = timeout(OBTAIN_PTY_TIMEOUT, async {
+ let mut lines = stdout_reader.lines();
+
+ while let Ok(Some(line)) = lines.next_line().await {
+ log::log!(log_level, "{log_prefix}{line}");
+
+ if let Some(path) = re.captures(&line).and_then(|cap| cap.get(1)) {
+ return Ok((path.as_str().to_owned(), lines.into_inner()));
+ }
+ }
+
+ Err(NoPty)
+ })
+ .await
+ .map_err(|_| NoPty)??;
+
+ process.stdout.replace(reader.into_inner());
+
+ Ok(pty_path)
+}
diff --git a/test/test-manager/test_macro/Cargo.toml b/test/test-manager/test_macro/Cargo.toml
new file mode 100644
index 0000000000..7883deaecc
--- /dev/null
+++ b/test/test-manager/test_macro/Cargo.toml
@@ -0,0 +1,12 @@
+[lib]
+proc-macro = true
+
+[package]
+name = "test_macro"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+syn = "1.0"
+quote = "1.0"
+proc-macro2 = "1.0"
diff --git a/test/test-manager/test_macro/src/lib.rs b/test/test-manager/test_macro/src/lib.rs
new file mode 100644
index 0000000000..b82b796eba
--- /dev/null
+++ b/test/test-manager/test_macro/src/lib.rs
@@ -0,0 +1,278 @@
+use proc_macro::TokenStream;
+use quote::{quote, ToTokens};
+use syn::{AttributeArgs, Lit, Meta, NestedMeta};
+
+/// Register an `async` function to be run by `test-manager`.
+///
+/// The `test_function` macro will inject two arguments to your function:
+///
+/// * `rpc` - a [`test_rpc::client::ServiceClient]` used to make
+/// remote-procedure calls inside the virtual machine running the test. This can
+/// be used to perform arbitrary network requests, inspect the local file
+/// system, rebooting ..
+///
+/// * `mullvad_client` - a
+/// [`mullvad_management_interface::ManagementServiceClient`] which provides a
+/// bi-directional communication channel with the `mullvad-daemon` running
+/// inside of the virtual machine. All RPC-calls as defined by
+/// [`mullvad_management_interface::client`] are available on `mullvad_client`.
+///
+///# Arguments
+///
+/// The `test_function` macro takes 4 optional arguments
+///
+/// * `priority` - The order in which tests will be run where low numbers run
+/// before high numbers and tests with the same number run in undefined order.
+/// `priority` defaults to 0.
+///
+/// * `cleanup` - If the cleanup function will run after the test is finished
+/// and among other things reset the settings to the default value for the
+/// daemon.
+/// `cleanup` defaults to true.
+///
+/// * `must_succeed` - If the testing suite stops running if this test fails.
+/// `must_succeed` defaults to false.
+///
+/// * `always_run` - If the test should always run regardless of what test
+/// filters are provided by the user.
+/// `always_run` defaults to false.
+///
+/// # Examples
+///
+/// ## Create a standard test.
+///
+/// Remember that [`test_function`] will inject `rpc` and `mullvad_client` for
+/// us.
+///
+/// ```
+/// #[test_function]
+/// pub async fn test_function(
+/// rpc: ServiceClient,
+/// mut mullvad_client: mullvad_management_interface::ManagementServiceClient,
+/// ) -> Result<(), Error> {
+/// Ok(())
+/// }
+/// ```
+///
+/// ## Create a test with custom parameters
+///
+/// This test will run early in the test loop, won't perform any cleanup, must
+/// succeed and will always run.
+///
+/// ```
+/// #[test_function(priority = -1337, cleanup = false, must_succeed = true, always_run = true)]
+/// pub async fn test_function(
+/// rpc: ServiceClient,
+/// mut mullvad_client: mullvad_management_interface::ManagementServiceClient,
+/// ) -> Result<(), Error> {
+/// Ok(())
+/// }
+/// ```
+#[proc_macro_attribute]
+pub fn test_function(attributes: TokenStream, code: TokenStream) -> TokenStream {
+ let function: syn::ItemFn = syn::parse(code).unwrap();
+ let attributes = syn::parse_macro_input!(attributes as AttributeArgs);
+
+ let test_function = parse_marked_test_function(&attributes, &function);
+
+ let register_test = create_test(test_function);
+
+ quote! {
+ #function
+ #register_test
+ }
+ .into_token_stream()
+ .into()
+}
+
+fn parse_marked_test_function(attributes: &AttributeArgs, function: &syn::ItemFn) -> TestFunction {
+ let macro_parameters = get_test_macro_parameters(attributes);
+
+ let function_parameters = get_test_function_parameters(&function.sig.inputs);
+
+ TestFunction {
+ name: function.sig.ident.clone(),
+ function_parameters,
+ macro_parameters,
+ }
+}
+
+fn get_test_macro_parameters(attributes: &syn::AttributeArgs) -> MacroParameters {
+ let mut priority = None;
+ let mut cleanup = true;
+ let mut always_run = false;
+ let mut must_succeed = false;
+ for attribute in attributes {
+ if let NestedMeta::Meta(Meta::NameValue(nv)) = attribute {
+ if nv.path.is_ident("priority") {
+ match &nv.lit {
+ Lit::Int(lit_int) => {
+ priority = Some(lit_int.clone());
+ }
+ _ => panic!("'priority' should have an integer value"),
+ }
+ } else if nv.path.is_ident("always_run") {
+ match &nv.lit {
+ Lit::Bool(lit_bool) => {
+ always_run = lit_bool.value();
+ }
+ _ => panic!("'always_run' should have a bool value"),
+ }
+ } else if nv.path.is_ident("must_succeed") {
+ match &nv.lit {
+ Lit::Bool(lit_bool) => {
+ must_succeed = lit_bool.value();
+ }
+ _ => panic!("'must_succeed' should have a bool value"),
+ }
+ } else if nv.path.is_ident("cleanup") {
+ match &nv.lit {
+ Lit::Bool(lit_bool) => {
+ cleanup = lit_bool.value();
+ }
+ _ => panic!("'cleanup' should have a bool value"),
+ }
+ }
+ }
+ }
+
+ MacroParameters {
+ priority,
+ cleanup,
+ always_run,
+ must_succeed,
+ }
+}
+
+fn create_test(test_function: TestFunction) -> proc_macro2::TokenStream {
+ let test_function_priority = match test_function.macro_parameters.priority {
+ Some(priority) => quote! {Some(#priority)},
+ None => quote! {None},
+ };
+ 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;
+
+ 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;
+ quote! {
+ |test_context: crate::tests::TestContext,
+ rpc: test_rpc::ServiceClient,
+ mullvad_client: Box<dyn std::any::Any + Send>,|
+ {
+ use std::any::Any;
+ let mullvad_client = mullvad_client.downcast::<#mullvad_client_type>().expect("invalid mullvad client");
+ Box::pin(async move {
+ #func_name(test_context, rpc, *mullvad_client).await
+ })
+ }
+ }
+ }
+ MullvadClient::None { .. } => {
+ quote! {
+ |test_context: crate::tests::TestContext,
+ rpc: test_rpc::ServiceClient,
+ mullvad_client: Box<dyn std::any::Any + Send>| {
+ Box::pin(async move {
+ #func_name(test_context, rpc).await
+ })
+ }
+ }
+ }
+ };
+
+ quote! {
+ inventory::submit!(crate::tests::test_metadata::TestMetadata {
+ name: stringify!(#func_name),
+ command: stringify!(#func_name),
+ mullvad_client_version: #function_mullvad_version,
+ func: Box::new(#wrapper_closure),
+ priority: #test_function_priority,
+ always_run: #always_run,
+ must_succeed: #must_succeed,
+ cleanup: #should_cleanup,
+ });
+ }
+}
+
+struct TestFunction {
+ name: syn::Ident,
+ function_parameters: FunctionParameters,
+ macro_parameters: MacroParameters,
+}
+
+struct MacroParameters {
+ priority: Option<syn::LitInt>,
+ cleanup: bool,
+ always_run: bool,
+ must_succeed: bool,
+}
+
+enum MullvadClient {
+ None {
+ mullvad_client_version: proc_macro2::TokenStream,
+ },
+ New {
+ mullvad_client_type: Box<syn::Type>,
+ mullvad_client_version: proc_macro2::TokenStream,
+ },
+}
+
+impl MullvadClient {
+ fn version(&self) -> proc_macro2::TokenStream {
+ match self {
+ MullvadClient::None {
+ mullvad_client_version,
+ } => mullvad_client_version.clone(),
+ MullvadClient::New {
+ mullvad_client_version,
+ ..
+ } => mullvad_client_version.clone(),
+ }
+ }
+}
+
+struct FunctionParameters {
+ mullvad_client: MullvadClient,
+}
+
+fn get_test_function_parameters(
+ inputs: &syn::punctuated::Punctuated<syn::FnArg, syn::Token![,]>,
+) -> FunctionParameters {
+ if inputs.len() > 2 {
+ match inputs[2].clone() {
+ syn::FnArg::Typed(pat_type) => {
+ let mullvad_client = match &*pat_type.ty {
+ syn::Type::Path(syn::TypePath { path, .. }) => {
+ match path.segments[0].ident.to_string().as_str() {
+ "mullvad_management_interface" | "ManagementServiceClient" => {
+ let mullvad_client_version =
+ quote! { test_rpc::mullvad_daemon::MullvadClientVersion::New };
+ MullvadClient::New {
+ mullvad_client_type: pat_type.ty,
+ mullvad_client_version,
+ }
+ }
+ _ => panic!("cannot infer mullvad client type"),
+ }
+ }
+ _ => panic!("unexpected 'mullvad_client' type"),
+ };
+ FunctionParameters { mullvad_client }
+ }
+ syn::FnArg::Receiver(_) => panic!("unexpected 'mullvad_client' arg"),
+ }
+ } else {
+ FunctionParameters {
+ mullvad_client: MullvadClient::None {
+ mullvad_client_version: quote! { test_rpc::mullvad_daemon::MullvadClientVersion::None },
+ },
+ }
+ }
+}
diff --git a/test/test-rpc/Cargo.toml b/test/test-rpc/Cargo.toml
new file mode 100644
index 0000000000..2814088bf7
--- /dev/null
+++ b/test/test-rpc/Cargo.toml
@@ -0,0 +1,29 @@
+[package]
+name = "test-rpc"
+version = "0.1.0"
+edition = "2021"
+description = "Supports IPC between test-runner and test-manager"
+
+[dependencies]
+futures = { workspace = true }
+tokio = { workspace = true }
+tokio-serde = { workspace = true }
+tarpc = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+once_cell = { workspace = true }
+bytes = { workspace = true }
+err-derive = { workspace = true }
+log = { workspace = true }
+colored = { workspace = true }
+async-trait = { workspace = true }
+
+hyper = { version = "0.14.23", features = ["client", "http2"] }
+hyper-rustls = { version = "0.24", features = ["log", "webpki-roots"] }
+tokio-rustls = "0.24"
+rustls-pemfile = "0.2"
+
+[dependencies.tokio-util]
+version = "0.7"
+features = ["codec"]
+default-features = false
diff --git a/test/test-rpc/src/client.rs b/test/test-rpc/src/client.rs
new file mode 100644
index 0000000000..387d0a2435
--- /dev/null
+++ b/test/test-rpc/src/client.rs
@@ -0,0 +1,289 @@
+use std::{
+ collections::HashMap,
+ time::{Duration, SystemTime},
+};
+
+use crate::mullvad_daemon::ServiceStatus;
+
+use super::*;
+
+const INSTALL_TIMEOUT: Duration = Duration::from_secs(300);
+const REBOOT_TIMEOUT: Duration = Duration::from_secs(30);
+const LOG_LEVEL_TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(Debug, Clone)]
+pub struct ServiceClient {
+ connection_handle: transport::ConnectionHandle,
+ client: service::ServiceClient,
+}
+
+// TODO: implement wrapper methods using macro on Service trait
+
+impl ServiceClient {
+ pub fn new(
+ connection_handle: transport::ConnectionHandle,
+ transport: tarpc::transport::channel::UnboundedChannel<
+ tarpc::Response<service::ServiceResponse>,
+ tarpc::ClientMessage<service::ServiceRequest>,
+ >,
+ ) -> Self {
+ Self {
+ connection_handle,
+ client: super::service::ServiceClient::new(tarpc::client::Config::default(), transport)
+ .spawn(),
+ }
+ }
+
+ /// Install app package.
+ pub async fn install_app(&self, package_path: package::Package) -> Result<(), Error> {
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(INSTALL_TIMEOUT).unwrap();
+
+ self.client
+ .install_app(ctx, package_path)
+ .await
+ .map_err(Error::Tarpc)?
+ }
+
+ /// Remove app package.
+ pub async fn uninstall_app(&self, env: HashMap<String, String>) -> Result<(), Error> {
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(INSTALL_TIMEOUT).unwrap();
+
+ self.client.uninstall_app(ctx, env).await?
+ }
+
+ /// Execute a program.
+ pub async fn exec_env<
+ I: IntoIterator<Item = T>,
+ M: IntoIterator<Item = (K, T)>,
+ T: AsRef<str>,
+ K: AsRef<str>,
+ >(
+ &self,
+ path: T,
+ args: I,
+ env: M,
+ ) -> Result<ExecResult, Error> {
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(INSTALL_TIMEOUT).unwrap();
+ self.client
+ .exec(
+ ctx,
+ path.as_ref().to_string(),
+ args.into_iter().map(|v| v.as_ref().to_string()).collect(),
+ env.into_iter()
+ .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
+ .collect(),
+ )
+ .await?
+ }
+
+ /// Execute a program.
+ pub async fn exec<I: IntoIterator<Item = T>, T: AsRef<str>>(
+ &self,
+ path: T,
+ args: I,
+ ) -> Result<ExecResult, Error> {
+ let env: [(&str, T); 0] = [];
+ self.exec_env(path, args, env).await
+ }
+
+ /// Get the output of the runners stdout logs since the last time this function was called.
+ /// Block if there is no output until some output is provided by the runner.
+ pub async fn poll_output(&self) -> Result<Vec<logging::Output>, Error> {
+ self.client.poll_output(tarpc::context::current()).await?
+ }
+
+ /// Get the output of the runners stdout logs since the last time this function was called.
+ /// Block if there is no output until some output is provided by the runner.
+ pub async fn try_poll_output(&self) -> Result<Vec<logging::Output>, Error> {
+ self.client
+ .try_poll_output(tarpc::context::current())
+ .await?
+ }
+
+ pub async fn get_mullvad_app_logs(&self) -> Result<logging::LogOutput, Error> {
+ self.client
+ .get_mullvad_app_logs(tarpc::context::current())
+ .await
+ .map_err(Error::Tarpc)
+ }
+
+ /// Return the OS of the guest.
+ pub async fn get_os(&self) -> Result<meta::Os, Error> {
+ self.client
+ .get_os(tarpc::context::current())
+ .await
+ .map_err(Error::Tarpc)
+ }
+
+ /// Wait for the Mullvad service to enter a specified state. The state is inferred from the presence
+ /// of a named pipe or UDS, not the actual system service state.
+ pub async fn mullvad_daemon_wait_for_state(
+ &self,
+ accept_state_fn: impl Fn(ServiceStatus) -> bool,
+ ) -> Result<mullvad_daemon::ServiceStatus, Error> {
+ const MAX_ATTEMPTS: usize = 10;
+ const POLL_INTERVAL: Duration = Duration::from_secs(3);
+
+ for _ in 0..MAX_ATTEMPTS {
+ let last_state = self.mullvad_daemon_get_status().await?;
+ match accept_state_fn(last_state) {
+ true => return Ok(last_state),
+ false => tokio::time::sleep(POLL_INTERVAL).await,
+ }
+ }
+ Err(Error::Timeout)
+ }
+
+ /// Return status of the system service. The state is inferred from the presence of
+ /// a named pipe or UDS, not the actual system service state.
+ pub async fn mullvad_daemon_get_status(&self) -> Result<mullvad_daemon::ServiceStatus, Error> {
+ self.client
+ .mullvad_daemon_get_status(tarpc::context::current())
+ .await
+ .map_err(Error::Tarpc)
+ }
+
+ /// Returns all Mullvad app files, directories, and other data found on the system.
+ pub async fn find_mullvad_app_traces(&self) -> Result<Vec<AppTrace>, Error> {
+ self.client
+ .find_mullvad_app_traces(tarpc::context::current())
+ .await?
+ }
+
+ /// Send TCP packet
+ pub async fn send_tcp(
+ &self,
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), Error> {
+ self.client
+ .send_tcp(tarpc::context::current(), interface, bind_addr, destination)
+ .await?
+ }
+
+ /// Send UDP packet
+ pub async fn send_udp(
+ &self,
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), Error> {
+ self.client
+ .send_udp(tarpc::context::current(), interface, bind_addr, destination)
+ .await?
+ }
+
+ /// Send ICMP
+ pub async fn send_ping(
+ &self,
+ interface: Option<Interface>,
+ destination: IpAddr,
+ ) -> Result<(), Error> {
+ self.client
+ .send_ping(tarpc::context::current(), interface, destination)
+ .await?
+ }
+
+ /// Fetch the current location.
+ pub async fn geoip_lookup(&self, mullvad_host: String) -> Result<AmIMullvad, Error> {
+ self.client
+ .geoip_lookup(tarpc::context::current(), mullvad_host)
+ .await?
+ }
+
+ /// Returns the IP of the given interface.
+ pub async fn get_interface_name(&self, interface: Interface) -> Result<String, Error> {
+ self.client
+ .get_interface_name(tarpc::context::current(), interface)
+ .await?
+ }
+
+ /// Returns the IP of the given interface.
+ pub async fn get_interface_ip(&self, interface: Interface) -> Result<IpAddr, Error> {
+ self.client
+ .get_interface_ip(tarpc::context::current(), interface)
+ .await?
+ }
+
+ pub async fn resolve_hostname(&self, hostname: String) -> Result<Vec<SocketAddr>, Error> {
+ self.client
+ .resolve_hostname(tarpc::context::current(), hostname)
+ .await?
+ }
+
+ pub async fn set_daemon_log_level(
+ &self,
+ verbosity_level: mullvad_daemon::Verbosity,
+ ) -> Result<(), Error> {
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(LOG_LEVEL_TIMEOUT).unwrap();
+ self.client
+ .set_daemon_log_level(ctx, verbosity_level)
+ .await??;
+
+ self.mullvad_daemon_wait_for_state(|state| state == ServiceStatus::Running)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn set_daemon_environment(&self, env: HashMap<String, String>) -> Result<(), Error> {
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(LOG_LEVEL_TIMEOUT).unwrap();
+ self.client.set_daemon_environment(ctx, env).await??;
+
+ self.mullvad_daemon_wait_for_state(|state| state == ServiceStatus::Running)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn copy_file(&self, src: String, dest: String) -> Result<(), Error> {
+ log::debug!("Copying \"{src}\" to \"{dest}\"");
+ self.client
+ .copy_file(tarpc::context::current(), src, dest)
+ .await?
+ }
+
+ pub async fn reboot(&mut self) -> Result<(), Error> {
+ log::debug!("Rebooting server");
+
+ let mut ctx = tarpc::context::current();
+ ctx.deadline = SystemTime::now().checked_add(REBOOT_TIMEOUT).unwrap();
+
+ self.client.reboot(ctx).await??;
+ self.connection_handle.reset_connected_state().await;
+ self.connection_handle.wait_for_server().await?;
+
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+
+ Ok(())
+ }
+
+ pub async fn set_mullvad_daemon_service_state(&self, on: bool) -> Result<(), Error> {
+ self.client
+ .set_mullvad_daemon_service_state(tarpc::context::current(), on)
+ .await??;
+
+ self.mullvad_daemon_wait_for_state(|state| {
+ if on {
+ state == ServiceStatus::Running
+ } else {
+ state == ServiceStatus::NotRunning
+ }
+ })
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn make_device_json_old(&self) -> Result<(), Error> {
+ self.client
+ .make_device_json_old(tarpc::context::current())
+ .await?
+ }
+}
diff --git a/test/test-rpc/src/lib.rs b/test/test-rpc/src/lib.rs
new file mode 100644
index 0000000000..2fd4411f49
--- /dev/null
+++ b/test/test-rpc/src/lib.rs
@@ -0,0 +1,179 @@
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::BTreeMap,
+ net::{IpAddr, SocketAddr},
+ path::PathBuf,
+};
+
+pub mod client;
+pub mod logging;
+pub mod meta;
+pub mod mullvad_daemon;
+pub mod net;
+pub mod package;
+pub mod transport;
+
+#[derive(err_derive::Error, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum Error {
+ #[error(display = "Test runner RPC failed")]
+ Tarpc(#[error(source)] tarpc::client::RpcError),
+ #[error(display = "Syscall failed")]
+ Syscall,
+ #[error(display = "Interface not found")]
+ InterfaceNotFound,
+ #[error(display = "HTTP request failed")]
+ HttpRequest(String),
+ #[error(display = "Failed to deserialize HTTP body")]
+ DeserializeBody,
+ #[error(display = "DNS resolution failed")]
+ DnsResolution,
+ #[error(display = "Test runner RPC timed out")]
+ TestRunnerTimeout,
+ #[error(display = "Package error")]
+ Package(#[error(source)] package::Error),
+ #[error(display = "Logger error")]
+ Logger(#[error(source)] logging::Error),
+ #[error(display = "Failed to send UDP datagram")]
+ SendUdp,
+ #[error(display = "Failed to send TCP segment")]
+ SendTcp,
+ #[error(display = "Failed to send ping")]
+ Ping,
+ #[error(display = "Failed to get or set registry value")]
+ Registry(String),
+ #[error(display = "Failed to change the service")]
+ Service(String),
+ #[error(display = "Could not read from or write to the file system")]
+ FileSystem(String),
+ #[error(display = "Could not serialize or deserialize file")]
+ FileSerialization(String),
+ #[error(display = "User must be logged in but is not")]
+ UserNotLoggedIn(String),
+ #[error(display = "Invalid URL")]
+ InvalidUrl,
+ #[error(display = "Timeout")]
+ Timeout,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
+pub enum Interface {
+ Tunnel,
+ NonTunnel,
+}
+
+/// Response from am.i.mullvad.net
+#[derive(Debug, Serialize, Deserialize)]
+pub struct AmIMullvad {
+ pub ip: IpAddr,
+ pub mullvad_exit_ip: bool,
+ pub mullvad_exit_ip_hostname: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct ExecResult {
+ pub code: Option<i32>,
+ pub stdout: Vec<u8>,
+ pub stderr: Vec<u8>,
+}
+
+impl ExecResult {
+ pub fn success(&self) -> bool {
+ self.code == Some(0)
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub enum AppTrace {
+ Path(PathBuf),
+}
+
+mod service {
+ use std::collections::HashMap;
+
+ pub use super::*;
+
+ #[tarpc::service]
+ pub trait Service {
+ /// Install app package.
+ async fn install_app(package_path: package::Package) -> Result<(), Error>;
+
+ /// Remove app package.
+ async fn uninstall_app(env: HashMap<String, String>) -> Result<(), Error>;
+
+ /// Execute a program.
+ async fn exec(
+ path: String,
+ args: Vec<String>,
+ env: BTreeMap<String, String>,
+ ) -> Result<ExecResult, Error>;
+
+ /// Get the output of the runners stdout logs since the last time this function was called.
+ /// Block if there is no output until some output is provided by the runner.
+ async fn poll_output() -> Result<Vec<logging::Output>, Error>;
+
+ /// Get the output of the runners stdout logs since the last time this function was called.
+ /// Block if there is no output until some output is provided by the runner.
+ async fn try_poll_output() -> Result<Vec<logging::Output>, Error>;
+
+ async fn get_mullvad_app_logs() -> logging::LogOutput;
+
+ /// Return the OS of the guest.
+ async fn get_os() -> meta::Os;
+
+ /// Return status of the system service.
+ async fn mullvad_daemon_get_status() -> mullvad_daemon::ServiceStatus;
+
+ /// Returns all Mullvad app files, directories, and other data found on the system.
+ async fn find_mullvad_app_traces() -> Result<Vec<AppTrace>, Error>;
+
+ /// Send TCP packet
+ async fn send_tcp(
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), Error>;
+
+ /// Send UDP packet
+ async fn send_udp(
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), Error>;
+
+ /// Send ICMP
+ async fn send_ping(interface: Option<Interface>, destination: IpAddr) -> Result<(), Error>;
+
+ /// Fetch the current location.
+ async fn geoip_lookup(mullvad_host: String) -> Result<AmIMullvad, Error>;
+
+ /// Returns the name of the given interface.
+ async fn get_interface_name(interface: Interface) -> Result<String, Error>;
+
+ /// Returns the IP of the given interface.
+ async fn get_interface_ip(interface: Interface) -> Result<IpAddr, Error>;
+
+ /// Perform DNS resolution.
+ async fn resolve_hostname(hostname: String) -> Result<Vec<SocketAddr>, Error>;
+
+ /// Sets the log level of the daemon service, the verbosity level represents the number of
+ /// `-v`s passed on the command line. This will restart the daemon system service.
+ async fn set_daemon_log_level(
+ verbosity_level: mullvad_daemon::Verbosity,
+ ) -> Result<(), Error>;
+
+ /// Set environment variables for the daemon service. This will restart the daemon system service.
+ async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), Error>;
+
+ /// Copy a file from `src` to `dest` on the test runner.
+ async fn copy_file(src: String, dest: String) -> Result<(), Error>;
+
+ async fn reboot() -> Result<(), Error>;
+
+ async fn set_mullvad_daemon_service_state(on: bool) -> Result<(), Error>;
+
+ async fn make_device_json_old() -> Result<(), Error>;
+ }
+}
+
+pub use client::ServiceClient;
+pub use service::{Service, ServiceRequest, ServiceResponse};
diff --git a/test/test-rpc/src/logging.rs b/test/test-rpc/src/logging.rs
new file mode 100644
index 0000000000..85f25c8060
--- /dev/null
+++ b/test/test-rpc/src/logging.rs
@@ -0,0 +1,43 @@
+use colored::Colorize;
+use serde::{Deserialize, Serialize};
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(err_derive::Error, Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
+pub enum Error {
+ #[error(display = "Could not get standard output from runner")]
+ StandardOutput,
+ #[error(display = "Could not get mullvad app logs from runner")]
+ Logs(String),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Output {
+ Error(String),
+ Warning(String),
+ Info(String),
+ Other(String),
+}
+
+impl std::fmt::Display for Output {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ match self {
+ Output::Error(s) => f.write_fmt(format_args!("{}", s.as_str().red())),
+ Output::Warning(s) => f.write_fmt(format_args!("{}", s.as_str().yellow())),
+ Output::Info(s) => f.write_fmt(format_args!("{}", s.as_str())),
+ Output::Other(s) => f.write_fmt(format_args!("{}", s.as_str())),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LogOutput {
+ pub settings_json: Result<String>,
+ pub log_files: Result<Vec<Result<LogFile>>>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LogFile {
+ pub name: std::path::PathBuf,
+ pub content: String,
+}
diff --git a/test/test-rpc/src/meta.rs b/test/test-rpc/src/meta.rs
new file mode 100644
index 0000000000..67c87738e0
--- /dev/null
+++ b/test/test-rpc/src/meta.rs
@@ -0,0 +1,27 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum Os {
+ Linux,
+ Macos,
+ Windows,
+}
+
+impl std::fmt::Display for Os {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Os::Linux => f.write_str("Linux"),
+ Os::Macos => f.write_str("macOS"),
+ Os::Windows => f.write_str("Windows"),
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+pub const CURRENT_OS: Os = Os::Linux;
+
+#[cfg(target_os = "windows")]
+pub const CURRENT_OS: Os = Os::Windows;
+
+#[cfg(target_os = "macos")]
+pub const CURRENT_OS: Os = Os::Macos;
diff --git a/test/test-rpc/src/mullvad_daemon.rs b/test/test-rpc/src/mullvad_daemon.rs
new file mode 100644
index 0000000000..10cc00c3fc
--- /dev/null
+++ b/test/test-rpc/src/mullvad_daemon.rs
@@ -0,0 +1,34 @@
+use serde::{Deserialize, Serialize};
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub const SOCKET_PATH: &str = "/var/run/mullvad-vpn";
+#[cfg(windows)]
+pub const SOCKET_PATH: &str = "//./pipe/Mullvad VPN";
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum Error {
+ ConnectError,
+ DisconnectError,
+ DaemonError(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
+pub enum ServiceStatus {
+ NotRunning,
+ Running,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
+pub enum Verbosity {
+ Info,
+ Debug,
+ Trace,
+}
+
+#[derive(Clone, Copy, PartialEq)]
+pub enum MullvadClientVersion {
+ None,
+ New,
+}
diff --git a/test/test-rpc/src/net.rs b/test/test-rpc/src/net.rs
new file mode 100644
index 0000000000..b4e114ea47
--- /dev/null
+++ b/test/test-rpc/src/net.rs
@@ -0,0 +1,65 @@
+use hyper::{Client, Uri};
+use once_cell::sync::Lazy;
+use serde::de::DeserializeOwned;
+use tokio_rustls::rustls::ClientConfig;
+
+use crate::{AmIMullvad, Error};
+
+const LE_ROOT_CERT: &[u8] = include_bytes!("../../../mullvad-api/le_root_cert.pem");
+
+static CLIENT_CONFIG: Lazy<ClientConfig> = Lazy::new(|| {
+ ClientConfig::builder()
+ .with_safe_default_cipher_suites()
+ .with_safe_default_kx_groups()
+ .with_safe_default_protocol_versions()
+ .unwrap()
+ .with_root_certificates(read_cert_store())
+ .with_no_client_auth()
+});
+
+pub async fn geoip_lookup(mullvad_host: String) -> Result<AmIMullvad, Error> {
+ let uri = Uri::try_from(format!("https://ipv4.am.i.{mullvad_host}/json"))
+ .map_err(|_| Error::InvalidUrl)?;
+ http_get(uri).await
+}
+
+pub async fn http_get<T: DeserializeOwned>(url: Uri) -> Result<T, Error> {
+ log::debug!("GET {url}");
+
+ let https = hyper_rustls::HttpsConnectorBuilder::new()
+ .with_tls_config(CLIENT_CONFIG.clone())
+ .https_only()
+ .enable_http1()
+ .build();
+
+ let client: Client<_, hyper::Body> = Client::builder().build(https);
+ let body = client
+ .get(url)
+ .await
+ .map_err(|error| Error::HttpRequest(error.to_string()))?
+ .into_body();
+
+ // TODO: limit length
+ let bytes = hyper::body::to_bytes(body).await.map_err(|error| {
+ log::error!("Failed to convert body to bytes buffer: {}", error);
+ Error::DeserializeBody
+ })?;
+
+ serde_json::from_slice(&bytes).map_err(|error| {
+ log::error!("Failed to deserialize response: {}", error);
+ Error::DeserializeBody
+ })
+}
+
+fn read_cert_store() -> tokio_rustls::rustls::RootCertStore {
+ let mut cert_store = tokio_rustls::rustls::RootCertStore::empty();
+
+ let certs = rustls_pemfile::certs(&mut std::io::BufReader::new(LE_ROOT_CERT))
+ .expect("Failed to parse pem file");
+ let (num_certs_added, num_failures) = cert_store.add_parsable_certificates(&certs);
+ if num_failures > 0 || num_certs_added != 1 {
+ panic!("Failed to add root cert");
+ }
+
+ cert_store
+}
diff --git a/test/test-rpc/src/package.rs b/test/test-rpc/src/package.rs
new file mode 100644
index 0000000000..89d6dce495
--- /dev/null
+++ b/test/test-rpc/src/package.rs
@@ -0,0 +1,46 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(err_derive::Error, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[error(no_from)]
+pub enum Error {
+ #[error(display = "Failed open file for writing")]
+ OpenFile,
+
+ #[error(display = "Failed to write downloaded file to disk")]
+ WriteFile,
+
+ #[error(display = "Failed to convert download to bytes")]
+ ToBytes,
+
+ #[error(display = "Failed to convert download to bytes")]
+ RequestFailed,
+
+ #[error(display = "Cannot parse version")]
+ InvalidVersion,
+
+ #[error(display = "Failed to run package installer")]
+ RunApp,
+
+ #[error(display = "Failed to create temporary uninstaller")]
+ CreateTempUninstaller,
+
+ #[error(
+ display = "Installer or uninstaller failed due to an unknown error: {}",
+ _0
+ )]
+ InstallerFailed(i32),
+
+ #[error(display = "Installer or uninstaller failed due to a signal")]
+ InstallerFailedSignal,
+
+ #[error(display = "Unrecognized OS: {}", _0)]
+ UnknownOs(String),
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Package {
+ pub path: PathBuf,
+}
diff --git a/test/test-rpc/src/transport.rs b/test/test-rpc/src/transport.rs
new file mode 100644
index 0000000000..6c8b7a7060
--- /dev/null
+++ b/test/test-rpc/src/transport.rs
@@ -0,0 +1,492 @@
+use bytes::{Buf, BufMut, Bytes, BytesMut};
+use futures::{channel::mpsc, SinkExt, StreamExt};
+use serde::{de::DeserializeOwned, Serialize};
+use std::{
+ fmt::Write,
+ io,
+ sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ },
+ time::Duration,
+};
+use tarpc::{ClientMessage, Response};
+use tokio::{
+ io::{AsyncRead, AsyncWrite},
+ sync::futures::Notified,
+};
+use tokio_util::codec::{Decoder, Encoder, LengthDelimitedCodec};
+
+use crate::{Error, ServiceRequest, ServiceResponse};
+
+/// How long to wait for the RPC server to start
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(300);
+const FRAME_TYPE_SIZE: usize = std::mem::size_of::<FrameType>();
+const DAEMON_CHANNEL_BUF_SIZE: usize = 16 * 1024;
+
+/// Unique payload that comes with the "handshake" frame
+const MULLVAD_SIGNATURE: &[u8] = b"MULLV4D;";
+
+pub enum Frame {
+ Handshake,
+ TestRunner(Bytes),
+ DaemonRpc(Bytes),
+}
+
+#[repr(u8)]
+enum FrameType {
+ Handshake,
+ TestRunner,
+ DaemonRpc,
+}
+
+impl TryFrom<u8> for FrameType {
+ type Error = ();
+
+ fn try_from(value: u8) -> Result<Self, Self::Error> {
+ match value {
+ i if i == FrameType::Handshake as u8 => Ok(FrameType::Handshake),
+ i if i == FrameType::TestRunner as u8 => Ok(FrameType::TestRunner),
+ i if i == FrameType::DaemonRpc as u8 => Ok(FrameType::DaemonRpc),
+ _ => Err(()),
+ }
+ }
+}
+
+pub type GrpcForwarder = tokio::io::DuplexStream;
+pub type CompletionHandle = tokio::task::JoinHandle<()>;
+
+#[derive(Debug, Clone)]
+pub struct ConnectionHandle {
+ handshake_fwd_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<()>>>,
+ // True if the connection has received an initial "handshake" frame from the other end.
+ is_connected: Arc<AtomicBool>,
+ reset_notify: Arc<tokio::sync::Notify>,
+}
+
+impl ConnectionHandle {
+ /// Returns a new "handshake forwarder" and connection handle.
+ fn new() -> (mpsc::UnboundedSender<()>, Self) {
+ let (handshake_fwd_tx, handshake_fwd_rx) = mpsc::unbounded();
+
+ (
+ handshake_fwd_tx,
+ Self {
+ handshake_fwd_rx: Arc::new(tokio::sync::Mutex::new(handshake_fwd_rx)),
+ is_connected: Self::new_connected_state(false),
+ reset_notify: Arc::new(tokio::sync::Notify::new()),
+ },
+ )
+ }
+
+ pub async fn wait_for_server(&mut self) -> Result<(), Error> {
+ let mut handshake_fwd = self.handshake_fwd_rx.lock().await;
+
+ log::info!("Waiting for server");
+
+ match tokio::time::timeout(CONNECT_TIMEOUT, handshake_fwd.next()).await {
+ Ok(_) => {
+ log::info!("Server responded");
+ Ok(())
+ }
+ _ => {
+ log::error!("Connection timed out");
+ Err(Error::TestRunnerTimeout)
+ }
+ }
+ }
+
+ /// Resets `Self::is_connected`.
+ pub async fn reset_connected_state(&self) {
+ let mut handshake_fwd = self.handshake_fwd_rx.lock().await;
+ // empty stream
+ while let Ok(Some(_)) = handshake_fwd.try_next() {}
+
+ self.is_connected.store(false, Ordering::SeqCst);
+ self.reset_notify.notify_waiters();
+ }
+
+ /// Returns a future that is notified when `reset_connected_state` is called.
+ pub fn notified_reset(&self) -> Notified {
+ self.reset_notify.notified()
+ }
+
+ fn connected_state(&self) -> Arc<AtomicBool> {
+ self.is_connected.clone()
+ }
+
+ fn new_connected_state(initial: bool) -> Arc<AtomicBool> {
+ Arc::new(AtomicBool::new(initial))
+ }
+}
+
+pub fn create_server_transports(
+ serial_stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+) -> (
+ tarpc::transport::channel::UnboundedChannel<
+ ClientMessage<ServiceRequest>,
+ Response<ServiceResponse>,
+ >,
+ GrpcForwarder,
+ CompletionHandle,
+) {
+ let (runner_forwarder_1, runner_forwarder_2) = tarpc::transport::channel::unbounded();
+
+ let (daemon_rx, mullvad_daemon_forwarder) = tokio::io::duplex(DAEMON_CHANNEL_BUF_SIZE);
+
+ let (handshake_tx, handshake_rx) = mpsc::unbounded();
+
+ let _ = handshake_tx.unbounded_send(());
+
+ let completion_handle = tokio::spawn(async move {
+ if let Err(error) = forward_messages(
+ serial_stream,
+ runner_forwarder_2,
+ mullvad_daemon_forwarder,
+ (handshake_tx, handshake_rx),
+ None,
+ // The server needs to be init to connected, or it will skip things it shouldn't
+ ConnectionHandle::new_connected_state(true),
+ )
+ .await
+ {
+ log::error!(
+ "forward_messages stopped due an error: {}",
+ display_chain(error)
+ );
+ } else {
+ log::debug!("forward_messages stopped");
+ }
+ });
+
+ (runner_forwarder_1, daemon_rx, completion_handle)
+}
+
+pub async fn create_client_transports(
+ serial_stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+) -> Result<
+ (
+ tarpc::transport::channel::UnboundedChannel<
+ Response<ServiceResponse>,
+ ClientMessage<ServiceRequest>,
+ >,
+ GrpcForwarder,
+ ConnectionHandle,
+ CompletionHandle,
+ ),
+ Error,
+> {
+ let (runner_forwarder_1, runner_forwarder_2) = tarpc::transport::channel::unbounded();
+
+ let (daemon_rx, mullvad_daemon_forwarder) = tokio::io::duplex(DAEMON_CHANNEL_BUF_SIZE);
+
+ let (handshake_tx, handshake_rx) = mpsc::unbounded();
+
+ let (handshake_fwd_tx, conn_handle) = ConnectionHandle::new();
+
+ let _ = handshake_tx.unbounded_send(());
+
+ let connected_state = conn_handle.connected_state();
+
+ let completion_handle = tokio::spawn(async move {
+ if let Err(error) = forward_messages(
+ serial_stream,
+ runner_forwarder_1,
+ mullvad_daemon_forwarder,
+ (handshake_tx, handshake_rx),
+ Some(handshake_fwd_tx),
+ connected_state,
+ )
+ .await
+ {
+ log::error!(
+ "forward_messages stopped due an error: {}",
+ display_chain(error)
+ );
+ } else {
+ log::debug!("forward_messages stopped");
+ }
+ });
+
+ Ok((
+ runner_forwarder_2,
+ daemon_rx,
+ conn_handle,
+ completion_handle,
+ ))
+}
+
+#[derive(err_derive::Error, Debug)]
+#[error(no_from)]
+enum ForwardError {
+ #[error(display = "Failed to deserialize JSON data")]
+ DeserializeFailed(#[error(source)] serde_json::Error),
+
+ #[error(display = "Failed to serialize JSON data")]
+ SerializeFailed(#[error(source)] serde_json::Error),
+
+ #[error(display = "Serial connection error")]
+ SerialConnection(#[error(source)] io::Error),
+
+ #[error(display = "Test runner channel error")]
+ TestRunnerChannel(#[error(source)] tarpc::transport::channel::ChannelError),
+
+ #[error(display = "Daemon channel error")]
+ DaemonChannel(#[error(source)] io::Error),
+
+ #[error(display = "Handshake error")]
+ HandshakeError(#[error(source)] io::Error),
+}
+
+async fn forward_messages<
+ T: Serialize + Unpin + Send + 'static,
+ S: DeserializeOwned + Unpin + Send + 'static,
+>(
+ serial_stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+ mut runner_forwarder: tarpc::transport::channel::UnboundedChannel<T, S>,
+ mullvad_daemon_forwarder: GrpcForwarder,
+ mut handshaker: (mpsc::UnboundedSender<()>, mpsc::UnboundedReceiver<()>),
+ handshake_fwd: Option<mpsc::UnboundedSender<()>>,
+ connected_state: Arc<AtomicBool>,
+) -> Result<(), ForwardError> {
+ let codec = MultiplexCodec::new(connected_state);
+ let mut serial_stream = codec.framed(serial_stream);
+
+ // Needs to be framed to allow empty messages.
+ let mut mullvad_daemon_forwarder = LengthDelimitedCodec::new().framed(mullvad_daemon_forwarder);
+
+ loop {
+ match futures::future::select(
+ futures::future::select(serial_stream.next(), handshaker.1.next()),
+ futures::future::select(runner_forwarder.next(), mullvad_daemon_forwarder.next()),
+ )
+ .await
+ {
+ futures::future::Either::Left((futures::future::Either::Left((Some(frame), _)), _)) => {
+ let frame = frame.map_err(ForwardError::SerialConnection)?;
+
+ //
+ // Deserialize frame and send it to one of the channels
+ //
+
+ match frame {
+ Frame::TestRunner(data) => {
+ let message = serde_json::from_slice(&data)
+ .map_err(ForwardError::DeserializeFailed)?;
+ runner_forwarder
+ .send(message)
+ .await
+ .map_err(ForwardError::TestRunnerChannel)?;
+ }
+ Frame::DaemonRpc(data) => {
+ mullvad_daemon_forwarder
+ .send(data)
+ .await
+ .map_err(ForwardError::DaemonChannel)?;
+ }
+ Frame::Handshake => {
+ log::trace!("shake: recv");
+ if let Some(shake_fwd) = handshake_fwd.as_ref() {
+ let _ = shake_fwd.unbounded_send(());
+ } else {
+ let _ = handshaker.0.unbounded_send(());
+ }
+ }
+ }
+ }
+ futures::future::Either::Left((futures::future::Either::Right((Some(()), _)), _)) => {
+ log::trace!("shake: send");
+
+ // Ping the other end
+ serial_stream
+ .send(Frame::Handshake)
+ .await
+ .map_err(ForwardError::HandshakeError)?;
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Left((Some(message), _)),
+ _,
+ )) => {
+ let message = message.map_err(ForwardError::TestRunnerChannel)?;
+
+ //
+ // Serialize messages from tarpc channel into frames
+ // and send them over the serial connection
+ //
+
+ let serialized =
+ serde_json::to_vec(&message).map_err(ForwardError::SerializeFailed)?;
+ serial_stream
+ .send(Frame::TestRunner(serialized.into()))
+ .await
+ .map_err(ForwardError::SerialConnection)?;
+ }
+ futures::future::Either::Right((
+ futures::future::Either::Right((Some(data), _)),
+ _,
+ )) => {
+ let data = data.map_err(ForwardError::DaemonChannel)?;
+
+ //
+ // Forward whatever the heck this is
+ //
+
+ serial_stream
+ .send(Frame::DaemonRpc(data.into()))
+ .await
+ .map_err(ForwardError::SerialConnection)?;
+ }
+ futures::future::Either::Right((futures::future::Either::Right((None, _)), _)) => {
+ //
+ // Force management interface socket to close
+ //
+ let _ = serial_stream.send(Frame::DaemonRpc(Bytes::new())).await;
+
+ break Ok(());
+ }
+ _ => {
+ break Ok(());
+ }
+ }
+ }
+}
+
+const MULTIPLEX_LEN_DELIMITED_HEADER_SIZE: usize = 4;
+
+#[derive(Default, Debug, Clone)]
+pub struct MultiplexCodec {
+ len_delim_codec: LengthDelimitedCodec,
+ has_connected: Arc<AtomicBool>,
+}
+
+impl MultiplexCodec {
+ fn new(has_connected: Arc<AtomicBool>) -> Self {
+ let mut codec_builder = LengthDelimitedCodec::builder();
+
+ codec_builder.length_field_length(MULTIPLEX_LEN_DELIMITED_HEADER_SIZE);
+
+ Self {
+ has_connected,
+ len_delim_codec: codec_builder.new_codec(),
+ }
+ }
+
+ fn decode_frame(mut frame: BytesMut) -> Result<Frame, io::Error> {
+ if frame.len() < FRAME_TYPE_SIZE {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "frame does not contain frame type",
+ ));
+ }
+
+ let mut type_bytes = frame.split_to(FRAME_TYPE_SIZE);
+ let frame_type = FrameType::try_from(type_bytes.get_u8())
+ .map_err(|_err| io::Error::new(io::ErrorKind::InvalidInput, "invalid frame type"))?;
+
+ match frame_type {
+ FrameType::Handshake => Ok(Frame::Handshake),
+ FrameType::TestRunner => Ok(Frame::TestRunner(frame.into())),
+ FrameType::DaemonRpc => Ok(Frame::DaemonRpc(frame.into())),
+ }
+ }
+
+ fn encode_frame(
+ &mut self,
+ frame_type: FrameType,
+ bytes: Option<Bytes>,
+ dst: &mut BytesMut,
+ ) -> Result<(), io::Error> {
+ let mut buffer = BytesMut::new();
+ if let Some(bytes) = bytes {
+ buffer.reserve(bytes.len() + FRAME_TYPE_SIZE);
+ buffer.put_u8(frame_type as u8);
+ // TODO: implement without copying
+ buffer.put(&bytes[..]);
+ } else {
+ buffer.reserve(FRAME_TYPE_SIZE);
+ buffer.put_u8(frame_type as u8);
+ }
+ self.len_delim_codec.encode(buffer.into(), dst)
+ }
+
+ fn decode_inner(&mut self, src: &mut BytesMut) -> Result<Option<Frame>, io::Error> {
+ self.skip_noise(src);
+ if !self.has_connected.load(Ordering::SeqCst) {
+ return Ok(None);
+ }
+ let frame = self.len_delim_codec.decode(src)?;
+ frame.map(Self::decode_frame).transpose()
+ }
+
+ fn skip_noise(&mut self, src: &mut BytesMut) {
+ // The test runner likes to send ^@ once in while. Unclear why,
+ // but it probably occurs (sometimes) when it reconnects to the
+ // serial device. Ignoring these control characters is safe.
+ while src.len() >= 2 {
+ if src[0] == b'^' {
+ log::debug!("ignoring control character");
+ src.advance(2);
+ continue;
+ }
+
+ // We use a magic constant to ignore any garbage sent before
+ // our service starts. The reason is that OVMF sends stuff to
+ // our serial device that we don't care about.
+ if !self.has_connected.load(Ordering::SeqCst) {
+ for (window_i, window) in src.windows(MULLVAD_SIGNATURE.len()).enumerate() {
+ if window == MULLVAD_SIGNATURE {
+ log::debug!("Found conn signature");
+
+ // Skip to where the first frame begins
+ src.advance(
+ window_i
+ .saturating_sub(FRAME_TYPE_SIZE)
+ .saturating_sub(MULTIPLEX_LEN_DELIMITED_HEADER_SIZE),
+ );
+
+ self.has_connected.store(true, Ordering::SeqCst);
+
+ break;
+ }
+ }
+ }
+
+ break;
+ }
+ }
+}
+
+impl Decoder for MultiplexCodec {
+ type Item = Frame;
+ type Error = io::Error;
+
+ fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+ self.decode_inner(src)
+ }
+}
+
+impl Encoder<Frame> for MultiplexCodec {
+ type Error = io::Error;
+
+ fn encode(&mut self, frame: Frame, dst: &mut BytesMut) -> Result<(), Self::Error> {
+ match frame {
+ Frame::Handshake => self.encode_frame(
+ FrameType::Handshake,
+ Some(Bytes::from_static(MULLVAD_SIGNATURE)),
+ dst,
+ ),
+ Frame::TestRunner(bytes) => self.encode_frame(FrameType::TestRunner, Some(bytes), dst),
+ Frame::DaemonRpc(bytes) => self.encode_frame(FrameType::DaemonRpc, Some(bytes), dst),
+ }
+ }
+}
+
+fn display_chain(error: impl std::error::Error) -> String {
+ let mut s = error.to_string();
+ let mut error = &error as &dyn std::error::Error;
+ while let Some(source) = error.source() {
+ write!(&mut s, "\nCaused by: {}", source).unwrap();
+ error = source;
+ }
+ s
+}
diff --git a/test/test-runner/Cargo.toml b/test/test-runner/Cargo.toml
new file mode 100644
index 0000000000..4a18569271
--- /dev/null
+++ b/test/test-runner/Cargo.toml
@@ -0,0 +1,58 @@
+[package]
+name = "test-runner"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+futures = { workspace = true }
+tarpc = { workspace = true }
+tokio = { workspace = true }
+tokio-serial = { workspace = true }
+err-derive = { workspace = true }
+log = { workspace = true }
+once_cell = { workspace = true }
+parity-tokio-ipc = "0.9"
+bytes = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio-serde = { workspace = true }
+
+libc = "0.2"
+chrono = { workspace = true }
+
+test-rpc = { path = "../test-rpc" }
+mullvad-paths = { path = "../../mullvad-paths" }
+talpid-platform-metadata = { path = "../../talpid-platform-metadata" }
+
+socket2 = { version = "0.5", features = ["all"] }
+
+[target."cfg(target_os=\"windows\")".dependencies]
+talpid-windows-net = { path = "../../talpid-windows-net" }
+
+windows-service = "0.6"
+winreg = "0.50"
+
+[target.'cfg(windows)'.dependencies.windows-sys]
+version = "0.45.0"
+features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_System_Shutdown",
+ "Win32_System_SystemServices",
+ "Win32_System_Threading",
+ "Win32_UI_WindowsAndMessaging",
+]
+
+[dependencies.tokio-util]
+version = "0.7"
+features = ["codec"]
+default-features = false
+
+[target.'cfg(unix)'.dependencies]
+nix = { version = "0.25", features = ["socket", "net"] }
+
+[target.'cfg(target_os = "linux")'.dependencies]
+rs-release = "0.1.7"
+
+[target.'cfg(target_os = "macos")'.dependencies]
+plist = "1"
diff --git a/test/test-runner/src/app.rs b/test/test-runner/src/app.rs
new file mode 100644
index 0000000000..43aca23abb
--- /dev/null
+++ b/test/test-runner/src/app.rs
@@ -0,0 +1,144 @@
+use chrono::{DateTime, Utc};
+use std::path::Path;
+
+use test_rpc::{AppTrace, Error};
+
+#[cfg(target_os = "windows")]
+pub fn find_traces() -> Result<Vec<AppTrace>, Error> {
+ // TODO: Check GUI data
+ // TODO: Check temp data
+ // TODO: Check devices and drivers
+
+ let settings_dir = mullvad_paths::get_default_settings_dir().map_err(|error| {
+ log::error!("Failed to obtain system app data: {error}");
+ Error::Syscall
+ })?;
+
+ let mut traces = vec![
+ Path::new(r"C:\Program Files\Mullvad VPN"),
+ // NOTE: This only works as of `499c06decda37dc639e5f` in the Mullvad app.
+ // Older builds have no way of silently fully uninstalling the app.
+ Path::new(r"C:\ProgramData\Mullvad VPN"),
+ // NOTE: Works as of `4116ebc` (Mullvad app).
+ &settings_dir,
+ ];
+
+ filter_non_existent_paths(&mut traces)?;
+
+ Ok(traces
+ .into_iter()
+ .map(|path| AppTrace::Path(path.to_path_buf()))
+ .collect())
+}
+
+#[cfg(target_os = "linux")]
+pub fn find_traces() -> Result<Vec<AppTrace>, Error> {
+ // TODO: Check GUI data
+ // TODO: Check temp data
+
+ let mut traces = vec![
+ Path::new(r"/etc/mullvad-vpn/"),
+ Path::new(r"/var/log/mullvad-vpn/"),
+ Path::new(r"/var/cache/mullvad-vpn/"),
+ Path::new(r"/opt/Mullvad VPN/"),
+ // management interface socket
+ Path::new(r"/var/run/mullvad-vpn"),
+ // service unit config files
+ Path::new(r"/usr/lib/systemd/system/mullvad-daemon.service"),
+ Path::new(r"/usr/lib/systemd/system/mullvad-early-boot-blocking.service"),
+ Path::new(r"/usr/bin/mullvad"),
+ Path::new(r"/usr/bin/mullvad-daemon"),
+ Path::new(r"/usr/bin/mullvad-exclude"),
+ Path::new(r"/usr/bin/mullvad-problem-report"),
+ Path::new(r"/usr/share/bash-completion/completions/mullvad"),
+ Path::new(r"/usr/local/share/zsh/site-functions/_mullvad"),
+ Path::new(r"/usr/share/fish/vendor_completions.d/mullvad.fish"),
+ ];
+
+ filter_non_existent_paths(&mut traces)?;
+
+ Ok(traces
+ .into_iter()
+ .map(|path| AppTrace::Path(path.to_path_buf()))
+ .collect())
+}
+
+#[cfg(target_os = "macos")]
+pub fn find_traces() -> Result<Vec<AppTrace>, Error> {
+ // TODO: Check GUI data
+ // TODO: Check temp data
+
+ let mut traces = vec![
+ Path::new(r"/Applications/Mullvad VPN.app/"),
+ Path::new(r"/var/log/mullvad-vpn/"),
+ Path::new(r"/Library/Caches/mullvad-vpn/"),
+ // management interface socket
+ Path::new(r"/var/run/mullvad-vpn"),
+ // launch daemon
+ Path::new(r"/Library/LaunchDaemons/net.mullvad.daemon.plist"),
+ Path::new(r"/usr/local/bin/mullvad"),
+ Path::new(r"/usr/local/bin/mullvad-problem-report"),
+ // completions
+ Path::new(r"/usr/local/share/zsh/site-functions/_mullvad"),
+ Path::new(r"/opt/homebrew/share/fish/vendor_completions.d/mullvad.fish"),
+ Path::new(r"/usr/local/share/fish/vendor_completions.d/mullvad.fish"),
+ ];
+
+ filter_non_existent_paths(&mut traces)?;
+
+ Ok(traces
+ .into_iter()
+ .map(|path| AppTrace::Path(path.to_path_buf()))
+ .collect())
+}
+
+fn filter_non_existent_paths(paths: &mut Vec<&Path>) -> Result<(), Error> {
+ for i in (0..paths.len()).rev() {
+ let path_exists = paths[i].try_exists().map_err(|error| {
+ log::error!("Failed to check whether path exists: {error}");
+ Error::Syscall
+ })?;
+ if !path_exists {
+ paths.swap_remove(i);
+ continue;
+ }
+ }
+ Ok(())
+}
+
+pub async fn make_device_json_old() -> Result<(), Error> {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ const DEVICE_JSON_PATH: &str = "/etc/mullvad-vpn/device.json";
+ #[cfg(target_os = "windows")]
+ const DEVICE_JSON_PATH: &str =
+ "C:\\Windows\\system32\\config\\systemprofile\\AppData\\Local\\Mullvad VPN\\device.json";
+ let device_json = tokio::fs::read_to_string(DEVICE_JSON_PATH)
+ .await
+ .map_err(|e| Error::FileSystem(e.to_string()))?;
+
+ let mut device_state: serde_json::Value =
+ serde_json::from_str(&device_json).map_err(|e| Error::FileSerialization(e.to_string()))?;
+ let created_ref: &mut serde_json::Value = device_state
+ .get_mut("logged_in")
+ .unwrap()
+ .get_mut("device")
+ .unwrap()
+ .get_mut("wg_data")
+ .unwrap()
+ .get_mut("created")
+ .unwrap();
+ let created: DateTime<Utc> = serde_json::from_value(created_ref.clone()).unwrap();
+ let created = created
+ .checked_sub_signed(chrono::Duration::days(365))
+ .unwrap();
+
+ *created_ref = serde_json::to_value(created).unwrap();
+
+ let device_json = serde_json::to_string(&device_state)
+ .map_err(|e| Error::FileSerialization(e.to_string()))?;
+ tokio::fs::write(DEVICE_JSON_PATH, device_json.as_bytes())
+ .await
+ .map_err(|e| Error::FileSystem(e.to_string()))?;
+
+ Ok(())
+}
diff --git a/test/test-runner/src/logging.rs b/test/test-runner/src/logging.rs
new file mode 100644
index 0000000000..3c9aad9d15
--- /dev/null
+++ b/test/test-runner/src/logging.rs
@@ -0,0 +1,111 @@
+use log::{Level, LevelFilter, Metadata, Record, SetLoggerError};
+use once_cell::sync::Lazy;
+use std::path::{Path, PathBuf};
+use test_rpc::logging::Error;
+use test_rpc::logging::{LogFile, LogOutput, Output};
+use tokio::{
+ fs::read_to_string,
+ sync::{
+ broadcast::{channel, Receiver, Sender},
+ Mutex,
+ },
+};
+
+const MAX_OUTPUT_BUFFER: usize = 10_000;
+
+pub static LOGGER: Lazy<StdOutBuffer> = Lazy::new(|| {
+ let (sender, listener) = channel(MAX_OUTPUT_BUFFER);
+ StdOutBuffer(Mutex::new(listener), sender)
+});
+
+pub struct StdOutBuffer(pub Mutex<Receiver<Output>>, pub Sender<Output>);
+
+impl log::Log for StdOutBuffer {
+ fn enabled(&self, metadata: &Metadata) -> bool {
+ metadata.level() <= Level::Info
+ }
+
+ fn log(&self, record: &Record) {
+ if self.enabled(record.metadata()) {
+ match record.metadata().level() {
+ Level::Error => {
+ self.1
+ .send(Output::Error(format!("{}", record.args())))
+ .unwrap();
+ }
+ Level::Warn => {
+ self.1
+ .send(Output::Warning(format!("{}", record.args())))
+ .unwrap();
+ }
+ Level::Info => {
+ if !record.metadata().target().contains("tarpc") {
+ self.1
+ .send(Output::Info(format!("{}", record.args())))
+ .unwrap();
+ }
+ }
+ _ => (),
+ }
+ println!("{}", record.args());
+ }
+ }
+
+ fn flush(&self) {}
+}
+
+pub fn init_logger() -> Result<(), SetLoggerError> {
+ log::set_logger(&*LOGGER).map(|()| log::set_max_level(LevelFilter::Info))
+}
+
+pub async fn get_mullvad_app_logs() -> LogOutput {
+ LogOutput {
+ settings_json: read_settings_file().await,
+ log_files: read_log_files().await,
+ }
+}
+
+async fn read_settings_file() -> Result<String, Error> {
+ let mut settings_path = mullvad_paths::get_default_settings_dir()
+ .map_err(|error| Error::Logs(format!("{}", error)))?;
+ settings_path.push("settings.json");
+ read_to_string(&settings_path)
+ .await
+ .map_err(|error| Error::Logs(format!("{}: {}", settings_path.display(), error)))
+}
+
+async fn read_log_files() -> Result<Vec<Result<LogFile, Error>>, Error> {
+ let log_dir =
+ mullvad_paths::get_default_log_dir().map_err(|error| Error::Logs(format!("{}", error)))?;
+ let paths = list_logs(log_dir)
+ .await
+ .map_err(|error| Error::Logs(format!("{}", error)))?;
+ let mut log_files = Vec::new();
+ for path in paths {
+ let log_file = read_to_string(&path)
+ .await
+ .map_err(|error| Error::Logs(format!("{}: {}", path.display(), error)))
+ .map(|content| LogFile {
+ content,
+ name: path,
+ });
+ log_files.push(log_file);
+ }
+ Ok(log_files)
+}
+
+async fn list_logs<T: AsRef<Path>>(log_dir: T) -> Result<Vec<PathBuf>, Error> {
+ let mut dir_entries = tokio::fs::read_dir(&log_dir)
+ .await
+ .map_err(|e| Error::Logs(format!("{}: {}", log_dir.as_ref().display(), e)))?;
+ let log_extension = Some(std::ffi::OsStr::new("log"));
+
+ let mut paths = Vec::new();
+ while let Ok(Some(entry)) = dir_entries.next_entry().await {
+ let path = entry.path();
+ if path.extension() == log_extension {
+ paths.push(path);
+ }
+ }
+ Ok(paths)
+}
diff --git a/test/test-runner/src/main.rs b/test/test-runner/src/main.rs
new file mode 100644
index 0000000000..8d7991d6fa
--- /dev/null
+++ b/test/test-runner/src/main.rs
@@ -0,0 +1,409 @@
+use futures::{pin_mut, SinkExt, StreamExt};
+use logging::LOGGER;
+use std::{
+ collections::{BTreeMap, HashMap},
+ net::{IpAddr, SocketAddr},
+ path::Path,
+};
+
+use tarpc::context;
+use tarpc::server::Channel;
+use test_rpc::{
+ meta,
+ mullvad_daemon::{ServiceStatus, SOCKET_PATH},
+ package::Package,
+ transport::GrpcForwarder,
+ AppTrace, Interface, Service,
+};
+use tokio::sync::broadcast::error::TryRecvError;
+use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ process::Command,
+};
+use tokio_util::codec::{Decoder, LengthDelimitedCodec};
+
+mod app;
+mod logging;
+mod net;
+mod package;
+mod sys;
+
+#[derive(Clone)]
+pub struct TestServer(pub ());
+
+#[tarpc::server]
+impl Service for TestServer {
+ async fn install_app(
+ self,
+ _: context::Context,
+ package: Package,
+ ) -> Result<(), test_rpc::Error> {
+ log::debug!("Installing app");
+
+ package::install_package(package).await?;
+
+ log::debug!("Install complete");
+
+ Ok(())
+ }
+
+ async fn uninstall_app(
+ self,
+ _: context::Context,
+ env: HashMap<String, String>,
+ ) -> Result<(), test_rpc::Error> {
+ log::debug!("Uninstalling app");
+
+ package::uninstall_app(env).await?;
+
+ log::debug!("Uninstalled app");
+
+ Ok(())
+ }
+
+ async fn exec(
+ self,
+ _: context::Context,
+ path: String,
+ args: Vec<String>,
+ env: BTreeMap<String, String>,
+ ) -> Result<test_rpc::ExecResult, test_rpc::Error> {
+ log::debug!("Exec {} (args: {args:?})", path);
+
+ let mut cmd = Command::new(&path);
+ cmd.args(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(env);
+
+ let output = cmd.output().await.map_err(|error| {
+ log::error!("Failed to exec {}: {error}", path);
+ test_rpc::Error::Syscall
+ })?;
+
+ let result = test_rpc::ExecResult {
+ code: output.status.code(),
+ stdout: output.stdout,
+ stderr: output.stderr,
+ };
+
+ log::debug!("Finished exec: {:?}", result.code);
+
+ Ok(result)
+ }
+
+ async fn get_os(self, _: context::Context) -> meta::Os {
+ meta::CURRENT_OS
+ }
+
+ async fn mullvad_daemon_get_status(
+ self,
+ _: context::Context,
+ ) -> test_rpc::mullvad_daemon::ServiceStatus {
+ get_pipe_status()
+ }
+
+ async fn find_mullvad_app_traces(
+ self,
+ _: context::Context,
+ ) -> Result<Vec<AppTrace>, test_rpc::Error> {
+ app::find_traces()
+ }
+
+ async fn send_tcp(
+ self,
+ _: context::Context,
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), test_rpc::Error> {
+ net::send_tcp(interface, bind_addr, destination).await
+ }
+
+ async fn send_udp(
+ self,
+ _: context::Context,
+ interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+ ) -> Result<(), test_rpc::Error> {
+ net::send_udp(interface, bind_addr, destination).await
+ }
+
+ async fn send_ping(
+ self,
+ _: context::Context,
+ interface: Option<Interface>,
+ destination: IpAddr,
+ ) -> Result<(), test_rpc::Error> {
+ net::send_ping(interface, destination).await
+ }
+
+ async fn geoip_lookup(
+ self,
+ _: context::Context,
+ mullvad_host: String,
+ ) -> Result<test_rpc::AmIMullvad, test_rpc::Error> {
+ test_rpc::net::geoip_lookup(mullvad_host).await
+ }
+
+ async fn resolve_hostname(
+ self,
+ _: context::Context,
+ hostname: String,
+ ) -> Result<Vec<SocketAddr>, test_rpc::Error> {
+ Ok(tokio::net::lookup_host(&format!("{hostname}:0"))
+ .await
+ .map_err(|error| {
+ log::debug!("resolve_hostname failed: {error}");
+ test_rpc::Error::DnsResolution
+ })?
+ .collect())
+ }
+
+ async fn get_interface_name(
+ self,
+ _: context::Context,
+ interface: Interface,
+ ) -> Result<String, test_rpc::Error> {
+ Ok(net::get_interface_name(interface).to_owned())
+ }
+
+ async fn get_interface_ip(
+ self,
+ _: context::Context,
+ interface: Interface,
+ ) -> Result<IpAddr, test_rpc::Error> {
+ net::get_interface_ip(interface)
+ }
+
+ async fn poll_output(
+ self,
+ _: context::Context,
+ ) -> Result<Vec<test_rpc::logging::Output>, test_rpc::Error> {
+ let mut listener = LOGGER.0.lock().await;
+ if let Ok(output) = listener.recv().await {
+ let mut buffer = vec![output];
+ while let Ok(output) = listener.try_recv() {
+ buffer.push(output);
+ }
+ Ok(buffer)
+ } else {
+ Err(test_rpc::Error::Logger(
+ test_rpc::logging::Error::StandardOutput,
+ ))
+ }
+ }
+
+ async fn try_poll_output(
+ self,
+ _: context::Context,
+ ) -> Result<Vec<test_rpc::logging::Output>, test_rpc::Error> {
+ let mut listener = LOGGER.0.lock().await;
+ match listener.try_recv() {
+ Ok(output) => {
+ let mut buffer = vec![output];
+ while let Ok(output) = listener.try_recv() {
+ buffer.push(output);
+ }
+ Ok(buffer)
+ }
+ Err(TryRecvError::Empty) => Ok(Vec::new()),
+ Err(_) => Err(test_rpc::Error::Logger(
+ test_rpc::logging::Error::StandardOutput,
+ )),
+ }
+ }
+
+ async fn get_mullvad_app_logs(self, _: context::Context) -> test_rpc::logging::LogOutput {
+ logging::get_mullvad_app_logs().await
+ }
+
+ async fn set_daemon_log_level(
+ self,
+ _: context::Context,
+ verbosity_level: test_rpc::mullvad_daemon::Verbosity,
+ ) -> Result<(), test_rpc::Error> {
+ sys::set_daemon_log_level(verbosity_level).await
+ }
+
+ async fn set_daemon_environment(
+ self,
+ _: context::Context,
+ env: HashMap<String, String>,
+ ) -> Result<(), test_rpc::Error> {
+ sys::set_daemon_environment(env).await
+ }
+
+ async fn copy_file(
+ self,
+ _: context::Context,
+ src: String,
+ dest: String,
+ ) -> Result<(), test_rpc::Error> {
+ tokio::fs::copy(&src, &dest).await.map_err(|error| {
+ log::error!("Failed to copy \"{src}\" to \"{dest}\": {error}");
+ test_rpc::Error::Syscall
+ })?;
+ Ok(())
+ }
+
+ async fn reboot(self, _: context::Context) -> Result<(), test_rpc::Error> {
+ sys::reboot()
+ }
+
+ async fn set_mullvad_daemon_service_state(
+ self,
+ _: context::Context,
+ on: bool,
+ ) -> Result<(), test_rpc::Error> {
+ sys::set_mullvad_daemon_service_state(on).await
+ }
+
+ async fn make_device_json_old(self, _: context::Context) -> Result<(), test_rpc::Error> {
+ app::make_device_json_old().await
+ }
+}
+
+fn get_pipe_status() -> ServiceStatus {
+ match Path::new(SOCKET_PATH).exists() {
+ true => ServiceStatus::Running,
+ false => ServiceStatus::NotRunning,
+ }
+}
+
+const BAUD: u32 = 115200;
+
+#[derive(err_derive::Error, Debug)]
+pub enum Error {
+ #[error(display = "Unknown RPC")]
+ UnknownRpc,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Error> {
+ logging::init_logger().unwrap();
+
+ let mut args = std::env::args();
+ let _ = args.next();
+ let path = args.next().expect("serial/COM path must be provided");
+
+ loop {
+ log::info!("Connecting to {}", path);
+
+ let serial_stream =
+ tokio_serial::SerialStream::open(&tokio_serial::new(&path, BAUD)).unwrap();
+ let (runner_transport, mullvad_daemon_transport, _completion_handle) =
+ test_rpc::transport::create_server_transports(serial_stream);
+
+ log::info!("Running server");
+
+ tokio::spawn(forward_to_mullvad_daemon_interface(
+ mullvad_daemon_transport,
+ ));
+
+ let server = tarpc::server::BaseChannel::with_defaults(runner_transport);
+ server.execute(TestServer(()).serve()).await;
+
+ log::error!("Restarting server since it stopped");
+ }
+}
+
+/// Forward data between the test manager and Mullvad management interface socket
+async fn forward_to_mullvad_daemon_interface(proxy_transport: GrpcForwarder) {
+ const IPC_READ_BUF_SIZE: usize = 16 * 1024;
+
+ let mut srv_read_buf = [0u8; IPC_READ_BUF_SIZE];
+ let mut proxy_transport = LengthDelimitedCodec::new().framed(proxy_transport);
+
+ loop {
+ // Wait for input from the test manager before connecting to the UDS or named pipe.
+ // Connect at the last moment since the daemon may not even be running when the
+ // test runner first starts.
+ let first_message = match proxy_transport.next().await {
+ Some(Ok(bytes)) => {
+ if bytes.is_empty() {
+ log::debug!("ignoring EOF from client");
+ continue;
+ }
+ bytes
+ }
+ Some(Err(error)) => {
+ log::error!("daemon client channel error: {error}");
+ break;
+ }
+ None => break,
+ };
+
+ log::info!("mullvad daemon: connecting");
+
+ let mut daemon_socket_endpoint =
+ match parity_tokio_ipc::Endpoint::connect(SOCKET_PATH).await {
+ Ok(uds_endpoint) => uds_endpoint,
+ Err(error) => {
+ log::error!("mullvad daemon: failed to connect: {error}");
+ // send EOF
+ let _ = proxy_transport.send(bytes::Bytes::new()).await;
+ continue;
+ }
+ };
+
+ log::info!("mullvad daemon: connected");
+
+ if let Err(error) = daemon_socket_endpoint.write_all(&first_message).await {
+ log::error!("writing to uds failed: {error}");
+ continue;
+ }
+
+ loop {
+ let srv_read = daemon_socket_endpoint.read(&mut srv_read_buf);
+ pin_mut!(srv_read);
+
+ match futures::future::select(srv_read, proxy_transport.next()).await {
+ futures::future::Either::Left((read, _)) => match read {
+ Ok(num_bytes) => {
+ if num_bytes == 0 {
+ log::debug!("uds EOF; restarting server");
+ break;
+ }
+ if let Err(error) = proxy_transport
+ .send(srv_read_buf[..num_bytes].to_vec().into())
+ .await
+ {
+ log::error!("writing to client channel failed: {error}");
+ break;
+ }
+ }
+ Err(error) => {
+ log::error!("reading from uds failed: {error}");
+ let _ = proxy_transport.send(bytes::Bytes::new()).await;
+ break;
+ }
+ },
+ futures::future::Either::Right((read, _)) => match read {
+ Some(Ok(bytes)) => {
+ if bytes.is_empty() {
+ log::debug!("management interface EOF; restarting server");
+ break;
+ }
+ if let Err(error) = daemon_socket_endpoint.write_all(&bytes).await {
+ log::error!("writing to uds failed: {error}");
+ break;
+ }
+ }
+ Some(Err(error)) => {
+ log::error!("daemon client channel error: {error}");
+ break;
+ }
+ None => break,
+ },
+ }
+ }
+
+ log::info!("mullvad daemon: disconnected");
+ }
+}
diff --git a/test/test-runner/src/net.rs b/test/test-runner/src/net.rs
new file mode 100644
index 0000000000..df2c66dae5
--- /dev/null
+++ b/test/test-runner/src/net.rs
@@ -0,0 +1,353 @@
+use socket2::SockAddr;
+#[cfg(target_os = "macos")]
+use std::{ffi::CString, num::NonZeroU32};
+use std::{
+ net::{IpAddr, SocketAddr},
+ process::Output,
+};
+use test_rpc::Interface;
+use tokio::{
+ io::AsyncWriteExt,
+ net::{TcpStream, UdpSocket},
+ process::Command,
+};
+
+#[cfg(target_os = "linux")]
+const TUNNEL_INTERFACE: &str = "wg-mullvad";
+
+#[cfg(target_os = "windows")]
+const TUNNEL_INTERFACE: &str = "Mullvad";
+
+#[cfg(target_os = "macos")]
+const TUNNEL_INTERFACE: &str = "utun3";
+
+pub async fn send_tcp(
+ bind_interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+) -> Result<(), test_rpc::Error> {
+ 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))
+ .map_err(|error| {
+ log::error!("Failed to create TCP socket: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ sock.set_nonblocking(true).map_err(|error| {
+ log::error!("Failed to set non-blocking TCP socket: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ if let Some(iface) = bind_interface {
+ let iface = get_interface_name(iface);
+
+ #[cfg(target_os = "macos")]
+ let interface_index = unsafe {
+ let name = CString::new(iface).unwrap();
+ let index = libc::if_nametoindex(name.as_bytes_with_nul().as_ptr() as _);
+ NonZeroU32::new(index).ok_or_else(|| {
+ log::error!("Invalid interface index");
+ test_rpc::Error::SendTcp
+ })?
+ };
+
+ #[cfg(target_os = "macos")]
+ sock.bind_device_by_index(Some(interface_index))
+ .map_err(|error| {
+ log::error!("Failed to set IP_BOUND_IF on socket: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ #[cfg(target_os = "linux")]
+ sock.bind_device(Some(iface.as_bytes())).map_err(|error| {
+ log::error!("Failed to bind TCP socket to {iface}: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ #[cfg(windows)]
+ log::trace!("Bind interface {iface} is ignored on Windows")
+ }
+
+ sock.bind(&SockAddr::from(bind_addr)).map_err(|error| {
+ log::error!("Failed to bind TCP socket to {bind_addr}: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ log::debug!("Connecting from {bind_addr} to {destination}/TCP");
+
+ sock.connect(&SockAddr::from(destination))
+ .map_err(|error| {
+ log::error!("Failed to connect to {destination}: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ let std_stream = std::net::TcpStream::from(sock);
+ let mut stream = TcpStream::from_std(std_stream).map_err(|error| {
+ log::error!("Failed to convert to TCP stream to tokio stream: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ stream.write_all(b"hello").await.map_err(|error| {
+ log::error!("Failed to send message to {destination}: {error}");
+ test_rpc::Error::SendTcp
+ })?;
+
+ Ok(())
+}
+
+pub async fn send_udp(
+ bind_interface: Option<Interface>,
+ bind_addr: SocketAddr,
+ destination: SocketAddr,
+) -> Result<(), test_rpc::Error> {
+ 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))
+ .map_err(|error| {
+ log::error!("Failed to create UDP socket: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ sock.set_nonblocking(true).map_err(|error| {
+ log::error!("Failed to set non-blocking UDP socket: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ if let Some(iface) = bind_interface {
+ let iface = get_interface_name(iface);
+
+ #[cfg(target_os = "macos")]
+ let interface_index = unsafe {
+ let name = CString::new(iface).unwrap();
+ let index = libc::if_nametoindex(name.as_bytes_with_nul().as_ptr() as _);
+ NonZeroU32::new(index).ok_or_else(|| {
+ log::error!("Invalid interface index");
+ test_rpc::Error::SendUdp
+ })?
+ };
+
+ #[cfg(target_os = "macos")]
+ sock.bind_device_by_index(Some(interface_index))
+ .map_err(|error| {
+ log::error!("Failed to set IP_BOUND_IF on socket: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ #[cfg(target_os = "linux")]
+ sock.bind_device(Some(iface.as_bytes())).map_err(|error| {
+ log::error!("Failed to bind UDP socket to {iface}: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ #[cfg(windows)]
+ log::trace!("Bind interface {iface} is ignored on Windows")
+ }
+
+ sock.bind(&SockAddr::from(bind_addr)).map_err(|error| {
+ log::error!("Failed to bind UDP socket to {bind_addr}: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ log::debug!("Send message from {bind_addr} to {destination}/UDP");
+
+ let std_socket = std::net::UdpSocket::from(sock);
+ let tokio_socket = UdpSocket::from_std(std_socket).map_err(|error| {
+ log::error!("Failed to convert to UDP socket to tokio socket: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ tokio_socket
+ .send_to(b"hello", destination)
+ .await
+ .map_err(|error| {
+ log::error!("Failed to send message to {destination}: {error}");
+ test_rpc::Error::SendUdp
+ })?;
+
+ Ok(())
+}
+
+pub async fn send_ping(
+ interface: Option<Interface>,
+ destination: IpAddr,
+) -> Result<(), test_rpc::Error> {
+ #[cfg(target_os = "windows")]
+ let mut source_ip = None;
+ #[cfg(target_os = "windows")]
+ if let Some(interface) = interface {
+ let family = match destination {
+ IpAddr::V4(_) => talpid_windows_net::AddressFamily::Ipv4,
+ IpAddr::V6(_) => talpid_windows_net::AddressFamily::Ipv6,
+ };
+ source_ip = get_interface_ip_for_family(interface, family)
+ .map_err(|_error| test_rpc::Error::Syscall)?;
+ if source_ip.is_none() {
+ log::error!("Failed to obtain interface IP");
+ return Err(test_rpc::Error::Ping);
+ }
+ }
+
+ let mut cmd = Command::new("ping");
+ cmd.arg(destination.to_string());
+
+ #[cfg(target_os = "windows")]
+ cmd.args(["-n", "1"]);
+
+ #[cfg(not(target_os = "windows"))]
+ cmd.args(["-c", "1"]);
+
+ match interface {
+ Some(Interface::Tunnel) => {
+ log::info!("Pinging {destination} in tunnel");
+
+ #[cfg(target_os = "windows")]
+ if let Some(source_ip) = source_ip {
+ cmd.args(["-S", &source_ip.to_string()]);
+ }
+
+ #[cfg(target_os = "windows")]
+ cmd.args(["-I", TUNNEL_INTERFACE]);
+
+ #[cfg(target_os = "macos")]
+ cmd.args(["-b", TUNNEL_INTERFACE]);
+ }
+ Some(Interface::NonTunnel) => {
+ log::info!("Pinging {destination} outside tunnel");
+
+ #[cfg(target_os = "windows")]
+ if let Some(source_ip) = source_ip {
+ cmd.args(["-S", &source_ip.to_string()]);
+ }
+
+ #[cfg(target_os = "linux")]
+ cmd.args(["-I", non_tunnel_interface()]);
+
+ #[cfg(target_os = "macos")]
+ cmd.args(["-b", non_tunnel_interface()]);
+ }
+ None => log::info!("Pinging {destination}"),
+ }
+
+ cmd.kill_on_drop(true);
+
+ cmd.spawn()
+ .map_err(|error| {
+ log::error!("Failed to spawn ping process: {error}");
+ test_rpc::Error::Ping
+ })?
+ .wait_with_output()
+ .await
+ .map_err(|error| {
+ log::error!("Failed to wait on ping: {error}");
+ test_rpc::Error::Ping
+ })
+ .and_then(|output| result_from_output("ping", output, test_rpc::Error::Ping))
+}
+
+#[cfg(unix)]
+pub fn get_interface_ip(interface: Interface) -> Result<IpAddr, test_rpc::Error> {
+ // TODO: IPv6
+ use std::net::Ipv4Addr;
+
+ let alias = get_interface_name(interface);
+
+ let addrs = nix::ifaddrs::getifaddrs().map_err(|error| {
+ log::error!("Failed to obtain interfaces: {}", error);
+ test_rpc::Error::Syscall
+ })?;
+ for addr in addrs {
+ if addr.interface_name == alias {
+ if let Some(address) = addr.address {
+ if let Some(sockaddr) = address.as_sockaddr_in() {
+ return Ok(IpAddr::V4(Ipv4Addr::from(sockaddr.ip())));
+ }
+ }
+ }
+ }
+
+ log::error!("Could not find tunnel interface");
+ Err(test_rpc::Error::InterfaceNotFound)
+}
+
+pub fn get_interface_name(interface: Interface) -> &'static str {
+ match interface {
+ Interface::Tunnel => TUNNEL_INTERFACE,
+ Interface::NonTunnel => non_tunnel_interface(),
+ }
+}
+
+#[cfg(target_os = "windows")]
+pub fn get_interface_ip(interface: Interface) -> Result<IpAddr, test_rpc::Error> {
+ // TODO: IPv6
+
+ get_interface_ip_for_family(interface, talpid_windows_net::AddressFamily::Ipv4)
+ .map_err(|_error| test_rpc::Error::Syscall)?
+ .ok_or(test_rpc::Error::InterfaceNotFound)
+}
+
+#[cfg(target_os = "windows")]
+fn get_interface_ip_for_family(
+ interface: Interface,
+ family: talpid_windows_net::AddressFamily,
+) -> Result<Option<IpAddr>, ()> {
+ let interface = match interface {
+ Interface::NonTunnel => non_tunnel_interface(),
+ Interface::Tunnel => TUNNEL_INTERFACE,
+ };
+ let interface_alias = talpid_windows_net::luid_from_alias(interface).map_err(|error| {
+ log::error!("Failed to obtain interface LUID: {error}");
+ })?;
+
+ talpid_windows_net::get_ip_address_for_interface(family, interface_alias).map_err(|error| {
+ log::error!("Failed to obtain interface IP: {error}");
+ })
+}
+
+#[cfg(target_os = "windows")]
+fn non_tunnel_interface() -> &'static str {
+ use once_cell::sync::OnceCell;
+ use talpid_platform_metadata::WindowsVersion;
+
+ static WINDOWS_VERSION: OnceCell<WindowsVersion> = OnceCell::new();
+ let version = WINDOWS_VERSION
+ .get_or_init(|| WindowsVersion::new().expect("failed to obtain Windows version"));
+
+ if version.build_number() >= 22000 {
+ // Windows 11
+ return "Ethernet";
+ }
+
+ "Ethernet Instance 0"
+}
+
+#[cfg(target_os = "linux")]
+fn non_tunnel_interface() -> &'static str {
+ "ens3"
+}
+
+#[cfg(target_os = "macos")]
+fn non_tunnel_interface() -> &'static str {
+ "en0"
+}
+
+fn result_from_output<E>(action: &'static str, output: Output, err: E) -> Result<(), E> {
+ if output.status.success() {
+ return Ok(());
+ }
+
+ let stdout_str = std::str::from_utf8(&output.stdout).unwrap_or("non-utf8 string");
+ let stderr_str = std::str::from_utf8(&output.stderr).unwrap_or("non-utf8 string");
+
+ log::error!(
+ "{action} failed:\n\ncode: {:?}\n\nstdout:\n\n{}\n\nstderr:\n\n{}",
+ output.status.code(),
+ stdout_str,
+ stderr_str
+ );
+ Err(err)
+}
diff --git a/test/test-runner/src/package.rs b/test/test-runner/src/package.rs
new file mode 100644
index 0000000000..5312da95d9
--- /dev/null
+++ b/test/test-runner/src/package.rs
@@ -0,0 +1,292 @@
+#[cfg(any(target_os = "linux", target_os = "windows"))]
+use std::path::Path;
+use std::{
+ collections::HashMap,
+ process::{Output, Stdio},
+};
+use test_rpc::package::{Error, Package, Result};
+use tokio::process::Command;
+
+#[cfg(target_os = "linux")]
+pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
+ match get_distribution()? {
+ Distribution::Debian | Distribution::Ubuntu => {
+ uninstall_dpkg("mullvad-vpn", env, true).await
+ }
+ Distribution::Fedora => uninstall_rpm("mullvad-vpn", env).await,
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
+ use tokio::io::AsyncWriteExt;
+
+ // Uninstall uses sudo -- patch sudoers to not strip env vars
+ let mut sudoers = tokio::fs::OpenOptions::new()
+ .append(true)
+ .open("/etc/sudoers")
+ .await
+ .map_err(|e| strip_error(Error::WriteFile, e))?;
+
+ for k in env.keys() {
+ sudoers
+ .write_all(format!("\nDefaults env_keep += \"{k}\"").as_bytes())
+ .await
+ .map_err(|e| strip_error(Error::WriteFile, e))?;
+ }
+ drop(sudoers);
+
+ // Run uninstall script, answer yes to everything
+ let mut cmd = Command::new("zsh");
+ cmd.arg("-c");
+ cmd.arg(
+ "\"/Applications/Mullvad VPN.app/Contents/Resources/uninstall.sh\" << EOF
+y
+y
+y
+EOF",
+ );
+ cmd.envs(env);
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("uninstall.sh", output))
+}
+
+#[cfg(target_os = "windows")]
+pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
+ // TODO: obtain from registry
+ // TODO: can this mimic an actual uninstall more closely?
+
+ let program_dir = Path::new(r"C:\Program Files\Mullvad VPN");
+ let uninstall_path = program_dir.join("Uninstall Mullvad VPN.exe");
+
+ // To wait for the uninstaller, we must copy it to a temporary directory and
+ // supply it with the install path.
+
+ let temp_uninstaller = std::env::temp_dir().join("mullvad_uninstall.exe");
+ tokio::fs::copy(uninstall_path, &temp_uninstaller)
+ .await
+ .map_err(|e| strip_error(Error::CreateTempUninstaller, e))?;
+
+ let mut cmd = Command::new(temp_uninstaller);
+
+ cmd.kill_on_drop(true);
+ cmd.arg("/allusers");
+ // Silent mode
+ cmd.arg("/S");
+ // NSIS doesn't understand that it shouldn't fork itself unless
+ // there's whitespace prepended to "_?=".
+ cmd.arg(format!(" _?={}", program_dir.display()));
+ cmd.envs(env);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("uninstall app", output))
+}
+
+#[cfg(target_os = "windows")]
+pub async fn install_package(package: Package) -> Result<()> {
+ install_nsis_exe(&package.path).await
+}
+
+#[cfg(target_os = "linux")]
+pub async fn install_package(package: Package) -> Result<()> {
+ match get_distribution()? {
+ Distribution::Debian | Distribution::Ubuntu => install_dpkg(&package.path).await,
+ Distribution::Fedora => install_rpm(&package.path).await,
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub async fn install_package(package: Package) -> Result<()> {
+ let mut cmd = Command::new("/usr/sbin/installer");
+ cmd.arg("-pkg");
+ cmd.arg(package.path);
+ cmd.arg("-target");
+ cmd.arg("/");
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("installer -pkg", output))
+}
+
+#[cfg(target_os = "linux")]
+async fn install_dpkg(path: &Path) -> Result<()> {
+ let mut cmd = Command::new("/usr/bin/dpkg");
+ cmd.arg("-i");
+ cmd.arg(path.as_os_str());
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("dpkg -i", output))
+}
+
+#[cfg(target_os = "linux")]
+async fn uninstall_dpkg(name: &str, env: HashMap<String, String>, purge: bool) -> Result<()> {
+ let action;
+ let mut cmd = Command::new("/usr/bin/dpkg");
+ if purge {
+ action = "dpkg --purge";
+ cmd.args(["--purge", name]);
+ } else {
+ action = "dpkg -r";
+ cmd.args(["-r", name]);
+ }
+ cmd.envs(env);
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output(action, output))
+}
+
+#[cfg(target_os = "linux")]
+async fn install_rpm(path: &Path) -> Result<()> {
+ use std::time::Duration;
+
+ const MAX_INSTALL_ATTEMPTS: usize = 5;
+ const RETRY_SUBSTRING: &[u8] = b"Failed to download";
+ const RETRY_WAIT_INTERVAL: Duration = Duration::from_secs(3);
+
+ let mut cmd = Command::new("/usr/bin/dnf");
+ cmd.args(["install", "-y"]);
+ cmd.arg(path.as_os_str());
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+
+ let mut attempt = 0;
+ let mut output;
+
+ loop {
+ output = cmd
+ .spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))?;
+
+ let should_retry = !output.status.success()
+ && output
+ .stderr
+ .windows(RETRY_SUBSTRING.len())
+ .any(|slice| slice == RETRY_SUBSTRING);
+ attempt += 1;
+ if should_retry && attempt < MAX_INSTALL_ATTEMPTS {
+ log::debug!("Retrying package install: retry attempt {}", attempt);
+ tokio::time::sleep(RETRY_WAIT_INTERVAL).await;
+ continue;
+ }
+
+ return result_from_output("dnf install", output);
+ }
+}
+
+#[cfg(target_os = "linux")]
+async fn uninstall_rpm(name: &str, env: HashMap<String, String>) -> Result<()> {
+ let mut cmd = Command::new("/usr/bin/dnf");
+ cmd.args(["remove", "-y", name]);
+ cmd.envs(env);
+ cmd.kill_on_drop(true);
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("dnf remove", output))
+}
+
+#[cfg(target_os = "windows")]
+async fn install_nsis_exe(path: &Path) -> Result<()> {
+ log::info!("Installing {}", path.display());
+ let mut cmd = Command::new(path);
+
+ cmd.kill_on_drop(true);
+
+ // Run the installer in silent mode
+ cmd.arg("/S");
+
+ cmd.spawn()
+ .map_err(|e| strip_error(Error::RunApp, e))?
+ .wait_with_output()
+ .await
+ .map_err(|e| strip_error(Error::RunApp, e))
+ .and_then(|output| result_from_output("install app", output))
+}
+
+#[cfg(target_os = "linux")]
+enum Distribution {
+ Debian,
+ Ubuntu,
+ Fedora,
+}
+
+#[cfg(target_os = "linux")]
+fn get_distribution() -> Result<Distribution> {
+ let os_release =
+ rs_release::get_os_release().map_err(|_error| Error::UnknownOs("unknown".to_string()))?;
+ match os_release
+ .get("id")
+ .or(os_release.get("ID"))
+ .ok_or(Error::UnknownOs("unknown".to_string()))?
+ .as_str()
+ {
+ "debian" => Ok(Distribution::Debian),
+ "ubuntu" => Ok(Distribution::Ubuntu),
+ "fedora" => Ok(Distribution::Fedora),
+ os => Err(Error::UnknownOs(os.to_string())),
+ }
+}
+
+fn strip_error<T: std::error::Error>(error: Error, source: T) -> Error {
+ log::error!("Error: {error}\ncause: {source}");
+ error
+}
+
+fn result_from_output(action: &'static str, output: Output) -> Result<()> {
+ if output.status.success() {
+ return Ok(());
+ }
+
+ let stdout_str = std::str::from_utf8(&output.stdout).unwrap_or("non-utf8 string");
+ let stderr_str = std::str::from_utf8(&output.stderr).unwrap_or("non-utf8 string");
+
+ log::error!(
+ "{action} failed:\n\nstdout:\n\n{}\n\nstderr:\n\n{}",
+ stdout_str,
+ stderr_str
+ );
+
+ Err(output
+ .status
+ .code()
+ .map(Error::InstallerFailed)
+ .unwrap_or(Error::InstallerFailedSignal))
+}
diff --git a/test/test-runner/src/sys.rs b/test/test-runner/src/sys.rs
new file mode 100644
index 0000000000..93d148a2b5
--- /dev/null
+++ b/test/test-runner/src/sys.rs
@@ -0,0 +1,531 @@
+use std::collections::HashMap;
+#[cfg(target_os = "windows")]
+use std::io;
+use test_rpc::mullvad_daemon::Verbosity;
+
+#[cfg(target_os = "windows")]
+use std::ffi::OsString;
+#[cfg(target_os = "windows")]
+use windows_service::{
+ service::{ServiceAccess, ServiceInfo},
+ service_manager::{ServiceManager, ServiceManagerAccess},
+};
+
+#[cfg(target_os = "windows")]
+pub fn reboot() -> Result<(), test_rpc::Error> {
+ use windows_sys::Win32::System::Shutdown::{
+ ExitWindowsEx, EWX_REBOOT, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_APPLICATION,
+ SHTDN_REASON_MINOR_OTHER,
+ };
+ use windows_sys::Win32::UI::WindowsAndMessaging::EWX_FORCEIFHUNG;
+
+ grant_shutdown_privilege()?;
+
+ std::thread::spawn(|| {
+ std::thread::sleep(std::time::Duration::from_secs(5));
+
+ let shutdown_result = unsafe {
+ ExitWindowsEx(
+ EWX_FORCEIFHUNG | EWX_REBOOT,
+ SHTDN_REASON_MAJOR_APPLICATION
+ | SHTDN_REASON_MINOR_OTHER
+ | SHTDN_REASON_FLAG_PLANNED,
+ )
+ };
+
+ if shutdown_result == 0 {
+ log::error!(
+ "Failed to restart test machine: {}",
+ io::Error::last_os_error()
+ );
+ std::process::exit(1);
+ }
+
+ std::process::exit(0);
+ });
+
+ // NOTE: We do not bother to revoke the privilege.
+
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+fn grant_shutdown_privilege() -> Result<(), test_rpc::Error> {
+ use windows_sys::Win32::Foundation::CloseHandle;
+ use windows_sys::Win32::Foundation::HANDLE;
+ use windows_sys::Win32::Foundation::LUID;
+ use windows_sys::Win32::Security::AdjustTokenPrivileges;
+ use windows_sys::Win32::Security::LookupPrivilegeValueW;
+ use windows_sys::Win32::Security::LUID_AND_ATTRIBUTES;
+ use windows_sys::Win32::Security::SE_PRIVILEGE_ENABLED;
+ use windows_sys::Win32::Security::TOKEN_ADJUST_PRIVILEGES;
+ use windows_sys::Win32::Security::TOKEN_PRIVILEGES;
+ use windows_sys::Win32::System::SystemServices::SE_SHUTDOWN_NAME;
+ use windows_sys::Win32::System::Threading::GetCurrentProcess;
+ use windows_sys::Win32::System::Threading::OpenProcessToken;
+
+ let mut privileges = TOKEN_PRIVILEGES {
+ PrivilegeCount: 1,
+ Privileges: [LUID_AND_ATTRIBUTES {
+ Luid: LUID {
+ HighPart: 0,
+ LowPart: 0,
+ },
+ Attributes: SE_PRIVILEGE_ENABLED,
+ }],
+ };
+
+ if unsafe {
+ LookupPrivilegeValueW(
+ std::ptr::null(),
+ SE_SHUTDOWN_NAME,
+ &mut privileges.Privileges[0].Luid,
+ )
+ } == 0
+ {
+ log::error!(
+ "Failed to lookup shutdown privilege LUID: {}",
+ io::Error::last_os_error()
+ );
+ return Err(test_rpc::Error::Syscall);
+ }
+
+ let mut token_handle: HANDLE = 0;
+
+ if unsafe {
+ OpenProcessToken(
+ GetCurrentProcess(),
+ TOKEN_ADJUST_PRIVILEGES,
+ &mut token_handle,
+ )
+ } == 0
+ {
+ log::error!("OpenProcessToken() failed: {}", io::Error::last_os_error());
+ return Err(test_rpc::Error::Syscall);
+ }
+
+ let result = unsafe {
+ AdjustTokenPrivileges(
+ token_handle,
+ 0,
+ &privileges,
+ 0,
+ std::ptr::null_mut(),
+ std::ptr::null_mut(),
+ )
+ };
+
+ unsafe { CloseHandle(token_handle) };
+
+ if result == 0 {
+ log::error!(
+ "Failed to enable SE_SHUTDOWN_NAME: {}",
+ io::Error::last_os_error()
+ );
+ return Err(test_rpc::Error::Syscall);
+ }
+
+ Ok(())
+}
+
+#[cfg(unix)]
+pub fn reboot() -> Result<(), test_rpc::Error> {
+ log::debug!("Rebooting system");
+
+ std::thread::spawn(|| {
+ #[cfg(target_os = "linux")]
+ let mut cmd = std::process::Command::new("/usr/sbin/shutdown");
+ #[cfg(target_os = "macos")]
+ let mut cmd = std::process::Command::new("/sbin/shutdown");
+ cmd.args(["-r", "now"]);
+
+ std::thread::sleep(std::time::Duration::from_secs(5));
+
+ let _ = cmd.spawn().map_err(|error| {
+ log::error!("Failed to spawn shutdown command: {error}");
+ error
+ });
+ });
+
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> {
+ use tokio::io::AsyncWriteExt;
+ const SYSTEMD_OVERRIDE_FILE: &str =
+ "/etc/systemd/system/mullvad-daemon.service.d/override.conf";
+
+ 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::Service(e.to_string()))?;
+ }
+
+ let mut file = tokio::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .open(override_path)
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ file.write_all(systemd_service_file_content.as_bytes())
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ tokio::process::Command::new("systemctl")
+ .args(["daemon-reload"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ tokio::process::Command::new("systemctl")
+ .args(["restart", "mullvad-daemon"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ wait_for_service_state(ServiceState::Running).await?;
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+pub async fn set_daemon_log_level(verbosity_level: Verbosity) -> Result<(), test_rpc::Error> {
+ 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::Service(e.to_string()))?;
+ let service = manager
+ .open_service(
+ "mullvadvpn",
+ ServiceAccess::QUERY_CONFIG
+ | ServiceAccess::CHANGE_CONFIG
+ | ServiceAccess::START
+ | ServiceAccess::STOP,
+ )
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ // Stop the service
+ service
+ .stop()
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ tokio::process::Command::new("net")
+ .args(["stop", "mullvadvpn"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ // Get the current service configuration
+ let config = service
+ .query_config()
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ 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::Service(e.to_string()))?;
+
+ // Start the service
+ service
+ .start::<String>(&[])
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+pub async fn set_daemon_log_level(_verbosity_level: Verbosity) -> Result<(), test_rpc::Error> {
+ // TODO: Not implemented
+ log::warn!("Setting log level is not implemented on macOS");
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+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();
+ }
+
+ 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::Service(e.to_string()))?;
+ }
+
+ let mut file = tokio::fs::OpenOptions::new()
+ .create(true)
+ .write(true)
+ .open(override_path)
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ file.write_all(override_content.as_bytes())
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ tokio::process::Command::new("systemctl")
+ .args(["daemon-reload"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ tokio::process::Command::new("systemctl")
+ .args(["restart", "mullvad-daemon"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ wait_for_service_state(ServiceState::Running).await?;
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> {
+ // Set environment globally (not for service) to prevent it from being lost on upgrade
+ for (k, v) in env {
+ tokio::process::Command::new("setx")
+ .arg("/m")
+ .args([k, v])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Registry(e.to_string()))?;
+ }
+
+ // Restart service
+ tokio::process::Command::new("net")
+ .args(["stop", "mullvadvpn"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ tokio::process::Command::new("net")
+ .args(["start", "mullvadvpn"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+pub fn get_system_path_var() -> Result<String, test_rpc::Error> {
+ use winreg::enums::*;
+ use winreg::*;
+
+ let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
+ let key = hklm
+ .open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
+ .map_err(|error| {
+ test_rpc::Error::Registry(format!("Failed to open environment subkey: {}", error))
+ })?;
+
+ let path: String = key
+ .get_value("Path")
+ .map_err(|error| test_rpc::Error::Registry(format!("Failed to get PATH: {}", error)))?;
+
+ Ok(path)
+}
+
+#[cfg(target_os = "macos")]
+pub async fn set_daemon_environment(env: HashMap<String, String>) -> Result<(), test_rpc::Error> {
+ 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)
+ .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")
+ .arg("setenv")
+ .args([&k, &v])
+ .status()
+ .map_err(|e| test_rpc::Error::Service(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::Service("plist missing dict".to_owned()))?
+ .insert(
+ "EnvironmentVariables".to_owned(),
+ plist::Value::Dictionary(vars),
+ );
+
+ let daemon_plist = std::fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(PLIST_PATH)
+ .map_err(|e| test_rpc::Error::Service(format!("failed to open plist: {e}")))?;
+
+ parsed_plist
+ .to_writer_xml(daemon_plist)
+ .map_err(|e| test_rpc::Error::Service(format!("failed to replace plist: {e}")))?;
+
+ Ok::<(), test_rpc::Error>(())
+ })
+ .await
+ .unwrap()?;
+
+ // Restart service
+ set_launch_daemon_state(false).await?;
+ tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
+ set_launch_daemon_state(true).await?;
+ tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+pub async fn set_mullvad_daemon_service_state(on: bool) -> Result<(), test_rpc::Error> {
+ if on {
+ tokio::process::Command::new("systemctl")
+ .args(["start", "mullvad-daemon"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ wait_for_service_state(ServiceState::Running).await?;
+ } else {
+ tokio::process::Command::new("systemctl")
+ .args(["stop", "mullvad-daemon"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ wait_for_service_state(ServiceState::Inactive).await?;
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+pub async fn set_mullvad_daemon_service_state(on: bool) -> Result<(), test_rpc::Error> {
+ if on {
+ tokio::process::Command::new("net")
+ .args(["start", "mullvadvpn"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ } else {
+ tokio::process::Command::new("net")
+ .args(["stop", "mullvadvpn"])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+pub async fn set_mullvad_daemon_service_state(on: bool) -> Result<(), test_rpc::Error> {
+ set_launch_daemon_state(on).await?;
+ tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+async fn set_launch_daemon_state(on: bool) -> Result<(), test_rpc::Error> {
+ tokio::process::Command::new("launchctl")
+ .args([
+ if on { "load" } else { "unload" },
+ "-w",
+ "/Library/LaunchDaemons/net.mullvad.daemon.plist",
+ ])
+ .status()
+ .await
+ .map_err(|e| test_rpc::Error::Service(e.to_string()))?;
+ Ok(())
+}
+
+#[cfg(target_os = "linux")]
+enum ServiceState {
+ Running,
+ Inactive,
+}
+
+#[cfg(target_os = "linux")]
+async fn wait_for_service_state(awaited_state: ServiceState) -> Result<(), test_rpc::Error> {
+ const RETRY_ATTEMPTS: usize = 10;
+ let mut attempt = 0;
+ loop {
+ attempt += 1;
+ if attempt > RETRY_ATTEMPTS {
+ return Err(test_rpc::Error::Service(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::Service(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(())
+}