diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 716d914..c14c6c7 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -72,12 +72,28 @@ jobs: avd-name: megaproxy-device profile: pixel_5 force-avd-creation: false - emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + # Stream kernel failures independently of ADB. Combining this with -logcat + # makes API 35 assign two serial devices to stdio and abort before boot. + emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -show-kernel disable-animations: true script: | mkdir -p test-results/android-api${{ inputs.api }} touch test-results/android-api${{ inputs.api }}/runner-started bundle exec fastlane android device_tests api:${{ inputs.api }} + - name: Collect runner diagnostics + if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' }} + env: + DEVICE_API: ${{ inputs.api }} + run: | + mkdir -p "test-results/android-api$DEVICE_API" + { + date -u + "$ANDROID_HOME/platform-tools/adb" devices -l + ps -eo pid,ppid,stat,rss,comm + free -m + df -h + sudo dmesg --ctime | tail -200 + } > "test-results/android-api$DEVICE_API/host-diagnostics.txt" 2>&1 - name: Retry emulator startup before tests only id: device_retry if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' && hashFiles(format('test-results/android-api{0}/runner-started', inputs.api)) == '' }} diff --git a/AGENTS.md b/AGENTS.md index 64388e9..1ec05a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,9 @@ branch names, credentials, signing material, or other secrets. - Compose interaction tests may run in `app/src/test` using Robolectric with a pinned SDK and plain test Application. Inject platform operations; do not load Go JNI or real Keystore in those tests. Gradle resolves pinned API 26/35 Robolectric SDK jars before tests; the test - runtime uses offline resolution. They run through Fastlane Android checks without an emulator. + runtime uses offline resolution. UI tests dispatch configuration work deterministically; + a controlled worker test verifies the background-read boundary. They run through Fastlane + Android checks without an emulator. - Exercise real screens and ConfigStore with a test-only Keystore provider. Before asserting service commands or their absence, drain ordered configuration writes and check both completion and failure state; `pending == 0` alone does not prove success. Recorded service intents do not @@ -101,6 +103,9 @@ branch names, credentials, signing material, or other secrets. ## Product and UI conventions +- MegaProxy is selected for split tunneling by default. Existing lists gain it once on upgrade; + subsequent explicit exclusion must survive reloads, unrelated edits and profile changes. + - All user-visible UI text must use Android string resources and be supplied in both the default English resources and Russian resources. The app language selects UI words; number, date and other data formatting follows the system locale. Do not localize standardized data-unit symbols. diff --git a/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt index 2e98a54..2225460 100644 --- a/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt @@ -52,6 +52,14 @@ class DnsDeviceTest : DeviceTestBase() { connect() dnsQuery(true) dnsQuery(false) + if (type == ProxyType.HTTPS && mode == "https") { + DatagramSocket().use { first -> + dnsQuery(false, first) + repeat(80) { dnsQuery(false) } + // Reuse an application socket after its idle TUN mapping was evicted. + dnsQuery(false, first) + } + } click(R.string.disconnect) stopped() } @@ -80,7 +88,7 @@ class DnsDeviceTest : DeviceTestBase() { stopped() } - private fun dnsQuery(tcp: Boolean) { + private fun dnsQuery(tcp: Boolean, udpSocket: DatagramSocket? = null) { val query = byteArrayOf(0x48, 0x73, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 7) + "private".toByteArray(Charsets.US_ASCII) + byteArrayOf(4) + "test".toByteArray(Charsets.US_ASCII) + byteArrayOf(0, 0, 1, 0, 1) @@ -89,12 +97,15 @@ class DnsDeviceTest : DeviceTestBase() { socket.connect(InetSocketAddress("10.77.0.2", 53), 10_000) DataOutputStream(socket.getOutputStream()).apply { writeShort(query.size); write(query); flush() } DataInputStream(socket.getInputStream()).let { input -> ByteArray(input.readUnsignedShort()).also { input.readFully(it) } } - } else DatagramSocket().use { socket -> - socket.soTimeout = 5_000 - socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53)) - val packet = DatagramPacket(ByteArray(512), 512) - socket.receive(packet) - packet.data.copyOf(packet.length) + } else { + fun exchange(socket: DatagramSocket): ByteArray { + socket.soTimeout = 5_000 + socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53)) + val packet = DatagramPacket(ByteArray(512), 512) + socket.receive(packet) + return packet.data.copyOf(packet.length) + } + if (udpSocket != null) exchange(udpSocket) else DatagramSocket().use(::exchange) } assertEquals(0x48, response[0].toInt()) assertEquals(0x73, response[1].toInt()) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index b1c46ec..8c2881d 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -368,13 +368,17 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun notificationActionStopsRealService() { connect() roundTrip() - device.openNotification() - val action = device.wait(androidx.test.uiautomator.Until.findObject( - androidx.test.uiautomator.By.desc(text(R.string.disconnect))), 10_000) - assertNotNull("VPN notification must expose Disconnect", action) - action.click() + val manager = context.getSystemService(android.app.NotificationManager::class.java) + var action: android.app.Notification.Action? = null + await("VPN notification must expose Disconnect") { + action = manager.activeNotifications.asSequence().flatMap { + it.notification.actions.orEmpty().asSequence() + }.firstOrNull { it.title.toString() == text(R.string.disconnect) } + action != null + } + // Send the published PendingIntent through Android; shade expansion varies by API/device. + requireNotNull(action).actionIntent.send() stopped() - device.pressBack() directOriginUnavailable() } diff --git a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt index 0b201eb..7aa2987 100644 --- a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -65,6 +66,7 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { var megabytes by remember { mutableStateOf(SPEED_TEST_MEGABYTES.first()) } var log by remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() + val store = remember(activity) { net.megaproxy487.data.ConfigStore(activity) } Scaffold( topBar = { TopAppBar( @@ -86,6 +88,15 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { stringResource(R.string.cloudflare_speed_explanation, megabytes, megabytes * 4), style = MaterialTheme.typography.bodyMedium, ) + val settings = readConfiguration(store) { store.globalConnectionSettings() } + if (settings != null && !settings.routeAllApps && activity.packageName !in settings.selectedPackages) { + Card(colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + )) { + Text(stringResource(R.string.speed_split_tunnel_warning), Modifier.padding(12.dp)) + } + } Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.SpaceEvenly) { SPEED_TEST_MEGABYTES.forEach { size -> Row( diff --git a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt index d6d75b9..f0ae959 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt @@ -3,8 +3,15 @@ package net.megaproxy487.data import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext /** Ordered background dispatcher for configuration encryption, JSON and persistence. */ -val ConfigIoDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { task -> - Thread(task, "megaproxy-config-io").apply { isDaemon = true } -}.asCoroutineDispatcher() +object ConfigIoDispatcher : CoroutineDispatcher() { + private val worker = Executors.newSingleThreadExecutor { task -> + Thread(task, "megaproxy-config-io").apply { isDaemon = true } + }.asCoroutineDispatcher() + @Volatile internal var delegate: CoroutineDispatcher = worker + internal fun reset() { delegate = worker } + override fun isDispatchNeeded(context: CoroutineContext) = delegate.isDispatchNeeded(context) + override fun dispatch(context: CoroutineContext, block: Runnable) = delegate.dispatch(context, block) +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 660d76d..7aede1d 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -178,8 +178,10 @@ class ConfigStore(context: Context) { if (stored != null) { val decoded = decodeGlobalConnectionSettings(stored) migrateGlobalIpv6ToProfiles(stored) - if (!operationResult { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) { - val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile) + val fields = JSONObject(stored) + if (!fields.has("sshProfile") || !fields.optBoolean("megaProxyRoutingDefaultApplied", false)) { + val upgraded = if (fields.has("sshProfile")) decoded + else decoded.copy(sshProfile = activeProfile().config.sshProfile) saveGlobalConnectionSettings(upgraded) return@synchronized upgraded } @@ -190,7 +192,7 @@ class ConfigStore(context: Context) { tlsProfile = source.profile.takeIf { it.available } ?: TlsProfile.DEFAULT, sshProfile = source.sshProfile, customJa3 = source.customJa3, - selectedPackages = source.selectedPackages, + selectedPackages = source.selectedPackages + context.packageName, routeAllApps = source.routeAllApps, bypassLocalNetworks = source.bypassLocalNetworks, ) @@ -760,13 +762,14 @@ class ConfigStore(context: Context) { put("failoverProfileIds", JSONArray(settings.failoverProfileIds)) put("customJa3", settings.customJa3.trim()) put("packages", JSONArray(settings.selectedPackages.sorted())) + put("megaProxyRoutingDefaultApplied", true) put("routeAllApps", settings.routeAllApps) put("bypassLocalNetworks", settings.bypassLocalNetworks) }.toString() private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult { val item = JSONObject(value) - for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6")) { + for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied")) { require(!item.has(key) || item.get(key) is Boolean) } for (key in listOf("packages", "failoverProfileIds")) { @@ -791,7 +794,10 @@ class ConfigStore(context: Context) { customJa3 = item.optString("customJa3"), selectedPackages = item.optJSONArray("packages")?.let { array -> (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }.toSet() - }.orEmpty(), + }.orEmpty().let { packages -> + // Old lists did not record whether excluding MegaProxy was deliberate. + if (item.optBoolean("megaProxyRoutingDefaultApplied", false)) packages else packages + context.packageName + }, routeAllApps = item.optBoolean("routeAllApps", true), bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true), ) diff --git a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt index 82d60ab..6ac5a94 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt @@ -113,7 +113,12 @@ internal fun retainProfileSecrets( presence: ProfileSecretPresence, ): net.megaproxy487.model.ProxyProfile { if (current == null) return source + val sameDns = source.config.dnsProvider == current.config.dnsProvider && + (source.config.dnsProvider != net.megaproxy487.model.DnsProvider.CUSTOM || + source.config.customDohUrl.trim() == current.config.customDohUrl.trim()) return source.copy(config = source.config.copy( + allowInsecureDns = sameDns && current.config.allowInsecureDns, + allowInvalidDohCertificate = sameDns && current.config.allowInvalidDohCertificate, password = source.config.password.takeIf { presence.password } ?: current.config.password, privateKey = source.config.privateKey.takeIf { presence.privateKey } ?: current.config.privateKey, jumpPassword = source.config.jumpPassword.takeIf { presence.jumpPassword } ?: current.config.jumpPassword, diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ffaeef0..628a641 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -576,4 +576,5 @@ Допускает самоподписанные, просроченные сертификаты и несовпадение имени только для DoH. DNS-трафик может быть перехвачен. Проверка сертификата прокси не меняется. Настройки батареи недоступны на этом устройстве. Достигнут лимит хранилища конфигурации. Удалите профили или уменьшите их содержимое. + MegaProxy не выбран для раздельного туннелирования. Для измерения скорости VPN-туннеля включите «Направлять MegaProxy через VPN» в настройках раздельного туннелирования и переподключитесь. Основную сеть можно измерить. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 043675a..27267a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -577,4 +577,5 @@ Allows self-signed, expired and mismatched certificates for DoH only. DNS traffic may be intercepted. Proxy certificate verification is unchanged. Battery settings are unavailable on this device. Configuration storage limit reached. Remove profiles or reduce their contents. + MegaProxy is not selected for split tunneling. To measure VPN tunnel speed, enable “Route MegaProxy through VPN” in split tunneling settings and reconnect. The underlying network can still be measured. diff --git a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt index 080ac7f..945f874 100644 --- a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt @@ -51,6 +51,33 @@ class ConfigSubscriptionIntegrationTest { private fun apply(value: PortableConfiguration, running: Boolean = false) = store.applySubscriptionSnapshot( store.subscriptionState()!!.generation, value, 0, 1000, running) + @Test fun jsonRefreshPreservesLocalDnsConsentOnlyForUnchangedResolver() { + val original = profile("one").let { it.copy(config = it.config.copy( + dnsProvider = DnsProvider.CUSTOM, customDohUrl = "https://dns.example/dns-query")) } + bootstrap(original) + store.saveProfile(original.copy(config = original.config.copy( + allowInsecureDns = true, allowInvalidDohCertificate = true))) + val rotated = original.copy(config = original.config.copy(password = "rotated")) + assertTrue(apply(parseSubscriptionSnapshot(document(rotated).toString(), store.profiles()))) + val retained = ConfigStore(context).profile("one")!!.config + assertTrue(retained.allowInsecureDns) + assertTrue(retained.allowInvalidDohCertificate) + assertEquals("rotated", retained.password) + val changed = rotated.copy(config = rotated.config.copy(customDohUrl = "https://other.example/dns-query")) + assertTrue(apply(parseSubscriptionSnapshot(document(changed).toString(), store.profiles()))) + val reset = ConfigStore(context).profile("one")!!.config + assertFalse(reset.allowInsecureDns) + assertFalse(reset.allowInvalidDohCertificate) + } + + @Test fun plainDnsConsentSurvivesJsonImportForUnchangedResolver() { + val original = profile("one").let { it.copy(config = it.config.copy( + dnsProvider = DnsProvider.CUSTOM, customDohUrl = "tcp://1.1.1.1", allowInsecureDns = true)) } + store.saveProfile(original, createIfMissing = true) + store.importConfiguration(ConfigTransfer.importJson(document(original).toString())) + assertTrue(ConfigStore(context).profile("one")!!.config.allowInsecureDns) + } + @Test fun omittedAndPartialGlobalsPreserveLocalSettingsOnImportAndRefresh() { bootstrap(profile("one")) store.setConnectionProfile("one") diff --git a/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt b/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt index 6c3f90e..4e9139a 100644 --- a/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt @@ -3,33 +3,40 @@ package net.megaproxy487 import android.os.Looper import androidx.compose.material3.Text import androidx.compose.ui.test.onNodeWithText +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import net.megaproxy487.data.ConfigIoDispatcher import org.junit.Assert.assertFalse import org.junit.Test -import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext class ConfigurationReadUiTest : MainUiTestBase() { @Test fun slowConfigurationReadLeavesUiResponsiveAndDoesNotExposeDefaults() { - val entered = CountDownLatch(1) - val release = CountDownLatch(1) + val tasks = LinkedBlockingQueue() + ConfigIoDispatcher.delegate = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { tasks.add(block) } + } + val worker = Executors.newSingleThreadExecutor() val onMain = AtomicBoolean() try { compose.setContent { val configuration = readConfiguration("slow") { onMain.set(Looper.myLooper() == Looper.getMainLooper()) - entered.countDown() - release.await() // The test owns completion; finally always releases the worker. "Loaded configuration" } Text(configuration ?: "Loading configuration") } - compose.waitForIdle() - compose.waitUntil(5_000) { entered.count == 0L } compose.onNodeWithText("Loaded configuration").assertDoesNotExist() + worker.submit(checkNotNull(tasks.poll(5, TimeUnit.SECONDS))).get(5, TimeUnit.SECONDS) assertFalse(onMain.get()) - release.countDown() - drainConfigIo() waitForText("Loaded configuration") - } finally { release.countDown() } + } finally { + ConfigIoDispatcher.delegate = Dispatchers.Unconfined + worker.shutdownNow() + } } } diff --git a/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt b/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt index a273066..902925c 100644 --- a/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt @@ -26,6 +26,33 @@ class ConfigurationRecoveryRegressionTest { org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() } + @Test fun megaProxyRoutingDefaultsOnAndExplicitExclusionSurvivesReload() { + val initial = store.globalConnectionSettings() + assertEquals(setOf(context.packageName), initial.selectedPackages) + assertTrue(initial.routeAllApps) + store.editGlobalSettings { it.copy(routeAllApps = false, selectedPackages = setOf("other.package")) } + assertEquals(setOf("other.package"), ConfigStore(context).globalConnectionSettings().selectedPackages) + store.editGlobalSettings { it.copy(bypassLocalNetworks = false) } + val another = store.addProfile() + store.setActiveProfile(another.id) + store.setConnectionProfile(another.id) + assertEquals(setOf("other.package"), ConfigStore(context).connectionConfig().selectedPackages) + } + + @Test fun existingSplitListGainsMegaProxyOnlyOnce() { + store.activeProfile() + val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) + prefs.edit().putString("global_connection_settings_v1", + """{"sshProfile":"DEFAULT","routeAllApps":false,"packages":["other.package"]}""").commit() + val migrated = store.globalConnectionSettings() + assertEquals(setOf("other.package", context.packageName), migrated.selectedPackages) + assertFalse(migrated.routeAllApps) + assertTrue(org.json.JSONObject(prefs.getString("global_connection_settings_v1", null)!!) + .getBoolean("megaProxyRoutingDefaultApplied")) + store.editGlobalSettings { it.copy(selectedPackages = it.selectedPackages - context.packageName) } + assertEquals(setOf("other.package"), ConfigStore(context).globalConnectionSettings().selectedPackages) + } + @Test fun failoverSkipsInvalidProfile() { val first = store.activeProfile().let { it.copy(config = ProxyConfig(type=ProxyType.SOCKS5,host="one.example",port=1080)) } store.saveProfile(first) diff --git a/app/src/test/java/net/megaproxy487/MainUiTestBase.kt b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt index 0512a78..c559164 100644 --- a/app/src/test/java/net/megaproxy487/MainUiTestBase.kt +++ b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt @@ -23,7 +23,7 @@ import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowVpnService import java.security.Security import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.launch import org.junit.Assert.assertFalse @RunWith(RobolectricTestRunner::class) @@ -39,15 +39,11 @@ abstract class MainUiTestBase { protected fun content(block: @Composable () -> Unit) { compose.setContent { MaterialTheme { block() } } drainConfigIo() - // Finishing IO does not guarantee that Compose has displayed its result. - compose.waitUntil(10_000) { - compose.onAllNodesWithText(text(R.string.loading_configuration)).fetchSemanticsNodes().isEmpty() - } } protected fun drainConfigIo() { compose.waitForIdle() val completed = AtomicBoolean() - ConfigIoDispatcher.dispatch(EmptyCoroutineContext, Runnable { completed.set(true) }) + kotlinx.coroutines.CoroutineScope(ConfigIoDispatcher).launch { completed.set(true) } compose.waitUntil(10_000) { completed.get() } compose.waitForIdle() } @@ -64,6 +60,8 @@ abstract class MainUiTestBase { }.also { store.saveProfile(it) } @Before fun preparePlatform() { + // Execute configuration work deterministically; async dispatch has a dedicated contract test. + ConfigIoDispatcher.delegate = kotlinx.coroutines.Dispatchers.Unconfined Security.removeProvider("AndroidKeyStore") UiTestKeyStore.keys.clear() Security.addProvider(UiTestKeyStoreProvider()) @@ -84,6 +82,7 @@ abstract class MainUiTestBase { } finally { Security.removeProvider("AndroidKeyStore") UiTestKeyStore.keys.clear() + ConfigIoDispatcher.reset() } } } diff --git a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt index 43c3770..d9a5e48 100644 --- a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt +++ b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt @@ -31,6 +31,8 @@ class NavigationUiTest : MainUiTestBase() { icon(R.string.main_actions).performClick() node(R.string.speed).performClick() compose.onNodeWithTag("screen-speed").assertIsDisplayed() + drainConfigIo() + node(R.string.speed_split_tunnel_warning).assertDoesNotExist() compose.onNodeWithText("h3").assertIsDisplayed() compose.onNodeWithText("h2").performClick() compose.onNodeWithText("h1").assertIsDisplayed() @@ -40,6 +42,22 @@ class NavigationUiTest : MainUiTestBase() { compose.onNodeWithTag("screen-main").assertIsDisplayed() } + @Test fun speedWarnsWhenMegaProxyIsExcludedFromSplitTunneling() { + store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( + routeAllApps = false, selectedPackages = setOf("other.package"), + )) + content { SpeedTestScreen(activity) {} } + node(R.string.speed_split_tunnel_warning).assertIsDisplayed() + } + + @Test fun speedDoesNotWarnWhenMegaProxyIsSelectedForSplitTunneling() { + store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( + routeAllApps = false, selectedPackages = setOf(activity.packageName), + )) + content { SpeedTestScreen(activity) {} } + node(R.string.speed_split_tunnel_warning).assertDoesNotExist() + } + @Test fun settingsDestinationsOpenAndBackReturnsToSettings() { content { MegaProxyNavHost(activity, readConnectionStats = { null }) } icon(R.string.main_actions).performClick() diff --git a/docs/en/connection-metrics.md b/docs/en/connection-metrics.md index 747817c..899984d 100644 --- a/docs/en/connection-metrics.md +++ b/docs/en/connection-metrics.md @@ -96,6 +96,9 @@ These are separate entries in the main menu; they measure different things: | Speed | Active Cloudflare transfer over the underlying network and VPN separately; progressively filled table with IP, flag/code, HTTP and TLS on separate lines, latency, download and upload. | | VPN visibility | Local Android/interface signals plus ProxyCheck public-IP reputation per available route. Local heuristics and remote reputation are not proof that all apps can detect the VPN. | +With split tunneling, select MegaProxy to measure the VPN route. It is selected by default; +existing application lists gain it once on upgrade. Explicitly removing it afterwards is preserved. + Speed uses 10/25/50 decimal MB per direction per route (10,000,000/25,000,000/50,000,000 bytes). Two routes and both directions can consume up to four times the selected payload, plus overhead. Default h3 falls back to h2 then h1; h2 permits h1 fallback, h1 stays h1. Fallback applies only to diff --git a/docs/en/dns.md b/docs/en/dns.md index a4d17d2..3f2fca3 100644 --- a/docs/en/dns.md +++ b/docs/en/dns.md @@ -27,8 +27,11 @@ apply to every mode. App UDP/TCP port 53 is intercepted; app-specific encrypted Portable profiles retain the endpoint in the legacy `dns.customDohUrl` string with an explicit `https://`, `tcp://` or `udp://` scheme. Existing schemas remain unchanged. Old clients reject plain endpoints. Security consent stays on this device: exports/imports and subscriptions cannot grant -permission for insecure DNS or invalid DoH certificates. Confirm locally before connecting. +permission for insecure DNS or invalid DoH certificates. On a new device, confirm locally before connecting. Refreshing/importing an unchanged resolver preserves its local +approvals; a changed resolver requires new approval. Proxy bootstrap DNS is unchanged and precedes the VPN; see the README endpoint inventory. -DNS admission is limited to 64 active flows per proxy, shared by intercepted TCP and UDP. +DNS admission is limited to 64 flows per proxy, shared by intercepted TCP and UDP. When full, +the oldest completed UDP flow is evicted together with its TUN mapping. Pending responses +and TCP DNS streams are retained. Excess flows fail immediately; stopping the VPN closes admitted flows and cancels their queries. diff --git a/docs/ru/connection-metrics.md b/docs/ru/connection-metrics.md index ab6041c..bc0fd4f 100644 --- a/docs/ru/connection-metrics.md +++ b/docs/ru/connection-metrics.md @@ -96,6 +96,10 @@ HTTP выводится числовой статус без произволь | Скорость | Активная передача Cloudflare отдельно через основную сеть и VPN; постепенная таблица IP, флаг/код, HTTP и TLS на отдельных строках, задержка, скачивание и отправка. | | Видимость VPN | Локальные Android/interface признаки и репутация публичного IP по ProxyCheck для каждого доступного маршрута. Эвристики и репутация не доказывают обнаружение VPN всеми приложениями. | +При раздельном туннелировании для замера VPN-маршрута выберите MegaProxy. По умолчанию +он выбран; при обновлении он однократно добавляется в существующие списки приложений. +Последующее явное исключение сохраняется. + Скорость использует 10/25/50 десятичных MB в каждом направлении каждого маршрута (10 000 000/25 000 000/50 000 000 байт). Два маршрута и оба направления могут расходовать до четырёх выбранных объёмов плюс overhead. По умолчанию h3 откатывается на h2, затем h1; diff --git a/docs/ru/dns.md b/docs/ru/dns.md index b6dc918..05fe9d0 100644 --- a/docs/ru/dns.md +++ b/docs/ru/dns.md @@ -28,8 +28,12 @@ UDP никогда не переключает DNS на прямой UDP. У о Переносимый профиль сохраняет адрес в прежнем строковом поле `dns.customDohUrl` с явной схемой `https://`, `tcp://` или `udp://`. Схемы конфигурации не меняются. Старые клиенты отклоняют обычные DNS-адреса. Разрешения небезопасных режимов и сертификатов остаются только на устройстве: -экспорт, импорт и подписки не дают такого согласия. Перед подключением подтвердите его локально. +экспорт, импорт и подписки не дают такого согласия. На новом устройстве подтвердите его локально перед подключением. +Обновление или импорт неизменившегося резолвера сохраняет локальные разрешения; +смена резолвера требует нового подтверждения. Начальное разрешение имени прокси до создания VPN не меняется; сервисы перечислены в README. -На прокси допускается до 64 активных DNS-потоков суммарно для перехваченного TCP и UDP. -Новые потоки сверх лимита сразу отклоняются; остановка VPN закрывает потоки и отменяет их запросы. +На прокси допускается до 64 DNS-потоков суммарно для перехваченного TCP и UDP. При заполнении +лимита удаляется самый старый завершённый UDP-поток вместе с его TUN-соединением. Потоки +с ожидающими ответами и TCP DNS сохраняются. +Если завершённый UDP-поток нельзя вытеснить, новые потоки сразу отклоняются; остановка VPN закрывает потоки и отменяет их запросы. diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go index 148db5b..d10b6d7 100644 --- a/native/mobile/dialer.go +++ b/native/mobile/dialer.go @@ -586,7 +586,7 @@ func (d *httpsConnectDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, erro client := d.dohClient limiter := d.dohInFlight d.cacheMu.Unlock() - return d.dnsFlows.open(func() *dohPacketConn { + return d.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/dns_flows.go b/native/mobile/dns_flows.go index 4158cc1..ebb9028 100644 --- a/native/mobile/dns_flows.go +++ b/native/mobile/dns_flows.go @@ -2,6 +2,7 @@ package mobile import ( "errors" + "io" "net" "sync" ) @@ -10,37 +11,117 @@ const maxDNSFlows = 64 // TCP and UDP share admission before allocating pipes, queues or goroutines. type dnsFlows struct { - mu sync.Mutex - closed bool - packets map[*dohPacketConn]struct{} + mu sync.Mutex + closed bool + sequence uint64 + packets map[*dnsFlow]struct{} } -func (f *dnsFlows) open(create func() *dohPacketConn) (net.PacketConn, error) { +type dnsFlow struct { + *dohPacketConn + owner *dnsFlows + udp bool + pending int + completed bool + lastUsed uint64 + origin io.Closer +} + +func (f *dnsFlows) open(udp bool, create func() *dohPacketConn) (net.PacketConn, error) { f.mu.Lock() - defer f.mu.Unlock() if f.closed { + f.mu.Unlock() return nil, net.ErrClosed } + var evicted *dnsFlow if len(f.packets) >= maxDNSFlows { - return nil, errors.New("DNS flow limit reached") + // ponytail: scan at most 64 flows; a separate LRU is unnecessary at this bound. + for packet := range f.packets { + if packet.udp && packet.completed && packet.pending == 0 && (evicted == nil || packet.lastUsed < evicted.lastUsed) { + evicted = packet + } + } + if evicted == nil { + f.mu.Unlock() + return nil, errors.New("DNS flow limit reached") + } + delete(f.packets, evicted) } if f.packets == nil { - f.packets = make(map[*dohPacketConn]struct{}) + f.packets = make(map[*dnsFlow]struct{}) } - packet := create() + packet := &dnsFlow{dohPacketConn: create(), owner: f, udp: udp} packet.onClose = func() { f.mu.Lock() delete(f.packets, packet) + origin := packet.origin f.mu.Unlock() + if origin != nil { + _ = origin.Close() + } } f.packets[packet] = struct{}{} + f.mu.Unlock() + if evicted != nil { + _ = evicted.Close() + } return packet, nil } +func (p *dnsFlow) bindOrigin(origin io.Closer) { + p.owner.mu.Lock() + _, open := p.owner.packets[p] + if open { + p.origin = origin + } + p.owner.mu.Unlock() + if !open { + _ = origin.Close() + } +} + +func (p *dnsFlow) WriteTo(b []byte, addr net.Addr) (int, error) { + p.owner.mu.Lock() + if _, open := p.owner.packets[p]; !open { + p.owner.mu.Unlock() + return 0, net.ErrClosed + } + p.pending++ + p.owner.mu.Unlock() + n, err := p.dohPacketConn.WriteTo(b, addr) + if err != nil { + p.complete(false) + } + return n, err +} + +func (p *dnsFlow) ReadFrom(b []byte) (int, net.Addr, error) { + n, addr, err := p.dohPacketConn.ReadFrom(b) + p.owner.mu.Lock() + direct := p.origin == nil + p.owner.mu.Unlock() + // The TUN relay completes only after delivering the response to the origin. + if err == nil && direct { + p.complete(true) + } + return n, addr, err +} + +func (p *dnsFlow) complete(answered bool) { + p.owner.mu.Lock() + defer p.owner.mu.Unlock() + if p.pending > 0 { + p.pending-- + } + p.completed = p.completed || answered + p.owner.sequence++ + p.lastUsed = p.owner.sequence +} + func (f *dnsFlows) close() { f.mu.Lock() f.closed = true - packets := make([]*dohPacketConn, 0, len(f.packets)) + packets := make([]*dnsFlow, 0, len(f.packets)) for packet := range f.packets { packets = append(packets, packet) } diff --git a/native/mobile/dns_flows_test.go b/native/mobile/dns_flows_test.go index 96d4ab8..86cfd9a 100644 --- a/native/mobile/dns_flows_test.go +++ b/native/mobile/dns_flows_test.go @@ -136,23 +136,16 @@ func (p *dnsLifecycleOrigin) WriteTo(b []byte, _ net.Addr) (int, error) { } func TestRealTunnelReleasesIdleDNSFlowsOnProxyClose(t *testing.T) { - const count = 64 + const count = maxDNSFlows * 2 proxy := &dnsLifecycleProxy{httpsConnectDialer: &httpsConnectDialer{config: config{DoHURL: "https://dns.example"}}, packets: make(chan net.PacketConn, count)} - transport := tunnel.New(proxy, statistic.DefaultManager) + transport := dnsTransport{tunnel.New(proxy, statistic.DefaultManager)} transport.ProcessAsync() defer transport.Close() ready := make(chan struct{}, count) query, _, _ := buildAQuery("example.test") binary.BigEndian.PutUint16(query[len(query)-4:], 28) baseline := len(statistic.DefaultManager.Snapshot().Connections) - var origins []net.Conn - for i := 0; i < count; i++ { - a, b := net.Pipe() - defer b.Close() - origins = append(origins, a) - origin := &dnsLifecycleOrigin{Conn: a, query: query, ready: ready, id: stack.TransportEndpointID{LocalAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 2}), LocalPort: 53, RemoteAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 1}), RemotePort: uint16(10000 + i)}} - transport.HandleUDP(origin) - } + var peers []net.Conn var packets []net.PacketConn defer func() { for _, pc := range packets { @@ -160,16 +153,27 @@ func TestRealTunnelReleasesIdleDNSFlowsOnProxyClose(t *testing.T) { } }() for i := 0; i < count; i++ { + a, b := net.Pipe() + defer b.Close() + peers = append(peers, b) + origin := &dnsLifecycleOrigin{Conn: a, query: query, ready: ready, id: stack.TransportEndpointID{LocalAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 2}), LocalPort: 53, RemoteAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 1}), RemotePort: uint16(10000 + i)}} + transport.HandleUDP(origin) select { case <-ready: case <-time.After(time.Second): - t.Fatal("DNS fixture did not answer") + t.Fatal("DNS fixture did not answer", i) } packets = append(packets, <-proxy.packets) + if len(proxy.dnsFlows.packets) > maxDNSFlows { + t.Fatal("DNS admission became unbounded") + } } proxy.Close() - for _, origin := range origins { - origin.Close() + for _, peer := range peers { + _ = peer.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := peer.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatal("idle TUN origin survived close", err) + } } deadline := time.Now().Add(time.Second) for len(statistic.DefaultManager.Snapshot().Connections) > baseline && time.Now().Before(deadline) { @@ -190,7 +194,7 @@ func TestDNSAdmissionRacesWithClose(t *testing.T) { defer workers.Done() <-start for j := 0; j < 100; j++ { - packet, err := flows.open(func() *dohPacketConn { + packet, err := flows.open(true, func() *dohPacketConn { return newDoHPacketConnWithClient(config{}, nil, nil, "https://dns.example", nil, nil) }) if err == nil { @@ -206,3 +210,39 @@ func TestDNSAdmissionRacesWithClose(t *testing.T) { t.Fatal("DNS admission leaked after close") } } + +func TestDNSCompletedFlowsAreEvictedWithoutDiscardingQueuedReplies(t *testing.T) { + d := &httpsConnectDialer{config: config{DoHURL: "https://unused.example"}} + defer d.Close() + query, _, _ := buildAQuery("example.test") + binary.BigEndian.PutUint16(query[len(query)-4:], 28) + var packets []net.PacketConn + for i := 0; i < maxDNSFlows; i++ { + packet, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}) + if err != nil { + t.Fatal(err) + } + packets = append(packets, packet) + if _, err = packet.WriteTo(query, nil); err != nil { + t.Fatal(err) + } + } + if packet, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}); err == nil { + packet.Close() + t.Fatal("queued responses were evicted") + } + if _, _, err := packets[0].ReadFrom(make([]byte, 512)); err != nil { + t.Fatal(err) + } + replacement, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}) + if err != nil { + t.Fatal("completed flow still consumes admission", err) + } + defer replacement.Close() + if _, _, err := packets[0].ReadFrom(make([]byte, 512)); !errors.Is(err, net.ErrClosed) { + t.Fatal("completed flow was not evicted", err) + } + if _, _, err := packets[1].ReadFrom(make([]byte, 512)); err != nil { + t.Fatal("pending response was discarded", err) + } +} diff --git a/native/mobile/dns_udp.go b/native/mobile/dns_udp.go new file mode 100644 index 0000000..2fa06a3 --- /dev/null +++ b/native/mobile/dns_udp.go @@ -0,0 +1,73 @@ +package mobile + +import ( + "net" + "net/netip" + "time" + + "github.com/xjasonlyu/tun2socks/v2/buffer" + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "github.com/xjasonlyu/tun2socks/v2/tunnel" + "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" +) + +// DNS eviction must close both relay directions, including the idle TUN endpoint. +// The upstream UDP pipe otherwise waits for the idle origin until its timeout. +// Other TCP/UDP traffic keeps the upstream tunnel's forwarding behavior. +type dnsTransport struct{ *tunnel.Tunnel } + +func (t dnsTransport) HandleUDP(origin adapter.UDPConn) { + id := origin.ID() + if id.LocalPort != 53 { + t.Tunnel.HandleUDP(origin) + return + } + source, _ := netip.AddrFromSlice(id.RemoteAddress.AsSlice()) + target, _ := netip.AddrFromSlice(id.LocalAddress.AsSlice()) + metadata := &M.Metadata{Network: M.UDP, SrcIP: source, SrcPort: id.RemotePort, DstIP: target, DstPort: id.LocalPort} + packet, err := t.Proxy().DialUDP(metadata) + if err != nil { + _ = origin.Close() + return + } + flow, ok := packet.(*dnsFlow) + if !ok { + _ = packet.Close() + _ = origin.Close() + return + } + flow.bindOrigin(origin) + remote := statistic.NewUDPTracker(packet, metadata, statistic.DefaultManager) + peer := metadata.UDPAddr() + // Admission above bounds the buffers and goroutines even during DNS floods. + go func() { + defer remote.Close() + defer origin.Close() + go func() { + defer remote.Close() + defer origin.Close() + relayDNSPackets(remote, origin, peer, nil) + }() + relayDNSPackets(origin, remote, nil, func() { flow.complete(true) }) + }() +} + +func relayDNSPackets(dst, src net.PacketConn, peer net.Addr, delivered func()) { + buf := buffer.Get(buffer.MaxSegmentSize) + defer buffer.Put(buf) + for { + _ = src.SetReadDeadline(time.Now().Add(60 * time.Second)) + n, _, err := src.ReadFrom(buf) + if err != nil { + return + } + if _, err = dst.WriteTo(buf[:n], peer); err != nil { + return + } + if delivered != nil { + delivered() + } + _ = dst.SetReadDeadline(time.Now().Add(60 * time.Second)) + } +} diff --git a/native/mobile/masque.go b/native/mobile/masque.go index ad5aca7..1901e04 100644 --- a/native/mobile/masque.go +++ b/native/mobile/masque.go @@ -525,7 +525,7 @@ func (d *masqueDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { } client, limiter := d.dohClient, d.dohInFlight d.mu.Unlock() - return d.direct.dnsFlows.open(func() *dohPacketConn { + return d.direct.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/masque_test.go b/native/mobile/masque_test.go index 1e11558..24bca5c 100644 --- a/native/mobile/masque_test.go +++ b/native/mobile/masque_test.go @@ -249,7 +249,7 @@ func TestMASQUERoutingPolicy(t *testing.T) { t.Fatal(err) } defer dns.Close() - doh, ok := dns.(*dohPacketConn) + doh, ok := dns.(*dnsFlow) if !ok || len(doh.endpoints) != 2 || doh.endpoints[0] != c.DoHURL || doh.endpoints[1] != c.DoHFallbackURLs[0] { t.Fatal("DNS did not retain selected DoH and fallback policy") } diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index 9ea28e6..d326e30 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -124,7 +124,7 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R t.SetProxy(sshProxy) proxyCloser = sshProxy } - netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: t}) + netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: dnsTransport{t}}) if err != nil { dev.Close() if proxyCloser != nil { diff --git a/native/mobile/socks5.go b/native/mobile/socks5.go index b4d66d1..b07f18d 100644 --- a/native/mobile/socks5.go +++ b/native/mobile/socks5.go @@ -274,7 +274,7 @@ func (d *socks5Dialer) DialUDP(m *M.Metadata) (net.PacketConn, error) { } client, limiter := d.dohClient, d.dohInFlight d.mu.Unlock() - return d.dnsFlows.open(func() *dohPacketConn { + return d.dnsFlows.open(m.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/ssh_dialer.go b/native/mobile/ssh_dialer.go index 4485083..ba261f5 100644 --- a/native/mobile/ssh_dialer.go +++ b/native/mobile/ssh_dialer.go @@ -404,7 +404,7 @@ func (d *sshDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { return nil, errUDPBlocked } client, limiter := d.sharedDoHResources() - return d.direct.dnsFlows.open(func() *dohPacketConn { + return d.direct.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet