diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt index 24e368a20156..00fb797c5c94 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -33,6 +34,8 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.Chevron import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.compose.preview.RelayItemCheckableCellPreviewParameterProvider +import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -55,6 +58,7 @@ private fun PreviewCheckableRelayLocationCell( expanded = false, depth = 0, onExpand = {}, + modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), ) } } @@ -159,6 +163,7 @@ fun RelayItemCell( color = MaterialTheme.colorScheme.onSurface, isExpanded = isExpanded, onClick = { onToggleExpand(!isExpanded) }, + modifier = Modifier.testTag(EXPAND_BUTTON_TEST_TAG), ) } } @@ -213,6 +218,7 @@ private fun Name(modifier: Modifier = Modifier, relay: RelayItem) { @Composable private fun RowScope.ExpandButton( + modifier: Modifier, color: Color, isExpanded: Boolean, onClick: (expand: Boolean) -> Unit, @@ -225,7 +231,8 @@ private fun RowScope.ExpandButton( color = color, isExpanded = isExpanded, modifier = - Modifier.fillMaxHeight() + modifier + .fillMaxHeight() .clickable { onClick(!isExpanded) } .padding(horizontal = Dimens.largePadding) .align(Alignment.CenterVertically), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt index 3fbd40c53770..cefeef87fc68 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationInfo.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_IN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LOCATION_INFO_CONNECTION_OUT_TEST_TAG import net.mullvad.mullvadvpn.lib.model.GeoIpLocation import net.mullvad.mullvadvpn.lib.model.TransportProtocol @@ -104,7 +105,9 @@ fun LocationInfo( text = "${stringResource(id = R.string.in_address)} $textInAddress", color = colorExpanded, style = MaterialTheme.typography.labelMedium, - modifier = Modifier.alpha(if (isExpanded) AlphaVisible else AlphaInvisible), + modifier = + Modifier.testTag(LOCATION_INFO_CONNECTION_IN_TEST_TAG) + .alpha(if (isExpanded) AlphaVisible else AlphaInvisible), ) Text( text = "${stringResource(id = R.string.out_address)} $outAddress", diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt index e56db510e7c7..650c882f79f2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.test.SWITCH_TEST_TAG import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled @@ -55,7 +57,7 @@ fun MullvadSwitch( Switch( checked = checked, onCheckedChange = onCheckedChange, - modifier = modifier, + modifier = modifier.testTag(SWITCH_TEST_TAG), thumbContent = thumbContent, enabled = enabled, colors = colors, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 013ed1d02947..775f64f770f6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -84,6 +84,7 @@ import net.mullvad.mullvadvpn.compose.screen.BottomSheetState.ShowLocationBottom import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.LOCATION_CELL_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG @@ -429,6 +430,7 @@ fun LazyItemScope.RelayLocationItem( onToggleExpand = { onExpand(it) }, isExpanded = relayItem.expanded, depth = relayItem.depth, + modifier = Modifier.testTag(LOCATION_CELL_TEST_TAG), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index 47c109d35364..428cce87723a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -19,9 +19,12 @@ const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_AUTOMATIC_TEST_TAG = "lazy_list_udp_over_tcp_item_automatic_test_tag" const val LAZY_LIST_UDP_OVER_TCP_PORT_ITEM_X_TEST_TAG = "lazy_list_udp_over_tcp_item_%d_test_tag" const val CUSTOM_PORT_DIALOG_INPUT_TEST_TAG = "custom_port_dialog_input_test_tag" +const val SWITCH_TEST_TAG = "switch_test_tag" // SelectLocationScreen, ConnectScreen, CustomListLocationsScreen const val CIRCULAR_PROGRESS_INDICATOR = "circular_progress_indicator" +const val EXPAND_BUTTON_TEST_TAG = "expand_button_test_tag" +const val LOCATION_CELL_TEST_TAG = "location_cell_test_tag" // ConnectScreen const val SCROLLABLE_COLUMN_TEST_TAG = "scrollable_column_test_tag" @@ -29,6 +32,7 @@ const val SELECT_LOCATION_BUTTON_TEST_TAG = "select_location_button_test_tag" const val CONNECT_BUTTON_TEST_TAG = "connect_button_test_tag" const val RECONNECT_BUTTON_TEST_TAG = "reconnect_button_test_tag" const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" +const val LOCATION_INFO_CONNECTION_IN_TEST_TAG = "location_info_connection_in_test_tag" const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag" // ConnectScreen - Notification banner diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 72feb3124537..8fcb3c84adc1 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -39,12 +39,16 @@ grpc-protobuf = "4.27.2" koin = "3.5.6" koin-compose = "3.5.6" +# Ktor +ktor = "3.0.0-beta-2" + # Kotlin # Bump kotlin and kotlin-ksp together, find matching release here: # https://github.com/google/ksp/releases kotlin = "2.0.0" kotlin-ksp = "2.0.0-1.0.22" kotlinx = "1.8.1" +kotlinx-serialization = "2.0.20" # Protobuf protobuf = "0.9.4" @@ -129,6 +133,13 @@ kotlin-native-prebuilt = { module = "org.jetbrains.kotlin:kotlin-native-prebuilt kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# Ktor +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } # MockK mockk = { module = "io.mockk:mockk", version.ref = "mockk" } @@ -158,6 +169,9 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp"} +# Kotlinx +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinx-serialization" } + # Protobuf protobuf-core = { id = "com.google.protobuf", version.ref = "protobuf" } protobuf-protoc = { id = "com.google.protobuf:protoc", version.ref = "grpc-protobuf" } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt index 5684b58e17e5..dfb2473e8f68 100644 --- a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/interactor/AppInteractor.kt @@ -89,16 +89,32 @@ class AppInteractor( device.findObjectWithTimeout(By.text("Account")) } - fun extractIpAddress(): String { + fun extractOutIpAddress(): String { device.findObjectWithTimeout(By.res("location_info_test_tag")).click() - return device - .findObjectWithTimeout( - // Text exist and contains IP address - By.res("location_info_connection_out_test_tag").textContains("."), - CONNECTION_TIMEOUT, - ) - .text - .extractIpAddress() + val outString = + device + .findObjectWithTimeout( + By.res("location_info_connection_out_test_tag"), + CONNECTION_TIMEOUT, + ) + .text + + val extractedIpAddress = outString.split(" ")[1] + return extractedIpAddress + } + + fun extractInIpAddress(): String { + device.findObjectWithTimeout(By.res("location_info_test_tag")).click() + val inString = + device + .findObjectWithTimeout( + By.res("location_info_connection_in_test_tag"), + CONNECTION_TIMEOUT, + ) + .text + + val extractedIpAddress = inString.split(" ")[1].split(":")[0] + return extractedIpAddress } fun clickSettingsCog() { @@ -125,8 +141,4 @@ class AppInteractor( device.findObjectWithTimeout(By.desc("Remove")).click() clickActionButtonByText("Yes, log out device") } - - private fun String.extractIpAddress(): String { - return split(" ")[1].split(" ")[0] - } } diff --git a/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt new file mode 100644 index 000000000000..891e52a14777 --- /dev/null +++ b/android/test/common/src/main/kotlin/net/mullvad/mullvadvpn/test/common/misc/Attachment.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.test.common.misc + +import android.content.ContentValues +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import co.touchlab.kermit.Logger +import java.io.File +import java.io.IOException + +class Attachment { + companion object { + fun saveAttachment(fileName: String, baseDir: String, data: ByteArray) { + val contentResolver = + getInstrumentation().targetContext.applicationContext.contentResolver + val contentValues = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream") + put( + MediaStore.MediaColumns.RELATIVE_PATH, + Environment.DIRECTORY_DOWNLOADS + "/$baseDir", + ) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val uri = + contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + if (uri != null) { + contentResolver.openOutputStream(uri).use { outputStream -> + outputStream?.write(data) + outputStream?.close() + contentResolver.update(uri, contentValues, null, null) + + Logger.v("Saved attachment ${uri.toString()}") + } + Logger.v("Saved attachment ${uri.toString()}") + } else { + Logger.e("Failed to save attachment $fileName") + } + } else { + val directory = + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + "/$baseDir" + ) + if (!directory.exists()) { + directory.mkdirs() + } + + val file = File(directory, fileName) + try { + file.writeBytes(data) + Logger.v("Saved attachment ${file.absolutePath}") + } catch (e: IOException) { + Logger.e("Failed to save attachment $fileName: ${e.message}") + } + } + } + } +} diff --git a/android/test/e2e/build.gradle.kts b/android/test/e2e/build.gradle.kts index fa59f81940fd..175b294b9837 100644 --- a/android/test/e2e/build.gradle.kts +++ b/android/test/e2e/build.gradle.kts @@ -5,6 +5,7 @@ import org.gradle.configurationcache.extensions.capitalized plugins { alias(libs.plugins.android.test) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) id(Dependencies.junit5AndroidPluginId) version Versions.junit5Plugin } @@ -141,6 +142,11 @@ dependencies { implementation(Dependencies.junit5AndroidTestExtensions) implementation(Dependencies.junit5AndroidTestRunner) implementation(libs.kotlin.stdlib) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.jodatime) androidTestUtil(libs.androidx.test.orchestrator) diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt index 3aac743544fc..a773a8ccac9c 100644 --- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/ConnectionTest.kt @@ -42,7 +42,7 @@ class ConnectionTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) { device.findObjectWithTimeout(By.text("Secure my connection")).click() device.findObjectWithTimeout(By.text("OK")).click() device.findObjectWithTimeout(By.text("SECURE CONNECTION"), CONNECTION_TIMEOUT) - val expected = ConnCheckState(true, app.extractIpAddress()) + val expected = ConnCheckState(true, app.extractOutIpAddress()) // Then val result = SimpleMullvadHttpClient(targetContext).runConnectionCheck() diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt index 278def0b35c1..97104477bfeb 100644 --- a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/EndToEndTest.kt @@ -33,10 +33,7 @@ abstract class EndToEndTest(private val infra: String) { lateinit var targetContext: Context lateinit var app: AppInteractor - @BeforeEach - fun setup() { - Logger.setTag(LOG_TAG) - + init { device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) targetContext = InstrumentationRegistry.getInstrumentation().targetContext @@ -49,4 +46,15 @@ abstract class EndToEndTest(private val infra: String) { app = AppInteractor(device, targetContext, "net.mullvad.mullvadvpn$targetPackageNameSuffix") } + + @BeforeEach + fun setup() { + Logger.setTag(LOG_TAG) + } + + companion object { + val defaultCountry = "Sweden" + val defaultCity = "Gothenburg" + val defaultRelay = "se-got-wg-001" + } } diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt new file mode 100644 index 000000000000..e4ce97c06e0a --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/LeakTest.kt @@ -0,0 +1,164 @@ +package net.mullvad.mullvadvpn.test.e2e + +import androidx.test.uiautomator.By +import co.touchlab.kermit.Logger +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import net.mullvad.mullvadvpn.BuildConfig +import net.mullvad.mullvadvpn.compose.test.EXPAND_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SWITCH_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.TOP_BAR_SETTINGS_BUTTON +import net.mullvad.mullvadvpn.test.common.constant.CONNECTION_TIMEOUT +import net.mullvad.mullvadvpn.test.common.extension.findObjectWithTimeout +import net.mullvad.mullvadvpn.test.common.rule.ForgetAllVpnAppsInSettingsTestRule +import net.mullvad.mullvadvpn.test.e2e.misc.AccountTestRule +import net.mullvad.mullvadvpn.test.e2e.misc.LeakCheck +import net.mullvad.mullvadvpn.test.e2e.misc.NoTrafficToHostRule +import net.mullvad.mullvadvpn.test.e2e.misc.PacketCapture +import net.mullvad.mullvadvpn.test.e2e.misc.PacketCaptureResult +import net.mullvad.mullvadvpn.test.e2e.misc.TrafficGenerator +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +class LeakTest : EndToEndTest(BuildConfig.FLAVOR_infrastructure) { + + @RegisterExtension @JvmField val accountTestRule = AccountTestRule() + + @RegisterExtension + @JvmField + val forgetAllVpnAppsInSettingsTestRule = ForgetAllVpnAppsInSettingsTestRule() + + @BeforeEach + fun setupVPNSettings() { + app.launchAndEnsureLoggedIn(accountTestRule.validAccountNumber) + device.findObjectWithTimeout(By.res(TOP_BAR_SETTINGS_BUTTON)).click() + device.findObjectWithTimeout(By.text("VPN settings")).click() + + val localNetworkSharingCell = + device.findObjectWithTimeout(By.text("Local network sharing")).parent + val localNetworkSharingSwitch = + localNetworkSharingCell.findObjectWithTimeout(By.res(SWITCH_TEST_TAG)) + + if (localNetworkSharingSwitch.isChecked.not()) { + localNetworkSharingSwitch.click() + } + + // Only use port 51820 to make packet capture more deterministic + device.findObjectWithTimeout(By.text("51820")).click() + + device.pressBack() + device.pressBack() + } + + @Test + fun testNegativeLeak() = + runBlocking { + Logger.v("Running test testNegativeLeak") + + app.launch() + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION")) + + val targetIpAddress = "45.83.223.209" + val targetPort = 80 + lateinit var relayIpAddress: String + + device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click() + clickLocationExpandButton((EndToEndTest.defaultCountry)) + clickLocationExpandButton((EndToEndTest.defaultCity)) + device.findObjectWithTimeout(By.text(EndToEndTest.defaultRelay)).click() + device.findObjectWithTimeout(By.text("OK")).click() + device.findObjectWithTimeout(By.text("SECURE CONNECTION"), CONNECTION_TIMEOUT) + + lateinit var captureResult: PacketCaptureResult + val connectTime = DateTime.now() + TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) { + captureResult = + PacketCapture().capturePackets { + relayIpAddress = app.extractInIpAddress() + + // Give it some time for generating traffic + delay(3000) + } + } + + val disconnectTime = DateTime.now() + device.findObjectWithTimeout(By.text("Disconnect")).click() + Thread.sleep(2000) // TODO: check if there's better way to wait + + val capturedStreams = captureResult.streams + val capturedPcap = captureResult.pcap + + // Attachment.saveAttachment("capture.pcap", "should-leak", capturedPcap) + + val leakRules = listOf(NoTrafficToHostRule(targetIpAddress)) + + LeakCheck.assertNoLeaks(capturedStreams, leakRules, connectTime, disconnectTime) + } + + @Test + fun testShouldHaveNegativeLeak() = + runBlocking { + Logger.v("Running test testShouldHaveNegativeLeak") + + app.launch() + device.findObjectWithTimeout(By.text("UNSECURED CONNECTION")) + + val targetIpAddress = "45.83.223.209" + val targetPort = 80 + lateinit var relayIpAddress: String + + device.findObjectWithTimeout(By.res(SELECT_LOCATION_BUTTON_TEST_TAG)).click() + val countryCell = device.findObjectWithTimeout(By.text(EndToEndTest.defaultCountry)) + delay(1000.milliseconds) + clickLocationExpandButton((EndToEndTest.defaultCountry)) + clickLocationExpandButton((EndToEndTest.defaultCity)) + device.findObjectWithTimeout(By.text(EndToEndTest.defaultRelay)).click() + device.findObjectWithTimeout(By.text("OK")).click() + device.findObjectWithTimeout(By.text("SECURE CONNECTION"), CONNECTION_TIMEOUT) + + lateinit var captureResult: PacketCaptureResult + val connectTime = DateTime.now() + + TrafficGenerator(targetIpAddress, targetPort).generateTraffic(10.milliseconds) { + captureResult = + PacketCapture().capturePackets { + relayIpAddress = app.extractInIpAddress() + + delay( + 3000.milliseconds + ) // Give it some time for generating traffic in tunnel + device.findObjectWithTimeout(By.text("Disconnect")).click() + delay( + 2000.milliseconds + ) // Give it some time to leak traffic outside of tunnel + device.findObjectWithTimeout(By.text("Secure my connection")).click() + delay( + 3000.milliseconds + ) // Give it some time for generating traffic in tunnel + } + } + + val disconnectTime = DateTime.now() + device.findObjectWithTimeout(By.text("Disconnect")).click() + + val capturedStreams = captureResult.streams + val capturedPcap = captureResult.pcap + + // val pcapContent = packetCapture.getPcap() + // Attachment.saveAttachment("capture.pcap", "should-leak", pcapContent.toByteArray()) + + val leakRules = listOf(NoTrafficToHostRule(targetIpAddress)) + + LeakCheck.assertLeaks(capturedStreams, leakRules, connectTime, disconnectTime) + } + + private fun clickLocationExpandButton(locationName: String) { + val locationCell = device.findObjectWithTimeout(By.text(locationName)).parent + val expandButton = locationCell.findObjectWithTimeout(By.res(EXPAND_BUTTON_TEST_TAG)) + expandButton.click() + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt new file mode 100644 index 000000000000..9e57da964f94 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/LeakCheck.kt @@ -0,0 +1,43 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import org.joda.time.DateTime +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue + +class LeakCheck { + companion object { + fun assertNoLeaks( + streams: List, + rules: List, + start: DateTime, + end: DateTime, + ) { + for (rule in rules) { + assertFalse(rule.isViolated(streams, start, end)) + } + } + + fun assertLeaks( + streams: List, + rules: List, + start: DateTime, + end: DateTime, + ) { + for (rule in rules) { + assertTrue(rule.isViolated(streams, start, end)) + } + } + } +} + +interface LeakRule { + fun isViolated(streams: List, start: DateTime, end: DateTime): Boolean +} + +class NoTrafficToHostRule(private val host: String) : LeakRule { + override fun isViolated(streams: List, start: DateTime, end: DateTime): Boolean { + return streams + .filter { it.startDate.isAfter(start) && it.endDate.isBefore(end) } + .any { it.destinationHost.ipAddress == host } + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt new file mode 100644 index 000000000000..323e27ac3176 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/Networking.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import java.net.Inet4Address +import java.net.NetworkInterface +import org.junit.Assert.fail + +class Networking { + companion object { + fun getIpAddress(): String { + NetworkInterface.getNetworkInterfaces()?.toList()?.map { networkInterface -> + networkInterface.inetAddresses + ?.toList() + ?.find { !it.isLoopbackAddress && it is Inet4Address } + ?.let { + it.hostAddress?.let { + return it + } + } + } + + fail("Failed to get test device IP address") + return "" + } + } +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt new file mode 100644 index 000000000000..60016785479e --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/PacketCapture.kt @@ -0,0 +1,219 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import co.touchlab.kermit.Logger +import io.ktor.client.* +import io.ktor.client.call.body +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.HttpResponseValidator +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.* +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import java.util.UUID +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import org.joda.time.DateTime +import org.junit.jupiter.api.fail + +data class PacketCaptureSession(val identifier: String = UUID.randomUUID().toString()) + +class PacketCapture { + private val client = PacketCaptureClient() + private var sessionUUID = UUID.randomUUID() + + private suspend fun startCapture() { + client.sendStartCaptureRequest(sessionUUID) + } + + private suspend fun stopCapture() { + client.sendStopCaptureRequest(sessionUUID) + } + + private suspend fun getParsedCapture(): List { + val parsedPacketsResponse = client.sendGetCapturedPacketsRequest(sessionUUID) + val capturedStreams = parsedPacketsResponse.body>() + Logger.v("Captured streams: $capturedStreams") + return capturedStreams + } + + private suspend fun getPcap(): String { + val pcapContent = client.sendGetPcapFileRequest(sessionUUID) + Logger.v("PCAP content: ${pcapContent.body()}") + return pcapContent.body() + } + + suspend fun capturePackets(block: suspend () -> Unit): PacketCaptureResult = runBlocking { + startCapture() + block() + stopCapture() + val parsedCapture = getParsedCapture() + val pcapString = getPcap() + return@runBlocking PacketCaptureResult(parsedCapture, pcapString.toByteArray()) + } +} + +class PacketCaptureClient { + private val baseUrl = "http://192.168.105.1" + private val httpClient = + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + prettyPrint = true + } + ) + } + + HttpResponseValidator { + validateResponse { response -> + val statusCode = response.status.value + if (statusCode >= 400) { + fail( + "Request failed with response status code $statusCode: ${response.body()}" + ) + } + } + handleResponseExceptionWithRequest { exception, _ -> + fail("Request failed to be sent with exception: ${exception.message}") + } + } + } + + suspend fun sendStartCaptureRequest(sessionUUID: UUID) { + val jsonBody = buildJsonObject { put("label", sessionUUID.toString()) } + + Logger.v("Sending start capture request with body: $jsonBody.toString()") + + val response = + httpClient.post("$baseUrl/capture") { + contentType(ContentType.Application.Json) + setBody(jsonBody.toString()) + } + } + + suspend fun sendStopCaptureRequest(sessionUUID: UUID) { + val response = httpClient.post("$baseUrl/stop-capture/${sessionUUID.toString()}") + } + + suspend fun sendGetCapturedPacketsRequest(sessionUUID: UUID): HttpResponse { + val testDeviceIpAddress = Networking.getIpAddress() + return httpClient.put("$baseUrl/parse-capture/${sessionUUID.toString()}") { + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + setBody("[\"$testDeviceIpAddress\"]") + } + } + + suspend fun sendGetPcapFileRequest(sessionUUID: UUID): HttpResponse { + return httpClient.get("$baseUrl/last-capture/${sessionUUID.toString()}") { + // contentType(ContentType.parse("application/pcap")) + accept(ContentType.parse("application/json")) + } + } +} + +data class PacketCaptureResult(val streams: List, val pcap: ByteArray) + +@Serializable +enum class NetworkTransportProtocol() { + @SerialName("tcp") TCP, + @SerialName("udp") UDP, + @SerialName("icmp") ICMP, +} + +data class Host(val ipAddress: String, val port: Int) + +object PacketSerializer : KSerializer> { + override val descriptor: SerialDescriptor = ListSerializer(Packet.serializer()).descriptor + + override fun deserialize(decoder: Decoder): List { + val jsonDecoder = decoder as? JsonDecoder ?: error("Can only be deserialized from JSON") + val elements = jsonDecoder.decodeJsonElement().jsonArray + + return elements.map { element: JsonElement -> + val jsonObject = element.jsonObject + val fromPeer = + jsonObject["from_peer"]?.jsonPrimitive?.booleanOrNull + ?: error("Missing from_peer field") + + if (fromPeer) { + jsonDecoder.json.decodeFromJsonElement(TxPacket.serializer(), element) + } else { + jsonDecoder.json.decodeFromJsonElement(RxPacket.serializer(), element) + } + } + } + + override fun serialize(encoder: Encoder, value: List) { + throw NotImplementedError("Only interested in deserialization") + } +} + +@Serializable +data class Stream( + @SerialName("peer_addr") private val sourceAddressAndPort: String, + @SerialName("other_addr") private val destinationAddressAndPort: String, + @SerialName("flow_id") val flowId: String?, + @SerialName("transport_protocol") val transportProtocol: NetworkTransportProtocol, + @Serializable(with = PacketSerializer::class) val packets: List, +) { + @Contextual + public val sourceHost: Host = + Host( + sourceAddressAndPort.split(":").first(), + sourceAddressAndPort.split(":").last().toInt(), + ) + @Contextual + public val destinationHost: Host = + Host( + destinationAddressAndPort.split(":").first(), + destinationAddressAndPort.split(":").last().toInt(), + ) + + @Contextual val startDate: DateTime = packets.first().date + @Contextual val endDate: DateTime = packets.last().date + @Contextual val txStartDate: DateTime? = packets.firstOrNull { it.fromPeer }?.date + @Contextual val txEndDate: DateTime? = packets.lastOrNull { it.fromPeer }?.date + @Contextual val rxStartDate: DateTime? = packets.firstOrNull { !it.fromPeer }?.date + @Contextual val rxEndDate: DateTime? = packets.lastOrNull { !it.fromPeer }?.date + + fun getTxPackets(): List = packets.filterIsInstance() + + fun getRxPackets(): List = packets.filterIsInstance() +} + +@Serializable +sealed class Packet { + abstract val timestamp: String + abstract val fromPeer: Boolean + abstract val date: DateTime +} + +@Serializable +@SerialName("RxPacket") +data class RxPacket( + @SerialName("timestamp") override val timestamp: String, + @SerialName("from_peer") override val fromPeer: Boolean, +) : Packet() { + @Contextual override val date = DateTime(timestamp.toLong() / 1000) +} + +@Serializable +@SerialName("TxPacket") +data class TxPacket( + @SerialName("timestamp") override val timestamp: String, + @SerialName("from_peer") override val fromPeer: Boolean, +) : Packet() { + @Contextual override val date = DateTime(timestamp.toLong() / 1000) +} diff --git a/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt new file mode 100644 index 000000000000..ab5e09fee766 --- /dev/null +++ b/android/test/e2e/src/main/kotlin/net/mullvad/mullvadvpn/test/e2e/misc/TrafficGenerator.kt @@ -0,0 +1,47 @@ +package net.mullvad.mullvadvpn.test.e2e.misc + +import co.touchlab.kermit.Logger +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class TrafficGenerator(val destinationHost: String, val destinationPort: Int) { + private var sendTrafficJob: Job? = null + + private fun startGeneratingUDPTraffic(interval: Duration) { + val socket = DatagramSocket() + val address = InetAddress.getByName(destinationHost) + val data = ByteArray(1024) + val packet = DatagramPacket(data, data.size, address, destinationPort) + + sendTrafficJob = + CoroutineScope(Dispatchers.IO).launch { + while (true) { + socket.send(packet) + Logger.v( + "TrafficGenerator sending UDP packet to $destinationHost:$destinationPort" + ) + delay(interval.toLong(DurationUnit.MILLISECONDS)) + } + } + } + + private fun stopGeneratingUDPTraffic() { + sendTrafficJob!!.cancel() + } + + suspend fun generateTraffic(interval: Duration, block: suspend () -> Unit) = runBlocking { + startGeneratingUDPTraffic(interval) + block() + stopGeneratingUDPTraffic() + return@runBlocking Unit + } +} diff --git a/wireguard-go-rs/libwg/wireguard-go b/wireguard-go-rs/libwg/wireguard-go index 265d73245fad..de174ac6de29 160000 --- a/wireguard-go-rs/libwg/wireguard-go +++ b/wireguard-go-rs/libwg/wireguard-go @@ -1 +1 @@ -Subproject commit 265d73245fad6e313f153e031445cafa203c5b4a +Subproject commit de174ac6de2934dc82d9b8301c17de87cbd575f3