diff --git a/.github/workflows/android-app.yml b/.github/workflows/android-app.yml index 962b811cc69b..42d4e04a5ef6 100644 --- a/.github/workflows/android-app.yml +++ b/.github/workflows/android-app.yml @@ -59,9 +59,6 @@ on: permissions: {} -env: - E2E_TEST_INFRA_FLAVOR: ${{ github.event.inputs.e2e_tests_infra_flavor || 'stagemole' }} - jobs: prepare: name: Prepare @@ -79,8 +76,18 @@ jobs: if: ${{ github.event.inputs.override_container_image == '' }} run: | echo "inner_container_image=$(cat ./building/android-container-image.txt)" >> $GITHUB_ENV + + # Preparing variables this way instead of using `env.*` due to: + # https://github.com/orgs/community/discussions/26388 + - name: Prepare environment variables + run: | + echo "INNER_E2E_TEST_INFRA_FLAVOR=${{ github.event.inputs.e2e_tests_infra_flavor || 'stagemole' }}" \ + >> $GITHUB_ENV + echo "INNER_E2E_TEST_REPEAT=${{ github.event.inputs.e2e_test_repeat || 0 }}" >> $GITHUB_ENV outputs: container_image: ${{ env.inner_container_image }} + E2E_TEST_INFRA_FLAVOR: ${{ env.INNER_E2E_TEST_INFRA_FLAVOR }} + E2E_TEST_REPEAT: ${{ env.INNER_E2E_TEST_REPEAT }} build-native: name: Build native # Used by wait for jobs. @@ -113,7 +120,7 @@ jobs: - name: Checkout wireguard-go-rs recursively run: | git config --global --add safe.directory '*' - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Calculate native lib cache hash id: native-lib-cache-hash @@ -272,7 +279,8 @@ jobs: - name: Build stagemole app uses: burrunan/gradle-cache-action@v1 if: > - (github.event.inputs.e2e_test_repeat != '0' && env.E2E_TEST_INFRA_FLAVOR == 'stagemole') || + (needs.prepare.outputs.E2E_TEST_REPEAT != '0' && + needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole') || github.event.inputs.run_firebase_tests == 'true' with: job-id: jdk17 @@ -367,7 +375,7 @@ jobs: test-repeat: 1 - test-type: mockapi path: android/test/mockapi/build/outputs/apk - test-repeat: ${{ github.event_name == 'schedule' && 10 || github.event.inputs.mockapi_test_repeat || 1 }} + test-repeat: ${{ github.event_name == 'schedule' && 100 || github.event.inputs.mockapi_test_repeat || 1 }} steps: - name: Prepare report dir if: ${{ matrix.test-repeat != 0 }} @@ -425,16 +433,26 @@ jobs: instrumented-e2e-tests: name: Run instrumented e2e tests # Temporary workaround for targeting the runner android-runner-v1 - runs-on: [self-hosted, android-device, android-emulator] + runs-on: [self-hosted, android-device] + needs: [prepare, build-app, build-instrumented-tests] if: > github.event_name == 'schedule' || - (github.event.inputs.e2e_test_repeat != '0' && github.event_name != 'pull_request') - needs: [build-app, build-instrumented-tests] + (github.event_name != 'pull_request' && needs.prepare.outputs.E2E_TEST_REPEAT != '0') strategy: matrix: include: - - test-repeat: ${{ github.event.inputs.e2e_test_repeat || 1 }} + - test-repeat: ${{ needs.prepare.outputs.E2E_TEST_REPEAT || 1 }} steps: + - name: Resolve unique runner test account secret name + if: needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod' + run: | + echo "RUNNER_SECRET_NAME=ANDROID_PROD_TEST_ACCOUNT_$(echo $RUNNER_NAME | tr '[:lower:]-' '[:upper:]_')" \ + >> $GITHUB_ENV + + - name: Resolve runner test account + if: needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod' + run: echo "RESOLVED_TEST_ACCOUNT=${{ secrets[env.RUNNER_SECRET_NAME] }}" >> $GITHUB_ENV + - name: Prepare report dir if: ${{ matrix.test-repeat != 0 }} id: prepare-report-dir @@ -474,12 +492,11 @@ jobs: env: AUTO_FETCH_TEST_HELPER_APKS: true TEST_TYPE: e2e - BILLING_FLAVOR: ${{ env.E2E_TEST_INFRA_FLAVOR == 'prod' && 'oss' || 'play' }} - INFRA_FLAVOR: ${{ env.E2E_TEST_INFRA_FLAVOR }} + BILLING_FLAVOR: ${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod' && 'oss' || 'play' }} + INFRA_FLAVOR: "${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR }}" PARTNER_AUTH: |- - ${{ env.E2E_TEST_INFRA_FLAVOR == 'stagemole' && secrets.STAGEMOLE_PARTNER_AUTH || '' }} - VALID_TEST_ACCOUNT_NUMBER: |- - ${{ env.E2E_TEST_INFRA_FLAVOR == 'prod' && secrets.ANDROID_PROD_TEST_ACCOUNT || '' }} + ${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole' && secrets.STAGEMOLE_PARTNER_AUTH || '' }} + VALID_TEST_ACCOUNT_NUMBER: ${{ env.RESOLVED_TEST_ACCOUNT }} INVALID_TEST_ACCOUNT_NUMBER: '0000000000000000' ENABLE_HIGHLY_RATE_LIMITED_TESTS: ${{ github.event_name == 'schedule' && 'true' || 'false' }} ENABLE_ACCESS_TO_LOCAL_API_TESTS: true @@ -489,7 +506,7 @@ jobs: - name: Upload e2e instrumentation report uses: actions/upload-artifact@v4 if: > - always() && matrix.test-repeat != 0 && env.E2E_TEST_INFRA_FLAVOR == 'stagemole' + always() && matrix.test-repeat != 0 && needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole' with: name: e2e-instrumentation-report path: ${{ steps.prepare-report-dir.outputs.report_dir }} diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 218eeeef23d5..cacb0236cbd7 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -48,7 +48,7 @@ jobs: - name: Checkout submodules run: | git submodule update --init --depth=1 dist-assets/binaries - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install build dependencies if: matrix.os == 'ubuntu-latest' @@ -99,7 +99,7 @@ jobs: - name: Checkout wireguard-go submodule run: | git config --global --add safe.directory '*' - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Clippy check env: diff --git a/.github/workflows/daemon.yml b/.github/workflows/daemon.yml index 8e22397b9cfc..7bf893d1ea0e 100644 --- a/.github/workflows/daemon.yml +++ b/.github/workflows/daemon.yml @@ -77,7 +77,7 @@ jobs: run: | git config --global --add safe.directory '*' git submodule update --init --depth=1 dist-assets/binaries - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go # The container image already has rustup and the pinned version of Rust - name: Install Rust toolchain @@ -100,7 +100,7 @@ jobs: - name: Checkout wireguard-go submodule run: | git config --global --add safe.directory '*' - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install Protoc uses: arduino/setup-protoc@v3 @@ -131,7 +131,7 @@ jobs: - name: Checkout submodules run: | git submodule update --init --depth=1 - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install Protoc # NOTE: ARM runner already has protoc diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index a44905e55f78..2165308d766f 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -124,7 +124,7 @@ jobs: run: | git config --global --add safe.directory '*' git submodule update --init --depth=1 dist-assets/binaries - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Build app env: USE_MOLD: false @@ -187,7 +187,7 @@ jobs: submodules: true - name: Checkout submodules run: | - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install Protoc uses: arduino/setup-protoc@v3 with: @@ -274,7 +274,7 @@ jobs: - name: Checkout submodules run: | git config --global --add safe.directory '*' - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install Go uses: actions/setup-go@v3 with: diff --git a/.github/workflows/ios-rust-ffi.yml b/.github/workflows/ios-rust-ffi.yml index 6d1457d4f1a6..bc289a03f952 100644 --- a/.github/workflows/ios-rust-ffi.yml +++ b/.github/workflows/ios-rust-ffi.yml @@ -20,6 +20,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Checkout wireguard-go-rs + run: | + git config --global --add safe.directory '*' + git submodule update --init wireguard-go-rs + - name: Install Protoc uses: arduino/setup-protoc@v3 with: diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml index c65bf1450416..084896fda431 100644 --- a/.github/workflows/osv-scanner-pr.yml +++ b/.github/workflows/osv-scanner-pr.yml @@ -17,4 +17,6 @@ jobs: actions: read # yamllint disable rule:line-length - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@19ec1116569a47416e11a45848722b1af31a857b" # v1.9.0 + uses: "mullvad/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@ab8175fc65a74d8c0308f623b1c617a39bdc34fe" # v1.9.0 + with: + checkout-submodules: true diff --git a/.github/workflows/osv-scanner-scheduled.yml b/.github/workflows/osv-scanner-scheduled.yml index 58b982a107d2..a42305db844a 100644 --- a/.github/workflows/osv-scanner-scheduled.yml +++ b/.github/workflows/osv-scanner-scheduled.yml @@ -18,4 +18,6 @@ jobs: actions: read # yamllint disable rule:line-length - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@19ec1116569a47416e11a45848722b1af31a857b" # v1.9.0 + uses: "mullvad/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@ab8175fc65a74d8c0308f623b1c617a39bdc34fe" # v1.9.0 + with: + checkout-submodules: true diff --git a/.github/workflows/rust-unused-dependencies.yml b/.github/workflows/rust-unused-dependencies.yml index aed6d4ea2202..47c58bcca27b 100644 --- a/.github/workflows/rust-unused-dependencies.yml +++ b/.github/workflows/rust-unused-dependencies.yml @@ -48,7 +48,7 @@ jobs: run: | git config --global --add safe.directory '*' git submodule update --init --depth=1 dist-assets/binaries - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install nightly Rust toolchain run: rustup override set $RUST_NIGHTLY_TOOLCHAIN @@ -79,7 +79,7 @@ jobs: - name: Checkout wireguard-go submodule run: | git config --global --add safe.directory '*' - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install nightly Rust run: | @@ -106,7 +106,7 @@ jobs: run: | git config --global --add safe.directory '*' git submodule update --init --depth=1 - git submodule update --init --recursive --depth=1 wireguard-go-rs + git submodule update --init wireguard-go-rs/libwg/wireguard-go - name: Install msbuild if: matrix.os == 'windows-latest' diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f8ada84f3d..868d7a072e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ Line wrap the file at 100 chars. Th - Add back wireguard-go (userspace WireGuard) support. +## [2025.3] - 2025-02-07 +### Changed +- Change order of items in settings view to show DAITA and multihop at the top. +- Update Electron from 33.2.1 to 33.4.0. + + ## [2025.3-beta1] - 2025-01-21 ### Added #### Windows diff --git a/Cargo.lock b/Cargo.lock index e7a426717dff..d280ba649b82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,9 +431,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] name = "c2rust-bitfields" @@ -1248,9 +1248,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1279,9 +1279,9 @@ checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -2107,9 +2107,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jnix" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd797d41e48568eb956ded20d7e5e3f2df1c02980d9e5b9aab9b47bd3a9f599" +checksum = "542b2072131a62ec940ee161ff0a01e7a1c2a129796b30143efc952cb6e0f28f" dependencies = [ "jni", "jnix-macros", @@ -2196,9 +2196,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.158" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libdbus-sys" @@ -2888,9 +2888,9 @@ dependencies = [ [[package]] name = "netlink-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416060d346fbaf1f23f9512963e3e878f1a78e707cb699ba9215761754244307" +checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" dependencies = [ "bytes", "futures", @@ -4789,6 +4789,7 @@ dependencies = [ "bitflags 2.6.0", "futures", "ipnetwork", + "jnix", "libc", "log", "netlink-packet-route", diff --git a/README.md b/README.md index aeec4cb332cf..46d5cf3c6343 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ cd mullvadvpn-app git submodule update --init ``` -On Android, Linux and macOS you also want to checkout the wireguard-go submodule recursively: +On Android, Windows, Linux and macOS you also want to checkout the wireguard-go submodule: ```bash -git submodule update --init --recursive --depth=1 wireguard-go-rs +git submodule update --init wireguard-go-rs/libwg/wireguard-go ``` Further details on why this is necessary can be found in the [wireguard-go-rs crate](./wireguard-go-rs/README.md). diff --git a/android/BuildInstructions.md b/android/BuildInstructions.md index cfff1b3d73af..5755737df84b 100644 --- a/android/BuildInstructions.md +++ b/android/BuildInstructions.md @@ -128,8 +128,6 @@ Linux distro: #### 5. Install and configure Rust toolchain - Get the latest **stable** Rust toolchain via [rustup.rs](https://rustup.rs/). - Also install `cbindgen` which is required to build `wireguard-go-rs`: - `cargo install --force cbindgen` - Configure Android cross-compilation targets and set up linker and archiver. This can be done by setting the following environment variables: @@ -157,7 +155,7 @@ environment variables: ``` #### 6. Download wireguard-go-rs submodule -Run the following command to download wireguard-go-rs submodule: `git submodule update --init --recursive --depth=1 wireguard-go-rs` +Run the following command to download wireguard-go-rs submodule: `git submodule update --init wireguard-go-rs/libwg/wireguard-go` ### Debug build Run the following command to build a debug build: diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index f03a1617972b..05f3443af5fc 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -22,9 +22,17 @@ Line wrap the file at 100 chars. Th * **Security**: in case of vulnerabilities. ## [Unreleased] +### Changed +- Disable Wireguard port setting when a obfuscation is selected since it is not used when an + obfuscation is applied. + ### Removed - Remove Google's resolvers from encrypted DNS proxy. +### Fixed +- Will no longer try to connect over IPv6 if IPv6 is not available. + + ## [android/2024.10-beta2] - 2024-12-20 ### Fixed diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index 3a7df2b84161..bbe22c9b1110 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -190,7 +190,7 @@ private fun BaseButton( Text( text = text, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt index f67e7228af43..18ad113a2612 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt @@ -54,7 +54,7 @@ private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton( Text( text = text, textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt index 17c4d24460ab..8e22be8b7e00 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt @@ -148,7 +148,7 @@ fun BaseSubtitleCell( start = Dimens.cellStartPadding, top = Dimens.cellFooterTopPadding, end = Dimens.cellEndPadding, - bottom = Dimens.cellLabelVerticalPadding, + bottom = Dimens.cellVerticalSpacing, ) .fillMaxWidth() .wrapContentHeight(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt index 0ea351e8b14a..bc6aebe5d0ef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt @@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,7 +27,7 @@ import net.mullvad.mullvadvpn.compose.component.SpacedColumn import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.theme.color.onSelected import net.mullvad.mullvadvpn.lib.theme.color.selected @@ -65,6 +62,7 @@ fun CustomPortCell( port: Port?, mainTestTag: String = "", numberTestTag: String = "", + isEnabled: Boolean = true, onMainCellClicked: () -> Unit, onPortCellClicked: () -> Unit, ) { @@ -77,7 +75,7 @@ fun CustomPortCell( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, modifier = - Modifier.clickable { onMainCellClicked() } + Modifier.clickable(enabled = isEnabled) { onMainCellClicked() } .height(Dimens.cellHeight) .weight(1f) .background( @@ -90,13 +88,10 @@ fun CustomPortCell( .padding(start = Dimens.cellStartPadding) .testTag(mainTestTag), ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSelected, - modifier = - Modifier.padding(end = Dimens.selectableCellTextMargin) - .alpha(if (isSelected) AlphaVisible else AlphaInvisible), + SelectableIcon( + isSelected = isSelected, + iconContentDescription = null, + isEnabled = isEnabled, ) BaseCellTitle( title = title, @@ -104,16 +99,17 @@ fun CustomPortCell( textAlign = TextAlign.Start, textColor = if (isSelected) { - MaterialTheme.colorScheme.onSelected - } else { - MaterialTheme.colorScheme.onSurface - }, + MaterialTheme.colorScheme.onSelected + } else { + MaterialTheme.colorScheme.onSurface + } + .copy(alpha = if (isEnabled) AlphaVisible else AlphaDisabled), ) } Spacer(modifier = Modifier.width(Dimens.verticalSpacer)) Box( modifier = - Modifier.clickable { onPortCellClicked() } + Modifier.clickable(enabled = isEnabled) { onPortCellClicked() } .height(Dimens.cellHeight) .wrapContentWidth() .defaultMinSize(minWidth = Dimens.customPortBoxMinWidth) @@ -122,7 +118,15 @@ fun CustomPortCell( ) { Text( text = port?.value?.toString() ?: stringResource(id = R.string.port), - color = MaterialTheme.colorScheme.onPrimary, + color = + MaterialTheme.colorScheme.onPrimary.copy( + alpha = + if (isEnabled) { + AlphaVisible + } else { + AlphaDisabled + } + ), modifier = Modifier.align(Alignment.Center), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt index ece54c9102ab..b05b0f2cbe73 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt @@ -136,7 +136,7 @@ internal fun NavigationCellBody( verticalAlignment = Alignment.CenterVertically, modifier = modifier.wrapContentWidth().wrapContentHeight(), ) { - Text(text = content, style = MaterialTheme.typography.bodyMedium, color = textColor) + Text(text = content, style = MaterialTheme.typography.titleMedium, color = textColor) Spacer(modifier = Modifier.width(Dimens.sideMargin)) if (isExternalLink) { DefaultExternalLinkView(content, tint = contentColor) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 0de30c408a11..779b4792b423 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -320,7 +320,6 @@ fun VpnSettingsScreen( ) { if (state.systemVpnSettingsAvailable) { item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) NavigationComposeCell( title = stringResource(id = R.string.auto_connect_and_lockdown_mode), onClick = { navigateToAutoConnectScreen() }, @@ -333,7 +332,6 @@ fun VpnSettingsScreen( } } else { item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) HeaderSwitchComposeCell( title = stringResource(R.string.connect_on_start), isToggled = state.autoStartAndConnectOnBoot, @@ -350,7 +348,6 @@ fun VpnSettingsScreen( } item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) HeaderSwitchComposeCell( title = stringResource(R.string.local_network_sharing), isToggled = state.isLocalNetworkSharingEnabled, @@ -358,7 +355,7 @@ fun VpnSettingsScreen( onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) }, onInfoClicked = navigateToLocalNetworkSharingInfo, ) - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } itemWithDivider { @@ -443,7 +440,7 @@ fun VpnSettingsScreen( start = Dimens.cellStartPadding, top = topPadding, end = Dimens.cellEndPadding, - bottom = Dimens.cellLabelVerticalPadding, + bottom = Dimens.cellVerticalSpacing, ) ) } @@ -497,7 +494,7 @@ fun VpnSettingsScreen( start = Dimens.cellStartPadding, top = topPadding, end = Dimens.cellEndPadding, - bottom = Dimens.cellLabelVerticalPadding, + bottom = Dimens.cellVerticalSpacing, ), ) } @@ -507,6 +504,7 @@ fun VpnSettingsScreen( title = stringResource(id = R.string.wireguard_port_title), onInfoClicked = { navigateToWireguardPortInfo(state.availablePortRanges) }, onCellClicked = { navigateToWireguardPortInfo(state.availablePortRanges) }, + isEnabled = state.isWireguardPortEnabled, ) } @@ -515,6 +513,7 @@ fun VpnSettingsScreen( title = stringResource(id = R.string.automatic), isSelected = state.selectedWireguardPort == Constraint.Any, onCellClicked = { onWireguardPortSelected(Constraint.Any) }, + isEnabled = state.isWireguardPortEnabled, ) } @@ -530,6 +529,7 @@ fun VpnSettingsScreen( ), isSelected = state.selectedWireguardPort.getOrNull() == port, onCellClicked = { onWireguardPortSelected(Constraint.Only(port)) }, + isEnabled = state.isWireguardPortEnabled, ) } } @@ -547,13 +547,34 @@ fun VpnSettingsScreen( } }, onPortCellClicked = navigateToWireguardPortDialog, + isEnabled = state.isWireguardPortEnabled, mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG, numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG, ) } + if (!state.isWireguardPortEnabled) { + item { + Text( + text = + stringResource( + id = R.string.wg_port_subtitle, + stringResource(R.string.wireguard), + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding( + start = Dimens.cellStartPadding, + top = topPadding, + end = Dimens.cellEndPadding, + ), + ) + } + } + itemWithDivider { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) InformationComposeCell( title = stringResource(R.string.obfuscation_title), onInfoClicked = navigateToObfuscationInfo, @@ -598,7 +619,7 @@ fun VpnSettingsScreen( } itemWithDivider { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) InformationComposeCell( title = stringResource(R.string.quantum_resistant_title), onInfoClicked = navigateToQuantumResistanceInfo, @@ -627,16 +648,13 @@ fun VpnSettingsScreen( isSelected = state.quantumResistant == QuantumResistantState.Off, onCellClicked = { onSelectQuantumResistanceSetting(QuantumResistantState.Off) }, ) - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } item { MtuComposeCell(mtuValue = state.mtu, onEditMtu = { navigateToMtuDialog(state.mtu) }) } - item { - MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) - } + item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) } item { ServerIpOverrides(navigateToServerIpOverrides) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt index 49d0ebd4aa34..c9fd0257c028 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt @@ -29,6 +29,9 @@ data class VpnSettingsUiState( selectedWireguardPort is Constraint.Only && selectedWireguardPort.value == customWireguardPort + val isWireguardPortEnabled = + obfuscationMode == ObfuscationMode.Auto || obfuscationMode == ObfuscationMode.Off + companion object { fun createDefault( mtu: Mtu? = null, diff --git a/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt b/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt new file mode 100644 index 000000000000..27e7658a11de --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt @@ -0,0 +1,146 @@ +package net.mullvad.talpid + +import android.net.VpnService +import android.os.ParcelFileDescriptor +import arrow.core.right +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.spyk +import java.net.InetAddress +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe +import net.mullvad.mullvadvpn.lib.model.Prepared +import net.mullvad.talpid.model.CreateTunResult +import net.mullvad.talpid.model.InetNetwork +import net.mullvad.talpid.model.TunConfig +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertInstanceOf + +class TalpidVpnServiceFallbackDnsTest { + lateinit var talpidVpnService: TalpidVpnService + var builderMockk = mockk<VpnService.Builder>() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + mockkStatic(VPN_SERVICE_EXTENSION) + + talpidVpnService = spyk<TalpidVpnService>(recordPrivateCalls = true) + every { talpidVpnService.prepareVpnSafe() } returns Prepared.right() + builderMockk = mockk<VpnService.Builder>() + + mockkConstructor(VpnService.Builder::class) + every { anyConstructed<VpnService.Builder>().setMtu(any()) } returns builderMockk + every { anyConstructed<VpnService.Builder>().setBlocking(any()) } returns builderMockk + every { anyConstructed<VpnService.Builder>().addAddress(any<InetAddress>(), any()) } returns + builderMockk + every { anyConstructed<VpnService.Builder>().addRoute(any<InetAddress>(), any()) } returns + builderMockk + every { + anyConstructed<VpnService.Builder>() + .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER) + } returns builderMockk + val parcelFileDescriptor: ParcelFileDescriptor = mockk() + every { anyConstructed<VpnService.Builder>().establish() } returns parcelFileDescriptor + every { parcelFileDescriptor.detachFd() } returns 1 + } + + @Test + fun `opening tun with no DnsServers should add fallback DNS server`() { + val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf()) + + val result = talpidVpnService.openTun(tunConfig) + + assertInstanceOf<CreateTunResult.Success>(result) + + // Fallback DNS server should be added if no DNS servers are provided + coVerify(exactly = 1) { + anyConstructed<VpnService.Builder>() + .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER) + } + } + + @Test + fun `opening tun with all bad DnsServers should return InvalidDnsServers and add fallback`() { + val badDns1 = InetAddress.getByName("0.0.0.0") + val badDns2 = InetAddress.getByName("255.255.255.255") + every { anyConstructed<VpnService.Builder>().addDnsServer(badDns1) } throws + IllegalArgumentException() + every { anyConstructed<VpnService.Builder>().addDnsServer(badDns2) } throws + IllegalArgumentException() + + val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(badDns1, badDns2)) + val result = talpidVpnService.openTun(tunConfig) + + assertInstanceOf<CreateTunResult.InvalidDnsServers>(result) + assertLists(tunConfig.dnsServers, result.addresses) + // Fallback DNS server should be added if no valid DNS servers are provided + coVerify(exactly = 1) { + anyConstructed<VpnService.Builder>() + .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER) + } + } + + @Test + fun `opening tun with 1 good and 1 bad DnsServers should return InvalidDnsServers`() { + val goodDnsServer = InetAddress.getByName("1.1.1.1") + val badDns = InetAddress.getByName("255.255.255.255") + every { anyConstructed<VpnService.Builder>().addDnsServer(goodDnsServer) } returns + builderMockk + every { anyConstructed<VpnService.Builder>().addDnsServer(badDns) } throws + IllegalArgumentException() + + val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(goodDnsServer, badDns)) + val result = talpidVpnService.openTun(tunConfig) + + assertInstanceOf<CreateTunResult.InvalidDnsServers>(result) + assertLists(arrayListOf(badDns), result.addresses) + + // Fallback DNS server should not be added since we have 1 good DNS server + coVerify(exactly = 0) { + anyConstructed<VpnService.Builder>() + .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER) + } + } + + @Test + fun `providing good dns servers should not add the fallback dns and return success`() { + val goodDnsServer = InetAddress.getByName("1.1.1.1") + every { anyConstructed<VpnService.Builder>().addDnsServer(goodDnsServer) } returns + builderMockk + + val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(goodDnsServer)) + val result = talpidVpnService.openTun(tunConfig) + + assertInstanceOf<CreateTunResult.Success>(result) + + // Fallback DNS server should not be added since we have good DNS servers. + coVerify(exactly = 0) { + anyConstructed<VpnService.Builder>() + .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER) + } + } + + companion object { + private const val VPN_SERVICE_EXTENSION = + "net.mullvad.mullvadvpn.lib.common.util.VpnServiceUtilsKt" + + val baseTunConfig = + TunConfig( + addresses = arrayListOf(InetAddress.getByName("45.83.223.209")), + dnsServers = arrayListOf(), + routes = + arrayListOf( + InetNetwork(InetAddress.getByName("0.0.0.0"), 0), + InetNetwork(InetAddress.getByName("::"), 0), + ), + mtu = 1280, + excludedPackages = arrayListOf(), + ) + } +} diff --git a/android/docker/Dockerfile b/android/docker/Dockerfile index 3635fc55c0e7..705c0d1ed044 100644 --- a/android/docker/Dockerfile +++ b/android/docker/Dockerfile @@ -117,11 +117,4 @@ RUN patch -p1 -f -N -r- -d /usr/local/go < /tmp/goruntime-boottime-over-monotoni # Add rust targets RUN rustup target add x86_64-linux-android i686-linux-android aarch64-linux-android armv7-linux-androideabi -# Install cbindgen to address maybenot.h (checked in) sometimes needing to be -# re-generated due to how `make` looks at last-modifications while git neither -# stores nor consistently sets modification metadata on file checkout. -# This is an intermediate solution that will be further improved as part of -# issue: DROID-1328. -RUN cargo install --force cbindgen --version "0.26.0" && rm -rf ~/.cargo/registry - WORKDIR /build diff --git a/android/docs/BuildInstructions.macos.md b/android/docs/BuildInstructions.macos.md index 0368c1e917a7..4ef004d595e4 100644 --- a/android/docs/BuildInstructions.macos.md +++ b/android/docs/BuildInstructions.macos.md @@ -28,11 +28,6 @@ Finish the install of `rustup`: rustup-init ``` -Install `cbindgen` which is required to build `wireguard-go-rs`: -```bash -cargo install --force cbindgen -``` - ## 2. Install SDK Tools and Android NDK Toolchain Open Android Studio -> Tools -> SDK Manager, and install `Android SDK Command-line Tools (latest)`. @@ -70,7 +65,7 @@ export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$NDK_TOOLCHAIN_DIR/x86_64-linux wireguard-go-rs submodule need to be downloaded: ```bash -git submodule update --init --recursive --depth=1 wireguard-go-rs +git submodule update --init wireguard-go-rs/libwg/wireguard-go ``` ## 4. Debug build diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt index 59833cb3961d..06c862936b65 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt @@ -2,10 +2,14 @@ package net.mullvad.mullvadvpn.lib.common.util import android.content.Context import android.content.Intent +import android.net.VpnService import android.net.VpnService.prepare +import android.os.ParcelFileDescriptor import arrow.core.Either -import arrow.core.flatten +import arrow.core.flatMap import arrow.core.left +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull import arrow.core.right import co.touchlab.kermit.Logger import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.getInstalledPackagesList @@ -13,6 +17,8 @@ import net.mullvad.mullvadvpn.lib.model.PrepareError import net.mullvad.mullvadvpn.lib.model.Prepared /** + * Prepare to establish a VPN connection safely. + * * Invoking VpnService.prepare() can result in 3 out comes: * 1. IllegalStateException - There is a legacy VPN profile marked as always on * 2. Intent @@ -34,7 +40,7 @@ fun Context.prepareVpnSafe(): Either<PrepareError, Prepared> = else -> throw it } } - .map { intent -> + .flatMap { intent -> if (intent == null) { Prepared.right() } else { @@ -46,7 +52,6 @@ fun Context.prepareVpnSafe(): Either<PrepareError, Prepared> = } } } - .flatten() fun Context.getAlwaysOnVpnAppName(): String? { return resolveAlwaysOnVpnPackageName() @@ -59,3 +64,38 @@ fun Context.getAlwaysOnVpnAppName(): String? { ?.loadLabel(packageManager) ?.toString() } + +/** + * Establish a VPN connection safely. + * + * This function wraps the [VpnService.Builder.establish] function and catches any exceptions that + * may be thrown and type them to a more specific error. + * + * @return [ParcelFileDescriptor] if successful, [EstablishError] otherwise + */ +fun VpnService.Builder.establishSafe(): Either<EstablishError, ParcelFileDescriptor> = either { + val vpnInterfaceFd = + Either.catch { establish() } + .mapLeft { + when (it) { + is IllegalStateException -> EstablishError.ParameterNotApplied(it) + is IllegalArgumentException -> EstablishError.ParameterNotAccepted(it) + else -> EstablishError.UnknownError(it) + } + } + .bind() + + ensureNotNull(vpnInterfaceFd) { EstablishError.NullVpnInterface } + + vpnInterfaceFd +} + +sealed interface EstablishError { + data class ParameterNotApplied(val exception: IllegalStateException) : EstablishError + + data class ParameterNotAccepted(val exception: IllegalArgumentException) : EstablishError + + data object NullVpnInterface : EstablishError + + data class UnknownError(val error: Throwable) : EstablishError +} diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index daa04fc8d996..fe4cf11881ba 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -36,9 +36,6 @@ import net.mullvad.mullvadvpn.lib.model.DnsState import net.mullvad.mullvadvpn.lib.model.Endpoint import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.ErrorStateCause -import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.AuthFailed -import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.OtherAlwaysOnApp -import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.TunnelParameterError import net.mullvad.mullvadvpn.lib.model.FeatureIndicator import net.mullvad.mullvadvpn.lib.model.GeoIpLocation import net.mullvad.mullvadvpn.lib.model.GeoLocationId @@ -125,7 +122,7 @@ private fun ManagementInterface.TunnelState.Error.toDomain(): TunnelState.Error val otherAlwaysOnAppError = errorState.let { if (it.hasOtherAlwaysOnAppError()) { - OtherAlwaysOnApp(it.otherAlwaysOnAppError.appName) + ErrorStateCause.OtherAlwaysOnApp(it.otherAlwaysOnAppError.appName) } else { null } @@ -238,7 +235,7 @@ internal fun ManagementInterface.ErrorState.toDomain( cause = when (cause!!) { ManagementInterface.ErrorState.Cause.AUTH_FAILED -> - AuthFailed(authFailedError.toDomain()) + ErrorStateCause.AuthFailed(authFailedError.toDomain()) ManagementInterface.ErrorState.Cause.IPV6_UNAVAILABLE -> ErrorStateCause.Ipv6Unavailable ManagementInterface.ErrorState.Cause.SET_FIREWALL_POLICY_ERROR -> @@ -247,7 +244,7 @@ internal fun ManagementInterface.ErrorState.toDomain( ManagementInterface.ErrorState.Cause.START_TUNNEL_ERROR -> ErrorStateCause.StartTunnelError ManagementInterface.ErrorState.Cause.TUNNEL_PARAMETER_ERROR -> - TunnelParameterError(parameterError.toDomain()) + ErrorStateCause.TunnelParameterError(parameterError.toDomain()) ManagementInterface.ErrorState.Cause.IS_OFFLINE -> ErrorStateCause.IsOffline ManagementInterface.ErrorState.Cause.SPLIT_TUNNEL_ERROR -> ErrorStateCause.StartTunnelError @@ -255,7 +252,6 @@ internal fun ManagementInterface.ErrorState.toDomain( ManagementInterface.ErrorState.Cause.NEED_FULL_DISK_PERMISSIONS, ManagementInterface.ErrorState.Cause.CREATE_TUNNEL_DEVICE -> throw IllegalArgumentException("Unrecognized error state cause") - ManagementInterface.ErrorState.Cause.NOT_PREPARED -> ErrorStateCause.NotPrepared ManagementInterface.ErrorState.Cause.OTHER_ALWAYS_ON_APP -> otherAlwaysOnApp!! ManagementInterface.ErrorState.Cause.OTHER_LEGACY_ALWAYS_ON_VPN -> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 6af8e233ba84..567c7fde9901 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -408,4 +408,5 @@ <string name="obfuscation_info_shadowsocks_batteryusage">Attention: Shadowsocks can increase battery consumption depending on data usage, such as streaming a video.</string> <string name="see_full_changelog">See full changelog</string> <string name="changelog_empty">No changelog was added for this version</string> + <string name="wg_port_subtitle">Set %s obfuscation to \"Automatic\" or \"Off\" below to activate this setting.</string> </resources> diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt index 86b27e3ba83d..11b6d347642e 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt @@ -6,54 +6,112 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import co.touchlab.kermit.Logger +import java.net.DatagramSocket +import java.net.Inet4Address +import java.net.Inet6Address import java.net.InetAddress +import kotlin.collections.ArrayList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import net.mullvad.talpid.model.Connectivity +import net.mullvad.talpid.model.NetworkState +import net.mullvad.talpid.util.IPAvailabilityUtils import net.mullvad.talpid.util.NetworkEvent -import net.mullvad.talpid.util.defaultNetworkFlow -import net.mullvad.talpid.util.networkFlow +import net.mullvad.talpid.util.RawNetworkState +import net.mullvad.talpid.util.defaultRawNetworkStateFlow +import net.mullvad.talpid.util.networkEvents -class ConnectivityListener(val connectivityManager: ConnectivityManager) { - private lateinit var _isConnected: StateFlow<Boolean> +class ConnectivityListener( + val connectivityManager: ConnectivityManager, + val protect: (socket: DatagramSocket) -> Boolean, +) { + private lateinit var _isConnected: StateFlow<Connectivity> // Used by JNI val isConnected get() = _isConnected.value - private lateinit var _currentDnsServers: StateFlow<List<InetAddress>> + private lateinit var _currentNetworkState: StateFlow<NetworkState?> + + // Used by JNI + val currentDefaultNetworkState: NetworkState? + get() = _currentNetworkState.value + // Used by JNI - val currentDnsServers - get() = ArrayList(_currentDnsServers.value) + val currentDnsServers: ArrayList<InetAddress> + get() = _currentNetworkState.value?.dnsServers ?: ArrayList() fun register(scope: CoroutineScope) { - _currentDnsServers = - dnsServerChanges().stateIn(scope, SharingStarted.Eagerly, currentDnsServers()) + // Consider implementing retry logic for the flows below, because registering a listener on + // the default network may fail if the network on Android 11 + // https://issuetracker.google.com/issues/175055271?pli=1 + _currentNetworkState = + connectivityManager + .defaultRawNetworkStateFlow() + .map { it?.toNetworkState() } + .onEach { notifyDefaultNetworkChange(it) } + .stateIn(scope, SharingStarted.Eagerly, null) _isConnected = - hasInternetCapability() - .onEach { notifyConnectivityChange(it) } - .stateIn(scope, SharingStarted.Eagerly, false) + combine(connectivityManager.defaultRawNetworkStateFlow(), hasInternetCapability()) { + rawNetworkState: RawNetworkState?, + hasInternetCapability: Boolean -> + if (hasInternetCapability) { + val isUnderlyingNetwork = + rawNetworkState + ?.networkCapabilities + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == true + if (isUnderlyingNetwork) { + // If the default network is not a VPN we can check the addresses + // directly + Connectivity.Status( + ipv4 = + rawNetworkState.linkProperties?.routes?.any { + it.destination.address is Inet4Address + } == true, + ipv6 = + rawNetworkState.linkProperties?.routes?.any { + it.destination.address is Inet6Address + } == true, + ) + } else { + // If the default network is a VPN we need to use a socket to check + // the underlying network + Connectivity.Status( + IPAvailabilityUtils.isIPv4Available(protect = { protect(it) }), + IPAvailabilityUtils.isIPv6Available(protect = { protect(it) }), + ) + } + // If we have internet, but both IPv4 and IPv6 are not available, we + // assume something is wrong and instead will return presume online. + .takeUnless { !it.ipv4 && !it.ipv6 } ?: Connectivity.PresumeOnline + } else { + Connectivity.Status(false, false) + } + } + .distinctUntilChanged() + .onEach { + when (it) { + Connectivity.PresumeOnline -> notifyConnectivityChange(true, true) + is Connectivity.Status -> notifyConnectivityChange(it.ipv4, it.ipv6) + } + } + .stateIn( + scope + Dispatchers.IO, + SharingStarted.Eagerly, + Connectivity.Status(false, false), + ) } - private fun dnsServerChanges(): Flow<List<InetAddress>> = - connectivityManager - .defaultNetworkFlow() - .filterIsInstance<NetworkEvent.LinkPropertiesChanged>() - .onEach { Logger.d("Link properties changed") } - .map { it.linkProperties.dnsServersWithoutFallback() } - - private fun currentDnsServers(): List<InetAddress> = - connectivityManager - .getLinkProperties(connectivityManager.activeNetwork) - ?.dnsServersWithoutFallback() ?: emptyList() - private fun LinkProperties.dnsServersWithoutFallback(): List<InetAddress> = dnsServers.filter { it.hostAddress != TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER } @@ -65,7 +123,7 @@ class ConnectivityListener(val connectivityManager: ConnectivityManager) { .build() return connectivityManager - .networkFlow(request) + .networkEvents(request) .scan(setOf<Network>()) { networks, event -> when (event) { is NetworkEvent.Available -> { @@ -87,5 +145,14 @@ class ConnectivityListener(val connectivityManager: ConnectivityManager) { .distinctUntilChanged() } - private external fun notifyConnectivityChange(isConnected: Boolean) + private fun RawNetworkState.toNetworkState(): NetworkState = + NetworkState( + network.networkHandle, + linkProperties?.routes, + linkProperties?.dnsServersWithoutFallback(), + ) + + private external fun notifyConnectivityChange(isIPv4: Boolean, isIPv6: Boolean) + + private external fun notifyDefaultNetworkChange(networkState: NetworkState?) } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt index 74d44005cd7c..1feeb7c21cdf 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt @@ -1,18 +1,29 @@ package net.mullvad.talpid import android.net.ConnectivityManager +import android.net.VpnService import android.os.ParcelFileDescriptor import androidx.annotation.CallSuper import androidx.core.content.getSystemService import androidx.lifecycle.lifecycleScope +import arrow.core.Either +import arrow.core.mapOrAccumulate +import arrow.core.merge +import arrow.core.raise.either import co.touchlab.kermit.Logger import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress import kotlin.properties.Delegates.observable +import net.mullvad.mullvadvpn.lib.common.util.establishSafe import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe import net.mullvad.mullvadvpn.lib.model.PrepareError import net.mullvad.talpid.model.CreateTunResult +import net.mullvad.talpid.model.CreateTunResult.EstablishError +import net.mullvad.talpid.model.CreateTunResult.InvalidDnsServers +import net.mullvad.talpid.model.CreateTunResult.NotPrepared +import net.mullvad.talpid.model.CreateTunResult.OtherAlwaysOnApp +import net.mullvad.talpid.model.CreateTunResult.OtherLegacyAlwaysOnVpn import net.mullvad.talpid.model.TunConfig import net.mullvad.talpid.util.TalpidSdkUtils.setMeteredIfSupported @@ -22,7 +33,7 @@ open class TalpidVpnService : LifecycleVpnService() { val oldTunFd = when (oldTunStatus) { is CreateTunResult.Success -> oldTunStatus.tunFd - is CreateTunResult.InvalidDnsServers -> oldTunStatus.tunFd + is InvalidDnsServers -> oldTunStatus.tunFd else -> null } @@ -39,30 +50,35 @@ open class TalpidVpnService : LifecycleVpnService() { @CallSuper override fun onCreate() { super.onCreate() - connectivityListener = ConnectivityListener(getSystemService<ConnectivityManager>()!!) + connectivityListener = + ConnectivityListener(getSystemService<ConnectivityManager>()!!, ::protect) connectivityListener.register(lifecycleScope) } - fun openTun(config: TunConfig): CreateTunResult { + // Used by JNI + fun openTun(config: TunConfig): CreateTunResult = synchronized(this) { val tunStatus = activeTunStatus if (config == currentTunConfig && tunStatus != null && tunStatus.isOpen) { - return tunStatus + tunStatus } else { - return openTunImpl(config) + openTunImpl(config) } } - } - fun openTunForced(config: TunConfig): CreateTunResult { - synchronized(this) { - return openTunImpl(config) - } - } + // Used by JNI + fun openTunForced(config: TunConfig): CreateTunResult = + synchronized(this) { openTunImpl(config) } + + // Used by JNI + fun closeTun(): Unit = synchronized(this) { activeTunStatus = null } + + // Used by JNI + fun bypass(socket: Int): Boolean = protect(socket) private fun openTunImpl(config: TunConfig): CreateTunResult { - val newTunStatus = createTun(config) + val newTunStatus = createTun(config).merge() currentTunConfig = config activeTunStatus = newTunStatus @@ -70,95 +86,76 @@ open class TalpidVpnService : LifecycleVpnService() { return newTunStatus } - fun closeTun() { - synchronized(this) { activeTunStatus = null } - } - - // DROID-1407 - // Function is to be cleaned up and lint suppression to be removed. - @Suppress("ReturnCount") - private fun createTun(config: TunConfig): CreateTunResult { - prepareVpnSafe() - .mapLeft { it.toCreateTunResult() } - .onLeft { - return it + private fun createTun( + config: TunConfig + ): Either<CreateTunResult.Error, CreateTunResult.Success> = either { + prepareVpnSafe().mapLeft { it.toCreateTunError() }.bind() + + val builder = Builder() + builder.setMtu(config.mtu) + builder.setBlocking(false) + builder.setMeteredIfSupported(false) + + config.addresses.forEach { builder.addAddress(it, it.prefixLength()) } + config.routes.forEach { builder.addRoute(it.address, it.prefixLength.toInt()) } + config.excludedPackages.forEach { app -> builder.addDisallowedApplication(app) } + + // We don't care if adding DNS servers fails at this point, since we can still create a + // tunnel to consume traffic and then notify daemon to later enter blocked state. + val dnsConfigureResult = + config.dnsServers.mapOrAccumulate { + builder.addDnsServerSafe(it).bind() + Unit } - val invalidDnsServerAddresses = ArrayList<InetAddress>() - - val builder = - Builder().apply { - for (address in config.addresses) { - addAddress(address, address.prefixLength()) - } - - for (dnsServer in config.dnsServers) { - try { - addDnsServer(dnsServer) - } catch (exception: IllegalArgumentException) { - invalidDnsServerAddresses.add(dnsServer) - } - } - - // Avoids creating a tunnel with no DNS servers or if all DNS servers was invalid, - // since apps then may leak DNS requests. - // https://issuetracker.google.com/issues/337961996 - if (invalidDnsServerAddresses.size == config.dnsServers.size) { - Logger.w( - "All DNS servers invalid or non set, using fallback DNS server to " + - "minimize leaks, dnsServers.isEmpty(): ${config.dnsServers.isEmpty()}" - ) - addDnsServer(FALLBACK_DUMMY_DNS_SERVER) - } - - for (route in config.routes) { - addRoute(route.address, route.prefixLength.toInt()) - } - - config.excludedPackages.forEach { app -> addDisallowedApplication(app) } - setMtu(config.mtu) - setBlocking(false) - setMeteredIfSupported(false) - } + // Never create a tunnel where all DNS servers are invalid or if none was ever set, since + // apps then may leak DNS requests. + // https://issuetracker.google.com/issues/337961996 + val shouldAddFallbackDns = + dnsConfigureResult.fold( + { invalidDnsServers -> invalidDnsServers.size == config.dnsServers.size }, + { addedDnsServers -> addedDnsServers.isEmpty() }, + ) + if (shouldAddFallbackDns) { + Logger.w( + "All DNS servers invalid or non set, using fallback DNS server to " + + "minimize leaks, dnsServers.isEmpty(): ${config.dnsServers.isEmpty()}" + ) + builder.addDnsServer(FALLBACK_DUMMY_DNS_SERVER) + } val vpnInterfaceFd = - try { - builder.establish() - } catch (e: IllegalStateException) { - Logger.e("Failed to establish, a parameter could not be applied", e) - return CreateTunResult.TunnelDeviceError - } catch (e: IllegalArgumentException) { - Logger.e("Failed to establish a parameter was not accepted", e) - return CreateTunResult.TunnelDeviceError - } - - if (vpnInterfaceFd == null) { - Logger.e("VpnInterface returned null") - return CreateTunResult.TunnelDeviceError - } + builder + .establishSafe() + .onLeft { Logger.w("Failed to establish tunnel $it") } + .mapLeft { EstablishError } + .bind() val tunFd = vpnInterfaceFd.detachFd() - waitForTunnelUp(tunFd, config.routes.any { route -> route.isIpv6 }) + dnsConfigureResult.mapLeft { InvalidDnsServers(it, tunFd) }.bind() - if (invalidDnsServerAddresses.isNotEmpty()) { - return CreateTunResult.InvalidDnsServers(invalidDnsServerAddresses, tunFd) - } - - return CreateTunResult.Success(tunFd) - } - - fun bypass(socket: Int): Boolean { - return protect(socket) + CreateTunResult.Success(tunFd) } - private fun PrepareError.toCreateTunResult() = + private fun PrepareError.toCreateTunError() = when (this) { - is PrepareError.OtherLegacyAlwaysOnVpn -> CreateTunResult.OtherLegacyAlwaysOnVpn - is PrepareError.NotPrepared -> CreateTunResult.NotPrepared - is PrepareError.OtherAlwaysOnApp -> CreateTunResult.OtherAlwaysOnApp(appName) + is PrepareError.OtherLegacyAlwaysOnVpn -> OtherLegacyAlwaysOnVpn + is PrepareError.NotPrepared -> NotPrepared + is PrepareError.OtherAlwaysOnApp -> OtherAlwaysOnApp(appName) } + private fun Builder.addDnsServerSafe( + dnsServer: InetAddress + ): Either<InetAddress, VpnService.Builder> = + Either.catch { addDnsServer(dnsServer) } + .mapLeft { + when (it) { + is IllegalArgumentException -> dnsServer + else -> throw it + } + } + private fun InetAddress.prefixLength(): Int = when (this) { is Inet4Address -> IPV4_PREFIX_LENGTH @@ -166,8 +163,6 @@ open class TalpidVpnService : LifecycleVpnService() { else -> throw IllegalArgumentException("Invalid IP address (not IPv4 nor IPv6)") } - private external fun waitForTunnelUp(tunFd: Int, isIpv6Enabled: Boolean) - companion object { const val FALLBACK_DUMMY_DNS_SERVER = "192.0.2.1" diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt new file mode 100644 index 000000000000..bdf0a44611e5 --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt @@ -0,0 +1,7 @@ +package net.mullvad.talpid.model + +sealed class Connectivity { + data class Status(val ipv4: Boolean, val ipv6: Boolean) : Connectivity() + + data object PresumeOnline : Connectivity() +} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt index 3cd73685f715..ef10dcd2f3b7 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt @@ -1,29 +1,38 @@ package net.mullvad.talpid.model import java.net.InetAddress +import java.util.ArrayList -sealed class CreateTunResult { - open val isOpen - get() = false +sealed interface CreateTunResult { + val isOpen: Boolean - class Success(val tunFd: Int) : CreateTunResult() { - override val isOpen - get() = true + data class Success(val tunFd: Int) : CreateTunResult { + override val isOpen = true } - class InvalidDnsServers(val addresses: ArrayList<InetAddress>, val tunFd: Int) : - CreateTunResult() { - override val isOpen - get() = true + sealed interface Error : CreateTunResult + + // Prepare errors + data object OtherLegacyAlwaysOnVpn : Error { + override val isOpen: Boolean = false } - // Establish error - data object TunnelDeviceError : CreateTunResult() + data class OtherAlwaysOnApp(val appName: String) : Error { + override val isOpen: Boolean = false + } - // Prepare errors - data object OtherLegacyAlwaysOnVpn : CreateTunResult() + data object NotPrepared : Error { + override val isOpen: Boolean = false + } - data class OtherAlwaysOnApp(val appName: String) : CreateTunResult() + // Establish error + data object EstablishError : Error { + override val isOpen: Boolean = false + } - data object NotPrepared : CreateTunResult() + data class InvalidDnsServers(val addresses: ArrayList<InetAddress>, val tunFd: Int) : Error { + constructor(address: List<InetAddress>, tunFd: Int) : this(ArrayList(address), tunFd) + + override val isOpen = true + } } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt new file mode 100644 index 000000000000..ca0b6db7e22a --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt @@ -0,0 +1,19 @@ +package net.mullvad.talpid.model + +import java.net.InetAddress + +data class NetworkState( + val networkHandle: Long, + val routes: ArrayList<RouteInfo>?, + val dnsServers: ArrayList<InetAddress>?, +) { + constructor( + networkHandle: Long, + routes: List<AndroidRouteInfo>?, + dnsServers: List<InetAddress>?, + ) : this( + networkHandle = networkHandle, + routes = routes?.map { it.toRoute() }?.let { ArrayList(it) }, + dnsServers = dnsServers?.let { ArrayList(it) }, + ) +} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt new file mode 100644 index 000000000000..a2b63b3ca73f --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt @@ -0,0 +1,18 @@ +package net.mullvad.talpid.model + +import java.net.InetAddress + +typealias AndroidRouteInfo = android.net.RouteInfo + +data class RouteInfo( + val destination: InetNetwork, + val gateway: InetAddress?, + val interfaceName: String?, +) + +fun AndroidRouteInfo.toRoute() = + RouteInfo( + destination = InetNetwork(destination.address, destination.prefixLength.toShort()), + gateway = gateway, + interfaceName = `interface`, + ) diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt index daf155c6e8ef..fddaa6fb8806 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt @@ -10,59 +10,56 @@ import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.scan + +internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow { + val callback = + object : NetworkCallback() { + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + super.onLinkPropertiesChanged(network, linkProperties) + trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties)) + } -fun ConnectivityManager.defaultNetworkFlow(): Flow<NetworkEvent> = - callbackFlow<NetworkEvent> { - val callback = - object : NetworkCallback() { - override fun onLinkPropertiesChanged( - network: Network, - linkProperties: LinkProperties, - ) { - super.onLinkPropertiesChanged(network, linkProperties) - trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties)) - } - - override fun onAvailable(network: Network) { - super.onAvailable(network) - trySendBlocking(NetworkEvent.Available(network)) - } + override fun onAvailable(network: Network) { + super.onAvailable(network) + trySendBlocking(NetworkEvent.Available(network)) + } - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities, - ) { - super.onCapabilitiesChanged(network, networkCapabilities) - trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities)) - } + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities)) + } - override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { - super.onBlockedStatusChanged(network, blocked) - trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked)) - } + override fun onBlockedStatusChanged(network: Network, blocked: Boolean) { + super.onBlockedStatusChanged(network, blocked) + trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked)) + } - override fun onLosing(network: Network, maxMsToLive: Int) { - super.onLosing(network, maxMsToLive) - trySendBlocking(NetworkEvent.Losing(network, maxMsToLive)) - } + override fun onLosing(network: Network, maxMsToLive: Int) { + super.onLosing(network, maxMsToLive) + trySendBlocking(NetworkEvent.Losing(network, maxMsToLive)) + } - override fun onLost(network: Network) { - super.onLost(network) - trySendBlocking(NetworkEvent.Lost(network)) - } + override fun onLost(network: Network) { + super.onLost(network) + trySendBlocking(NetworkEvent.Lost(network)) + } - override fun onUnavailable() { - super.onUnavailable() - trySendBlocking(NetworkEvent.Unavailable) - } + override fun onUnavailable() { + super.onUnavailable() + trySendBlocking(NetworkEvent.Unavailable) } - registerDefaultNetworkCallback(callback) + } + registerDefaultNetworkCallback(callback) - awaitClose { unregisterNetworkCallback(callback) } - } + awaitClose { unregisterNetworkCallback(callback) } +} -fun ConnectivityManager.networkFlow(networkRequest: NetworkRequest): Flow<NetworkEvent> = - callbackFlow<NetworkEvent> { +fun ConnectivityManager.networkEvents(networkRequest: NetworkRequest): Flow<NetworkEvent> = + callbackFlow { val callback = object : NetworkCallback() { override fun onLinkPropertiesChanged( @@ -111,6 +108,26 @@ fun ConnectivityManager.networkFlow(networkRequest: NetworkRequest): Flow<Networ awaitClose { unregisterNetworkCallback(callback) } } +internal fun ConnectivityManager.defaultRawNetworkStateFlow(): Flow<RawNetworkState?> = + defaultNetworkEvents() + .scan( + null as RawNetworkState?, + { state, event -> + return@scan when (event) { + is NetworkEvent.Available -> RawNetworkState(network = event.network) + is NetworkEvent.BlockedStatusChanged -> + state?.copy(blockedStatus = event.blocked) + is NetworkEvent.CapabilitiesChanged -> + state?.copy(networkCapabilities = event.networkCapabilities) + is NetworkEvent.LinkPropertiesChanged -> + state?.copy(linkProperties = event.linkProperties) + is NetworkEvent.Losing -> state?.copy(maxMsToLive = event.maxMsToLive) + is NetworkEvent.Lost -> null + NetworkEvent.Unavailable -> null + } + }, + ) + sealed interface NetworkEvent { data class Available(val network: Network) : NetworkEvent @@ -130,3 +147,11 @@ sealed interface NetworkEvent { data class Lost(val network: Network) : NetworkEvent } + +internal data class RawNetworkState( + val network: Network, + val linkProperties: LinkProperties? = null, + val networkCapabilities: NetworkCapabilities? = null, + val blockedStatus: Boolean = false, + val maxMsToLive: Int? = null, +) diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt new file mode 100644 index 000000000000..425a578307eb --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt @@ -0,0 +1,46 @@ +package net.mullvad.talpid.util + +import co.touchlab.kermit.Logger +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.SocketException + +object IPAvailabilityUtils { + fun isIPv4Available(protect: (socket: DatagramSocket) -> Boolean): Boolean = + isIPAvailable(InetAddress.getByName(PUBLIC_IPV4_ADDRESS), protect) + + fun isIPv6Available(protect: (socket: DatagramSocket) -> Boolean): Boolean = + isIPAvailable(InetAddress.getByName(PUBLIC_IPV6_ADDRESS), protect) + + // Fake a connection to a public ip address using a UDP socket. + // We don't care about the result of the connection, only that it is possible to create. + // This is done this way since otherwise there is not way to check the availability of an ip + // version on the underlying network if the VPN is turned on. + // Since we are protecting the socket it will use the underlying network regardless + // if the VPN is turned on or not. + // If the ip version is not supported on the underlying network it will trigger a socket + // exception. Otherwise we assume it is available. + private inline fun <reified T : InetAddress> isIPAvailable( + ip: T, + protect: (socket: DatagramSocket) -> Boolean, + ): Boolean { + val socket = DatagramSocket() + if (!protect(socket)) { + Logger.e("Unable to protect the socket VPN is not set up correctly") + return false + } + return try { + socket.connect(InetSocketAddress(ip, 1)) + true + } catch (_: SocketException) { + Logger.e("Socket could not be set up") + false + } finally { + socket.close() + } + } + + private const val PUBLIC_IPV4_ADDRESS = "1.1.1.1" + private const val PUBLIC_IPV6_ADDRESS = "2606:4700:4700::1001" +} diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt index f38602bf161a..9eb0aae4b202 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt @@ -38,7 +38,6 @@ private val MullvadTypography = headlineSmall = TextStyle(fontSize = TypeScale.TextBig, fontWeight = FontWeight.Bold), bodySmall = TextStyle(fontSize = TypeScale.TextSmall), titleSmall = TextStyle(fontSize = TypeScale.TextMedium, fontWeight = FontWeight.SemiBold), - bodyMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.Bold), titleMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.SemiBold), titleLarge = TextStyle(fontSize = TypeScale.TitleLarge, fontFamily = FontFamily.SansSerif), diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 6a5da5c18d31..51b4f5efb895 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -16,10 +16,9 @@ data class Dimensions( val cellFooterTopPadding: Dp = 6.dp, val cellHeight: Dp = 56.dp, val cellHeightTwoRows: Dp = 72.dp, - val cellLabelVerticalPadding: Dp = 14.dp, val cellStartPadding: Dp = 14.dp, val cellTopPadding: Dp = 6.dp, - val cellVerticalSpacing: Dp = 14.dp, + val cellVerticalSpacing: Dp = 24.dp, val chipSpace: Dp = 8.dp, val chipVerticalPadding: Dp = 4.dp, val circularProgressBarLargeSize: Dp = 44.dp, diff --git a/ci/buildserver-build-android.sh b/ci/buildserver-build-android.sh index 0010161eadf3..a2eb39a1121b 100755 --- a/ci/buildserver-build-android.sh +++ b/ci/buildserver-build-android.sh @@ -65,7 +65,7 @@ function checkout_ref { git reset --hard git checkout "$ref" git submodule update - git submodule update --init --recursive --depth=1 wireguard-go-rs || true + git submodule update --init wireguard-go-rs/libwg/wireguard-go || true git clean -df } diff --git a/ci/buildserver-build.sh b/ci/buildserver-build.sh index b68bd79e77a6..d932f6825dc6 100755 --- a/ci/buildserver-build.sh +++ b/ci/buildserver-build.sh @@ -159,7 +159,7 @@ function checkout_ref { git reset --hard git checkout "$ref" git submodule update - git submodule update --init --recursive --depth=1 wireguard-go-rs || true + git submodule update --init wireguard-go-rs/libwg/wireguard-go || true git clean -df } diff --git a/desktop/package-lock.json b/desktop/package-lock.json index bc2c4084ad12..fc1c088646f5 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -2031,9 +2031,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.17.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", - "integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==", + "version": "20.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz", + "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -5375,11 +5375,12 @@ } }, "node_modules/electron": { - "version": "33.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz", - "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==", + "version": "33.4.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.0.tgz", + "integrity": "sha512-AO+Q/ygWwKKs+JtNEFgfS5ntjG3TA2HX7s4IEbiYi6lktaocuLP2oScG1/mmKRuUWoOcow2RRsf995L2mM4bvQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^20.9.0", @@ -14691,7 +14692,7 @@ "chai-as-promised": "^7.1.1", "chai-spies": "^1.0.0", "cross-env": "^7.0.3", - "electron": "33.2.1", + "electron": "33.4.0", "electron-builder": "^24.13.3", "electron-devtools-installer": "^3.2.0", "eslint-plugin-react": "^7.36.1", @@ -16285,9 +16286,9 @@ "dev": true }, "@types/node": { - "version": "20.17.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", - "integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==", + "version": "20.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz", + "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==", "requires": { "undici-types": "~6.19.2" } @@ -18948,9 +18949,9 @@ } }, "electron": { - "version": "33.2.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz", - "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==", + "version": "33.4.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.0.tgz", + "integrity": "sha512-AO+Q/ygWwKKs+JtNEFgfS5ntjG3TA2HX7s4IEbiYi6lktaocuLP2oScG1/mmKRuUWoOcow2RRsf995L2mM4bvQ==", "dev": true, "requires": { "@electron/get": "^2.0.0", @@ -22573,7 +22574,7 @@ "chai-as-promised": "^7.1.1", "chai-spies": "^1.0.0", "cross-env": "^7.0.3", - "electron": "33.2.1", + "electron": "33.4.0", "electron-builder": "^24.13.3", "electron-devtools-installer": "^3.2.0", "eslint-plugin-react": "^7.36.1", diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot index dd5747d6ad6c..0157e0c32fe0 100644 --- a/desktop/packages/mullvad-vpn/locales/messages.pot +++ b/desktop/packages/mullvad-vpn/locales/messages.pot @@ -2638,6 +2638,9 @@ msgstr "" msgid "See full changelog" msgstr "" +msgid "Set %s obfuscation to \"Automatic\" or \"Off\" below to activate this setting." +msgstr "" + msgid "Set WireGuard MTU value. Valid range: %d - %d." msgstr "" diff --git a/desktop/packages/mullvad-vpn/package.json b/desktop/packages/mullvad-vpn/package.json index 1b070ac9dbb7..056b0be93fcf 100644 --- a/desktop/packages/mullvad-vpn/package.json +++ b/desktop/packages/mullvad-vpn/package.json @@ -50,7 +50,7 @@ "chai-as-promised": "^7.1.1", "chai-spies": "^1.0.0", "cross-env": "^7.0.3", - "electron": "33.2.1", + "electron": "33.4.0", "electron-builder": "^24.13.3", "electron-devtools-installer": "^3.2.0", "eslint-plugin-react": "^7.36.1", diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts index 679951ec6782..50808598f414 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts @@ -1,7 +1,7 @@ import { ColorTokens } from '../tokens'; export const colors = { - '--color-darker-blue': ColorTokens.darkBlue, + '--color-darker-blue': ColorTokens.darkerBlue, '--color-dark-blue': ColorTokens.darkBlue, '--color-blue': ColorTokens.blue, '--color-dark-green': ColorTokens.darkGreen, diff --git a/desktop/scripts/prepare-release.sh b/desktop/scripts/release/1-prepare-release similarity index 68% rename from desktop/scripts/prepare-release.sh rename to desktop/scripts/release/1-prepare-release index 18859d260d51..4becc19d0608 100755 --- a/desktop/scripts/prepare-release.sh +++ b/desktop/scripts/release/1-prepare-release @@ -1,22 +1,21 @@ #!/usr/bin/env bash # This script prepares for a release. Run it with the release version as the first argument and it -# will update version numbers, commit and add a signed tag. +# will update version numbers, update the changelog, and update copyright year. set -eu SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$SCRIPT_DIR" -REPO_ROOT=../../ +REPO_ROOT=../../../ source $REPO_ROOT/scripts/utils/log +source $REPO_ROOT/scripts/utils/print-and-run -PUSH_TAG="false" for argument in "$@"; do case "$argument" in - --push-tag) PUSH_TAG="true" ;; -*) log_error "Unknown option \"$argument\"" exit 1 @@ -27,15 +26,10 @@ for argument in "$@"; do esac done -changes_path=../packages/mullvad-vpn/changes.txt +changes_path=$REPO_ROOT/desktop/packages/mullvad-vpn/changes.txt changelog_path=$REPO_ROOT/CHANGELOG.md product_version_path=$REPO_ROOT/dist-assets/desktop-product-version.txt -function print_and_run { - echo "+ $*" - "$@" -} - function checks { if [[ -z ${PRODUCT_VERSION+x} ]]; then log_error "Please give the release version as an argument to this script." @@ -88,7 +82,9 @@ function check_changelog { function update_copyright_year { $REPO_ROOT/scripts/update-copyright - print_and_run git commit -A -S -m "Update copyright year in project files and code" + if [[ $(git diff --shortstat 2> /dev/null | tail -n1) != "" ]]; then + print_and_run git commit -a -S -m "Update copyright year in project files and code" + fi } function update_changelog { @@ -107,31 +103,18 @@ function update_product_version { $product_version_path } -function push_tag { - product_version=$(echo -n "$(cat $product_version_path)") - echo "Tagging current git commit with release tag $product_version..." - print_and_run git tag -s "$product_version" -m "$product_version" - print_and_run git push origin "$product_version" - log_success "\nTag pushed!" -} - -if [[ $PUSH_TAG == "true" ]]; then - check_commit_signature - push_tag -else - checks - check_commit_signature - check_changelog - update_changelog - update_copyright_year - update_product_version - - log_success "\n=================================================" - log_success "| DONE preparing for a release! |" - log_success "| Now verify that everything looks correct |" - log_success "| and then create and push the tag by |" - log_success "| running: |" - log_success "| $ $0 \\ " - log_success "| --push-tag |" - log_success "=================================================" -fi +checks +check_commit_signature +check_changelog +update_changelog +update_copyright_year +update_product_version + +log_success "\n=================================================" +log_success "| DONE preparing for a release! |" +log_success "| Now verify that everything looks correct |" +log_success "| and then create and push the tag by |" +log_success "| running: |" +log_success "| $ $0 \\ " +log_success "| --push-tag |" +log_success "=================================================" diff --git a/desktop/scripts/release/2-push-release-tag b/desktop/scripts/release/2-push-release-tag new file mode 100755 index 000000000000..a5115ed860c3 --- /dev/null +++ b/desktop/scripts/release/2-push-release-tag @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# This script creates and pushes a signed release tag. This should be run after `1-prepare-release`. + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +REPO_ROOT=../../../ +PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt +PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH) + +source $REPO_ROOT/scripts/utils/print-and-run +source $REPO_ROOT/scripts/utils/log + +function push_tag { + product_version=$(echo -n "$PRODUCT_VERSION") + echo "Tagging current git commit with release tag $product_version..." + print_and_run git tag -s "$product_version" -m "$product_version" + git push + print_and_run git push origin "$product_version" + log_success "\nTag pushed!" +} + +git verify-commit HEAD +push_tag diff --git a/desktop/scripts/release/3-verify-build b/desktop/scripts/release/3-verify-build new file mode 100755 index 000000000000..be8ebde50dc1 --- /dev/null +++ b/desktop/scripts/release/3-verify-build @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# This script verifies the build produced by the buildserver. It helps the user verify the staging +# repository versions and triggers a e2e run with a small subset of the tests to verify the build. +# This should be be run after `2-push-release-tag` and after the build server has finished building. + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +REPO_ROOT=../../../ +PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt +PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH) + +$REPO_ROOT/scripts/utils/gh-ready-check +source $REPO_ROOT/scripts/utils/log + +function verify_repository_versions { + print_versions_args=( --staging ) + + if [[ "$PRODUCT_VERSION" == *-beta* ]]; then + print_versions_args+=( --beta ) + fi + + ./print-package-versions "${print_versions_args[@]}" + read -r -n 1 -p "Does the versions look correct? (y/N): " response + printf "\n\n" + + if [[ "$response" =~ ^[Yy]$ ]]; then + return + elif [[ "$response" =~ ^[Nn]$ ]]; then + log_info "Aborting" + exit 1 + else + log_error "Invalid response" + exit 1 + fi +} + +verify_repository_versions +gh workflow run desktop-e2e.yml --ref "$PRODUCT_VERSION" \ + -f oses="fedora41 ubuntu2404 windows11 macos15" \ + -f tests="test_quantum_resistant_tunnel test_ui_tunnel_settings" diff --git a/desktop/scripts/make-release b/desktop/scripts/release/4-make-release similarity index 87% rename from desktop/scripts/make-release rename to desktop/scripts/release/4-make-release index 8af3a94119f2..0f421e30b140 100755 --- a/desktop/scripts/make-release +++ b/desktop/scripts/release/4-make-release @@ -1,23 +1,19 @@ #!/usr/bin/env bash +# This script downloads the build artifacts along with the signatures, verifies the signatures and +# creates a GitHub draft release. This should be run after `3-verify-build`. + set -eu -if ! command -v gh > /dev/null; then - echo >&2 "gh (GitHub CLI) is required to run this script" - exit 1 -fi +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" -if ! gh auth status > /dev/null; then - echo >&2 "Authentication through gh (GitHub CLI) is required to run this script" - exit 1 -fi +REPO_ROOT=../../../ +PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt +PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH) -if [[ $# != 1 ]]; then - echo "!!! Please pass the app version as the first and only argument" - exit 1 -fi +$REPO_ROOT/scripts/utils/gh-ready-check -PRODUCT_VERSION=$1 REPO_URL="git@github.com:mullvad/mullvadvpn-app" ARTIFACT_DIR=$(mktemp -d) REPO_DIR=$(mktemp -d) diff --git a/desktop/scripts/print-package-versions.sh b/desktop/scripts/release/print-package-versions similarity index 96% rename from desktop/scripts/print-package-versions.sh rename to desktop/scripts/release/print-package-versions index 9d255f5fac47..2170f5417c31 100755 --- a/desktop/scripts/print-package-versions.sh +++ b/desktop/scripts/release/print-package-versions @@ -27,9 +27,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$SCRIPT_DIR" # shellcheck source=ci/linux-repository-builder/build-linux-repositories-config.sh -source build-linux-repositories-config.sh +source ../../../ci/linux-repository-builder/build-linux-repositories-config.sh # shellcheck source=scripts/utils/log -source ../../scripts/utils/log +source ../../../scripts/utils/log deb="false" rpm="false" diff --git a/desktop/scripts/test-release-artifacts b/desktop/scripts/test-release-artifacts deleted file mode 100755 index fb5ffe6ccbfa..000000000000 --- a/desktop/scripts/test-release-artifacts +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -if ! command -v gh > /dev/null; then - echo >&2 "gh (GitHub CLI) is required to run this script" - exit 1 -fi - -if ! gh auth status > /dev/null; then - echo >&2 "Authentication through gh (GitHub CLI) is required to run this script" - exit 1 -fi - -if [[ $# != 1 ]]; then - echo "!!! Please pass the app version as the first and only argument" - exit 1 -fi - -PRODUCT_VERSION=$1 -gh workflow run desktop-e2e.yml --ref "$PRODUCT_VERSION" \ - -f oses="fedora41 ubuntu2404 windows11 macos15" \ - -f tests="test_quantum_resistant_tunnel test_ui_tunnel_settings" diff --git a/dist-assets/desktop-product-version.txt b/dist-assets/desktop-product-version.txt index ff47b30c5bdd..68c0b64f7fc3 100644 --- a/dist-assets/desktop-product-version.txt +++ b/dist-assets/desktop-product-version.txt @@ -1 +1 @@ -2025.3-beta1 +2025.3 diff --git a/ios/Configurations/UITests.xcconfig.template b/ios/Configurations/UITests.xcconfig.template index af31a89b2664..988a9a65d786 100644 --- a/ios/Configurations/UITests.xcconfig.template +++ b/ios/Configurations/UITests.xcconfig.template @@ -23,8 +23,11 @@ AD_SERVING_DOMAIN = vpnlist.to // A domain which should be reachable. Used to verify Internet connectivity. Must be running a server on port 80. SHOULD_BE_REACHABLE_DOMAIN = mullvad.net + +// An IP address which should always be reachable. Must be running a server on port 80. +SHOULD_BE_REACHABLE_IP_ADDRESS = 45.83.223.209 -// Base URL for the firewall API, Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8 +// Base URL for the firewall API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8 FIREWALL_API_BASE_URL = http:/${}/8.8.8.8 // URL for Mullvad provided JSON data with information about the connection. https://am.i.mullvad.net/json for production, https://am.i.stagemole.eu/json for staging. @@ -32,3 +35,6 @@ AM_I_JSON_URL = https:/${}/am.i.stagemole.eu/json // Specify whether app logs should be extracted and attached to test report for failing tests ATTACH_APP_LOGS_ON_FAILURE = 0 + +// Base URL for the packet capture API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8 +PACKET_CAPTURE_BASE_URL = http:/${}/8.8.8.8 diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 25a47cd2ee74..76e944a9c751 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -687,21 +687,25 @@ 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */; }; 8542CE242B95F7B9006FCA14 /* VPNSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */; }; 8542F7532BCFBD050035C042 /* SelectLocationFilterPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */; }; - 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */; }; + 85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallClient.swift */; }; 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0F2B59215F00795FE1 /* FirewallRule.swift */; }; 85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B112B594FC900795FE1 /* ConnectivityTests.swift */; }; 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */; }; 85557B1E2B5FB8C700795FE1 /* HeaderBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */; }; 85557B202B5FBBD700795FE1 /* AccountPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */; }; + 8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */; }; 8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */; }; 8556EB542B9A1D7100D26DD4 /* BridgingHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */; }; 8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */; }; 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */; }; + 85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */; }; 856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */; }; 856952E22BD6B04C008C1F84 /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */; }; 8585CBE32BC684180015B6A4 /* EditAccessMethodPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */; }; 8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8587A05C2B84D43100152938 /* ChangeLogAlert.swift */; }; 8590896F2B61763B003AF5F5 /* LoggedOutUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */; }; + 8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */; }; + 85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85978A532BE0F10E00F999A7 /* PacketCapture.swift */; }; 85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */; }; 85B267612B849ADB0098E3CD /* mullvad-api.h in Headers */ = {isa = PBXBuildFile; fileRef = 85B267602B849ADB0098E3CD /* mullvad-api.h */; }; 85C7A2E92B89024B00035D5A /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C7A2E82B89024B00035D5A /* SettingsTests.swift */; }; @@ -709,6 +713,7 @@ 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */; }; 85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E3BDE42B70E18C00FA71FD /* Networking.swift */; }; 85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */; }; + 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; }; 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; }; 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; }; A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */; }; @@ -2082,17 +2087,19 @@ 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmittedPage.swift; sourceTree = "<group>"; }; 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsPage.swift; sourceTree = "<group>"; }; 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationFilterPage.swift; sourceTree = "<group>"; }; - 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallAPIClient.swift; sourceTree = "<group>"; }; + 85557B0D2B591B2600795FE1 /* FirewallClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallClient.swift; sourceTree = "<group>"; }; 85557B0F2B59215F00795FE1 /* FirewallRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallRule.swift; sourceTree = "<group>"; }; 85557B112B594FC900795FE1 /* ConnectivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityTests.swift; sourceTree = "<group>"; }; 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadAPIWrapper.swift; sourceTree = "<group>"; }; 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElementQuery+Extensions.swift"; sourceTree = "<group>"; }; 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = "<group>"; }; 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPage.swift; sourceTree = "<group>"; }; + 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakCheck.swift; sourceTree = "<group>"; }; 8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MullvadApi.swift; path = MullvadVPNUITests/MullvadApi.swift; sourceTree = "<group>"; }; 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = "<group>"; }; 8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDevicePage.swift; sourceTree = "<group>"; }; 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportPage.swift; sourceTree = "<group>"; }; + 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRouterAPIClient.swift; sourceTree = "<group>"; }; 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerAPIClient.swift; sourceTree = "<group>"; }; 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = "<group>"; }; 8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodPage.swift; sourceTree = "<group>"; }; @@ -2100,11 +2107,14 @@ 859089692B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedInWithTimeUITestCase.swift; sourceTree = "<group>"; }; 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseUITestCase.swift; sourceTree = "<group>"; }; 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedOutUITestCase.swift; sourceTree = "<group>"; }; + 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficGenerator.swift; sourceTree = "<group>"; }; + 85978A532BE0F10E00F999A7 /* PacketCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketCapture.swift; sourceTree = "<group>"; }; 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementPage.swift; sourceTree = "<group>"; }; 85B267602B849ADB0098E3CD /* mullvad-api.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "mullvad-api.h"; path = "../../mullvad-api/include/mullvad-api.h"; sourceTree = "<group>"; }; 85C7A2E82B89024B00035D5A /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = "<group>"; }; 85D039972BA4711800940E7F /* SettingsMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationTests.swift; sourceTree = "<group>"; }; 85E3BDE42B70E18C00FA71FD /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = "<group>"; }; + 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakTests.swift; sourceTree = "<group>"; }; 85FB5A0B2B6903990015DCED /* WelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage.swift; sourceTree = "<group>"; }; 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionPage.swift; sourceTree = "<group>"; }; A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = "<group>"; }; @@ -4203,21 +4213,22 @@ 852969262B4D9C1F007EAD4C /* MullvadVPNUITests */ = { isa = PBXGroup; children = ( - 85557B0C2B591B0F00795FE1 /* Networking */, - 852969312B4E9220007EAD4C /* Pages */, - 7A45CFCD2C08697100D80B21 /* Screenshots */, - 852969372B4ED20E007EAD4C /* Info.plist */, 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */, 85B267602B849ADB0098E3CD /* mullvad-api.h */, + 852969372B4ED20E007EAD4C /* Info.plist */, 852969272B4D9C1F007EAD4C /* AccountTests.swift */, 85557B112B594FC900795FE1 /* ConnectivityTests.swift */, A9BFAFFE2BD004ED00F2BCA1 /* CustomListsTests.swift */, + 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */, 850201DA2B503D7700EF8C96 /* RelayTests.swift */, 85D039972BA4711800940E7F /* SettingsMigrationTests.swift */, 85C7A2E82B89024B00035D5A /* SettingsTests.swift */, - 8518F6392B601910009EB113 /* Base */, 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */, 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */, + 8518F6392B601910009EB113 /* Base */, + 85557B0C2B591B0F00795FE1 /* Networking */, + 852969312B4E9220007EAD4C /* Pages */, + 7A45CFCD2C08697100D80B21 /* Screenshots */, ); path = MullvadVPNUITests; sourceTree = "<group>"; @@ -4265,11 +4276,15 @@ 85557B0C2B591B0F00795FE1 /* Networking */ = { isa = PBXGroup; children = ( - 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */, + 85557B0D2B591B2600795FE1 /* FirewallClient.swift */, 85557B0F2B59215F00795FE1 /* FirewallRule.swift */, + 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */, 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */, 85E3BDE42B70E18C00FA71FD /* Networking.swift */, + 85978A532BE0F10E00F999A7 /* PacketCapture.swift */, 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */, + 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */, + 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */, ); path = Networking; sourceTree = "<group>"; @@ -6486,6 +6501,7 @@ 8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */, 85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */, A9DF789D2B7D1E8B0094E4AD /* LoggedInWithTimeUITestCase.swift in Sources */, + 85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */, 85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */, F09084682C6E88ED001CD36E /* DaitaPromptAlert.swift in Sources */, 8529693C2B4F0257007EAD4C /* Alert.swift in Sources */, @@ -6508,20 +6524,24 @@ 852969352B4E9270007EAD4C /* LoginPage.swift in Sources */, A998DA832BD2B055001D61A2 /* EditCustomListLocationsPage.swift in Sources */, 7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */, + 8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */, 7ACD79392C0DAADD00DBEE14 /* AddCustomListLocationsPage.swift in Sources */, 8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */, A9BFAFFF2BD004ED00F2BCA1 /* CustomListsTests.swift in Sources */, 85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */, - 85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */, + 85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */, 852969282B4D9C1F007EAD4C /* AccountTests.swift in Sources */, 8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */, 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */, 7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */, 7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */, 856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */, + 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */, 85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */, 855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */, 8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */, + 85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */, + 8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */, 85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */, 8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */, 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift index 710a689c56e3..0752fee599f8 100644 --- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift @@ -57,7 +57,7 @@ class AboutViewController: UIViewController { label.text = header label.font = .systemFont(ofSize: 28, weight: .bold) - label.textColor = .white + label.textColor = .primaryTextColor label.numberOfLines = 0 label.textAlignment = .center @@ -70,7 +70,7 @@ class AboutViewController: UIViewController { label.text = preamble label.font = .systemFont(ofSize: 18) - label.textColor = .white + label.textColor = .primaryTextColor label.numberOfLines = 0 label.textAlignment = .center @@ -83,7 +83,7 @@ class AboutViewController: UIViewController { label.text = text label.font = .systemFont(ofSize: 15) - label.textColor = .white + label.textColor = .secondaryTextColor label.numberOfLines = 0 contentView.addArrangedSubview(label) diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift index 43db6d09cdb2..6e43027a95e0 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift @@ -52,7 +52,7 @@ extension IPOverrideCoordinator: @preconcurrency IPOverrideViewControllerDelegat let header = NSLocalizedString( "IP_OVERRIDE_HEADER", tableName: "IPOverride", - value: "IP Override", + value: "Server IP override", comment: "" ) let body = [ diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift index 57e5cac3f306..5ccbf827440e 100644 --- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift +++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift @@ -82,15 +82,15 @@ class IPOverrideViewController: UIViewController { private func addHeaderView() { let body = NSLocalizedString( - "ACCESS_METHOD_HEADER_BODY", - tableName: "APIAccess", - value: "Manage default and setup custom methods to access the Mullvad API.", + "IP_OVERRIDE_HEADER_BODY", + tableName: "IPOverride", + value: "Import files or text with the new IP addresses for the servers in the Select location view.", comment: "" ) let link = NSLocalizedString( - "ACCESS_METHOD_HEADER_LINK", - tableName: "APIAccess", - value: "About API access...", + "IP_OVERRIDE_HEADER_LINK", + tableName: "IPOverride", + value: "About Server IP override...", comment: "" ) diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index e7a36263cf3b..cf6ad054adc4 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -109,6 +109,7 @@ class AccountContentView: UIView { logoutButton, deleteButton, ]) + arrangedSubviews.forEach { $0.isExclusiveTouch = true } let stackView = UIStackView(arrangedSubviews: arrangedSubviews) stackView.axis = .vertical stackView.spacing = UIMetrics.padding16 diff --git a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift index 55c8bccf1978..aa1fbe0502d3 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift @@ -41,6 +41,7 @@ class AccountDeviceRow: UIView { private let infoButton: UIButton = { let button = IncreasedHitButton(type: .system) + button.isExclusiveTouch = true button.setAccessibilityIdentifier(.infoButton) button.tintColor = .white button.setImage(UIImage(named: "IconInfo"), for: .normal) diff --git a/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift index e1fe907b3e97..80cd434f99b2 100644 --- a/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift +++ b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift @@ -33,6 +33,7 @@ class RestorePurchasesView: UIView { private lazy var infoButton: UIButton = { let button = IncreasedHitButton(type: .custom) + button.isExclusiveTouch = true button.setImage(UIImage(resource: .iconInfo), for: .normal) button.tintColor = .white button.addTarget(self, action: #selector(didTapInfoButton), for: .touchUpInside) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift index 2fd62e6c41a9..e837f65cd423 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift @@ -30,12 +30,13 @@ final class SettingsCellFactory: @preconcurrency CellFactoryProtocol, Sendable { func makeCell(for item: SettingsDataSource.Item, indexPath: IndexPath) -> UITableViewCell { let cell: UITableViewCell - // Instantiate cell based on the specific item type - if item == .changelog { - cell = SettingsCell(style: .subtitle, reuseIdentifier: item.reuseIdentifier.rawValue) - } else { - cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath) - } + cell = tableView + .dequeueReusableCell( + withIdentifier: item.reuseIdentifier.rawValue + ) ?? SettingsCell( + style: item.reuseIdentifier.cellStyle, + reuseIdentifier: item.reuseIdentifier.rawValue + ) // Configure the cell with the common logic configureCell(cell, item: item, indexPath: indexPath) diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift index 8b992ad077cf..138fabf45070 100644 --- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift @@ -11,12 +11,20 @@ import UIKit final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource.Section, SettingsDataSource.Item>, UITableViewDelegate { - enum CellReuseIdentifiers: String, CaseIterable { + enum CellReuseIdentifier: String, CaseIterable { case basic + case changelog var reusableViewClass: AnyClass { SettingsCell.self } + + var cellStyle: UITableViewCell.CellStyle { + switch self { + case .basic: .default + case .changelog: .subtitle + } + } } private enum HeaderFooterReuseIdentifier: String, CaseIterable, HeaderFooterIdentifierProtocol { @@ -68,8 +76,11 @@ final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource } } - var reuseIdentifier: CellReuseIdentifiers { - .basic + var reuseIdentifier: CellReuseIdentifier { + switch self { + case .changelog: .changelog + default: .basic + } } } @@ -132,13 +143,6 @@ final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource // MARK: - Private private func registerClasses() { - CellReuseIdentifiers.allCases.forEach { cellIdentifier in - tableView?.register( - cellIdentifier.reusableViewClass, - forCellReuseIdentifier: cellIdentifier.rawValue - ) - } - HeaderFooterReuseIdentifier.allCases.forEach { reuseIdentifier in tableView?.register( reuseIdentifier.headerFooterClass, diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index 3560a870d6fe..df39f51bee3e 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -59,6 +59,8 @@ struct SingleChoiceList<Value>: View where Value: Equatable { let tableAccessibilityIdentifier: String let itemDescription: (Value) -> String let customFieldMode: CustomFieldMode + // a latch to keep the custom field selected through changes of focus until the user taps elsewhere + @State var customFieldSelected = false /// The configuration for the field for a custom value row enum CustomFieldMode { @@ -206,7 +208,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable { // Construct a literal row for a specific literal value private func literalRow(_ item: Value) -> some View { row( - isSelected: value.wrappedValue == item && !customValueIsFocused + isSelected: value.wrappedValue == item && !customFieldSelected ) { Text(verbatim: itemDescription(item)) Spacer() @@ -215,6 +217,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable { value.wrappedValue = item customValueIsFocused = false customValueInput = "" + customFieldSelected = false } } @@ -229,7 +232,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable { fromValue: @escaping (Value) -> String? ) -> some View { row( - isSelected: value.wrappedValue == toValue(customValueInput) || customValueIsFocused + isSelected: value.wrappedValue == toValue(customValueInput) || customFieldSelected ) { Text(label) Spacer() @@ -304,6 +307,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable { } } .onTapGesture { + customFieldSelected = true if let v = toValue(customValueInput) { value.wrappedValue = v } else { @@ -343,6 +347,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable { switch opt.value { case let .literal(v): literalRow(v) + .listRowSeparator(.hidden) case let .custom( label, prompt, @@ -360,8 +365,10 @@ struct SingleChoiceList<Value>: View where Value: Equatable { toValue: toValue, fromValue: fromValue ) + .listRowSeparator(.hidden) if let legend { subtitleRow(legend) + .listRowSeparator(.hidden) } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift index 44c9c7d35707..00be5f526a39 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift @@ -38,6 +38,11 @@ struct ConnectionView: View { .background(BlurView(style: .dark)) .cornerRadius(12) .padding(EdgeInsets(top: 16, leading: 16, bottom: 24, trailing: 16)) + .onReceive(connectionViewModel.combinedState) { _ in + if !connectionViewModel.showsConnectionDetails { + isExpanded = false + } + } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift index 4db9a75b9954..1540b79b321d 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift @@ -30,7 +30,6 @@ class ConnectionViewViewModel: ObservableObject { @Published private(set) var tunnelStatus: TunnelStatus @Published var outgoingConnectionInfo: OutgoingConnectionInfo? @Published var showsActivityIndicator = false - @Published var showsConnectionDetails = false @Published var relayConstraints: RelayConstraints let destinationDescriber: DestinationDescribing @@ -73,14 +72,15 @@ class ConnectionViewViewModel: ObservableObject { func update(tunnelStatus: TunnelStatus) { self.tunnelStatus = tunnelStatus - self.showsConnectionDetails = shouldShowConnectionDetails(tunnelStatus) if !tunnelIsConnected { outgoingConnectionInfo = nil } } +} - private func shouldShowConnectionDetails(_ tunnelStatus: TunnelStatus) -> Bool { +extension ConnectionViewViewModel { + var showsConnectionDetails: Bool { switch tunnelStatus.state { case .connecting, .reconnecting, .negotiatingEphemeralPeer, .connected, .pendingReconnect: @@ -89,9 +89,7 @@ class ConnectionViewViewModel: ObservableObject { false } } -} -extension ConnectionViewViewModel { var textColorForSecureLabel: UIColor { switch tunnelStatus.state { case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index aa74ec5a6b76..b7cdeb454da7 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -177,13 +177,9 @@ class TunnelViewController: UIViewController, RootContainment { case let .connected(tunnelRelays, _, _): let center = tunnelRelays.exit.location.geoCoordinate - mapViewController.setCenter(center, animated: animated) { - // Connection can change during animation, so make sure we're still connected before adding marker. - if case .connected = self.tunnelState { - self.mapViewController.addLocationMarker(coordinate: center) - self.activityIndicator.stopAnimating() - } - } + mapViewController.setCenter(center, animated: animated) + activityIndicator.stopAnimating() + mapViewController.addLocationMarker(coordinate: center) case .pendingReconnect: activityIndicator.startAnimating() diff --git a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift index 69f86e52caf6..fd27c4590004 100644 --- a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift +++ b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift @@ -31,6 +31,13 @@ class BaseUITestCase: XCTestCase { /// Default relay to use in tests static let testsDefaultRelayName = "se-got-wg-001" + /// True when the current test case is capturing packets + private var currentTestCaseShouldCapturePackets = false + + /// True when a packet capture session is active + private var packetCaptureSessionIsActive = false + private var packetCaptureSession: PacketCaptureSession? + // swiftlint:disable force_cast let displayName = Bundle(for: BaseUITestCase.self) .infoDictionary?["DisplayName"] as! String @@ -90,7 +97,7 @@ class BaseUITestCase: XCTestCase { /// Create temporary account without time. Will be created using partner API if token is configured, else falling back to app API func createTemporaryAccountWithoutTime() -> String { - if let partnerApiToken { + if partnerApiToken != nil { let partnerAPIClient = PartnerAPIClient() return partnerAPIClient.createAccount() } else { @@ -136,7 +143,7 @@ class BaseUITestCase: XCTestCase { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) { - let alertAllowButton = springboard.buttons.element(boundBy: 0) + let alertAllowButton = springboard.buttons["Allow"] if alertAllowButton.waitForExistence(timeout: Self.defaultTimeout) { alertAllowButton.tap() } @@ -160,6 +167,29 @@ class BaseUITestCase: XCTestCase { } } + /// Start packet capture for this test case + func startPacketCapture() { + currentTestCaseShouldCapturePackets = true + packetCaptureSessionIsActive = true + let packetCaptureClient = PacketCaptureClient() + packetCaptureSession = packetCaptureClient.startCapture() + } + + /// Stop the current packet capture and return captured traffic + func stopPacketCapture() -> [Stream] { + packetCaptureSessionIsActive = false + guard let packetCaptureSession else { + XCTFail("Trying to stop capture when there is no active capture") + return [] + } + + let packetCaptureAPIClient = PacketCaptureClient() + packetCaptureAPIClient.stopCapture(session: packetCaptureSession) + let capturedData = packetCaptureAPIClient.getParsedCaptureObjects(session: packetCaptureSession) + + return capturedData + } + // MARK: - Setup & teardown /// Override this class function to change the uninstall behaviour in suite level teardown @@ -176,12 +206,42 @@ class BaseUITestCase: XCTestCase { /// Test level setup override func setUp() { + currentTestCaseShouldCapturePackets = false // Reset for each test case run continueAfterFailure = false app.launch() } /// Test level teardown override func tearDown() { + if currentTestCaseShouldCapturePackets { + guard let packetCaptureSession = packetCaptureSession else { + XCTFail("Packet capture session unexpectedly not set up") + return + } + + let packetCaptureClient = PacketCaptureClient() + + // If there's a an active session due to cancelled/failed test run make sure to end it + if packetCaptureSessionIsActive { + packetCaptureSessionIsActive = false + packetCaptureClient.stopCapture(session: packetCaptureSession) + } + + let pcapFileContents = packetCaptureClient.getPCAP(session: packetCaptureSession) + let parsedCapture = packetCaptureClient.getParsedCapture(session: packetCaptureSession) + self.packetCaptureSession = nil + + let pcapAttachment = XCTAttachment(data: pcapFileContents) + pcapAttachment.name = self.name + ".pcap" + pcapAttachment.lifetime = .keepAlways + self.add(pcapAttachment) + + let jsonAttachment = XCTAttachment(data: parsedCapture) + jsonAttachment.name = self.name + ".json" + jsonAttachment.lifetime = .keepAlways + self.add(jsonAttachment) + } + app.terminate() if let testRun = self.testRun, testRun.failureCount > 0, attachAppLogsOnFailure == true { @@ -341,4 +401,5 @@ class BaseUITestCase: XCTestCase { XCTFail("Failed to find 'Delete'") } } + // swiftlint:disable:next file_length } diff --git a/ios/MullvadVPNUITests/ConnectivityTests.swift b/ios/MullvadVPNUITests/ConnectivityTests.swift index 1cafff9ff30c..bcb683583f37 100644 --- a/ios/MullvadVPNUITests/ConnectivityTests.swift +++ b/ios/MullvadVPNUITests/ConnectivityTests.swift @@ -11,7 +11,7 @@ import Network import XCTest class ConnectivityTests: LoggedOutUITestCase { - let firewallAPIClient = FirewallAPIClient() + let firewallAPIClient = FirewallClient() /// Verifies that the app still functions when API has been blocked func testAPIConnectionViaBridges() throws { diff --git a/ios/MullvadVPNUITests/Info.plist b/ios/MullvadVPNUITests/Info.plist index 229e9483278b..2bdf415ada1c 100644 --- a/ios/MullvadVPNUITests/Info.plist +++ b/ios/MullvadVPNUITests/Info.plist @@ -24,10 +24,14 @@ <string>$(IOS_DEVICE_PIN_CODE)</string> <key>NoTimeAccountNumber</key> <string>$(NO_TIME_ACCOUNT_NUMBER)</string> + <key>PacketCaptureAPIBaseURL</key> + <string>$(PACKET_CAPTURE_BASE_URL)</string> <key>PartnerApiToken</key> <string>$(PARTNER_API_TOKEN)</string> <key>ShouldBeReachableDomain</key> <string>$(SHOULD_BE_REACHABLE_DOMAIN)</string> + <key>ShouldBeReachableIPAddress</key> + <string>$(SHOULD_BE_REACHABLE_IP_ADDRESS)</string> <key>TestDeviceIdentifier</key> <string>$(TEST_DEVICE_IDENTIFIER_UUID)</string> <key>TestDeviceIsIPad</key> diff --git a/ios/MullvadVPNUITests/LeakTests.swift b/ios/MullvadVPNUITests/LeakTests.swift new file mode 100644 index 000000000000..17f75683b9b1 --- /dev/null +++ b/ios/MullvadVPNUITests/LeakTests.swift @@ -0,0 +1,104 @@ +// +// LeakTests.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-05-31. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class LeakTests: LoggedInWithTimeUITestCase { + static let capturedStreamStartTimestamp: Double = 8 + static let capturedStreamEndTimestamp: Double = 3 + + override func tearDown() { + FirewallClient().removeRules() + super.tearDown() + } + + /// Send UDP traffic to a host, connect to relay and make sure - while connected to relay - + /// that no leaked traffic went directly to the host + func testConnectionStartedBeforeTunnelShouldNotLeakOutside() throws { + let skipReason = """ + Connections started before the packet tunnel will leak as long as + includeAllNetworks is not set to true when starting the tunnel. + """ + try XCTSkipIf(true, skipReason) + let targetIPAddress = Networking.getAlwaysReachableIPAddress() + startPacketCapture() + let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80) + trafficGenerator.startGeneratingUDPTraffic(interval: 1.0) + + TunnelControlPage(app) + .tapConnectButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForConnectedLabel() + + // Keep the tunnel connection for a while + RunLoop.current.run(until: .now + 30) + + TunnelControlPage(app) + .tapDisconnectButton() + + trafficGenerator.stopGeneratingUDPTraffic() + + var capturedStreams = stopPacketCapture() + // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up + capturedStreams = PacketCaptureClient.trimPackets( + streams: capturedStreams, + secondsStart: Self.capturedStreamStartTimestamp, + secondsEnd: Self.capturedStreamEndTimestamp + ) + LeakCheck.assertNoLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)]) + } + + /// Send UDP traffic to a host, connect to relay and then disconnect to intentionally leak traffic and make sure that the test catches the leak + func testTrafficCapturedOutsideOfTunnelShouldLeak() throws { + let targetIPAddress = Networking.getAlwaysReachableIPAddress() + startPacketCapture() + let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80) + trafficGenerator.startGeneratingUDPTraffic(interval: 1.0) + + TunnelControlPage(app) + .tapConnectButton() + + allowAddVPNConfigurationsIfAsked() + + TunnelControlPage(app) + .waitForConnectedLabel() + + RunLoop.current.run(until: .now + 2) + + TunnelControlPage(app) + .tapDisconnectButton() + + // Give it some time to generate traffic outside of tunnel + RunLoop.current.run(until: .now + 5) + + TunnelControlPage(app) + .tapConnectButton() + + // Keep the tunnel connection for a while + RunLoop.current.run(until: .now + 5) + + TunnelControlPage(app) + .tapDisconnectButton() + + // Keep the capture open for a while + RunLoop.current.run(until: .now + 15) + trafficGenerator.stopGeneratingUDPTraffic() + + var capturedStreams = stopPacketCapture() + // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up + capturedStreams = PacketCaptureClient.trimPackets( + streams: capturedStreams, + secondsStart: Self.capturedStreamStartTimestamp, + secondsEnd: Self.capturedStreamEndTimestamp + ) + LeakCheck.assertLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)]) + } +} diff --git a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift b/ios/MullvadVPNUITests/Networking/FirewallClient.swift similarity index 68% rename from ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift rename to ios/MullvadVPNUITests/Networking/FirewallClient.swift index 917ac13fd71d..35d8d25968cc 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallClient.swift @@ -11,20 +11,16 @@ import SystemConfiguration import UIKit import XCTest -class FirewallAPIClient { +class FirewallClient: TestRouterAPIClient { // swiftlint:disable force_cast - let baseURL = URL( - string: - Bundle(for: FirewallAPIClient.self).infoDictionary?["FirewallApiBaseURL"] as! String - )! - let testDeviceIdentifier = Bundle(for: FirewallAPIClient.self).infoDictionary?["TestDeviceIdentifier"] as! String + let testDeviceIdentifier = Bundle(for: FirewallClient.self).infoDictionary?["TestDeviceIdentifier"] as! String // swiftlint:enable force_cast lazy var sessionIdentifier = "urn:uuid:" + testDeviceIdentifier /// Create a new rule associated to the device under test public func createRule(_ firewallRule: FirewallRule) { - let createRuleURL = baseURL.appendingPathComponent("rule") + let createRuleURL = TestRouterAPIClient.baseURL.appendingPathComponent("rule") var request = URLRequest(url: createRuleURL) request.httpMethod = "POST" @@ -64,7 +60,9 @@ class FirewallAPIClient { } else { if let response = requestResponse as? HTTPURLResponse { if response.statusCode != 201 { - XCTFail("Failed to create firewall rule - unexpected server response") + XCTFail( + "Failed to create firewall rule - unexpected response status code \(response.statusCode)" + ) } } @@ -77,43 +75,9 @@ class FirewallAPIClient { } } - /// Gets the IP address of the device under test - public func getDeviceIPAddress() throws -> String { - let deviceIPURL = baseURL.appendingPathComponent("own-ip") - let request = URLRequest(url: deviceIPURL) - let completionHandlerInvokedExpectation = XCTestExpectation( - description: "Completion handler for the request is invoked" - ) - var deviceIPAddress = "" - var requestError: Error? - - let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in - defer { completionHandlerInvokedExpectation.fulfill() } - guard let data else { - requestError = NetworkingError.internalError(reason: "Could not get device IP") - return - } - - deviceIPAddress = String(data: data, encoding: .utf8)! - } - - dataTask.resume() - - let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) - if waitResult != .completed { - XCTFail("Failed to get device IP address - timeout") - } - - if let requestError { - throw requestError - } - - return deviceIPAddress - } - /// Remove all firewall rules associated to this device under test public func removeRules() { - let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)") + let removeRulesURL = TestRouterAPIClient.baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)") var request = URLRequest(url: removeRulesURL) request.httpMethod = "DELETE" diff --git a/ios/MullvadVPNUITests/Networking/FirewallRule.swift b/ios/MullvadVPNUITests/Networking/FirewallRule.swift index ed5cf01bc741..51d79c1931af 100644 --- a/ios/MullvadVPNUITests/Networking/FirewallRule.swift +++ b/ios/MullvadVPNUITests/Networking/FirewallRule.swift @@ -9,22 +9,16 @@ import Foundation import XCTest -enum NetworkingProtocol: String { - case TCP = "tcp" - case UDP = "udp" - case ICMP = "icmp" -} - struct FirewallRule { let fromIPAddress: String let toIPAddress: String - let protocols: [NetworkingProtocol] + let protocols: [NetworkTransportProtocol] /// - Parameters: /// - fromIPAddress: Block traffic originating from this source IP address. /// - toIPAddress: Block traffic to this destination IP address. /// - protocols: Protocols which should be blocked. If none is specified all will be blocked. - private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkingProtocol]) { + private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkTransportProtocol]) { self.fromIPAddress = fromIPAddress self.toIPAddress = toIPAddress self.protocols = protocols @@ -36,7 +30,7 @@ struct FirewallRule { /// Make a firewall rule blocking API access for the current device under test public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, @@ -46,7 +40,7 @@ struct FirewallRule { } public static func makeBlockAllTrafficRule(toIPAddress: String) throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, @@ -56,7 +50,7 @@ struct FirewallRule { } public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule { - let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress() + let deviceIPAddress = try FirewallClient().getDeviceIPAddress() return FirewallRule( fromIPAddress: deviceIPAddress, diff --git a/ios/MullvadVPNUITests/Networking/LeakCheck.swift b/ios/MullvadVPNUITests/Networking/LeakCheck.swift new file mode 100644 index 000000000000..2a9319976ff0 --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/LeakCheck.swift @@ -0,0 +1,41 @@ +// +// LeakCheck.swift +// MullvadVPN +// +// Created by Niklas Berglund on 2024-12-16. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class LeakCheck { + static func assertNoLeaks(streams: [Stream], rules: [NoTrafficToHostLeakRule]) { + XCTAssertFalse(streams.isEmpty, "No streams to leak check") + XCTAssertFalse(rules.isEmpty, "No leak rules to check") + + for rule in rules where rule.isViolated(streams: streams) { + XCTFail("Leaked traffic destined to \(rule.host) outside of the tunnel connection") + } + } + + static func assertLeaks(streams: [Stream], rules: [NoTrafficToHostLeakRule]) { + XCTAssertFalse(streams.isEmpty, "No streams to leak check") + XCTAssertFalse(rules.isEmpty, "No leak rules to check") + + for rule in rules where rule.isViolated(streams: streams) == false { + XCTFail("Expected to leak traffic to \(rule.host) outside of tunnel") + } + } +} + +class NoTrafficToHostLeakRule { + let host: String + + init(host: String) { + self.host = host + } + + func isViolated(streams: [Stream]) -> Bool { + streams.filter { $0.destinationAddress == host }.isEmpty == false + } +} diff --git a/ios/MullvadVPNUITests/Networking/Networking.swift b/ios/MullvadVPNUITests/Networking/Networking.swift index c260906670e6..27a47f716d8e 100644 --- a/ios/MullvadVPNUITests/Networking/Networking.swift +++ b/ios/MullvadVPNUITests/Networking/Networking.swift @@ -10,6 +10,12 @@ import Foundation import Network import XCTest +enum NetworkTransportProtocol: String, Codable { + case TCP = "tcp" + case UDP = "udp" + case ICMP = "icmp" +} + enum NetworkingError: Error { case notConfiguredError case internalError(reason: String) @@ -32,16 +38,6 @@ class Networking { return adServingDomain } - /// Get configured domain to use for Internet connectivity checks - private static func getAlwaysReachableDomain() throws -> String { - guard let shouldBeReachableDomain = Bundle(for: Networking.self) - .infoDictionary?["ShouldBeReachableDomain"] as? String else { - throw NetworkingError.notConfiguredError - } - - return shouldBeReachableDomain - } - /// Check whether host and port is reachable by attempting to connect a socket private static func canConnectSocket(host: String, port: String) throws -> Bool { let socketHost = NWEndpoint.Host(host) @@ -79,6 +75,26 @@ class Networking { return true } + /// Get configured domain to use for Internet connectivity checks + public static func getAlwaysReachableDomain() throws -> String { + guard let shouldBeReachableDomain = Bundle(for: Networking.self) + .infoDictionary?["ShouldBeReachableDomain"] as? String else { + throw NetworkingError.notConfiguredError + } + + return shouldBeReachableDomain + } + + public static func getAlwaysReachableIPAddress() -> String { + guard let shouldBeReachableIPAddress = Bundle(for: Networking.self) + .infoDictionary?["ShouldBeReachableIPAddress"] as? String else { + XCTFail("Should be reachable IP address not configured") + return String() + } + + return shouldBeReachableIPAddress + } + /// Verify API can be accessed by attempting to connect a socket to the configured API host and port public static func verifyCanAccessAPI() throws { let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress() diff --git a/ios/MullvadVPNUITests/Networking/PacketCapture.swift b/ios/MullvadVPNUITests/Networking/PacketCapture.swift new file mode 100644 index 000000000000..d47e8433bacc --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/PacketCapture.swift @@ -0,0 +1,341 @@ +// +// PacketCapture.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-04-30. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import XCTest + +struct PacketCaptureSession { + var identifier: String + + init(identifier: String = UUID().uuidString) { + self.identifier = identifier + + print("Current Packet Capture session identifier is: \(identifier)") + } +} + +/// Represents a stream in packet capture +class Stream: Codable, Equatable { + static func == (lhs: Stream, rhs: Stream) -> Bool { + return lhs.sourceAddress == rhs.sourceAddress && + lhs.destinationAddress == rhs.destinationAddress && + lhs.flowID == rhs.flowID && + lhs.transportProtocol == rhs.transportProtocol + } + + let sourceAddress: String + let sourcePort: Int + let destinationAddress: String + let destinationPort: Int + let flowID: String? + let transportProtocol: NetworkTransportProtocol + var packets: [Packet] { + didSet { + determineDateInterval() + } + } + + /// Date interval from first to last packet of this stream + var dateInterval: DateInterval + + /// Date interval from first to last tx(sent from test device) packet of this stream + var txInterval: DateInterval? + + /// Date interval from frist to last rx(sent to test device) packet of this stream + var rxInterval: DateInterval? + + enum CodingKeys: String, CodingKey { + case sourceAddress = "peer_addr" + case destinationAddress = "other_addr" + case flowID = "flow_id" + case transportProtocol = "transport_protocol" + case packets + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.flowID = try container.decodeIfPresent(String.self, forKey: .flowID) + self.transportProtocol = try container.decode(NetworkTransportProtocol.self, forKey: .transportProtocol) + self.packets = try container.decode([Packet].self, forKey: .packets) + dateInterval = DateInterval() + + // Separate source address and port + let sourceValue = try container.decode(String.self, forKey: .sourceAddress) + let sourceSplit = sourceValue.components(separatedBy: ":") + self.sourceAddress = try XCTUnwrap(sourceSplit.first) + self.sourcePort = try XCTUnwrap(Int(try XCTUnwrap(sourceSplit.last))) + + // Separate destination address and port + let destinationValue = try container.decode(String.self, forKey: .destinationAddress) + let destinationSplit = destinationValue.components(separatedBy: ":") + self.destinationAddress = try XCTUnwrap(destinationSplit.first) + self.destinationPort = try XCTUnwrap(Int(try XCTUnwrap(destinationSplit.last))) + + // Set date interval based on packets' time window + determineDateInterval() + } + + /// Determine the stream's date interval from the time between first to the last packet + private func determineDateInterval() { + guard packets.isEmpty == false else { + XCTFail("Stream unexpectedly have no packets") + return + } + + // Identify first tx and rx packets to set as initial values + let txPackets = packets.filter { $0.fromPeer == true }.sorted { $0.date < $1.date } + let rxPackets = packets.filter { $0.fromPeer == false }.sorted { $0.date < $1.date } + let allPackets = packets.sorted { $0.date < $1.date } + + if let firstTxPacket = txPackets.first, let lastTxPacket = txPackets.last { + txInterval = DateInterval(start: firstTxPacket.date, end: lastTxPacket.date) + } + + if let firstRxPacket = rxPackets.first, let lastRxPacket = rxPackets.last { + rxInterval = DateInterval(start: firstRxPacket.date, end: lastRxPacket.date) + } + + if let firstPacket = allPackets.first, let lastPacket = allPackets.last { + dateInterval = DateInterval(start: firstPacket.date, end: lastPacket.date) + } + } +} + +/// Represents a packet in packet capture +class Packet: Codable, Equatable { + /// True when packet is sent from device under test, false if from another host + public let fromPeer: Bool + + /// Timestamp in microseconds + private var timestamp: Int64 + + public var date: Date + + enum CodingKeys: String, CodingKey { + case fromPeer = "from_peer" + case timestamp + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fromPeer = try container.decode(Bool.self, forKey: .fromPeer) + timestamp = try container.decode(Int64.self, forKey: .timestamp) / 1000000 + date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + static func == (lhs: Packet, rhs: Packet) -> Bool { + return lhs.fromPeer == rhs.fromPeer && + lhs.timestamp == rhs.timestamp && + lhs.date == rhs.date + } +} + +class PacketCaptureClient: TestRouterAPIClient { + /// Start a new capture session + func startCapture() -> PacketCaptureSession { + let session = PacketCaptureSession() + + let jsonDictionary = [ + "label": session.identifier, + ] + + _ = sendRequest( + httpMethod: "POST", + endpoint: "capture", + contentType: "application/json", + jsonData: jsonDictionary + ) + + return session + } + + /// Stop capture for session + func stopCapture(session: PacketCaptureSession) { + _ = sendJSONRequest(httpMethod: "POST", endpoint: "stop-capture/\(session.identifier)", jsonData: nil) + } + + /// Cut specified number of seconds from the beginning and end of data capture + static func trimPackets(streams: [Stream], secondsStart: Double, secondsEnd: Double) -> [Stream] { + var collectionStartDate: Date? + var collectionEndDate: Date? + + XCTAssertTrue(streams.count >= 1, "Captured streams are empty, expected at least 1") + + for stream in streams { + if collectionStartDate != nil { + collectionStartDate = min(collectionStartDate!, stream.dateInterval.start) + } else { + collectionStartDate = stream.dateInterval.start + } + + if collectionEndDate != nil { + collectionEndDate = max(collectionEndDate!, stream.dateInterval.end) + } else { + collectionEndDate = stream.dateInterval.end + } + } + + let cutStartDate = collectionStartDate!.addingTimeInterval(secondsStart) + let cutEndDate = collectionEndDate!.addingTimeInterval(-secondsEnd) + + var trimmedStreams: [Stream] = [] + for stream in streams { + let packetsWithinTimeframe = stream.packets.filter { packet in + return packet.date >= cutStartDate && packet.date <= cutEndDate + } + + if packetsWithinTimeframe.isEmpty == false { + stream.packets = packetsWithinTimeframe + trimmedStreams.append(stream) + } + } + + return trimmedStreams + } + + /// Get captured traffic from this session parsed to objects + func getParsedCaptureObjects(session: PacketCaptureSession) -> [Stream] { + let parsedData = getParsedCapture(session: session) + let decoder = JSONDecoder() + + do { + let streams = try decoder.decode([Stream].self, from: parsedData) + return streams + } catch { + XCTFail("Failed to decode parsed capture") + return [] + } + } + + /// Get captured traffic from this session parsed to JSON + func getParsedCapture(session: PacketCaptureSession) -> Data { + var deviceIPAddress: String + + do { + deviceIPAddress = try getDeviceIPAddress() + } catch { + XCTFail("Failed to get device IP address") + return Data() + } + + let responseData = sendJSONRequest( + httpMethod: "PUT", + endpoint: "parse-capture/\(session.identifier)", + jsonData: [deviceIPAddress] + ) + + return responseData + } + + /// Get PCAP file contents for the capture of this session + func getPCAP(session: PacketCaptureSession) -> Data { + let response = sendPCAPRequest(httpMethod: "GET", endpoint: "last-capture/\(session.identifier)", jsonData: nil) + return response + } + + private func sendJSONRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data { + let responseData = sendRequest( + httpMethod: httpMethod, + endpoint: endpoint, + contentType: "application/json", + jsonData: jsonData + ) + + guard let responseData else { + XCTFail("Unexpectedly didn't get any data from JSON request") + return Data() + } + + return responseData + } + + private func sendPCAPRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data { + let responseData = sendRequest( + httpMethod: httpMethod, + endpoint: endpoint, + contentType: "application/pcap", + jsonData: jsonData + ) + + guard let responseData else { + XCTFail("Unexpectedly didn't get any data from response") + return Data() + } + + XCTAssertFalse(responseData.isEmpty, "PCAP response data should not be empty") + + return responseData + } + + private func sendRequest(httpMethod: String, endpoint: String, contentType: String?, jsonData: Any?) -> Data? { + let url = TestRouterAPIClient.baseURL.appendingPathComponent(endpoint) + + var request = URLRequest(url: url) + request.httpMethod = httpMethod + + if let contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + if let jsonData = jsonData { + do { + request.httpBody = try JSONSerialization.data(withJSONObject: jsonData) + } catch { + XCTFail("Failed to serialize JSON data") + } + } + + var requestResponse: URLResponse? + var requestError: Error? + var responseData: Data? + + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + + let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in + requestResponse = response + requestError = error + + guard let data = data, + let response = response as? HTTPURLResponse, + error == nil else { + XCTFail("Error: \(error?.localizedDescription ?? "Unknown error")") + return + } + + if 200 ... 204 ~= response.statusCode && error == nil { + responseData = data + } else { + XCTFail("Request failed") + } + + completionHandlerInvokedExpectation.fulfill() + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + + if waitResult != .completed { + XCTFail("Failed to send packet capture API request - timeout") + } else { + if let response = requestResponse as? HTTPURLResponse { + if (200 ... 201 ~= response.statusCode) == false { + XCTFail("Packet capture API request failed - unexpected server response") + } + } + + if let error = requestError { + XCTFail("Packet capture API request failed - encountered error \(error.localizedDescription)") + } + } + + return responseData + } +} diff --git a/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift new file mode 100644 index 000000000000..0d9c9da2587c --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift @@ -0,0 +1,48 @@ +// +// TestRouterAPIClient.swift +// MullvadVPN +// +// Created by Niklas Berglund on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import XCTest + +class TestRouterAPIClient { + // swiftlint:disable:next force_cast + static let baseURL = URL(string: Bundle(for: FirewallClient.self).infoDictionary?["FirewallApiBaseURL"] as! String)! + + /// Gets the IP address of the device under test + public func getDeviceIPAddress() throws -> String { + let deviceIPURL = TestRouterAPIClient.baseURL.appendingPathComponent("own-ip") + let request = URLRequest(url: deviceIPURL) + let completionHandlerInvokedExpectation = XCTestExpectation( + description: "Completion handler for the request is invoked" + ) + var deviceIPAddress = "" + var requestError: Error? + + let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in + defer { completionHandlerInvokedExpectation.fulfill() } + guard let data else { + requestError = NetworkingError.internalError(reason: "Could not get device IP") + return + } + + deviceIPAddress = String(data: data, encoding: .utf8)! + } + + dataTask.resume() + + let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30) + if waitResult != .completed { + XCTFail("Failed to get device IP address - timeout") + } + + if let requestError { + throw requestError + } + + return deviceIPAddress + } +} diff --git a/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift new file mode 100644 index 000000000000..8a911837f5bf --- /dev/null +++ b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift @@ -0,0 +1,178 @@ +// +// TrafficGenerator.swift +// MullvadVPNUITests +// +// Created by Niklas Berglund on 2024-06-25. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Network +import XCTest + +class TrafficGenerator { + let destinationHost: String + let port: Int + var connection: NWConnection + let dispatchQueue = DispatchQueue(label: "TrafficGeneratorDispatchQueue") + var sendDataTimer: DispatchSourceTimer + + init(destinationHost: String, port: Int) { + self.destinationHost = destinationHost + self.port = port + + sendDataTimer = DispatchSource.makeTimerSource(queue: dispatchQueue) + let params = NWParameters.udp + connection = NWConnection( + host: NWEndpoint.Host(destinationHost), + port: NWEndpoint.Port(integerLiteral: UInt16(port)), + using: params + ) + setupConnection() + setupOtherHandlers() + } + + func reconnect() { + print("Attempting to reconnect") + connection.forceCancel() + + connection = createConnection() + setupConnection() + setupOtherHandlers() + } + + func createConnection() -> NWConnection { + let params = NWParameters.udp + return NWConnection( + host: NWEndpoint.Host(destinationHost), + port: NWEndpoint.Port(integerLiteral: UInt16(port)), + using: params + ) + } + + func setupOtherHandlers() { + connection.pathUpdateHandler = { newPath in + let availableInterfaces = newPath.availableInterfaces.map { $0.customDebugDescription } + let availableGateways = newPath.gateways.map { $0.customDebugDescription } + + print("New interfaces available: \(availableInterfaces)") + print("New gateways available: \(availableGateways)") + } + + connection.viabilityUpdateHandler = { newViability in + print("Connection is viable: \(newViability)") + } + + connection.betterPathUpdateHandler = { betterPathAvailable in + print("A better path is available: \(betterPathAvailable)") + } + } + + func setupConnection() { + print("Setting up connection...") + let doneAttemptingConnectExpecation = XCTestExpectation(description: "Done attemping to connect") + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + print("Ready") + self.sendDataTimer.resume() + doneAttemptingConnectExpecation.fulfill() + case let .failed(error): + print("Failed to connect: \(error)") + self.sendDataTimer.cancel() + self.reconnect() + case .preparing: + print("Preparing connection...") + case .setup: + print("Setting upp connection...") + case let .waiting(error): + print("Waiting to connect: \(error)") + case .cancelled: + self.sendDataTimer.suspend() + print("Cancelled connection") + self.reconnect() + default: + break + } + } + connection.start(queue: dispatchQueue) + + XCTWaiter().wait(for: [doneAttemptingConnectExpecation], timeout: 10.0) + } + + public func startGeneratingUDPTraffic(interval: TimeInterval) { + sendDataTimer.schedule(deadline: .now(), repeating: interval) + + sendDataTimer.setEventHandler { + let data = Data("dGhpcyBpcyBqdXN0IHNvbWUgZHVtbXkgZGF0YSB0aGlzIGlzIGp1c3Qgc29tZSBkdW".utf8) + + print("Attempting to send data...") + + if self.connection.state != .ready { + print("Not connected, won't send data") + } else { + self.connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Failed to send data: \(error)") + } else { + print("Data sent") + } + }) + } + } + + sendDataTimer.activate() + } + + public func stopGeneratingUDPTraffic() { + sendDataTimer.cancel() + } +} + +extension NWInterface { + var customDebugDescription: String { + "type: \(type) name: \(self.name) index: \(index)" + } +} + +extension NWInterface.InterfaceType: @retroactive CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .cellular: "Cellular" + case .loopback: "Loopback" + case .other: "Other" + case .wifi: "Wifi" + case .wiredEthernet: "Wired Ethernet" + @unknown default: "Unknown interface type" + } + } +} + +extension NWEndpoint { + var customDebugDescription: String { + switch self { + case let .hostPort(host, port): "host: \(host.customDebugDescription) port: \(port)" + case let .opaque(endpoint): "opaque: \(endpoint.description)" + case let .url(url): "url: \(url)" + case let .service( + name, + type, + domain, + interface + ): "service named:\(name), type:\(type), domain:\(domain), interface:\(interface?.customDebugDescription ?? "[No interface]")" + case let .unix(path): "unix: \(path)" + @unknown default: "Unknown NWEndpoint type" + } + } +} + +extension NWEndpoint.Host { + var customDebugDescription: String { + switch self { + case let .ipv4(IPv4Address): "IPv4: \(IPv4Address)" + case let .ipv6(IPv6Address): "IPv6: \(IPv6Address)" + case let .name(name, interface): "named: \(name), \(interface?.customDebugDescription ?? "[No interface]")" + @unknown default: "Unknown host" + } + } +} diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift index 130b252b794a..d6b896215c47 100644 --- a/ios/MullvadVPNUITests/RelayTests.swift +++ b/ios/MullvadVPNUITests/RelayTests.swift @@ -27,7 +27,7 @@ class RelayTests: LoggedInWithTimeUITestCase { super.tearDown() if removeFirewallRulesInTearDown { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() } } @@ -102,7 +102,7 @@ class RelayTests: LoggedInWithTimeUITestCase { } func testConnectionRetryLogic() throws { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() removeFirewallRulesInTearDown = true addTeardownBlock { @@ -113,7 +113,7 @@ class RelayTests: LoggedInWithTimeUITestCase { let relayInfo = getDefaultRelayInfo() // Run actual test - try FirewallAPIClient().createRule( + try FirewallClient().createRule( FirewallRule.makeBlockAllTrafficRule(toIPAddress: relayInfo.ipAddress) ) @@ -215,7 +215,7 @@ class RelayTests: LoggedInWithTimeUITestCase { /// Test automatic switching to TCP is functioning when UDP traffic to relay is blocked. This test first connects to a realy to get the IP address of it, in order to block UDP traffic to this relay. func testWireGuardOverTCPAutomatically() throws { - FirewallAPIClient().removeRules() + FirewallClient().removeRules() removeFirewallRulesInTearDown = true addTeardownBlock { @@ -226,7 +226,7 @@ class RelayTests: LoggedInWithTimeUITestCase { let relayInfo = getDefaultRelayInfo() // Run actual test - try FirewallAPIClient().createRule( + try FirewallClient().createRule( FirewallRule.makeBlockUDPTrafficRule(toIPAddress: relayInfo.ipAddress) ) diff --git a/ios/MullvadVPNUITests/tests.json b/ios/MullvadVPNUITests/tests.json index 3841f81c2282..ab6a0ff3de64 100644 --- a/ios/MullvadVPNUITests/tests.json +++ b/ios/MullvadVPNUITests/tests.json @@ -4,6 +4,7 @@ "AccountTests", "ConnectivityTests", "CustomListsTests", + "LeakTests", "RelayTests", "SettingsTests" ], diff --git a/mullvad-cli/src/format.rs b/mullvad-cli/src/format.rs index ada97cbd5de3..fdf105e51fca 100644 --- a/mullvad-cli/src/format.rs +++ b/mullvad-cli/src/format.rs @@ -194,7 +194,7 @@ fn print_connection_info( for (name, value) in current_info .into_iter() // Hack that puts important items first, e.g. "Relay" - .sorted_by_key(|(name, _)| name.len()) + .sorted_by_key(|(name, _)| ( name.len(), name.to_owned() )) { let previous_value = previous_info.get(name).and_then(|i| i.clone()); match (value, previous_value) { diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index fbd60a8e79ab..6e91e6efa133 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -790,6 +790,8 @@ impl Daemon { mullvad_types::TUNNEL_FWMARK, #[cfg(target_os = "linux")] mullvad_types::TUNNEL_TABLE_ID, + #[cfg(target_os = "android")] + config.android_context.clone(), ) .await .map_err(Error::RouteManager)?; diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs index 8312657efb1e..6245bd901f7b 100644 --- a/mullvad-jni/src/classes.rs +++ b/mullvad-jni/src/classes.rs @@ -7,13 +7,17 @@ pub const CLASSES: &[&str] = &[ "net/mullvad/mullvadvpn/service/MullvadVpnService", "net/mullvad/talpid/model/InetNetwork", "net/mullvad/talpid/model/TunConfig", + "net/mullvad/talpid/model/NetworkState", + "net/mullvad/talpid/model/RouteInfo", "net/mullvad/talpid/model/CreateTunResult$Success", "net/mullvad/talpid/model/CreateTunResult$InvalidDnsServers", "net/mullvad/talpid/model/CreateTunResult$OtherLegacyAlwaysOnVpn", "net/mullvad/talpid/model/CreateTunResult$OtherAlwaysOnApp", "net/mullvad/talpid/model/CreateTunResult$NotPrepared", - "net/mullvad/talpid/model/CreateTunResult$TunnelDeviceError", + "net/mullvad/talpid/model/CreateTunResult$EstablishError", "net/mullvad/talpid/ConnectivityListener", "net/mullvad/talpid/TalpidVpnService", "net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointOverride", + "net/mullvad/talpid/model/Connectivity$Status", + "net/mullvad/talpid/model/Connectivity$PresumeOnline", ]; diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs index fd35396fd00f..8b1018d926ab 100644 --- a/mullvad-jni/src/lib.rs +++ b/mullvad-jni/src/lib.rs @@ -3,7 +3,6 @@ mod api; mod classes; mod problem_report; -mod talpid_vpn_service; use jnix::{ jni::{ @@ -88,17 +87,25 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initial assert!(ctx.is_none(), "multiple calls to MullvadDaemon.initialize"); let env = JnixEnv::from(env); + let files_dir = pathbuf_from_java(&env, files_directory); + start_logging(&files_dir) + .map_err(Error::InitializeLogging) + .unwrap(); + version::log_version(); + log::info!("Pre-loading classes!"); LOAD_CLASSES.call_once(|| env.preload_classes(classes::CLASSES.iter().cloned())); + log::info!("Done loading classes"); let rpc_socket = pathbuf_from_java(&env, rpc_socket_path); - let files_dir = pathbuf_from_java(&env, files_directory); let cache_dir = pathbuf_from_java(&env, cache_directory); let android_context = ok_or_throw!(&env, create_android_context(&env, vpn_service)); + log::info!("Created Android Context"); let api_endpoint = api::api_endpoint_from_java(&env, api_endpoint); + log::info!("Starting daemon"); let daemon = ok_or_throw!( &env, start( @@ -134,11 +141,8 @@ fn start( rpc_socket: PathBuf, files_dir: PathBuf, cache_dir: PathBuf, - api_endpoint: Option<mullvad_api::ApiEndpoint>, + api_endpoint: Option<ApiEndpoint>, ) -> Result<DaemonContext, Error> { - start_logging(&files_dir).map_err(Error::InitializeLogging)?; - version::log_version(); - #[cfg(not(feature = "api-override"))] if api_endpoint.is_some() { log::warn!("api_endpoint will be ignored since 'api-override' is not enabled"); diff --git a/mullvad-jni/src/talpid_vpn_service.rs b/mullvad-jni/src/talpid_vpn_service.rs deleted file mode 100644 index ea6928538a86..000000000000 --- a/mullvad-jni/src/talpid_vpn_service.rs +++ /dev/null @@ -1,181 +0,0 @@ -use ipnetwork::IpNetwork; -use jnix::jni::{ - objects::JObject, - sys::{jboolean, jint, JNI_FALSE}, - JNIEnv, -}; -use nix::sys::{ - select::{pselect, FdSet}, - time::{TimeSpec, TimeValLike}, -}; -use rand::{thread_rng, Rng}; -use std::{ - io, - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}, - os::unix::io::RawFd, - time::{Duration, Instant}, -}; -use talpid_types::ErrorExt; - -#[derive(Debug, thiserror::Error)] -enum Error { - #[error("Failed to verify the tunnel device")] - VerifyTunDevice(#[from] SendRandomDataError), - - #[error("Failed to select() on tunnel device")] - Select(#[from] nix::Error), - - #[error("Timed out while waiting for tunnel device to receive data")] - TunnelDeviceTimeout, -} - -#[no_mangle] -#[allow(non_snake_case)] -pub extern "system" fn Java_net_mullvad_talpid_TalpidVpnService_waitForTunnelUp( - _: JNIEnv<'_>, - _this: JObject<'_>, - tunFd: jint, - isIpv6Enabled: jboolean, -) { - let tun_fd = tunFd as RawFd; - let is_ipv6_enabled = isIpv6Enabled != JNI_FALSE; - - if let Err(error) = wait_for_tunnel_up(tun_fd, is_ipv6_enabled) { - log::error!( - "{}", - error.display_chain_with_msg("Failed to wait for tunnel device to be usable") - ); - } -} - -fn wait_for_tunnel_up(tun_fd: RawFd, is_ipv6_enabled: bool) -> Result<(), Error> { - let mut fd_set = FdSet::new(); - fd_set.insert(tun_fd); - let timeout = TimeSpec::microseconds(300); - const TIMEOUT: Duration = Duration::from_secs(60); - let start = Instant::now(); - while start.elapsed() < TIMEOUT { - // if tunnel device is ready to be read from, traffic is being routed through it - if pselect(None, Some(&mut fd_set), None, None, Some(&timeout), None)? > 0 { - return Ok(()); - } - // have to add tun_fd back into the bitset - fd_set.insert(tun_fd); - try_sending_random_udp(is_ipv6_enabled)?; - } - - Err(Error::TunnelDeviceTimeout) -} - -#[derive(Debug, thiserror::Error)] -enum SendRandomDataError { - #[error("Failed to bind an UDP socket")] - BindUdpSocket(#[source] io::Error), - - #[error("Failed to send random data through UDP socket")] - SendToUdpSocket(#[source] io::Error), -} - -fn try_sending_random_udp(is_ipv6_enabled: bool) -> Result<(), SendRandomDataError> { - let mut tried_ipv6 = false; - const TIMEOUT: Duration = Duration::from_millis(300); - let start = Instant::now(); - - while start.elapsed() < TIMEOUT { - // TODO: if we are to allow LAN on Android by changing the routes that are stuffed in - // TunConfig, then this should be revisited to be fair between IPv4 and IPv6 - let should_generate_ipv4 = !is_ipv6_enabled || tried_ipv6 || thread_rng().gen(); - let (bound_addr, random_public_addr) = random_socket_addrs(should_generate_ipv4); - - tried_ipv6 |= random_public_addr.ip().is_ipv6(); - - let socket = UdpSocket::bind(bound_addr).map_err(SendRandomDataError::BindUdpSocket)?; - match socket.send_to(&random_data(), random_public_addr) { - Ok(_) => return Ok(()), - // Always retry on IPv6 errors - Err(_) if random_public_addr.ip().is_ipv6() => continue, - Err(_err) if matches!(_err.raw_os_error(), Some(22) | Some(101)) => { - // Error code 101 - specified network is unreachable - // Error code 22 - specified address is not usable - continue; - } - Err(err) => return Err(SendRandomDataError::SendToUdpSocket(err)), - } - } - Ok(()) -} - -fn random_data() -> Vec<u8> { - let mut buf = vec![0u8; thread_rng().gen_range(17..214)]; - thread_rng().fill(buf.as_mut_slice()); - buf -} - -/// Returns a random local and public destination socket address. -/// If `ipv4` is true, then IPv4 addresses will be returned. Otherwise, IPv6 addresses will be -/// returned. -fn random_socket_addrs(ipv4: bool) -> (SocketAddr, SocketAddr) { - loop { - let rand_port = thread_rng().gen(); - let (local_addr, rand_dest_addr) = if ipv4 { - let mut ipv4_bytes = [0u8; 4]; - thread_rng().fill(&mut ipv4_bytes); - ( - SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0), - SocketAddr::new(IpAddr::from(ipv4_bytes), rand_port), - ) - } else { - let mut ipv6_bytes = [0u8; 16]; - thread_rng().fill(&mut ipv6_bytes); - ( - SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0), - SocketAddr::new(IpAddr::from(ipv6_bytes), rand_port), - ) - }; - - // TODO: once https://github.com/rust-lang/rust/issues/27709 is resolved, please use - // `is_global()` to check if a new address should be attempted. - if !is_public_ip(rand_dest_addr.ip()) { - continue; - } - - return (local_addr, rand_dest_addr); - } -} - -fn is_public_ip(addr: IpAddr) -> bool { - match addr { - IpAddr::V4(ipv4) => { - // 0.x.x.x is not a publicly routable address - if ipv4.octets()[0] == 0u8 { - return false; - } - } - IpAddr::V6(ipv6) => { - if ipv6.segments()[0] == 0u16 { - return false; - } - } - } - // A non-exhaustive list of non-public subnets - let publicly_unroutable_subnets: Vec<IpNetwork> = vec![ - // IPv4 local networks - "10.0.0.0/8".parse().unwrap(), - "172.16.0.0/12".parse().unwrap(), - "192.168.0.0/16".parse().unwrap(), - // IPv4 non-forwardable network - "169.254.0.0/16".parse().unwrap(), - "192.0.0.0/8".parse().unwrap(), - // Documentation networks - "192.0.2.0/24".parse().unwrap(), - "198.51.100.0/24".parse().unwrap(), - "203.0.113.0/24".parse().unwrap(), - // IPv6 publicly unroutable networks - "fc00::/7".parse().unwrap(), - "fe80::/10".parse().unwrap(), - ]; - - !publicly_unroutable_subnets - .iter() - .any(|net| net.contains(addr)) -} diff --git a/scripts/utils/gh-ready-check b/scripts/utils/gh-ready-check new file mode 100755 index 000000000000..164b5de903f6 --- /dev/null +++ b/scripts/utils/gh-ready-check @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# This script controls that the gh (GitHub CLI) command is installed and authenticated. This can be +# called in the beginning of all scripts depending on gh. + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# shellcheck source=scripts/utils/log +source ./log + +if ! command -v gh > /dev/null; then + log_error "gh (GitHub CLI) is required to run this script" + exit 1 +fi + +if ! gh auth status > /dev/null; then + log_error "Authentication through gh (GitHub CLI) is required to run this script" + exit 1 +fi diff --git a/scripts/utils/print-and-run b/scripts/utils/print-and-run new file mode 100755 index 000000000000..b323040f3c99 --- /dev/null +++ b/scripts/utils/print-and-run @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# This is a small utility script that can be sourced to provide a function that prints its arguments +# and then calls them as a command, e.g. `print_and_run sleep 5`. + +function print_and_run { + echo "+ $*" + "$@" +} diff --git a/talpid-core/src/connectivity_listener.rs b/talpid-core/src/connectivity_listener.rs index 9bdf4bf87ab7..a0c33a77f2b4 100644 --- a/talpid-core/src/connectivity_listener.rs +++ b/talpid-core/src/connectivity_listener.rs @@ -98,36 +98,34 @@ impl ConnectivityListener { /// Return the current offline/connectivity state pub fn connectivity(&self) -> Connectivity { - self.get_is_connected() - .map(|connected| Connectivity::Status { connected }) - .unwrap_or_else(|error| { - log::error!( - "{}", - error.display_chain_with_msg("Failed to check connectivity status") - ); - Connectivity::PresumeOnline - }) + self.get_is_connected().unwrap_or_else(|error| { + log::error!( + "{}", + error.display_chain_with_msg("Failed to check connectivity status") + ); + Connectivity::PresumeOnline + }) } - fn get_is_connected(&self) -> Result<bool, Error> { + fn get_is_connected(&self) -> Result<Connectivity, Error> { let env = JnixEnv::from( self.jvm .attach_current_thread_as_daemon() .map_err(Error::AttachJvmToThread)?, ); - let is_connected = - env.call_method(self.android_listener.as_obj(), "isConnected", "()Z", &[]); - - match is_connected { - Ok(JValue::Bool(JNI_TRUE)) => Ok(true), - Ok(JValue::Bool(_)) => Ok(false), - value => Err(Error::InvalidMethodResult( - "ConnectivityListener", + let is_connected = env + .call_method( + self.android_listener.as_obj(), "isConnected", - format!("{:?}", value), - )), - } + "()Lnet/mullvad/talpid/model/Connectivity;", + &[], + ) + .expect("Missing isConnected") + .l() + .expect("isConnected is not an object"); + + Ok(Connectivity::from_java(&env, is_connected)) } /// Return the current DNS servers according to Android @@ -160,9 +158,10 @@ impl ConnectivityListener { #[no_mangle] #[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyConnectivityChange( - _: JNIEnv<'_>, - _: JObject<'_>, - connected: jboolean, + _env: JNIEnv<'_>, + _obj: JObject<'_>, + is_ipv4: jboolean, + is_ipv6: jboolean, ) { let Some(tx) = &*CONNECTIVITY_TX.lock().unwrap() else { // No sender has been registered @@ -170,10 +169,14 @@ pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyConnec return; }; - let connected = JNI_TRUE == connected; + let is_ipv4 = JNI_TRUE == is_ipv4; + let is_ipv6 = JNI_TRUE == is_ipv6; if tx - .unbounded_send(Connectivity::Status { connected }) + .unbounded_send(Connectivity::Status { + ipv4: is_ipv4, + ipv6: is_ipv6, + }) .is_err() { log::warn!("Failed to send offline change event"); diff --git a/talpid-core/src/tunnel_state_machine/connecting_state.rs b/talpid-core/src/tunnel_state_machine/connecting_state.rs index 7c7637cd20c2..9060787536db 100644 --- a/talpid-core/src/tunnel_state_machine/connecting_state.rs +++ b/talpid-core/src/tunnel_state_machine/connecting_state.rs @@ -138,6 +138,7 @@ impl ConnectingState { &shared_values.route_manager, retry_attempt, ); + let params = connecting_state.tunnel_parameters.clone(); ( Box::new(connecting_state), diff --git a/talpid-routing/Cargo.toml b/talpid-routing/Cargo.toml index 14f30b83338a..b4d3e2a747d9 100644 --- a/talpid-routing/Cargo.toml +++ b/talpid-routing/Cargo.toml @@ -16,10 +16,11 @@ futures = { workspace = true } ipnetwork = { workspace = true } log = { workspace = true } tokio = { workspace = true, features = ["process", "rt-multi-thread", "net", "io-util", "time"] } - -[target.'cfg(not(target_os="android"))'.dependencies] talpid-types = { path = "../talpid-types" } +[target.'cfg(target_os = "android")'.dependencies] +jnix = { version = "0.5.2", features = ["derive"] } + [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" rtnetlink = "0.11" diff --git a/talpid-routing/src/lib.rs b/talpid-routing/src/lib.rs index b80f96ccdc56..89effbdd466b 100644 --- a/talpid-routing/src/lib.rs +++ b/talpid-routing/src/lib.rs @@ -24,7 +24,10 @@ mod imp; use netlink_packet_route::rtnl::constants::RT_TABLE_MAIN; #[cfg(target_os = "macos")] -pub use imp::{imp::RouteError, DefaultRouteEvent, PlatformError}; +pub use imp::{ + imp::{DefaultRouteEvent, RouteError}, + PlatformError, +}; pub use imp::{Error, RouteManagerHandle}; @@ -70,6 +73,7 @@ pub struct Gateway { } /// A network route with a specific network node, destination and an optional metric. +#[cfg(not(target_os = "android"))] #[derive(Debug, Hash, Eq, PartialEq, Clone)] pub struct Route { node: Node, @@ -81,8 +85,14 @@ pub struct Route { mtu: Option<u32>, } +/// A network route with a specific network node, destination and an optional metric. +#[cfg(target_os = "android")] +#[derive(Debug, Hash, Eq, PartialEq, Clone)] +pub struct Route(IpNetwork); + impl Route { /// Construct a new Route + #[cfg(not(target_os = "android"))] pub fn new(node: Node, prefix: IpNetwork) -> Self { Self { node, @@ -95,6 +105,12 @@ impl Route { } } + /// Construct a new Route + #[cfg(target_os = "android")] + pub fn new(prefix: IpNetwork) -> Self { + Self(prefix) + } + #[cfg(target_os = "linux")] fn table(mut self, new_id: u32) -> Self { self.table_id = new_id; @@ -102,11 +118,13 @@ impl Route { } /// Returns the network node of the route. + #[cfg(target_os = "linux")] pub fn get_node(&self) -> &Node { &self.node } } +#[cfg(target_os = "linux")] impl fmt::Display for Route { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} via {}", self.prefix, self.node)?; @@ -123,9 +141,22 @@ impl fmt::Display for Route { } } +#[cfg(target_os = "android")] +impl From<&talpid_types::android::RouteInfo> for Route { + fn from(route_info: &talpid_types::android::RouteInfo) -> Self { + let network = IpNetwork::new( + route_info.destination.address, + route_info.destination.prefix_length as u8, + ) + .unwrap(); + Self::new(network) + } +} + /// A network route that should be applied by the route manager. /// It can either be routed through a specific network node or it can be routed through the current /// default route. +#[cfg(not(target_os = "android"))] #[derive(Debug, Hash, Eq, PartialEq, Clone)] pub struct RequiredRoute { /// Route's prefix @@ -139,6 +170,7 @@ pub struct RequiredRoute { mtu: Option<u16>, } +#[cfg(not(target_os = "android"))] impl RequiredRoute { /// Constructs a new required route. pub fn new(prefix: IpNetwork, node: impl Into<NetNode>) -> Self { diff --git a/talpid-routing/src/unix/android.rs b/talpid-routing/src/unix/android.rs index 8abb23859bfd..be9f8b7d6ade 100644 --- a/talpid-routing/src/unix/android.rs +++ b/talpid-routing/src/unix/android.rs @@ -1,37 +1,232 @@ -use crate::imp::RouteManagerCommand; -use futures::{channel::mpsc, stream::StreamExt}; +use std::collections::HashSet; +use std::ops::{ControlFlow, Not}; +use std::sync::Mutex; + +use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use futures::channel::oneshot; +use futures::future::FutureExt; +use futures::select_biased; +use futures::stream::StreamExt; +use jnix::jni::objects::JValue; +use jnix::jni::{objects::JObject, JNIEnv}; +use jnix::{FromJava, JnixEnv}; + +use talpid_types::android::{AndroidContext, NetworkState}; + +use crate::{imp::RouteManagerCommand, Route}; /// Stub error type for routing errors on Android. +/// Errors that occur while setting up VpnService tunnel. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Timed out when waiting for network routes. + #[error("Timed out when waiting for network routes")] + RoutesTimedOut, +} + +/// Internal errors that may only happen during the initial poll for [NetworkState]. #[derive(Debug, thiserror::Error)] -#[error("Failed to send shutdown result")] -pub struct Error; +enum JvmError { + #[error("Failed to attach Java VM to tunnel thread")] + AttachJvmToThread(#[source] jnix::jni::errors::Error), + #[error("Failed to call Java method {0}")] + CallMethod(&'static str, #[source] jnix::jni::errors::Error), + #[error("Failed to create global reference to Java object")] + CreateGlobalRef(#[source] jnix::jni::errors::Error), + #[error("Received an invalid result from {0}.{1}: {2}")] + InvalidMethodResult(&'static str, &'static str, String), +} + +/// The sender used by [Java_net_mullvad_talpid_ConnectivityListener_notifyDefaultNetworkChange] +/// to notify the route manager of changes to the network. +static ROUTE_UPDATES_TX: Mutex<Option<UnboundedSender<Option<NetworkState>>>> = Mutex::new(None); + +/// Android route manager actor. +#[derive(Debug)] +pub struct RouteManagerImpl { + /// The receiving channel for updates on changes to the network. + network_state_updates: UnboundedReceiver<Option<NetworkState>>, -/// Stub route manager for Android -pub struct RouteManagerImpl {} + /// Cached [NetworkState]. If no update events have been received yet, this value will be [None]. + last_state: Option<NetworkState>, + + /// Clients waiting on response to [RouteManagerCommand::WaitForRoutes]. + waiting_for_routes: Vec<oneshot::Sender<()>>, +} impl RouteManagerImpl { #[allow(clippy::unused_async)] - pub async fn new() -> Result<Self, Error> { - Ok(RouteManagerImpl {}) + pub async fn new(android_context: AndroidContext) -> Result<Self, Error> { + // Create a channel between the kotlin client and route manager + let (tx, rx) = futures::channel::mpsc::unbounded(); + + *ROUTE_UPDATES_TX.lock().unwrap() = Some(tx); + + // Try to poll for the current network state at startup. + // This will most likely be null, but it covers the edge case where a NetworkState + // update has been emitted before we anyone starts to listen for route updates some + // time in the future (when connecting). + let last_state = match current_network_state(android_context) { + Ok(initial_state) => initial_state, + Err(err) => { + log::error!("Failed while polling for initial NetworkState"); + log::error!("{err}"); + None + } + }; + + let route_manager = RouteManagerImpl { + network_state_updates: rx, + last_state, + waiting_for_routes: Default::default(), + }; + + Ok(route_manager) } pub(crate) async fn run( - self, + mut self, manage_rx: mpsc::UnboundedReceiver<RouteManagerCommand>, ) -> Result<(), Error> { let mut manage_rx = manage_rx.fuse(); - while let Some(command) = manage_rx.next().await { - match command { - RouteManagerCommand::Shutdown(tx) => { - tx.send(()).map_err(|()| Error)?; - break; + + loop { + select_biased! { + command = manage_rx.next().fuse() => { + let Some(command) = command else { break }; + if self.handle_command(command).is_break() { + break; + } } - RouteManagerCommand::AddRoutes(_routes, tx) => { - let _ = tx.send(Ok(())); + + network_state_update = self.network_state_updates.next().fuse() => { + // None means that the sender was dropped + let Some(network_state) = network_state_update else { break }; + // update the last known NetworkState + self.last_state = network_state; + + if has_routes(self.last_state.as_ref()) { + // notify waiting clients that routes exist + for client in self.waiting_for_routes.drain(..) { + let _ = client.send(()); + } + } } - RouteManagerCommand::ClearRoutes => (), } } + + log::debug!("RouteManager exited"); + Ok(()) } + + fn handle_command(&mut self, command: RouteManagerCommand) -> ControlFlow<()> { + match command { + RouteManagerCommand::Shutdown(tx) => { + let _ = tx.send(()); + return ControlFlow::Break(()); + } + RouteManagerCommand::WaitForRoutes(response_tx) => { + // check if routes have already been configured on the Android system. + // otherwise, register a listener for network state changes. + // routes may come in at any moment in the future. + if has_routes(self.last_state.as_ref()) { + let _ = response_tx.send(()); + } else { + self.waiting_for_routes.push(response_tx); + } + } + } + + ControlFlow::Continue(()) + } +} + +/// Check whether the [NetworkState] contains any routes. +/// +/// Since we are the ones telling Android what routes to set, we make the assumption that: +/// If any routes exist whatsoever, they are the the routes we specified. +fn has_routes(state: Option<&NetworkState>) -> bool { + let Some(network_state) = state else { + return false; + }; + configured_routes(network_state).is_empty().not() +} + +fn configured_routes(state: &NetworkState) -> HashSet<Route> { + match &state.routes { + None => Default::default(), + Some(route_info) => route_info.iter().map(Route::from).collect(), + } +} + +/// Entry point for Android Java code to notify the current default network state. +#[no_mangle] +#[allow(non_snake_case)] +pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyDefaultNetworkChange( + env: JNIEnv<'_>, + _: JObject<'_>, + network_state: JObject<'_>, +) { + let env = JnixEnv::from(env); + + let network_state: Option<NetworkState> = FromJava::from_java(&env, network_state); + + let Some(tx) = &*ROUTE_UPDATES_TX.lock().unwrap() else { + // No sender has been registered + log::error!("Received routes notification wíth no channel"); + return; + }; + + log::trace!("Received network state update {:#?}", network_state); + + if tx.unbounded_send(network_state).is_err() { + log::warn!("Failed to send offline change event"); + } +} + +/// Return the current NetworkState according to Android +fn current_network_state( + android_context: AndroidContext, +) -> Result<Option<NetworkState>, JvmError> { + let env = JnixEnv::from( + android_context + .jvm + .attach_current_thread_as_daemon() + .map_err(JvmError::AttachJvmToThread)?, + ); + + let result = env + .call_method( + android_context.vpn_service.as_obj(), + "getConnectivityListener", + "()Lnet/mullvad/talpid/ConnectivityListener;", + &[], + ) + .map_err(|cause| JvmError::CallMethod("getConnectivityListener", cause))?; + + let connectivity_listener = match result { + JValue::Object(object) => env + .new_global_ref(object) + .map_err(JvmError::CreateGlobalRef)?, + value => { + return Err(JvmError::InvalidMethodResult( + "MullvadVpnService", + "getConnectivityListener", + format!("{:?}", value), + )) + } + }; + + let network_state = env + .call_method( + connectivity_listener.as_obj(), + "getCurrentDefaultNetworkState", + "()Lnet/mullvad/talpid/model/NetworkState;", + &[], + ) + .map_err(|cause| JvmError::CallMethod("getCurrentDefaultNetworkState", cause))?; + + let network_state: Option<NetworkState> = FromJava::from_java(&env, network_state); + Ok(network_state) } diff --git a/talpid-routing/src/unix/linux.rs b/talpid-routing/src/unix/linux.rs index 92b4513301d3..a43f0690bc3b 100644 --- a/talpid-routing/src/unix/linux.rs +++ b/talpid-routing/src/unix/linux.rs @@ -86,6 +86,7 @@ pub type Result<T> = std::result::Result<T, Error>; /// Errors that can happen in the Linux routing integration #[derive(thiserror::Error, Debug)] +#[allow(missing_docs)] pub enum Error { #[error("Failed to open a netlink connection")] Connect(#[source] io::Error), diff --git a/talpid-routing/src/unix/macos/mod.rs b/talpid-routing/src/unix/macos/mod.rs index 85a020ba797f..df89767e38fa 100644 --- a/talpid-routing/src/unix/macos/mod.rs +++ b/talpid-routing/src/unix/macos/mod.rs @@ -16,9 +16,10 @@ use std::{ use talpid_types::ErrorExt; use watch::RoutingTable; -use super::{DefaultRouteEvent, RouteManagerCommand}; +use super::RouteManagerCommand; use data::{Destination, RouteDestination, RouteMessage, RouteSocketMessage}; +pub use super::DefaultRouteEvent; pub use interface::DefaultRoute; mod data; diff --git a/talpid-routing/src/unix/mod.rs b/talpid-routing/src/unix/mod.rs index 34d2570137c6..042360d52027 100644 --- a/talpid-routing/src/unix/mod.rs +++ b/talpid-routing/src/unix/mod.rs @@ -1,18 +1,23 @@ -#[cfg(target_os = "linux")] -use crate::Route; #[cfg(target_os = "macos")] pub use crate::{imp::imp::DefaultRoute, Gateway}; +#[cfg(any(target_os = "linux", target_os = "macos"))] use super::RequiredRoute; +#[cfg(target_os = "linux")] +use super::Route; use futures::channel::{ mpsc::{self, UnboundedSender}, oneshot, }; -use std::{collections::HashSet, sync::Arc}; +use std::sync::Arc; +#[cfg(target_os = "android")] +use talpid_types::android::AndroidContext; #[cfg(any(target_os = "linux", target_os = "macos"))] use futures::stream::Stream; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use std::collections::HashSet; #[cfg(target_os = "linux")] use std::net::IpAddr; @@ -32,6 +37,7 @@ mod imp; #[path = "android.rs"] mod imp; +#[cfg(any(target_os = "macos", target_os = "linux"))] pub use imp::Error as PlatformError; /// Errors that can be encountered whilst interacting with a [RouteManagerHandle]. @@ -97,11 +103,7 @@ pub(crate) enum RouteManagerCommand { #[cfg(target_os = "android")] #[derive(Debug)] pub(crate) enum RouteManagerCommand { - AddRoutes( - HashSet<RequiredRoute>, - oneshot::Sender<Result<(), PlatformError>>, - ), - ClearRoutes, + WaitForRoutes(oneshot::Sender<()>), Shutdown(oneshot::Sender<()>), } @@ -165,6 +167,7 @@ impl RouteManagerHandle { pub async fn spawn( #[cfg(target_os = "linux")] fwmark: u32, #[cfg(target_os = "linux")] table_id: u32, + #[cfg(target_os = "android")] android_context: AndroidContext, ) -> Result<Self, Error> { let (manage_tx, manage_rx) = mpsc::unbounded(); let manage_tx = Arc::new(manage_tx); @@ -175,6 +178,8 @@ impl RouteManagerHandle { table_id, #[cfg(target_os = "macos")] Arc::downgrade(&manage_tx), + #[cfg(target_os = "android")] + android_context, ) .await?; tokio::spawn(manager.run(manage_rx)); @@ -192,6 +197,7 @@ impl RouteManagerHandle { } /// Applies the given routes until they are cleared + #[cfg(not(target_os = "android"))] pub async fn add_routes(&self, routes: HashSet<RequiredRoute>) -> Result<(), Error> { let (result_tx, result_rx) = oneshot::channel(); self.tx @@ -204,13 +210,43 @@ impl RouteManagerHandle { .map_err(Error::PlatformError) } + /// Wait for routes to come up. + /// + /// This function is guaranteed to *not* wait for longer than 2 seconds. + /// Please, see the implementation of this function for further details. + #[cfg(target_os = "android")] + pub async fn wait_for_routes(&self) -> Result<(), Error> { + use std::time::Duration; + use tokio::time::timeout; + /// Maximum time to wait for routes to come up. The expected mean time is low (~200 ms), but + /// we add some additional margin to give some slack to slower hardware primarily. + const WAIT_FOR_ROUTES_TIMEOUT: Duration = Duration::from_secs(2); + + let (result_tx, result_rx) = oneshot::channel(); + self.tx + .unbounded_send(RouteManagerCommand::WaitForRoutes(result_tx)) + .map_err(|_| Error::RouteManagerDown)?; + + timeout(WAIT_FOR_ROUTES_TIMEOUT, result_rx) + .await + .map_err(|_error| Error::PlatformError(imp::Error::RoutesTimedOut))? + .map_err(|_| Error::ManagerChannelDown) + } + /// Removes all routes previously applied in [`RouteManagerHandle::add_routes`]. + #[cfg(not(target_os = "android"))] pub fn clear_routes(&self) -> Result<(), Error> { self.tx .unbounded_send(RouteManagerCommand::ClearRoutes) .map_err(|_| Error::RouteManagerDown) } + /// (Android) This is a noop since we don't directly control the routes on Android. + #[cfg(target_os = "android")] + pub fn clear_routes(&self) -> Result<(), Error> { + Ok(()) + } + /// Listen for non-tunnel default route changes. #[cfg(target_os = "macos")] pub async fn default_route_listener( diff --git a/talpid-tunnel/src/tun_provider/android/mod.rs b/talpid-tunnel/src/tun_provider/android/mod.rs index 3d356e50d328..f285b4a64ca1 100644 --- a/talpid-tunnel/src/tun_provider/android/mod.rs +++ b/talpid-tunnel/src/tun_provider/android/mod.rs @@ -46,6 +46,9 @@ pub enum Error { #[error("Failed to create tunnel device")] TunnelDeviceError, + #[error("Routes timed out")] + RoutesTimedOut, + #[error("Profile for VPN has not been setup")] NotPrepared, @@ -381,7 +384,7 @@ impl AsRawFd for VpnServiceTun { enum CreateTunResult { Success { tun_fd: i32 }, InvalidDnsServers { addresses: Vec<IpAddr> }, - TunnelDeviceError, + EstablishError, OtherLegacyAlwaysOnVpn, OtherAlwaysOnApp { app_name: String }, NotPrepared, @@ -394,7 +397,7 @@ impl From<CreateTunResult> for Result<RawFd, Error> { CreateTunResult::InvalidDnsServers { addresses } => { Err(Error::InvalidDnsServers(addresses)) } - CreateTunResult::TunnelDeviceError => Err(Error::TunnelDeviceError), + CreateTunResult::EstablishError => Err(Error::TunnelDeviceError), CreateTunResult::OtherLegacyAlwaysOnVpn => Err(Error::OtherLegacyAlwaysOnVpn), CreateTunResult::OtherAlwaysOnApp { app_name } => { Err(Error::OtherAlwaysOnApp { app_name }) diff --git a/talpid-types/src/android/mod.rs b/talpid-types/src/android/mod.rs index 4169216f3b5e..e4326fe81f50 100644 --- a/talpid-types/src/android/mod.rs +++ b/talpid-types/src/android/mod.rs @@ -1,8 +1,61 @@ +use ipnetwork::{IpNetwork, IpNetworkError, Ipv4Network, Ipv6Network}; use jnix::jni::{objects::GlobalRef, JavaVM}; +use jnix::{FromJava, IntoJava}; +use std::net::IpAddr; use std::sync::Arc; +/// What Java calls an [IpAddr] +pub type InetAddress = IpAddr; + #[derive(Clone)] pub struct AndroidContext { pub jvm: Arc<JavaVM>, pub vpn_service: GlobalRef, } + +/// A Java-compatible variant of [IpNetwork] +#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)] +#[jnix(package = "net.mullvad.talpid.model")] +pub struct InetNetwork { + pub address: IpAddr, + pub prefix_length: i16, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)] +#[jnix(package = "net.mullvad.talpid.model")] +pub struct RouteInfo { + pub destination: InetNetwork, + pub gateway: Option<InetAddress>, + pub interface_name: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)] +#[jnix(package = "net.mullvad.talpid.model")] +pub struct NetworkState { + pub network_handle: i64, + pub routes: Option<Vec<RouteInfo>>, + pub dns_servers: Option<Vec<InetAddress>>, +} + +impl From<IpNetwork> for InetNetwork { + fn from(ip_network: IpNetwork) -> Self { + InetNetwork { + address: ip_network.ip(), + prefix_length: ip_network.prefix() as i16, + } + } +} + +impl TryFrom<InetNetwork> for IpNetwork { + type Error = IpNetworkError; + fn try_from(inet_network: InetNetwork) -> Result<Self, Self::Error> { + Ok(match inet_network.address { + IpAddr::V4(addr) => { + IpNetwork::V4(Ipv4Network::new(addr, inet_network.prefix_length as u8)?) + } + IpAddr::V6(addr) => { + IpNetwork::V6(Ipv6Network::new(addr, inet_network.prefix_length as u8)?) + } + }) + } +} diff --git a/talpid-types/src/net/mod.rs b/talpid-types/src/net/mod.rs index 2d67fc5cfe53..e04da4acc519 100644 --- a/talpid-types/src/net/mod.rs +++ b/talpid-types/src/net/mod.rs @@ -1,4 +1,5 @@ use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}; +use jnix::FromJava; use obfuscation::ObfuscatorConfig; use serde::{Deserialize, Serialize}; #[cfg(windows)] @@ -564,20 +565,15 @@ pub fn all_of_the_internet() -> Vec<ipnetwork::IpNetwork> { /// /// Information about the host's connectivity, such as the preesence of /// configured IPv4 and/or IPv6. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, FromJava)] +#[jnix(package = "net.mullvad.talpid.model")] pub enum Connectivity { - #[cfg(not(target_os = "android"))] Status { /// Whether IPv4 connectivity seems to be available on the host. ipv4: bool, /// Whether IPv6 connectivity seems to be available on the host. ipv6: bool, }, - #[cfg(target_os = "android")] - Status { - /// Whether _any_ connectivity seems to be available on the host. - connected: bool, - }, /// On/offline status could not be verified, but we have no particular /// reason to believe that the host is offline. PresumeOnline, @@ -591,7 +587,6 @@ impl Connectivity { /// If no IP4 nor IPv6 routes exist, we have no way of reaching the internet /// so we consider ourselves offline. - #[cfg(not(target_os = "android"))] pub fn is_offline(&self) -> bool { matches!( self, @@ -605,23 +600,7 @@ impl Connectivity { /// Whether IPv6 connectivity seems to be available on the host. /// /// If IPv6 status is unknown, `false` is returned. - #[cfg(not(target_os = "android"))] pub fn has_ipv6(&self) -> bool { matches!(self, Connectivity::Status { ipv6: true, .. }) } - - /// Whether IPv6 connectivity seems to be available on the host. - /// - /// If IPv6 status is unknown, `false` is returned. - #[cfg(target_os = "android")] - pub fn has_ipv6(&self) -> bool { - self.is_online() - } - - /// If the host does not have configured IPv6 routes, we have no way of - /// reaching the internet so we consider ourselves offline. - #[cfg(target_os = "android")] - pub fn is_offline(&self) -> bool { - matches!(self, Connectivity::Status { connected: false }) - } } diff --git a/talpid-wireguard/src/lib.rs b/talpid-wireguard/src/lib.rs index af8e2da79e82..fe1a848e9a74 100644 --- a/talpid-wireguard/src/lib.rs +++ b/talpid-wireguard/src/lib.rs @@ -403,7 +403,6 @@ impl WireguardMonitor { let desired_mtu = get_desired_mtu(params); let mut config = Config::from_parameters(params, desired_mtu).map_err(Error::WireguardConfigError)?; - let (close_obfs_sender, close_obfs_listener) = sync_mpsc::channel(); // Start obfuscation server and patch the WireGuard config to point the endpoint to it. let obfuscator = args @@ -466,6 +465,13 @@ impl WireguardMonitor { .on_event(TunnelEvent::InterfaceUp(metadata.clone(), allowed_traffic)) .await; + // Wait for routes to come up + args.route_manager + .wait_for_routes() + .await + .map_err(Error::SetupRoutingError) + .map_err(CloseMsg::SetupError)?; + if should_negotiate_ephemeral_peer { let ephemeral_obfs_sender = close_obfs_sender.clone(); diff --git a/talpid-wireguard/src/wireguard_go/mod.rs b/talpid-wireguard/src/wireguard_go/mod.rs index a3045659672e..db74ef3bacd6 100644 --- a/talpid-wireguard/src/wireguard_go/mod.rs +++ b/talpid-wireguard/src/wireguard_go/mod.rs @@ -361,8 +361,13 @@ impl WgGoTunnel { tun_config.addresses = config.tunnel.addresses.clone(); tun_config.ipv4_gateway = config.ipv4_gateway; tun_config.ipv6_gateway = config.ipv6_gateway; - tun_config.routes = routes.collect(); tun_config.mtu = config.mtu; + tun_config.routes = if cfg!(target_os = "android") { + // Route everything into the tunnel and have wireguard-go act as a firewall. + vec!["0.0.0.0/0".parse().unwrap(), "::/0".parse().unwrap()] + } else { + routes.collect() + }; for _ in 1..=MAX_PREPARE_TUN_ATTEMPTS { let tunnel_device = tun_provider diff --git a/wireguard-go-rs/Cargo.toml b/wireguard-go-rs/Cargo.toml index b00444b7fb39..d86cb6153529 100644 --- a/wireguard-go-rs/Cargo.toml +++ b/wireguard-go-rs/Cargo.toml @@ -12,10 +12,13 @@ thiserror.workspace = true log.workspace = true zeroize = "1.8.1" -# The app does not depend on maybenot-ffi itself, but adds it as a dependency to expose FFI symbols to wireguard-go. -# This is done, instead of using the makefile in wireguard-go to build maybenot-ffi into its archive, to prevent -# name clashes induced by link-time optimization. -# NOTE: the version of maybenot-ffi below must be the same as the version checked into the wireguard-go submodule +# On platforms where maybenot and wireguard-go can be built statically (Linux and macOS) we use +# this hack to include it. The hack is that we depend on this crate here even if neither +# wireguard-go-rs nor its upstream dependants use it. +# This is only here so that maybenot-ffi is built and its symbols are available to wireguard-go +# at link time. +# NOTE: for other platforms, maybenot-ffi is NOT declared here, but instead built directly from +# wireguard-go-rs/libwg/wireguard-go/maybenot-ffi [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] maybenot-ffi = "2.0.1" diff --git a/wireguard-go-rs/build.rs b/wireguard-go-rs/build.rs index f13f0341162f..a544e4e161e1 100644 --- a/wireguard-go-rs/build.rs +++ b/wireguard-go-rs/build.rs @@ -268,11 +268,16 @@ fn build_shared_maybenot_lib(out_dir: impl AsRef<Path>) -> anyhow::Result<()> { tmp_build_dir = tmp_build_dir.join("target"); build_command - .current_dir("./libwg/wireguard-go/maybenot/crates/maybenot-ffi") + .current_dir("./libwg/wireguard-go/maybenot-ffi") .env("RUSTFLAGS", "-C metadata=maybenot-ffi -Ctarget-feature=+crt-static") - // Set temporary target dir to prevent deadlock + // Set temporary target dir to prevent deadlock, since we are invoking cargo from within + // another cargo process. .env("CARGO_TARGET_DIR", &tmp_build_dir) - .arg("build") + .arg("rustc") + // Build a shared library to consume from another language (go) + .arg("--crate-type=cdylib") + // Always respect lockfiles + .args(["--locked"]) .args(["--profile", profile]) .args(["--target", &target_triple]); diff --git a/wireguard-go-rs/libwg/Android.mk b/wireguard-go-rs/libwg/Android.mk index 23b32cd81f80..9833c9b6a11a 100644 --- a/wireguard-go-rs/libwg/Android.mk +++ b/wireguard-go-rs/libwg/Android.mk @@ -3,7 +3,8 @@ # Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. DESTDIR ?= $(OUT_DIR) -CARGO_TARGET_DIR ?= +# Default to the workspace root if not set +CARGO_TARGET_DIR ?= $(CURDIR)/../../target TARGET ?= NDK_GO_ARCH_MAP_x86 := 386 @@ -25,7 +26,7 @@ default: $(DESTDIR)/libwg.so $(DESTDIR)/libwg.so: mkdir -p $(DESTDIR) # Build libmaybenot - make --directory wireguard-go libmaybenot.a LIBDEST="$(DESTDIR)" TARGET="$(TARGET)" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" + make --directory wireguard-go/maybenot-ffi $(DESTDIR)/libmaybenot.a TARGET="$(TARGET)" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)" # Build wireguard-go go get -tags "linux android daita" chmod -fR +w "$(GOPATH)/pkg/mod" diff --git a/wireguard-go-rs/libwg/wireguard-go b/wireguard-go-rs/libwg/wireguard-go index 209814b58498..0b750ea8445a 160000 --- a/wireguard-go-rs/libwg/wireguard-go +++ b/wireguard-go-rs/libwg/wireguard-go @@ -1 +1 @@ -Subproject commit 209814b584985679d7602387e6402b3a30b03014 +Subproject commit 0b750ea8445a378b6f314fa6135c889e9d171b19 diff --git a/wireguard-go-rs/src/lib.rs b/wireguard-go-rs/src/lib.rs index a98c9e056a9f..f49c4d4e52d3 100644 --- a/wireguard-go-rs/src/lib.rs +++ b/wireguard-go-rs/src/lib.rs @@ -29,7 +29,8 @@ pub type LoggingContext = u64; pub type LoggingCallback = unsafe extern "system" fn(level: WgLogLevel, msg: *const c_char, context: LoggingContext); -// Make symbols from maybenot-ffi visible to wireguard-go +// Make symbols from maybenot-ffi visible to wireguard-go, on the platforms where +// wireguard-go is statically linked into this crate. #[cfg(all(daita, any(target_os = "linux", target_os = "macos")))] use maybenot_ffi as _;