diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml new file mode 100644 index 000000000000..b28e70232df1 --- /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 f4ba78eaebaf..d13dd526cc4f 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 e2e6ccbd2bcb..6dc474227b94 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 { 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 000000000000..599d9caa35f4 --- /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 000000000000..8d9cb336ecb5 --- /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 000000000000..fbe249e656eb --- /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 -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 -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 000000000000..1b2e195e9a96 --- /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 000000000000..6eac149434af --- /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 000000000000..59aa5bb7764b --- /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 000000000000..c39785e9a9c5 --- /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 \ + --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 \ + --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 000000000000..8832d931ab69 --- /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 000000000000..95e834030991 --- /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 000000000000..9555fde9d143 --- /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 000000000000..851cb353e202 --- /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 000000000000..1cb5e6bb263a --- /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 000000000000..4aefcfdeed9d --- /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 + + + + + Label + net.mullvad.testrunner + + ProgramArguments + + $RUNNER_DIR/test-runner + /dev/tty.virtio + serve + + + UserName + root + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/runner.out + + StandardErrorPath + /tmp/runner.err + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/sbin + + + +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 000000000000..9e319bdf33be --- /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 000000000000..8e0da313753b --- /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` + +```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 000000000000..07be8b9677c0 --- /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 000000000000..7145dca8a5e3 --- /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, + pub mullvad_host: Option, +} + +#[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>(path: P) -> Result { + 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>(path: P) -> Result { + let data = tokio::fs::read(path).await.map_err(Error::Read)?; + serde_json::from_slice(&data).map_err(Error::InvalidConfig) + } + + async fn save>(&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>(path: P) -> Result { + 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, + + /// CPU architecture + #[arg(long, required_if_eq("os_type", "linux"))] + pub architecture: Option, + + /// 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, + + /// Password to use for SSH + #[arg(long, required_if_eq("provisioner", "ssh"))] + pub ssh_password: Option, + + /// Additional disk images to mount/include + #[arg(long)] + pub disks: Vec, + + /// Where artifacts, such as app packages, are stored. + /// Usually /opt/testing on Linux. + #[arg(long)] + pub artifacts_dir: Option, + + /// 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 000000000000..84b80282c212 --- /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) { + 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 000000000000..c11fdf28bfbc --- /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>, +} + +struct LoggerInner { + env_logger: env_logger::Logger, + buffer: bool, + stored_records: Vec, +} + +struct StoredRecord { + level: log::Level, + time: chrono::DateTime, + mod_path: String, + text: String, +} + +impl Logger { + pub fn get_or_init() -> Self { + static LOGGER: once_cell::sync::Lazy = 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, + pub test_name: &'static str, + pub result: Result, PanicMessage>, + pub log_output: Option, +} + +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!(""); + } 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) -> PanicMessage { + if let Some(result) = error.downcast_ref::() { + 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 000000000000..37a46c2580cb --- /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, + + /// 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, + + /// 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, + + /// Print results live + #[arg(long, short)] + verbose: bool, + + /// Output test results in a structured format. + #[arg(long)] + test_report: Option, + }, + + /// 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, + }, + + /// 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 { + 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 000000000000..2bfff38ddeba --- /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, +} + +impl Service for DummyService { + type Response = DuplexStream; + type Error = std::io::Error; + type Future = BoxFuture<'static, Result>; + + fn poll_ready( + &mut self, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + 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 { + 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 000000000000..02c1e24d9ec8 --- /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; + + 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 { + 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 { + 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>, + stop_tx: oneshot::Sender<()>, +} + +pub struct MonitorResult { + pub packets: Vec, + pub discarded_packets: usize, +} + +impl PacketMonitor { + /// Stop monitoring and return the result. + pub async fn into_result(self) -> Result { + 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 { + self.handle.await.expect("monitor panicked") + } +} + +#[derive(Default)] +pub struct MonitorOptions { + pub timeout: Option, + pub direction: Option, + 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 + Unpin, O>( + context: &mut std::task::Context<'_>, + fut: &mut F, + tx: &mut Option>, +) -> std::task::Poll { + 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 000000000000..3f35163e5e7d --- /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 = + 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 { + let package_type = (config.os_type, config.package_type, config.architecture); + + let current_app_path = find_app(¤t_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, Option), +) -> Result { + // 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, Option)) -> &'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, Option)) -> &'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 000000000000..f0ff40203404 --- /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, +) -> 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::().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::() { + 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( + 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>, +{ + 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 000000000000..cad11aca83cb --- /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 { + 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 { + 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, +} + +impl Summary { + /// Read test summary from `path`. + pub async fn parse_log>(path: P) -> Result { + 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>(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::().collect(); + + // Add some styling to the summary. + println!(" "); + + // Print a table + println!(""); + + // First row: Print summary names + println!(""); + println!(""); + + 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!( + "", + summary.name, counter_text + ); + } + + // A summary of all OSes + println!(""); + + // List all tests again + println!(""); + + println!(""); + + // Remaining rows: Print results for each test and each summary + for test in &tests { + println!(""); + + println!( + "", + 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!("", 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!(""); + + // List the test name again (Useful for the summary accross the different platforms) + println!("", test.name); + + // End row + println!(""); + } + + println!("
Test ⬇️ / Platform ➡️ {} {}"); + 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!("Test ⬇️
{}{}{}"); + print!( + "{}", + if failed_platforms.is_empty() { + TestResult::PASS_STR.to_string() + } else { + failed_platforms.join(", ") + } + ); + println!("{}
"); + + // Print explanation of test result + println!("

{} = Test passed

", TestResult::PASS_STR); + println!("

{} = Test failed

", 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 000000000000..5b1991abb5e0 --- /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, 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>, + T, +>( + new_attempt: impl Fn() -> F, +) -> Result { + 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 000000000000..a0a22368ddc1 --- /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); + +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 000000000000..e87da24db0ad --- /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, +>( + 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, + 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 000000000000..daef7832477a --- /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 { + 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, + destination: SocketAddr, +) -> Result { + 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, +) -> 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 { + 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> + Unpin, + accept_state_fn: impl Fn(&mullvad_types::states::TunnelState) -> bool, +) -> Result { + 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> + Unpin, + accept_state_fn: impl Fn(&mullvad_types::states::TunnelState) -> bool, +) -> Result { + 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 { + 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(pub tokio::task::JoinHandle); + +impl Drop for AbortOnDrop { + 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( + 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( + mullvad_client: &mut ManagementServiceClient, + criteria: Filter, +) -> Result, 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 { + 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> { + 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 { + 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 000000000000..abd105d4dd1a --- /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 { + 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 000000000000..e96e4ad0bacd --- /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, + ) -> 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 = 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 000000000000..4c5808f79042 --- /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 000000000000..39d802e5e09e --- /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, + 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 000000000000..c74518d45b3b --- /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); 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::().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::().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 000000000000..55b1d2074ed2 --- /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::().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 000000000000..1e97430061fa --- /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 + Debug>( + rpc: &ServiceClient, + params: &[T], +) -> Result { + let env: [(&str, T); 0] = []; + run_test_env(rpc, params, env).await +} + +pub async fn run_test_env< + I: IntoIterator + Debug, + K: AsRef + Debug, + T: AsRef + Debug, +>( + rpc: &ServiceClient, + params: &[T], + env: I, +) -> Result { + let new_params: Vec; + 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 = 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 000000000000..98223f01d5a1 --- /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(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 000000000000..a5c794a58a25 --- /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> { + 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 { + 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 { + 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 000000000000..ae4d708c012c --- /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 = + 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 = std::result::Result; + +// 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 { + 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 { + 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 { + // 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(args: I) -> Result<()> +where + I: IntoIterator, + S: AsRef, +{ + 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 000000000000..5e4bdae78680 --- /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 { + 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 000000000000..e5db39a42a9e --- /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 000000000000..b3b39a2c18ee --- /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 { + 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 { + 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( + 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 { + 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 000000000000..88f7f95430fb --- /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), + #[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 = std::result::Result; + +pub struct QemuInstance { + pub pty_path: String, + pub ip_addr: IpAddr, + child: Child, + _network_handle: network::linux::NetworkHandle, + _ovmf_handle: Option, + _tpm_emulator: Option, +} + +#[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 { + 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 { + 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>, + sock_path: PathBuf, +} + +impl TpmEmulator { + pub async fn run() -> Result { + 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 { + 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 000000000000..008045fc2b06 --- /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 { + // 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 { + 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 000000000000..1df55845ed6d --- /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, +} + +#[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 { + 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 { + 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 000000000000..f2ffef00518a --- /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), + Nothing, +} + +/// Update system packages in a VM. +/// +/// Note that this function is blocking. +pub fn packages(config: &VmConfig, guest_ip: std::net::IpAddr) -> Result { + 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> = 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 000000000000..b6eb610f21f3 --- /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 { + 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 000000000000..7883deaeccce --- /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 000000000000..b82b796eba55 --- /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,| + { + 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| { + 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, + cleanup: bool, + always_run: bool, + must_succeed: bool, +} + +enum MullvadClient { + None { + mullvad_client_version: proc_macro2::TokenStream, + }, + New { + mullvad_client_type: Box, + 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, +) -> 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 000000000000..2814088bf725 --- /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 000000000000..387d0a2435d1 --- /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, + tarpc::ClientMessage, + >, + ) -> 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) -> 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, + M: IntoIterator, + T: AsRef, + K: AsRef, + >( + &self, + path: T, + args: I, + env: M, + ) -> Result { + 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, T: AsRef>( + &self, + path: T, + args: I, + ) -> Result { + 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, 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, Error> { + self.client + .try_poll_output(tarpc::context::current()) + .await? + } + + pub async fn get_mullvad_app_logs(&self) -> Result { + 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 { + 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 { + 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 { + 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, Error> { + self.client + .find_mullvad_app_traces(tarpc::context::current()) + .await? + } + + /// Send TCP packet + pub async fn send_tcp( + &self, + interface: Option, + 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, + 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, + 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 { + 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 { + 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 { + self.client + .get_interface_ip(tarpc::context::current(), interface) + .await? + } + + pub async fn resolve_hostname(&self, hostname: String) -> Result, 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) -> 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 000000000000..2fd4411f4940 --- /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, + pub stdout: Vec, + pub stderr: Vec, +} + +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) -> Result<(), Error>; + + /// Execute a program. + async fn exec( + path: String, + args: Vec, + env: BTreeMap, + ) -> Result; + + /// 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, 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, 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, Error>; + + /// Send TCP packet + async fn send_tcp( + interface: Option, + bind_addr: SocketAddr, + destination: SocketAddr, + ) -> Result<(), Error>; + + /// Send UDP packet + async fn send_udp( + interface: Option, + bind_addr: SocketAddr, + destination: SocketAddr, + ) -> Result<(), Error>; + + /// Send ICMP + async fn send_ping(interface: Option, destination: IpAddr) -> Result<(), Error>; + + /// Fetch the current location. + async fn geoip_lookup(mullvad_host: String) -> Result; + + /// Returns the name of the given interface. + async fn get_interface_name(interface: Interface) -> Result; + + /// Returns the IP of the given interface. + async fn get_interface_ip(interface: Interface) -> Result; + + /// Perform DNS resolution. + async fn resolve_hostname(hostname: String) -> Result, 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) -> 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 000000000000..85f25c8060b4 --- /dev/null +++ b/test/test-rpc/src/logging.rs @@ -0,0 +1,43 @@ +use colored::Colorize; +use serde::{Deserialize, Serialize}; + +pub type Result = std::result::Result; + +#[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, + pub log_files: Result>>, +} + +#[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 000000000000..67c87738e03c --- /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 000000000000..10cc00c3fc96 --- /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 = std::result::Result; + +#[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 000000000000..b4e114ea47d1 --- /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 = 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 { + 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(url: Uri) -> Result { + 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 000000000000..89d6dce495e4 --- /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 = std::result::Result; + +#[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 000000000000..6c8b7a7060ce --- /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::(); +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 for FrameType { + type Error = (); + + fn try_from(value: u8) -> Result { + 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>>, + // True if the connection has received an initial "handshake" frame from the other end. + is_connected: Arc, + reset_notify: Arc, +} + +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 { + self.is_connected.clone() + } + + fn new_connected_state(initial: bool) -> Arc { + Arc::new(AtomicBool::new(initial)) + } +} + +pub fn create_server_transports( + serial_stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static, +) -> ( + tarpc::transport::channel::UnboundedChannel< + ClientMessage, + Response, + >, + 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, + ClientMessage, + >, + 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, + mullvad_daemon_forwarder: GrpcForwarder, + mut handshaker: (mpsc::UnboundedSender<()>, mpsc::UnboundedReceiver<()>), + handshake_fwd: Option>, + connected_state: Arc, +) -> 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, +} + +impl MultiplexCodec { + fn new(has_connected: Arc) -> 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 { + 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, + 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, 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, Self::Error> { + self.decode_inner(src) + } +} + +impl Encoder 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 000000000000..4a185692713a --- /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 000000000000..43aca23abb68 --- /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, 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, 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, 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 = 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 000000000000..3c9aad9d15c6 --- /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 = Lazy::new(|| { + let (sender, listener) = channel(MAX_OUTPUT_BUFFER); + StdOutBuffer(Mutex::new(listener), sender) +}); + +pub struct StdOutBuffer(pub Mutex>, pub Sender); + +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 { + 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>, 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>(log_dir: T) -> Result, 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 000000000000..8d7991d6fa29 --- /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, + ) -> 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, + env: BTreeMap, + ) -> Result { + 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, test_rpc::Error> { + app::find_traces() + } + + async fn send_tcp( + self, + _: context::Context, + interface: Option, + 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, + 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, + 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::net::geoip_lookup(mullvad_host).await + } + + async fn resolve_hostname( + self, + _: context::Context, + hostname: String, + ) -> Result, 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 { + Ok(net::get_interface_name(interface).to_owned()) + } + + async fn get_interface_ip( + self, + _: context::Context, + interface: Interface, + ) -> Result { + net::get_interface_ip(interface) + } + + async fn poll_output( + self, + _: context::Context, + ) -> Result, 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, 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, + ) -> 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 000000000000..df2c66dae578 --- /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, + 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, + 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, + 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 { + // 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 { + // 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, ()> { + 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 = 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(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 000000000000..5312da95d956 --- /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) -> 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) -> 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) -> 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, 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) -> 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 { + 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(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 000000000000..93d148a2b5c5 --- /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::(&[]) + .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) -> 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) -> 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 { + 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) -> 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(()) +}